Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: add TUI menu, badge, input, and text components with updated vi…
…ew layout
  • Loading branch information
prem315 committed Oct 13, 2026
commit 0612525d68ca18df8126bd135ed013bd2d3bf79d
1 change: 1 addition & 0 deletions packages/tui/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,7 @@ futures-util = "0.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = "0.4"
# Width of a character in cells, for the prompt line's caret and clipping. Already in the
# tree through ratatui, so it costs no extra build to depend on it directly rather than
# assume every character is one column wide.
unicode-width = "0.2"
38 changes: 27 additions & 11 deletions packages/tui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@

A fast and lightweight terminal interface for [CodeWork](https://www.codework.sh), written in **Rust**.

The TUI is an early skeleton. Today it opens one screen: the landing site's hero, its wordmark over
the same hairline lattice the site draws it on. Prompt entry, model selection and the session view
are not built yet; the launcher and the RPC transport they will need are already in place.
The TUI is an early skeleton. Today it opens one screen: the landing site's hero — its wordmark over
the same hairline lattice the site draws it on, with a prompt line under it that takes typing. A
leading slash on an empty line opens a menu of the app's commands, as a chat prompt does; as this is
written there are two, `theme`, which lists every palette the site ships, and `exit`. The prompt
cannot be sent, and model selection and the session view are not built yet; the launcher and the RPC
transport they will need are already in place.

---

Expand All @@ -19,12 +22,17 @@ newline-delimited JSON-RPC.
| `src/main.rs` | Wiring: build the app, terminal and event loop, then run them. |
| `src/tui.rs` | Terminal lifecycle: raw mode, alternate screen, input modes, restore. |
| `src/event.rs` | Input: the reader thread, and the app events it produces. |
| `src/action.rs` | Named actions and the keymap, with the `<leader>` prefix. |
| `src/app.rs` | UI state and `update`, the one place state changes. |
| `src/action.rs` | Named actions and the keymap, with the `<leader>` prefix. | | `src/app.rs` | UI state and `update`, the one place state changes. |
| `src/prompt.rs` | The prompt line's text and caret, and the keys that edit it. |
| `src/menu.rs` | A menu: its entries, its previews and where the cursor is. |
| `src/ui.rs` | Rendering: reads state and draws, nothing else. |
| `src/ui/glyphs.rs` | Pixel glyphs, generated from the landing site's wordmark. |
| `src/ui/wordmark.rs` | Drawing the wordmark, and the mark it falls back to, in half-blocks. |
| `src/ui/field.rs` | The lattice behind the wordmark, on the wordmark's own grid. |
| `src/ui/input.rs` | The prompt box and its line, which reports where the caret goes. |
| `src/ui/menu.rs` | Drawing a menu: its rows, its scrolling and what a row previews. |
| `src/ui/badge.rs` | The tag a box clips to its top border. |
| `src/ui/text.rs` | Measuring and cutting text by columns, which both of the above need. |
| `src/theme.rs` | The palette, generated from the landing site's themes. |
| `src/rpc/` | The transport: WebSocket client and wire protocol. |
| `src/types.rs` | The DTOs the transport speaks in. |
Expand Down Expand Up @@ -72,12 +80,20 @@ pnpm start

## Keyboard Shortcuts

| Shortcut | Action |
| :---------- | :-------------------------------- |
| `Esc` | Exit the app |
| `Ctrl+C` | Exit the app |
| `Ctrl+D` | Exit the app |
| `<leader>q` | Exit the app (`Ctrl+X`, then `q`) |
| Shortcut | Action |
| :------------------- | :--------------------------------------------- |
| _any character_ | Type it into the prompt |
| `←` `→` | Move the prompt's caret |
| `Home` `End` | Jump to the start or the end of the prompt |
| `Backspace` `Delete` | Erase a character |
| _paste_ | Insert the clipboard at the caret, on one line |
| `/` | Ask for the command menu |
| `↑` `↓` | Move through a menu |
| `Enter` | Take the entry a menu is on |
| `Esc` | Close the menu, or exit the app |
| `Ctrl+C` | Exit the app |
| `Ctrl+D` | Exit the app |
| `<leader>q` | Exit the app (`Ctrl+X`, then `q`) |

---

Expand Down
10 changes: 6 additions & 4 deletions packages/tui/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
//! OpenCode's (`app.exit`, `command.palette.show`, …). Only actions the TUI can
//! currently perform are listed — each new screen adds its own, instead of this
//! table binding behaviour that does not exist yet.
//!
//! These are the keys that mean one thing everywhere. A key whose meaning depends on what is on
//! screen — escape, which closes a menu or leaves — stays with the screen, because a keymap can
//! only hold one answer per key.

use std::time::{Duration, Instant};

Expand Down Expand Up @@ -46,10 +50,8 @@ impl Keymap {
(KeyCode::Char('c'), KeyModifiers::CONTROL) => Some(Action::Exit),
(KeyCode::Char('d'), KeyModifiers::CONTROL) => Some(Action::Exit),
(KeyCode::Char('q'), _) if leader => Some(Action::Exit),
// Ours, not OpenCode's: a full-screen app needs a key that leaves, and
// there is nothing for escape to close until dialogs exist. Drop it
// when the first dialog claims escape *and* a quit key is documented.
(KeyCode::Esc, _) => Some(Action::Exit),
// Escape is deliberately not bound here, though it is what leaves the app: a menu
// claims it first now, so what it means depends on the screen and `App` decides.
_ => None,
}
}
Expand Down
198 changes: 192 additions & 6 deletions packages/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,85 @@
//! whether the screen needs redrawing. That report is the whole redraw policy —
//! there is no tick, so an idle TUI does no work and no repaint.

use crossterm::event::KeyEvent;
use crossterm::event::{KeyCode, KeyEvent};

use crate::action::{Action, Keymap};
use crate::event::AppEvent;
use crate::menu::{Entry, Menu, Preview};
use crate::prompt::Prompt;
use crate::theme::{self, Palette};

/// What the prompt answers an Enter with, until the session screen can act on it. A key that does
/// nothing reads as a bug, so the one that will send says so instead.
const NOT_SENDING: &str = "sending is not wired up yet";

/// What a leading slash offers. Only commands the app can actually perform are listed, and an
/// entry's name is what choosing it matches on.
const COMMANDS: &[Entry] = &[
Entry {
name: "theme",
detail: "pick a palette",
preview: None,
},
Entry {
name: "exit",
detail: "close the app",
preview: None,
},
];

/// The screens the TUI can show. One, for now.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
Welcome,
}

/// A menu open over the hero, taking the keyboard while it is there.
///
/// There is one at a time: choosing a command opens the menu it names, so that one replaces the
/// command menu rather than stacking under it.
pub enum Popup {
/// What a leading slash asks for.
Commands(Menu),
/// The palettes the site ships.
Themes(Menu),
}

impl Popup {
pub fn menu(&self) -> &Menu {
match self {
Popup::Commands(menu) | Popup::Themes(menu) => menu,
}
}

fn menu_mut(&mut self) -> &mut Menu {
match self {
Popup::Commands(menu) | Popup::Themes(menu) => menu,
}
}

/// What the menu calls itself, on its border.
pub fn title(&self) -> &'static str {
match self {
Popup::Commands(_) => "commands",
Popup::Themes(_) => "themes",
}
}
}

pub struct App {
pub screen: Screen,
/// The palette every screen draws with. Held here rather than read from a global, so a frame
/// is a pure function of the app and can be reproduced from a test backend.
pub theme: &'static Palette,
/// The prompt line: its text and where the caret is in it.
pub prompt: Prompt,
/// The menu on screen, if there is one. It is modal: while it is up the prompt behind it does
/// not see a key.
pub popup: Option<Popup>,
/// A line of plain truth about the prompt, shown in place of the key hints. It is set by keys
/// the prompt cannot honour and cleared by the first one it can.
pub notice: Option<&'static str>,
pub should_quit: bool,
keymap: Keymap,
}
Expand All @@ -36,6 +98,9 @@ impl App {
Self {
screen: Screen::Welcome,
theme: theme::default_palette(),
prompt: Prompt::default(),
popup: None,
notice: None,
should_quit: false,
keymap: Keymap::new(),
}
Expand All @@ -45,20 +110,141 @@ impl App {
pub fn update(&mut self, event: AppEvent) -> bool {
match event {
AppEvent::Key(key) => self.handle_key(key),
// A paste is text, and a menu takes no text: it is dropped rather than typed into the
// line the menu is standing in front of.
AppEvent::Paste(text) => {
if self.popup.is_some() {
return false;
}
let changed = self.prompt.insert_text(&text);
if changed {
self.notice = None;
}
changed
}
// The view reads its size from the frame it draws into, so a resize
// needs no state change — only a repaint.
AppEvent::Resize => true,
}
}

fn handle_key(&mut self, key: KeyEvent) -> bool {
match self.keymap.resolve(key) {
// Named actions first, so a key bound to the app is never typed into the prompt. The exits
// are the terminal's whether or not a menu is up.
if let Some(Action::Exit) = self.keymap.resolve(key) {
self.should_quit = true;
// Nothing to redraw: the loop is about to end.
Some(Action::Exit) => {
self.should_quit = true;
false
return false;
}
// Escape closes what is open and, with nothing open, leaves. It is not in the keymap
// because a keymap holds one answer per key and this one has two.
if key.code == KeyCode::Esc {
return self.escape();
}
// A menu takes the keyboard while it is up. It is modal, so a key it does not want is
// dropped rather than typed into the prompt standing behind it.
if self.popup.is_some() {
return self.menu_key(key);
}
// Enter is the prompt's, not the keymap's, because it only means something once there is
// something to send.
if key.code == KeyCode::Enter {
self.notice = Some(NOT_SENDING);
return true;
}
// Everything the keymap did not claim belongs to the prompt, so a character types rather
// than doing nothing.
let edited = self.prompt.edit(key);
if edited {
self.notice = None;
// A slash alone on the line asks for the commands, the way a chat prompt does. The
// slash stays on the line: it is the command being completed, and what Escape takes
// back.
if self.prompt.text() == "/" {
self.popup = Some(Popup::Commands(Menu::new(COMMANDS.to_vec())));
}
None => false,
}
edited
}

/// A key for the menu on screen: the arrows walk it and Enter takes what it is on.
fn menu_key(&mut self, key: KeyEvent) -> bool {
let step = match key.code {
KeyCode::Up => -1,
KeyCode::Down => 1,
KeyCode::Enter => return self.choose(),
_ => return false,
};
self.popup
.as_mut()
.is_some_and(|popup| popup.menu_mut().step(step))
}

/// Take the entry the cursor is on.
fn choose(&mut self) -> bool {
let Some(popup) = self.popup.take() else {
return false;
};
let chosen = popup.menu().selected().name;
match popup {
// The command is spent with the slash that was typed for it, and the picker it opened
// starts on the palette already in use.
Popup::Commands(_) => match chosen {
"theme" => {
self.prompt.clear();
self.popup = Some(Popup::Themes(themes(self.theme.name)));
}
"exit" => self.should_quit = true,
_ => {}
},
// A palette: take it. Choosing the one already on screen just closes the picker, as
// the site's does.
Popup::Themes(_) => {
if let Some(palette) = theme::PALETTES
.iter()
.find(|palette| palette.name == chosen)
{
self.theme = palette;
}
}
}
true
}

/// Close what is open, or leave when nothing is.
fn escape(&mut self) -> bool {
let Some(popup) = self.popup.take() else {
self.should_quit = true;
return false;
};
// Closing the command menu takes its slash back, so the line is what it was before the
// command started and `/` asks for it again. The theme picker was opened by a command that
// spent its slash already.
if matches!(popup, Popup::Commands(_)) {
self.prompt.clear();
}
true
}
}

/// The theme picker: one entry per palette the site ships, its cursor on `active`.
///
/// Each entry previews its own palette on the strength of `field_bg` and `text` alone — the ground
/// the screen would stand on and the ink the words would be in, which is what tells one palette
/// from another at a glance.
fn themes(active: &str) -> Menu {
Menu::at(
theme::PALETTES
.iter()
.map(|palette| Entry {
name: palette.name,
detail: palette.scheme.name(),
preview: Some(Preview {
bg: palette.field_bg,
fg: palette.text,
}),
})
.collect(),
active,
)
}
5 changes: 5 additions & 0 deletions packages/tui/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ use tokio::sync::mpsc;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AppEvent {
Key(KeyEvent),
/// Text pasted into the app, carried whole. The terminal brackets it, so a
/// paste can never be mistaken for typing, and it arrives as one event
/// rather than as a burst of keys the app would have to reassemble.
Paste(String),
/// The terminal changed size. The payload is deliberately not carried: the
/// view reads its own dimensions from the frame it is drawing into, so a
/// stale copy in state would be one more thing to keep in sync.
Expand Down Expand Up @@ -73,6 +77,7 @@ impl EventHandler {
fn translate(event: Event) -> Option<AppEvent> {
match event {
Event::Key(key) if key.kind != KeyEventKind::Release => Some(AppEvent::Key(key)),
Event::Paste(text) => Some(AppEvent::Paste(text)),
Event::Resize(_, _) => Some(AppEvent::Resize),
_ => None,
}
Expand Down
7 changes: 5 additions & 2 deletions packages/tui/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
mod action;
mod app;
mod event;
mod menu;
mod prompt;
// The RPC transport (client + wire protocol) is kept whole: it is the layer the
// session screen will stream prompts over. Nothing on the welcome screen uses it.
#[allow(dead_code, unused_imports)]
Expand All @@ -20,10 +22,11 @@ use tui::TerminalGuard;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
// The app first: it owns the palette, and the terminal's own background is set from it so the
// ground beneath the frames matches the ground inside them.
// ground beneath the frames matches the ground inside them — which on this screen is the
// landing canvas rather than the page.
let mut app = App::new();
// Dropping the guard restores the terminal, including on an early return.
let mut terminal = TerminalGuard::init(app.theme.bg)?;
let mut terminal = TerminalGuard::init(app.theme.field_bg)?;
let mut events = EventHandler::new();

terminal.draw(|frame| ui::view(&app, frame))?;
Expand Down
Loading
Loading