diff --git a/Cargo.lock b/Cargo.lock index 36bc3719..befb590e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3615,6 +3615,7 @@ dependencies = [ "pod-registry", "protocol", "ratatui", + "serde", "serde_json", "session-store", "tokio", diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 828b1e12..b49a2b60 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -16,3 +16,4 @@ toml = { workspace = true } manifest = { workspace = true } session-store = { workspace = true } pod-registry = { workspace = true } +serde = { workspace = true, features = ["derive"] } diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index d570f74d..38e59b4f 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -11,6 +11,7 @@ use crate::block::{ use crate::cache::FileCache; use crate::input::InputBuffer; use crate::scroll::Scroll; +use crate::task::TaskStore; use crate::ui::Mode; /// In-flight completion popup state. Lives on `App` while the user is @@ -76,6 +77,16 @@ pub struct App { /// Completion popup state, when an `@` / `#` / `/` token is in /// flight. `None` whenever the trigger conditions don't hold. pub completion: Option, + /// In-TUI mirror of the Pod's session task store, reconstructed + /// directly from observed `TaskCreate` / `TaskUpdate` tool calls and + /// `[Session TaskStore snapshot]` system messages — no protocol + /// surface added on the Pod side. + pub task_store: TaskStore, + /// Whether the right-side task pane is currently open. + pub task_pane_open: bool, + /// Top entry index of the task pane's visible window. Clamped on + /// render so it never points past the end of the list. + pub task_pane_scroll: usize, } impl App { @@ -100,9 +111,27 @@ impl App { cache: FileCache::new(), assistant_streaming: false, completion: None, + task_store: TaskStore::new(), + task_pane_open: false, + task_pane_scroll: 0, } } + pub fn toggle_task_pane(&mut self) { + self.task_pane_open = !self.task_pane_open; + if !self.task_pane_open { + self.task_pane_scroll = 0; + } + } + + pub fn scroll_task_pane_up(&mut self, n: usize) { + self.task_pane_scroll = self.task_pane_scroll.saturating_sub(n); + } + + pub fn scroll_task_pane_down(&mut self, n: usize) { + self.task_pane_scroll = self.task_pane_scroll.saturating_add(n); + } + pub fn set_pod_status(&mut self, status: PodStatus) { self.pod_status = status; self.running = status == PodStatus::Running; @@ -352,6 +381,7 @@ impl App { self.blocks.push(Block::AssistantText { text }); } "system" if !text.is_empty() => { + self.task_store.apply_system_message_text(&text); self.blocks.push(Block::SystemMessage { text }); } _ => {} @@ -365,6 +395,9 @@ impl App { let id = item["call_id"].as_str().unwrap_or("").to_owned(); let name = item["name"].as_str().unwrap_or("?").to_owned(); let arguments = item["arguments"].as_str().map(|s| s.to_owned()); + if let Some(args) = arguments.as_deref() { + self.task_store.apply_tool_call(&name, args); + } self.blocks.push(Block::ToolCall(ToolCallBlock { id, name, @@ -535,6 +568,12 @@ impl App { } Event::ToolCallDone { id, arguments, .. } => { self.current_tool = None; + let name = self + .find_tool_call_mut(&id) + .map(|b| b.name.clone()); + if let Some(name) = name.as_deref() { + self.task_store.apply_tool_call(name, &arguments); + } if let Some(b) = self.find_tool_call_mut(&id) { b.arguments = Some(arguments); // Only advance the state when it's still in-flight. @@ -815,6 +854,8 @@ impl App { self.turn_index = 0; self.blocks.clear(); self.cache = FileCache::new(); + self.task_store = TaskStore::new(); + self.task_pane_scroll = 0; self.blocks.push(Block::Greeting(greeting)); self.assistant_streaming = false; @@ -1279,6 +1320,147 @@ mod completion_flow_tests { tools: Vec::new(), } } + + #[test] + fn live_task_create_updates_task_store() { + let mut app = App::new("test".into()); + app.handle_pod_event(Event::ToolCallStart { + id: "c1".into(), + name: "TaskCreate".into(), + }); + app.handle_pod_event(Event::ToolCallDone { + id: "c1".into(), + name: "TaskCreate".into(), + arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(), + }); + let tasks = app.task_store.tasks(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].subject, "impl tasks"); + assert_eq!(tasks[0].status, crate::task::TaskStatus::Pending); + } + + #[test] + fn live_task_update_advances_status() { + let mut app = App::new("test".into()); + for (id, args) in [ + ("c1", r#"{"subject":"a","description":"A"}"#), + ("u1", r#"{"taskid":1,"status":"completed"}"#), + ] { + let name = if id.starts_with('c') { + "TaskCreate" + } else { + "TaskUpdate" + }; + app.handle_pod_event(Event::ToolCallStart { + id: id.into(), + name: name.into(), + }); + app.handle_pod_event(Event::ToolCallDone { + id: id.into(), + name: name.into(), + arguments: args.into(), + }); + } + assert_eq!( + app.task_store.tasks()[0].status, + crate::task::TaskStatus::Completed + ); + } + + #[test] + fn live_system_snapshot_replaces_task_store() { + let mut app = App::new("test".into()); + // Stale entry that the snapshot must wipe out. + app.handle_pod_event(Event::ToolCallStart { + id: "c1".into(), + name: "TaskCreate".into(), + }); + app.handle_pod_event(Event::ToolCallDone { + id: "c1".into(), + name: "TaskCreate".into(), + arguments: r#"{"subject":"stale","description":""}"#.into(), + }); + + let snapshot = "[Session TaskStore snapshot]\n\n\ + TaskStore: 1 task(s)\n\n\ + ```json\n{\n \"tasks\": [\n {\n \"taskid\": 4,\n \ + \"status\": \"inprogress\",\n \"subject\": \"from snapshot\",\n \ + \"description\": \"d\"\n }\n ]\n}\n```\n"; + app.handle_pod_event(Event::SystemMessage { + item: serde_json::json!({ + "type": "message", + "role": "system", + "content": [{ "type": "text", "text": snapshot }], + }), + }); + + let tasks = app.task_store.tasks(); + assert_eq!(tasks.len(), 1); + assert_eq!(tasks[0].taskid, 4); + assert_eq!(tasks[0].subject, "from snapshot"); + } + + #[test] + fn history_replay_reconstructs_task_store() { + let mut app = App::new("test".into()); + // Live tool call before history lands — restore_history must + // wipe this so it doesn't double-count after replay. + app.handle_pod_event(Event::ToolCallStart { + id: "live".into(), + name: "TaskCreate".into(), + }); + app.handle_pod_event(Event::ToolCallDone { + id: "live".into(), + name: "TaskCreate".into(), + arguments: r#"{"subject":"live","description":""}"#.into(), + }); + + app.handle_pod_event(Event::History { + greeting: test_greeting(), + items: vec![ + serde_json::json!({ + "type": "tool_call", + "call_id": "c1", + "name": "TaskCreate", + "arguments": r#"{"subject":"a","description":"A"}"#, + }), + serde_json::json!({ + "type": "tool_call", + "call_id": "c2", + "name": "TaskCreate", + "arguments": r#"{"subject":"b","description":"B"}"#, + }), + serde_json::json!({ + "type": "tool_call", + "call_id": "u1", + "name": "TaskUpdate", + "arguments": r#"{"taskid":2,"status":"inprogress"}"#, + }), + ], + status: PodStatus::Running, + }); + + let tasks = app.task_store.tasks(); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0].subject, "a"); + assert_eq!(tasks[1].subject, "b"); + assert_eq!(tasks[1].status, crate::task::TaskStatus::Inprogress); + } + + #[test] + fn task_pane_toggle_flips_state_and_resets_scroll() { + let mut app = App::new("test".into()); + app.task_pane_scroll = 7; + assert!(!app.task_pane_open); + app.toggle_task_pane(); + assert!(app.task_pane_open); + // Scroll position is preserved on open so the user keeps their + // place if they re-open after closing. + assert_eq!(app.task_pane_scroll, 7); + app.toggle_task_pane(); + assert!(!app.task_pane_open); + assert_eq!(app.task_pane_scroll, 0); + } } /// Seed / mutate the file-content cache based on a completed tool call. diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 9463e4b9..7466238b 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -6,6 +6,7 @@ mod input; mod picker; mod scroll; mod spawn; +mod task; mod tool; mod ui; @@ -384,6 +385,11 @@ fn run_disconnected(_app: &mut App) -> Result<(), Box> { /// looking for. const WHEEL_LINES: usize = 3; +/// Lines to advance per PageUp / PageDown when the task side pane is +/// open. Calibrated so a couple of presses moves through one entry's +/// subject + description block. +const PANE_SCROLL_LINES: usize = 5; + fn handle_mouse(app: &mut App, mouse: MouseEvent) { match mouse.kind { MouseEventKind::ScrollUp => app.scroll.scroll_up(WHEEL_LINES), @@ -427,6 +433,10 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { app.mode = app.mode.cycle(); Some(None) } + KeyCode::Char('t') if ctrl => { + app.toggle_task_pane(); + Some(None) + } KeyCode::Char('a') if ctrl => { app.move_cursor_start(); Some(app.refresh_completion()) @@ -455,14 +465,24 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { return None; } - // Scroll / navigation (history view). + // Scroll / navigation. PageUp / PageDown defaults to history; while + // the task pane is open it scrolls the pane instead so the user can + // browse past entries without first closing the pane. match key.code { KeyCode::PageUp => { - app.scroll.page_up(); + if app.task_pane_open { + app.scroll_task_pane_up(PANE_SCROLL_LINES); + } else { + app.scroll.page_up(); + } return None; } KeyCode::PageDown => { - app.scroll.page_down(); + if app.task_pane_open { + app.scroll_task_pane_down(PANE_SCROLL_LINES); + } else { + app.scroll.page_down(); + } return None; } _ => {} diff --git a/crates/tui/src/task.rs b/crates/tui/src/task.rs new file mode 100644 index 00000000..0c9bdb7d --- /dev/null +++ b/crates/tui/src/task.rs @@ -0,0 +1,317 @@ +//! In-TUI mirror of the session-lifetime task store. +//! +//! This deliberately does NOT depend on `tools::TaskStore`. The TUI is a +//! presentation layer; pulling in `tools` would drag along `llm-worker` +//! and the whole tool surface. Instead we mirror the small subset we +//! need: +//! +//! - `TaskEntry` / `TaskStatus`: shaped to round-trip with `tools`'s JSON +//! serialization (`#[serde(rename_all = "lowercase")]` on the status, +//! matching field names on the entry). +//! - Just enough state machine to apply `TaskCreate` / `TaskUpdate` +//! tool-call arguments and the `[Session TaskStore snapshot]` system +//! message that compaction emits. +//! +//! The snapshot text format is owned by `tools::render_snapshot`. Since +//! `tools` itself parses it back on resume, the shape is a stable +//! contract. + +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TaskStatus { + Pending, + Inprogress, + Completed, + Deleted, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct TaskEntry { + pub taskid: u64, + pub status: TaskStatus, + pub subject: String, + pub description: String, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct TaskCounts { + pub pending: usize, + pub inprogress: usize, + pub completed: usize, + pub deleted: usize, +} + +impl TaskCounts { + pub fn total(&self) -> usize { + self.pending + self.inprogress + self.completed + self.deleted + } + + pub fn active(&self) -> usize { + self.pending + self.inprogress + } +} + +#[derive(Debug, Default, Clone)] +pub struct TaskStore { + next_taskid: u64, + tasks: Vec, +} + +impl TaskStore { + pub fn new() -> Self { + Self { + next_taskid: 1, + tasks: Vec::new(), + } + } + + pub fn tasks(&self) -> &[TaskEntry] { + &self.tasks + } + + pub fn is_empty(&self) -> bool { + self.tasks.is_empty() + } + + pub fn counts(&self) -> TaskCounts { + let mut c = TaskCounts::default(); + for t in &self.tasks { + match t.status { + TaskStatus::Pending => c.pending += 1, + TaskStatus::Inprogress => c.inprogress += 1, + TaskStatus::Completed => c.completed += 1, + TaskStatus::Deleted => c.deleted += 1, + } + } + c + } + + /// Apply a completed `TaskCreate` / `TaskUpdate` tool_call. Other + /// tool names and unparseable JSON are silent no-ops, matching the + /// resilience of `tools::TaskStore::replay_history`. + pub fn apply_tool_call(&mut self, name: &str, arguments: &str) { + match name { + "TaskCreate" => { + if let Ok(p) = serde_json::from_str::(arguments) { + self.tasks.push(TaskEntry { + taskid: self.next_taskid, + status: TaskStatus::Pending, + subject: p.subject, + description: p.description, + }); + self.next_taskid = self.next_taskid.saturating_add(1); + } + } + "TaskUpdate" => { + if let Ok(p) = serde_json::from_str::(arguments) + && let Some(t) = self.tasks.iter_mut().find(|t| t.taskid == p.taskid) + { + if let Some(s) = p.status { + t.status = s; + } + if let Some(s) = p.subject { + t.subject = s; + } + if let Some(d) = p.description { + t.description = d; + } + } + } + _ => {} + } + } + + /// Replace all state from a `[Session TaskStore snapshot]` system + /// message. No-op if the text doesn't carry one. + pub fn apply_system_message_text(&mut self, text: &str) { + if let Some(tasks) = parse_snapshot_text(text) { + self.replace_with(tasks); + } + } + + fn replace_with(&mut self, tasks: Vec) { + self.next_taskid = tasks + .iter() + .map(|t| t.taskid) + .max() + .unwrap_or(0) + .saturating_add(1) + .max(1); + self.tasks = tasks; + } +} + +#[derive(Debug, Deserialize)] +struct TaskCreateParams { + subject: String, + description: String, +} + +#[derive(Debug, Deserialize)] +struct TaskUpdateParams { + taskid: u64, + #[serde(default)] + status: Option, + #[serde(default)] + subject: Option, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Deserialize)] +struct TaskSnapshot { + tasks: Vec, +} + +fn parse_snapshot_text(text: &str) -> Option> { + if !text.starts_with("[Session TaskStore snapshot]") { + return None; + } + let start_marker = "```json\n"; + let end_marker = "\n```"; + let start = text.find(start_marker)? + start_marker.len(); + let rest = &text[start..]; + let end = rest.find(end_marker)?; + let snapshot: TaskSnapshot = serde_json::from_str(&rest[..end]).ok()?; + Some(snapshot.tasks) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_create_assigns_sequential_ids() { + let mut s = TaskStore::new(); + s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":"A"}"#); + s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":"B"}"#); + let tasks = s.tasks(); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0].taskid, 1); + assert_eq!(tasks[0].subject, "a"); + assert_eq!(tasks[0].status, TaskStatus::Pending); + assert_eq!(tasks[1].taskid, 2); + } + + #[test] + fn task_update_changes_status_and_fields() { + let mut s = TaskStore::new(); + s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":"A"}"#); + s.apply_tool_call( + "TaskUpdate", + r#"{"taskid":1,"status":"inprogress","subject":"a-renamed"}"#, + ); + let t = &s.tasks()[0]; + assert_eq!(t.status, TaskStatus::Inprogress); + assert_eq!(t.subject, "a-renamed"); + assert_eq!(t.description, "A"); + } + + #[test] + fn malformed_arguments_are_silently_ignored() { + let mut s = TaskStore::new(); + s.apply_tool_call("TaskCreate", r#"{"subject":1}"#); + s.apply_tool_call("TaskCreate", "not json"); + s.apply_tool_call("Unknown", r#"{"subject":"x","description":"y"}"#); + s.apply_tool_call("TaskUpdate", r#"{"taskid":99,"status":"deleted"}"#); + assert!(s.tasks().is_empty()); + } + + #[test] + fn counts_classifies_each_status() { + let mut s = TaskStore::new(); + s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#); + s.apply_tool_call("TaskCreate", r#"{"subject":"b","description":""}"#); + s.apply_tool_call("TaskCreate", r#"{"subject":"c","description":""}"#); + s.apply_tool_call("TaskUpdate", r#"{"taskid":1,"status":"inprogress"}"#); + s.apply_tool_call("TaskUpdate", r#"{"taskid":2,"status":"completed"}"#); + let c = s.counts(); + assert_eq!(c.pending, 1); + assert_eq!(c.inprogress, 1); + assert_eq!(c.completed, 1); + assert_eq!(c.deleted, 0); + assert_eq!(c.total(), 3); + assert_eq!(c.active(), 2); + } + + /// Snapshot text matches the wrapping `Pod::try_pre_run_compact` / + /// `tools::render_snapshot` produce: header line, blank, overview + /// line, blank, fenced JSON, trailing prose. + fn wrap_snapshot(json_body: &str, overview: &str) -> String { + format!( + "[Session TaskStore snapshot]\n\n{overview}\n\n```json\n{json_body}\n```\n\n\ + This is the complete session task list preserved across compaction. \ + The following TaskList tool result presents the same state through the tool lane." + ) + } + + #[test] + fn snapshot_text_replaces_state_and_advances_next_id() { + let body = r#"{ + "tasks": [ + { + "taskid": 5, + "status": "completed", + "subject": "first", + "description": "first desc" + }, + { + "taskid": 7, + "status": "pending", + "subject": "second", + "description": "second desc" + } + ] +}"#; + let text = wrap_snapshot( + body, + "TaskStore: 2 task(s) (pending: 1, inprogress: 0, completed: 1, deleted: 0)", + ); + let mut s = TaskStore::new(); + s.apply_tool_call("TaskCreate", r#"{"subject":"stale","description":""}"#); + s.apply_system_message_text(&text); + let tasks = s.tasks(); + assert_eq!(tasks.len(), 2); + assert_eq!(tasks[0].taskid, 5); + assert_eq!(tasks[0].status, TaskStatus::Completed); + assert_eq!(tasks[1].taskid, 7); + // Subsequent TaskCreate must continue beyond the highest taskid + // observed in the snapshot. + s.apply_tool_call("TaskCreate", r#"{"subject":"new","description":""}"#); + assert_eq!(s.tasks()[2].taskid, 8); + } + + #[test] + fn unrelated_system_message_is_ignored() { + let mut s = TaskStore::new(); + s.apply_tool_call("TaskCreate", r#"{"subject":"a","description":""}"#); + s.apply_system_message_text("[File: src/main.rs]\nfn main() {}"); + assert_eq!(s.tasks().len(), 1); + } + + #[test] + fn snapshot_text_with_multiline_subject_round_trips() { + // Newlines / shape-breaking chars must survive JSON escaping. + let body = r#"{ + "tasks": [ + { + "taskid": 1, + "status": "inprogress", + "subject": "subject with\nembedded newline", + "description": "desc:\n status: not-actually-a-field" + } + ] +}"#; + let text = wrap_snapshot(body, "TaskStore: 1 task(s)"); + let mut s = TaskStore::new(); + s.apply_system_message_text(&text); + let t = &s.tasks()[0]; + assert_eq!(t.subject, "subject with\nembedded newline"); + assert_eq!( + t.description, + "desc:\n status: not-actually-a-field" + ); + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 406552db..d22f2bd2 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -26,6 +26,7 @@ use protocol::{AlertLevel, CompletionEntry, Greeting, PodEvent, Segment}; use crate::app::{App, CompletionState, alert_source_label, fmt_tokens}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; +use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; /// Display density for the history view. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -64,9 +65,11 @@ pub fn draw(frame: &mut Frame, app: &mut App) { let input_content_width = area.width.saturating_sub(2).max(1); let input_render = app.input.render(input_content_width); let input_height = input_area_height(&input_render, area.height); + let mini_view_h = task_mini_view_height(&app.task_store); let chunks = Layout::vertical([ Constraint::Min(0), // history view + Constraint::Length(mini_view_h), // task mini-view (0 when empty) Constraint::Length(1), // separator Constraint::Length(1), // status Constraint::Length(input_height), // input area @@ -74,11 +77,109 @@ pub fn draw(frame: &mut Frame, app: &mut App) { .split(area); draw_history(frame, app, chunks[0]); - draw_separator(frame, chunks[1]); - draw_status(frame, app, chunks[2]); - draw_input(frame, &input_render, chunks[3]); + if mini_view_h > 0 { + draw_task_mini_view(frame, &app.task_store, chunks[1]); + } + draw_separator(frame, chunks[2]); + draw_status(frame, app, chunks[3]); + draw_input(frame, &input_render, chunks[4]); if let Some(state) = app.completion.as_ref().filter(|c| c.is_active()) { - draw_completion_popup(frame, state, chunks[3]); + draw_completion_popup(frame, state, chunks[4]); + } +} + +/// Maximum number of active (pending / inprogress) tasks the mini-view +/// shows above the summary line. Exceeding tasks are still counted in +/// the summary. +const MINI_VIEW_MAX_ACTIVE: usize = 3; + +/// Height the mini-view section occupies. Returns 0 when there are no +/// tasks at all, so the section collapses cleanly into surrounding +/// layout — there's no point reserving rows for an empty store. +fn task_mini_view_height(store: &TaskStore) -> u16 { + if store.is_empty() { + return 0; + } + let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE); + // active rows + 1 summary line + (active_shown as u16).saturating_add(1) +} + +fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) { + if area.height == 0 || area.width == 0 { + return; + } + let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING)); + let inner = outer_block.inner(area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let mut lines: Vec> = Vec::with_capacity(area.height as usize); + let mut shown = 0usize; + for entry in store.tasks() { + if shown >= MINI_VIEW_MAX_ACTIVE { + break; + } + if !matches!(entry.status, TaskStatus::Pending | TaskStatus::Inprogress) { + continue; + } + lines.push(mini_view_active_line(entry, inner.width)); + shown += 1; + } + lines.push(mini_view_summary_line(store.counts(), inner.width)); + + Paragraph::new(lines) + .block(outer_block) + .render(area, frame.buffer_mut()); +} + +fn mini_view_active_line(entry: &TaskEntry, width: u16) -> Line<'static> { + let mark = task_status_mark(entry.status); + // Subject's first line only; embedded newlines would otherwise + // wreck the one-row-per-task layout. + let subject = entry.subject.lines().next().unwrap_or(""); + let mark_width = UnicodeWidthStr::width(mark.0); + // Reserve mark + space. + let budget = (width as usize).saturating_sub(mark_width + 1); + let shown = truncate_with_ellipsis(subject, budget); + Line::from(vec![ + Span::styled(mark.0.to_owned(), mark.1), + Span::raw(" "), + Span::raw(shown), + ]) +} + +fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> { + let text = format!( + "{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}", + counts.total(), + counts.pending, + counts.inprogress, + counts.completed, + counts.deleted, + ); + let shown = truncate_with_ellipsis(&text, width as usize); + Line::from(Span::styled( + shown, + Style::default().fg(Color::DarkGray), + )) +} + +/// Two-character status marker + the style to render it with. Mirrors +/// the four `TaskStatus` values; deleted ones never appear in the +/// mini-view but are listed in the side pane. +fn task_status_mark(status: TaskStatus) -> (&'static str, Style) { + match status { + TaskStatus::Pending => ("[ ]", Style::default().fg(Color::DarkGray)), + TaskStatus::Inprogress => ( + "[~]", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + TaskStatus::Completed => ("[x]", Style::default().fg(Color::Green)), + TaskStatus::Deleted => ("[-]", Style::default().fg(Color::Red)), } } @@ -217,8 +318,23 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { app.scroll.turn_starts.clear(); return; } + + // When the task pane is open and the area is wide enough, carve a + // vertical strip on the right for it. Side pane lives inside the + // history rect only — separator / status / input stay full width to + // keep the input experience and completion popup geometry intact. + let pane_w = task_side_pane_width(area.width, app.task_pane_open); + let history_area = if pane_w > 0 { + let split = + Layout::horizontal([Constraint::Min(1), Constraint::Length(pane_w)]).split(area); + draw_task_side_pane(frame, app, split[1]); + split[0] + } else { + area + }; + let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING)); - let inner = outer_block.inner(area); + let inner = outer_block.inner(history_area); if inner.width == 0 || inner.height == 0 { return; } @@ -248,6 +364,98 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { // uniformly for all rows. Paragraph::new(visible) .block(outer_block) + .render(history_area, frame.buffer_mut()); +} + +/// Width to reserve for the task side pane within the history rect. +/// Returns 0 when the pane is closed or the rect is too narrow to host +/// it without crushing the history view. +fn task_side_pane_width(area_width: u16, open: bool) -> u16 { + if !open { + return 0; + } + // Need a reasonable history column on the left, and enough room on + // the right for taskid + status mark + a few words of subject. Skip + // entirely on narrow terminals. + if area_width < 60 { + return 0; + } + (area_width / 3).clamp(28, 44) +} + +fn draw_task_side_pane(frame: &mut Frame, app: &mut App, area: Rect) { + if area.width < 4 || area.height < 1 { + return; + } + let pane_block = UiBlock::default() + .borders(Borders::LEFT) + .border_style(Style::default().fg(Color::DarkGray)) + .padding(Padding::horizontal(1)); + let inner = pane_block.inner(area); + if inner.width == 0 || inner.height == 0 { + return; + } + + let store = &app.task_store; + let counts = store.counts(); + let title_style = Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD); + let body_style = Style::default().fg(Color::DarkGray); + let muted_style = Style::default().fg(Color::DarkGray); + + let mut logical: Vec> = Vec::new(); + logical.push(Line::from(Span::styled( + format!("Tasks ({})", counts.total()), + title_style, + ))); + logical.push(Line::from("")); + + if store.is_empty() { + logical.push(Line::from(Span::styled("(no tasks)", muted_style))); + } else { + for entry in store.tasks() { + let mark = task_status_mark(entry.status); + let subject_first = entry.subject.lines().next().unwrap_or(""); + logical.push(Line::from(vec![ + Span::styled(format!("#{} ", entry.taskid), muted_style), + Span::styled(mark.0.to_owned(), mark.1), + Span::raw(" "), + Span::raw(subject_first.to_owned()), + ])); + // Subject continuations (multiline subjects). + for cont in entry.subject.lines().skip(1) { + logical.push(Line::from(vec![ + Span::raw(" "), + Span::raw(cont.to_owned()), + ])); + } + for raw in entry.description.lines() { + logical.push(Line::from(vec![ + Span::styled(" ", body_style), + Span::styled(raw.to_owned(), body_style), + ])); + } + logical.push(Line::from("")); + } + } + + // Pre-wrap to inner width so scroll math degenerates to row indices. + let mut wrapped: Vec> = Vec::with_capacity(logical.len()); + for line in logical { + wrap_line_into(line, inner.width, &mut wrapped); + } + + let max_scroll = wrapped.len().saturating_sub(inner.height as usize); + if app.task_pane_scroll > max_scroll { + app.task_pane_scroll = max_scroll; + } + let start = app.task_pane_scroll; + let end = (start + inner.height as usize).min(wrapped.len()); + let visible: Vec> = wrapped[start..end].to_vec(); + + Paragraph::new(visible) + .block(pane_block) .render(area, frame.buffer_mut()); } diff --git a/tickets/tui-task-display.md b/tickets/tui-task-display.md index 1d15ab84..074906c7 100644 --- a/tickets/tui-task-display.md +++ b/tickets/tui-task-display.md @@ -20,31 +20,33 @@ - resume / 再接続時はサーバから来る history replay 経路に乗っかれば自動で復元される - 単一情報源(history)に揃うため、Pod と TUI で snapshot がズレない - TUI 上の表示は 2 ヶ所 - - **入力欄直上のミニビュー(3〜4 行)**: 常時表示。active(`pending` + `inprogress`)優先で最大 2〜3 件を `状態マーク + subject` 1 行ずつ、加えて件数サマリ 1 行 + - **history 直下のミニビュー**: history と separator の間に挟む段。タスクが 1 件以上あるときだけ描画し、0 件なら領域ごと畳む。active(`pending` + `inprogress`)優先で最大 2〜3 件を `状態マーク + subject` 1 行ずつ、加えて件数サマリ 1 行 - **サイドペイン(全件)**: トグルキーで右側に開閉。`pending` / `inprogress` / `completed` / `deleted` を区別して全件列挙、`description` も含める -- `tools` クレートに依存させる(または replay 部分のみを薄いヘルパとして切り出して共有する)。LLM コンテキスト加工原則との整合は自明(history の純粋な再現変換であり context への割り込みではない) +- **`tools` クレートには依存させない**。TUI は表示専門レイヤなので、依存すると `llm-worker` や全ファイルツール一式まで引き込んで割に合わない。TUI 内に薄い mirror(`TaskEntry` / `TaskStatus` / 小さい `TaskStore`)を持ち、`TaskCreate` / `TaskUpdate` の引数 JSON と `[Session TaskStore snapshot]` system message を直接 parse する。snapshot のフォーマットは `tools` 側 `render_snapshot` が source of truth で、resume 時に `tools` 自身が再 parse する契約になっているので、TUI 側もこの契約に乗る形になる。LLM コンテキスト加工原則との整合は自明(history の純粋な再現変換であり context への割り込みではない) ## 要件 ### TUI 側の TaskStore 取り回し -- `App` に最新 `TaskStore`(または `TaskSnapshot`)を保持するフィールドを追加する +- `App` に最新 `TaskStore`(または `TaskSnapshot`)を保持するフィールドを追加する。型は TUI 内 mirror(`tools` 依存なし) - 受信パスで以下を観測して TaskStore に反映する - - `ToolCall` 完了時、`name == "TaskCreate"` / `"TaskUpdate"` なら `arguments` を `TaskStore::from_history` と同じパースで適用 - - `SystemMessage` 観測時、本文が `[Session TaskStore snapshot]` で始まるなら `parse_compact_snapshot_text` 相当で snapshot に置き換え + - `ToolCall` 完了時、`name == "TaskCreate"` / `"TaskUpdate"` なら `arguments` を `tools` 側と同じスキーマで parse し、create / update を適用 + - `SystemMessage` 観測時、本文が `[Session TaskStore snapshot]` で始まるなら埋め込み JSON ブロックを抽出して snapshot に置き換え - 初回 history replay の際も同じ経路を通せば、TUI 起動時点の状態が自動的に復元される - 部分的引数(streaming 中)は適用しない。tool_call が完了して `arguments` が確定したタイミングだけ反映する +- 不正な JSON や未知のフィールドは黙って無視する(`tools` 側 `replay_history` と同じく落ちない) ### TUI ミニビュー -- 入力欄の直上に固定で 3〜4 行の領域を確保 +- 配置は `history / mini-view / separator / status / input` の縦積み(history 直下、separator より上) +- タスクが 1 件以上あるときだけ描画する。0 件なら領域ごと畳んで既存レイアウトと同じ高さに戻る - active(`pending` + `inprogress`)を優先して最大 2〜3 件、`状態マーク + subject` を 1 行ずつ表示。`subject` が改行を含む場合は先頭行のみ - 件数サマリ 1 行(`pending` / `inprogress` / `completed` / `deleted` の内訳) -- active 0 件のときの見せ方は実装時に決める(サマリ行のみ残すか、領域ごと畳むか) ### TUI サイドペイン -- トグルキーで右側に開閉する横分割レイアウト(割当キーは実装時に決める) +- トグルキー: `Ctrl-T` +- 横分割レイアウトは history 領域内で行う。status / input / separator は引き続き全幅で残し、completion popup や入力体験への影響を最小化する - 開いている間は全件を表示。各エントリは `taskid` / `status` / `subject` / `description` を含める - スクロール対応(タスク数が画面高を超えてもよい) @@ -72,6 +74,7 @@ ## 参照 -- 実装: `crates/tools/src/task.rs`(`TaskStore::from_history` / `parse_compact_snapshot_text` / `TaskStatus` / `TaskEntry`)、`crates/tui/src/ui.rs`、`crates/tui/src/block.rs`(`ToolCallBlock` / `SystemMessage`) +- 参考実装: `crates/tools/src/task.rs`(`TaskStore::from_history` / `parse_compact_snapshot_text` / `TaskStatus` / `TaskEntry` / `render_snapshot`)。TUI 側はこれらを依存として取り込まず、同等のロジックを薄く再実装する +- TUI 側受信パス: `crates/tui/src/app.rs`(`Event::ToolCallDone` / `Event::SystemMessage` / `Event::History`)、`crates/tui/src/ui.rs`、`crates/tui/src/block.rs`(`ToolCallBlock` / `SystemMessage`) - 設計指針: `AGENTS.md`「LLM コンテキストの加工原則」(history からの決定的再構成は許容変換) - 関連チケット: `tickets/session-todo.md`(Task ツール本体)、`tickets/session-todo-reminder.md`(LLM 側ナッジ)