feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,369 @@
|
||||
//! Attach mode — TUI-only client that connects to an existing daemon session
|
||||
//! over a Unix socket, forwarding key events and rendering state updates.
|
||||
//!
|
||||
//! Flow: `run_attach(session_id)` resolves the daemon's socket path from
|
||||
//! the store → `setup_attach_client()` connects and enters raw mode →
|
||||
//! enters a render loop: polls for local terminal events (key/resize/paste/
|
||||
//! scroll) → forwards them as `ClientRequest`s to the daemon via IPC →
|
||||
//! receives a `DaemonFrame` reply → `handle_daemon_frame()` /
|
||||
//! `apply_client_update()` applies the state snapshot onto a local
|
||||
//! `AppStateRest` mirror → `draw()` renders the TUI → on quit,
|
||||
//! sends `ClientRequest::Close`, cleans up terminal, and saves settings.
|
||||
//!
|
||||
//! The client has no agent logic — it is a pure render frontend.
|
||||
|
||||
use std::io::{self};
|
||||
|
||||
use anyhow::Result;
|
||||
use zesdex_domain::SettingsRepository;
|
||||
use crossterm::execute;
|
||||
use crossterm::terminal::{
|
||||
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
|
||||
};
|
||||
use ratatui::backend::CrosstermBackend;
|
||||
use ratatui::layout::{Constraint, Direction, Layout};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
|
||||
use ratatui::Terminal;
|
||||
use zesdex_infrastructure::ipc::client::IpcClient;
|
||||
use zesdex_infrastructure::ipc::protocol::{ClientRequest, DaemonFrame, StatePayload};
|
||||
use zesdex_infrastructure::Toast;
|
||||
use zesdex_infrastructure::ToastKind;
|
||||
|
||||
use crate::key_code::key_code_to_action;
|
||||
use crate::state::{AppStateRest, ChatMessageDisplay, Overlay, RoleWrapper};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// apply_client_update — apply a StatePayload onto the local AppStateRest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Apply a `StatePayload` received from the daemon onto the client's
|
||||
/// local `AppStateRest`, so the attach-mode TUI can render it.
|
||||
///
|
||||
/// Flow: copy scalar fields directly → rebuild the transcript cache from
|
||||
/// `MessageEntry`s (mapping role strings back to role variants) →
|
||||
/// resolve the overlay name string to an `Overlay` variant → rebuild
|
||||
/// toasts from `ToastEntry`s.
|
||||
///
|
||||
/// Why: unrecognised role/overlay/toast-kind strings fall back to a safe
|
||||
/// default (`User`, `Overlay::None`, `ToastKind::Info`) rather than
|
||||
/// panicking, so a protocol/version mismatch degrades gracefully.
|
||||
fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) {
|
||||
tracing::debug!("applying state update from daemon");
|
||||
state.session_id = payload.session_id;
|
||||
state.dirty = payload.dirty;
|
||||
|
||||
state.transcript_cache.messages = payload
|
||||
.messages
|
||||
.into_iter()
|
||||
.map(|m| ChatMessageDisplay {
|
||||
role: match m.role.as_str() {
|
||||
"Assistant" => RoleWrapper::Assistant,
|
||||
"System" => RoleWrapper::System,
|
||||
"Tool" => RoleWrapper::Tool,
|
||||
_ => RoleWrapper::User,
|
||||
},
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
})
|
||||
.collect();
|
||||
state.transcript_cache.dirty = true;
|
||||
|
||||
state.misc.overlay = match payload.overlay.as_deref() {
|
||||
Some("Help") => Overlay::Help,
|
||||
Some("Settings") => Overlay::Settings,
|
||||
Some("Bash") => Overlay::Bash,
|
||||
Some("QuitConfirm") => Overlay::QuitConfirm,
|
||||
Some("KeyInput") => Overlay::KeyInput,
|
||||
Some("Editor") => Overlay::Editor,
|
||||
Some("Effort") => Overlay::Effort,
|
||||
Some("Mcp") => Overlay::Mcp,
|
||||
Some("Todo") => Overlay::Todo,
|
||||
Some("Rewind") => Overlay::Rewind,
|
||||
Some("Learning") => Overlay::Learning,
|
||||
Some("Usage") => Overlay::Usage,
|
||||
Some("Loading") => Overlay::Loading,
|
||||
Some("ModelSelector") => Overlay::ModelSelector,
|
||||
Some("ClearConfirm") => Overlay::ClearConfirm,
|
||||
_ => Overlay::None,
|
||||
};
|
||||
|
||||
state.misc.toasts = payload
|
||||
.toasts
|
||||
.into_iter()
|
||||
.map(|t| Toast {
|
||||
kind: match t.kind.as_str() {
|
||||
"Success" => ToastKind::Success,
|
||||
"Warning" => ToastKind::Warning,
|
||||
"Error" => ToastKind::Error,
|
||||
"Lesson" => ToastKind::Lesson,
|
||||
_ => ToastKind::Info,
|
||||
},
|
||||
message: t.message,
|
||||
created_at: t.created_at,
|
||||
lifetime_ms: t.lifetime_ms,
|
||||
})
|
||||
.collect();
|
||||
|
||||
state.input.buffer = payload.input_buffer;
|
||||
state.input.cursor = payload.input_cursor;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// setup_attach_client — connect to daemon and set up terminal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Set up the IPC client connection, terminal, and initial state for attach mode.
|
||||
///
|
||||
/// Flow: resolve socket path → connect → enable raw/alt mode → create state.
|
||||
///
|
||||
/// Return: (client, terminal, `client_state`) on success.
|
||||
fn setup_attach_client(
|
||||
session_id: &str,
|
||||
) -> Result<(
|
||||
IpcClient,
|
||||
Terminal<CrosstermBackend<io::Stdout>>,
|
||||
AppStateRest,
|
||||
)> {
|
||||
tracing::debug!("setting up attach client for session {session_id}");
|
||||
let store = zesdex_infrastructure::Store::new();
|
||||
let socket_path = store
|
||||
.base_dir
|
||||
.join("run")
|
||||
.join(format!("{session_id}.sock"));
|
||||
let addr = socket_path.to_string_lossy().to_string();
|
||||
let client = IpcClient::connect_unix(&addr)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
execute!(stdout, crossterm::event::EnableBracketedPaste)?;
|
||||
execute!(stdout, crossterm::event::EnableMouseCapture)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
terminal.clear()?;
|
||||
|
||||
let workspace_roots = vec![std::env::current_dir()?];
|
||||
let session_dir = store.base_dir.join("sessions").join(session_id);
|
||||
std::fs::create_dir_all(&session_dir)?;
|
||||
let mut client_state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir);
|
||||
client_state.session_id = session_id.to_string();
|
||||
|
||||
Ok((client, terminal, client_state))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handle_daemon_frame — process a single DaemonFrame from the daemon
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Process a single daemon frame from the IPC channel, updating state accordingly.
|
||||
fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option<DaemonFrame>) {
|
||||
tracing::debug!("received daemon frame");
|
||||
match frame {
|
||||
Some(DaemonFrame::StateUpdate(payload)) => {
|
||||
apply_client_update(client_state, *payload);
|
||||
}
|
||||
Some(DaemonFrame::StreamToken(_token)) => {}
|
||||
Some(DaemonFrame::SystemNote { kind: _, message }) => {
|
||||
client_state.push_toast(Toast::new(ToastKind::Info, message));
|
||||
}
|
||||
Some(DaemonFrame::ClipboardCopy(text)) => {
|
||||
let _ = zesdex_infrastructure::utils::write_osc52(&mut io::stdout(), &text);
|
||||
client_state.push_toast(Toast::new(
|
||||
ToastKind::Success,
|
||||
"Copied to clipboard".to_string(),
|
||||
));
|
||||
}
|
||||
Some(DaemonFrame::Closed) | None => {
|
||||
client_state.quit = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// draw — minimal TUI render function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render the current application state onto the terminal.
|
||||
///
|
||||
/// Layout:
|
||||
/// - Top: title bar with session ID and overlay status
|
||||
/// - Middle: transcript message list (scrollable)
|
||||
/// - Bottom: input line with cursor
|
||||
/// - Overlay name shown when an overlay is active
|
||||
fn draw(frame: &mut ratatui::Frame, state: &AppStateRest) {
|
||||
let area = frame.area();
|
||||
|
||||
// Vertical layout: main content + input line
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Min(1), // main content (transcript + toasts)
|
||||
Constraint::Length(3), // input line
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// ── Main content area ───────────────────────────────────────────────
|
||||
let title = format!(
|
||||
" Zesdex — {} {}",
|
||||
&state.session_id[..state.session_id.len().min(8)],
|
||||
if state.misc.overlay.is_active() {
|
||||
format!("[{}]", state.misc.overlay)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
|
||||
let mut content_lines: Vec<String> = Vec::new();
|
||||
|
||||
// Show toasts at the top if present.
|
||||
for toast in &state.misc.toasts {
|
||||
content_lines.push(format!("[{}] {}", format!("{:?}", toast.kind), toast.message));
|
||||
}
|
||||
|
||||
// Show active overlay name.
|
||||
if state.misc.overlay.is_active() {
|
||||
content_lines.push(String::new());
|
||||
content_lines.push(format!("=== {} ===", state.misc.overlay));
|
||||
content_lines.push(String::new());
|
||||
}
|
||||
|
||||
// Transcript messages.
|
||||
for msg in &state.transcript_cache.messages {
|
||||
let prefix = match msg.role {
|
||||
RoleWrapper::User => "You",
|
||||
RoleWrapper::Assistant => "AI",
|
||||
RoleWrapper::System => "System",
|
||||
RoleWrapper::Tool => "Tool",
|
||||
};
|
||||
content_lines.push(format!("{}: {}", prefix, msg.content));
|
||||
}
|
||||
|
||||
// Scroll offset indicator.
|
||||
if state.scroll.offset > 0 {
|
||||
content_lines.push(format!("--- scrolled up {} lines ---", state.scroll.offset));
|
||||
}
|
||||
|
||||
let content = content_lines.join("\n");
|
||||
|
||||
let main_block = Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::TOP);
|
||||
let paragraph = Paragraph::new(content)
|
||||
.block(main_block)
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((state.scroll.offset as u16, 0));
|
||||
frame.render_widget(paragraph, chunks[0]);
|
||||
|
||||
// ── Input line ──────────────────────────────────────────────────────
|
||||
let input_block = Block::default().borders(Borders::TOP);
|
||||
let input_display = if state.input.buffer.is_empty() {
|
||||
"Type a message...".to_string()
|
||||
} else {
|
||||
state.input.buffer.clone()
|
||||
};
|
||||
let input_paragraph = Paragraph::new(input_display)
|
||||
.block(input_block);
|
||||
frame.render_widget(input_paragraph, chunks[1]);
|
||||
|
||||
// Set cursor position for the input line.
|
||||
use ratatui::layout::Position;
|
||||
frame.set_cursor_position(Position::new(
|
||||
chunks[1].x + state.input.cursor as u16 + 1,
|
||||
chunks[1].y + 1,
|
||||
));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// run_attach — main attach-mode entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Run zesdex as a TUI-only client attached to an existing daemon session.
|
||||
///
|
||||
/// Flow: connect to the daemon's Unix socket → enter raw mode/alternate
|
||||
/// screen → build a local `AppStateRest` mirror (only used for rendering
|
||||
/// and toast/overlay bookkeeping, not agent logic) → loop: poll for a
|
||||
/// terminal event (key/resize) and forward it as a `ClientRequest`, or
|
||||
/// send a `Tick` if idle → read the daemon's `DaemonFrame` reply and
|
||||
/// apply it via `apply_client_update` → redraw → exit when the daemon
|
||||
/// closes or the user quits (sending `ClientRequest::Close` first).
|
||||
///
|
||||
/// Why: Ctrl+C is intercepted locally to quit the client without going
|
||||
/// through the daemon, since the daemon has no notion of "this client
|
||||
/// wants to leave" beyond the explicit `Close` request.
|
||||
pub fn run_attach(session_id: &str) -> Result<()> {
|
||||
use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers, MouseEventKind};
|
||||
|
||||
let (client, mut terminal, mut client_state) = setup_attach_client(session_id)?;
|
||||
let _rt = tokio::runtime::Runtime::new()?;
|
||||
|
||||
loop {
|
||||
if client_state.quit {
|
||||
let _ = client.send(&ClientRequest::Close);
|
||||
break;
|
||||
}
|
||||
|
||||
let now_ms = chrono::Utc::now().timestamp_millis();
|
||||
client_state.misc.drain_expired_toasts(now_ms);
|
||||
|
||||
if crossterm::event::poll(std::time::Duration::from_millis(50))? {
|
||||
match crossterm::event::read()? {
|
||||
Event::Key(key) => {
|
||||
if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat {
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let alt = key.modifiers.contains(KeyModifiers::ALT);
|
||||
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
|
||||
|
||||
if key.code == KeyCode::Char('c') && ctrl {
|
||||
client_state.quit = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(key_action) = key_code_to_action(key.code) {
|
||||
client.send(&ClientRequest::KeyPress {
|
||||
key: key_action,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Paste(text) => {
|
||||
client.send(&ClientRequest::Paste(text))?;
|
||||
}
|
||||
Event::Resize(w, h) => {
|
||||
client.send(&ClientRequest::Resize(w, h))?;
|
||||
}
|
||||
Event::Mouse(mouse_event) => {
|
||||
if mouse_event.kind == MouseEventKind::ScrollUp {
|
||||
client.send(&ClientRequest::ScrollUp)?;
|
||||
} else if mouse_event.kind == MouseEventKind::ScrollDown {
|
||||
client.send(&ClientRequest::ScrollDown)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
client.send(&ClientRequest::Tick)?;
|
||||
}
|
||||
|
||||
handle_daemon_frame(
|
||||
&mut client_state,
|
||||
client.receive::<DaemonFrame>()?,
|
||||
);
|
||||
|
||||
terminal.draw(|f| {
|
||||
draw(f, &client_state);
|
||||
})?;
|
||||
}
|
||||
|
||||
let _ = execute!(io::stdout(), crossterm::event::DisableBracketedPaste);
|
||||
let _ = execute!(io::stdout(), crossterm::event::DisableMouseCapture);
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
let _ = disable_raw_mode();
|
||||
|
||||
let _ = zesdex_infrastructure::persistence::JsonSettingsRepository::new()
|
||||
.save(&client_state.store_base_dir(), &client_state.settings);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user