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 session management, model selection, and tool execution tra…
…cking to TUI
  • Loading branch information
prem315 committed Oct 14, 2026
commit 8b0c2a477a270c2b55f10ff9e2afbf6ab0f9590c
855 changes: 743 additions & 112 deletions packages/tui/src/app.rs

Large diffs are not rendered by default.

27 changes: 24 additions & 3 deletions packages/tui/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,22 @@
use crossterm::event::{Event, KeyEvent, KeyEventKind};
use tokio::sync::mpsc;

use crate::rpc::StreamEvent;
use crate::types::SessionInfo;

/// Asynchronous results arriving from the RPC server.
#[derive(Debug, Clone, PartialEq)]
pub enum RpcEvent {
Stream(StreamEvent),
SessionsLoaded(Result<Vec<SessionInfo>, String>),
SessionConfigured(Result<SessionInfo, String>),
PluginsReloaded(Result<(u64, Option<String>), String>),
}

/// What the app reacts to: the events that actually change the screen. Anything
/// else crossterm can deliver is dropped at the boundary rather than carried
/// around unhandled.
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq)]
pub enum AppEvent {
Key(KeyEvent),
/// Text pasted into the app, carried whole. The terminal brackets it, so a
Expand All @@ -23,9 +35,12 @@ pub enum AppEvent {
/// 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.
Resize,
/// An event or response from the CodeWork RPC server.
Rpc(RpcEvent),
}

pub struct EventHandler {
tx: mpsc::UnboundedSender<AppEvent>,
rx: mpsc::UnboundedReceiver<AppEvent>,
}

Expand All @@ -38,20 +53,26 @@ impl Default for EventHandler {
impl EventHandler {
pub fn new() -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let reader_tx = tx.clone();
std::thread::spawn(move || loop {
match crossterm::event::read() {
Ok(event) => {
let Some(event) = translate(event) else {
continue;
};
if tx.send(event).is_err() {
if reader_tx.send(event).is_err() {
break;
}
}
Err(_) => break,
}
});
Self { rx }
Self { tx, rx }
}

/// A sender for background RPC tasks to deliver events back to the UI loop.
pub fn sender(&self) -> mpsc::UnboundedSender<AppEvent> {
self.tx.clone()
}

/// Wait for the next event. `None` once the input thread has stopped.
Expand Down
17 changes: 6 additions & 11 deletions packages/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,9 @@ 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)]
mod rpc;
mod theme;
mod tui;
#[allow(dead_code)]
mod types;
mod ui;

Expand All @@ -21,15 +17,14 @@ use tui::TerminalGuard;

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let mut events = EventHandler::new();
// 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 — which on this screen is the
// landing canvas rather than the page.
let mut app = App::new();
// ground beneath the frames matches the ground inside them.
let mut app = App::new(events.sender());
// Dropping the guard restores the terminal, including on an early return.
let mut terminal = TerminalGuard::init(app.theme.field_bg)?;
let mut events = EventHandler::new();
let mut terminal = TerminalGuard::init(app.background())?;

terminal.draw(app.theme.field_bg, |frame| ui::view(&app, frame))?;
terminal.draw(app.background(), |frame| ui::view(&app, frame))?;

while let Some(event) = events.next().await {
let mut redraw = app.update(event);
Expand All @@ -42,7 +37,7 @@ async fn main() -> Result<(), Box<dyn Error>> {
break;
}
if redraw {
terminal.draw(app.theme.field_bg, |frame| ui::view(&app, frame))?;
terminal.draw(app.background(), |frame| ui::view(&app, frame))?;
}
}

Expand Down
56 changes: 46 additions & 10 deletions packages/tui/src/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,56 @@
//! command menu and the theme picker are both one of these, and so are the model and session
//! pickers that come next — the only difference between them is the entries they are given.

use std::borrow::Cow;

use ratatui::style::Color;

/// One choice in a menu.
#[derive(Clone, Copy, PartialEq, Eq)]
#[derive(Clone, PartialEq, Eq)]
pub struct Entry {
/// What the entry is called, and the first thing its row shows. It is also its identity: the
/// app matches on it, and [`Menu::at`] finds it.
pub name: &'static str,
/// What choosing this entry identifies (for a session, its id; otherwise its name).
pub id: Cow<'static, str>,
/// What the entry is called, and the first thing its row shows.
pub name: Cow<'static, str>,
/// A short note beside the name: what choosing it does, or what it is.
pub detail: &'static str,
pub detail: Cow<'static, str>,
/// The colours the entry is about, when it is about colour.
pub preview: Option<Preview>,
}

impl Entry {
pub const fn item(name: &'static str, detail: &'static str) -> Self {
Self {
id: Cow::Borrowed(name),
name: Cow::Borrowed(name),
detail: Cow::Borrowed(detail),
preview: None,
}
}

pub const fn with_preview(name: &'static str, detail: &'static str, preview: Preview) -> Self {
Self {
id: Cow::Borrowed(name),
name: Cow::Borrowed(name),
detail: Cow::Borrowed(detail),
preview: Some(preview),
}
}

pub fn owned(
id: impl Into<String>,
name: impl Into<String>,
detail: impl Into<String>,
) -> Self {
Self {
id: Cow::Owned(id.into()),
name: Cow::Owned(name.into()),
detail: Cow::Owned(detail.into()),
preview: None,
}
}
}

/// A ground and an ink an entry wears on its own name.
///
/// A list of themes is then a list of themes, rather than a list of their names: the site's picker
Expand Down Expand Up @@ -48,14 +84,14 @@ impl Menu {
}
}

/// A menu over `entries`, its cursor on the entry named `name`.
/// A menu over `entries`, its cursor on the entry named `name` (or matching `id`).
///
/// That is how a picker opens on what is already in use, as the site's does. When the name is
/// not there the cursor starts at the top rather than nowhere.
pub fn at(entries: Vec<Entry>, name: &str) -> Self {
let cursor = entries
.iter()
.position(|entry| entry.name == name)
.position(|entry| entry.name == name || entry.id == name)
.unwrap_or(0);
Self {
all: entries.clone(),
Expand Down Expand Up @@ -86,15 +122,15 @@ impl Menu {
/// Keep only the entries from [`Self::all`] that match `keep`, preserving the selected entry
/// when it still matches and otherwise starting at the top.
pub fn filter(&mut self, mut keep: impl FnMut(&Entry) -> bool) {
let active = self.selected().map(|entry| entry.name);
let active = self.selected().map(|entry| entry.id.clone());
self.entries = self
.all
.iter()
.copied()
.filter(|entry| keep(entry))
.cloned()
.collect();
self.cursor = active
.and_then(|name| self.entries.iter().position(|entry| entry.name == name))
.and_then(|id| self.entries.iter().position(|entry| entry.id == id))
.unwrap_or(0);
}

Expand Down
Loading
Loading