diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index fd632bb9..7571bb7f 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -233,6 +233,12 @@ pub struct InternalWorkerView { pub app: Box, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WorkerViewTab { + pub label: String, + pub selected: bool, +} + pub struct App { pub worker_name: String, pub connected: bool, @@ -281,8 +287,11 @@ pub struct App { /// replayable conversation rows during segment rotation. run_error_messages: Vec, /// Presentation-only Internal Worker projections keyed by session identity. - /// They are rendered in separate sub-panes and never mixed into `blocks`. + /// They are rendered in separate selectable views and never mixed into `blocks`. pub internal_workers: Vec, + /// Selected Internal Worker transcript/task view. `None` is the parent (`main`) + /// view; the stable session identity survives projection reordering. + selected_internal_worker_session_id: Option, pub scroll: Scroll, pub mode: Mode, pub cache: FileCache, @@ -361,6 +370,7 @@ impl App { blocks: Vec::new(), run_error_messages: Vec::new(), internal_workers: Vec::new(), + selected_internal_worker_session_id: None, scroll: Scroll::default(), mode: Mode::Normal, cache: FileCache::new(), @@ -445,16 +455,98 @@ impl App { 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; + self.selected_worker_view_mut().task_pane_scroll = 0; + } + } + + pub fn worker_view_tabs(&self) -> Vec { + let selected = self.selected_internal_worker_session_id.as_deref(); + let mut tabs = Vec::with_capacity(self.internal_workers.len().saturating_add(1)); + tabs.push(WorkerViewTab { + label: "main".to_owned(), + selected: selected.is_none(), + }); + tabs.extend(self.internal_workers.iter().map(|view| { + WorkerViewTab { + label: view + .worker + .name + .lines() + .next() + .filter(|name| !name.is_empty()) + .unwrap_or("subworker") + .to_owned(), + selected: selected == Some(view.worker.session_id.as_str()), + } + })); + tabs + } + + pub fn selected_internal_worker_index(&self) -> Option { + let selected = self.selected_internal_worker_session_id.as_deref()?; + self.internal_workers + .iter() + .position(|view| view.worker.session_id == selected) + } + + pub fn selected_worker_view(&self) -> &App { + self.selected_internal_worker_index() + .map(|index| self.internal_workers[index].app.as_ref()) + .unwrap_or(self) + } + + pub fn selected_worker_view_mut(&mut self) -> &mut App { + if let Some(index) = self.selected_internal_worker_index() { + self.internal_workers[index].app.as_mut() + } else { + self + } + } + + /// Cycle the presentation-only transcript/task view. Input and control + /// methods continue to target the parent Worker regardless of selection. + pub fn cycle_worker_view(&mut self) -> bool { + if self.internal_workers.is_empty() { + self.selected_internal_worker_session_id = None; + return false; + } + + self.selected_internal_worker_session_id = self + .selected_internal_worker_index() + .and_then(|index| self.internal_workers.get(index.saturating_add(1))) + .map(|view| view.worker.session_id.clone()) + .or_else(|| { + if self.selected_internal_worker_session_id.is_none() { + self.internal_workers + .first() + .map(|view| view.worker.session_id.clone()) + } else { + None + } + }); + true + } + + pub fn cycle_mode(&mut self) { + let mode = self.mode.cycle(); + self.set_mode_recursively(mode); + } + + fn set_mode_recursively(&mut self, mode: Mode) { + self.mode = mode; + for view in &mut self.internal_workers { + view.app.set_mode_recursively(mode); } } pub fn scroll_task_pane_up(&mut self, n: usize) { - self.task_pane_scroll = self.task_pane_scroll.saturating_sub(n); + let view = self.selected_worker_view_mut(); + view.task_pane_scroll = view.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); + let view = self.selected_worker_view_mut(); + view.task_pane_scroll = view.task_pane_scroll.saturating_add(n); } pub fn set_worker_status(&mut self, status: WorkerStatus) { @@ -1738,6 +1830,9 @@ impl App { } pub fn request_rewind_picker(&mut self) -> Option { + // Rewind is a parent Worker control surface. Bring the parent transcript + // back into view before presenting diagnostics or the picker. + self.selected_internal_worker_session_id = None; if self.rewind_submit_pending() { self.push_command_diagnostic( "rewind is already applying; wait for the Worker response", @@ -1998,14 +2093,65 @@ impl App { /// produced. Followed by `Event::Entry` updates for anything /// committed after the snapshot. fn replace_internal_worker_snapshots(&mut self, snapshots: Vec) { + let mode = self.mode; + let mut previous = std::mem::take(&mut self.internal_workers); self.internal_workers = snapshots .into_iter() - .map(Self::internal_worker_view_from_snapshot) + .map(|snapshot| { + if let Some(index) = previous + .iter() + .position(|view| view.worker.session_id == snapshot.worker.session_id) + { + let view = previous.remove(index); + Self::update_internal_worker_view_from_snapshot(view, snapshot, mode) + } else { + Self::internal_worker_view_from_snapshot(snapshot, mode) + } + }) .collect(); + if self.selected_internal_worker_index().is_none() { + self.selected_internal_worker_session_id = None; + } } - fn internal_worker_view_from_snapshot(snapshot: InternalWorkerSnapshot) -> InternalWorkerView { + fn update_internal_worker_view_from_snapshot( + mut previous: InternalWorkerView, + snapshot: InternalWorkerSnapshot, + mode: Mode, + ) -> InternalWorkerView { + let mut refreshed = Self::internal_worker_view_from_snapshot(snapshot, mode); + Self::transfer_worker_view_state(&mut previous.app, &mut refreshed.app); + refreshed + } + + fn transfer_worker_view_state(previous: &mut App, refreshed: &mut App) { + refreshed.scroll = std::mem::take(&mut previous.scroll); + refreshed.text_selection = std::mem::take(&mut previous.text_selection); + refreshed.task_pane_scroll = previous.task_pane_scroll; + refreshed.selected_internal_worker_session_id = + previous.selected_internal_worker_session_id.take(); + + let mut previous_children = std::mem::take(&mut previous.internal_workers); + for child in &mut refreshed.internal_workers { + if let Some(index) = previous_children + .iter() + .position(|old| old.worker.session_id == child.worker.session_id) + { + let mut old = previous_children.remove(index); + Self::transfer_worker_view_state(&mut old.app, &mut child.app); + } + } + if refreshed.selected_internal_worker_index().is_none() { + refreshed.selected_internal_worker_session_id = None; + } + } + + fn internal_worker_view_from_snapshot( + snapshot: InternalWorkerSnapshot, + mode: Mode, + ) -> InternalWorkerView { let mut app = App::new(snapshot.worker.name.clone()); + app.mode = mode; app.restore_entries(&snapshot.entries, None); app.apply_in_flight_snapshot(snapshot.in_flight); app.set_worker_status(snapshot.status); @@ -2036,10 +2182,12 @@ impl App { let target = if let Some(index) = index { &mut self.internal_workers[index] } else { + let mut app = App::new(worker.name.clone()); + app.mode = self.mode; self.internal_workers.push(InternalWorkerView { worker: worker.clone(), revision: 0, - app: Box::new(App::new(worker.name.clone())), + app: Box::new(app), }); self.internal_workers.last_mut().unwrap() }; @@ -3546,6 +3694,188 @@ mod completion_flow_tests { ); } + fn test_internal_worker_snapshot( + session_id: &str, + name: &str, + revision: u64, + ) -> InternalWorkerSnapshot { + InternalWorkerSnapshot { + worker: InternalWorkerRef { + session_id: session_id.into(), + name: name.into(), + parent_session_id: Some("parent".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision, + status: WorkerStatus::Idle, + entries: Vec::new(), + in_flight: protocol::InFlightSnapshot::default(), + error: None, + internal_workers: Vec::new(), + } + } + + #[test] + fn worker_view_cycle_uses_stable_session_identity_and_wraps_to_main() { + let mut app = App::new("parent".into()); + for (session_id, name) in [("child-a", "alpha"), ("child-b", "beta")] { + app.internal_workers.push(InternalWorkerView { + worker: InternalWorkerRef { + session_id: session_id.into(), + name: name.into(), + parent_session_id: Some("parent".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision: 1, + app: Box::new(App::new(name.into())), + }); + } + + assert_eq!(app.selected_worker_view().worker_name, "parent"); + assert!(app.cycle_worker_view()); + assert_eq!(app.selected_worker_view().worker_name, "alpha"); + + app.internal_workers.swap(0, 1); + assert_eq!(app.selected_worker_view().worker_name, "alpha"); + assert!(app.cycle_worker_view()); + assert_eq!(app.selected_worker_view().worker_name, "parent"); + assert!(app.cycle_worker_view()); + assert_eq!(app.selected_worker_view().worker_name, "beta"); + } + + #[test] + fn worker_view_cycle_preserves_each_views_text_selection() { + use crate::text_selection::{HistoryViewport, SelectionRow}; + + fn select_first_row(app: &mut App, text: &str) { + app.text_selection.set_history_snapshot( + HistoryViewport { + x: 0, + y: 0, + width: 20, + height: 1, + top_offset: 0, + total_lines: 1, + }, + vec![SelectionRow::new(text.into(), true)], + ); + assert!(app.text_selection.begin_drag(0, 0)); + } + + let mut app = App::new("parent".into()); + app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( + "child", "child", 1, + )]); + select_first_row(&mut app, "parent selection"); + select_first_row(app.internal_workers[0].app.as_mut(), "child selection"); + + app.cycle_worker_view(); + assert!(app.selected_worker_view().text_selection.has_selection()); + app.cycle_worker_view(); + + assert!(app.text_selection.has_selection()); + assert!(app.internal_workers[0].app.text_selection.has_selection()); + } + + #[test] + fn snapshot_removal_falls_selected_worker_view_back_to_main() { + let mut app = App::new("parent".into()); + app.internal_workers.push(InternalWorkerView { + worker: InternalWorkerRef { + session_id: "old".into(), + name: "old".into(), + parent_session_id: Some("parent".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision: 1, + app: Box::new(App::new("old".into())), + }); + app.cycle_worker_view(); + assert_eq!(app.selected_worker_view().worker_name, "old"); + + app.replace_internal_worker_snapshots(Vec::new()); + + assert_eq!(app.selected_worker_view().worker_name, "parent"); + assert_eq!( + app.worker_view_tabs(), + vec![WorkerViewTab { + label: "main".into(), + selected: true, + }] + ); + } + + #[test] + fn same_session_snapshot_preserves_subworker_view_local_state() { + use crate::text_selection::{HistoryViewport, SelectionRow}; + + let mut app = App::new("parent".into()); + app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( + "child", "child", 1, + )]); + let child = app.internal_workers[0].app.as_mut(); + child.scroll.follow_tail = false; + child.scroll.top_offset = 7; + child.task_pane_scroll = 4; + child.text_selection.set_history_snapshot( + HistoryViewport { + x: 0, + y: 0, + width: 20, + height: 1, + top_offset: 0, + total_lines: 1, + }, + vec![SelectionRow::new("selected".into(), true)], + ); + assert!(child.text_selection.begin_drag(0, 0)); + + app.replace_internal_worker_snapshots(vec![test_internal_worker_snapshot( + "child", + "renamed-child", + 2, + )]); + + let view = &app.internal_workers[0]; + assert_eq!(view.revision, 2); + assert_eq!(view.app.worker_name, "renamed-child"); + assert!(!view.app.scroll.follow_tail); + assert_eq!(view.app.scroll.top_offset, 7); + assert_eq!(view.app.task_pane_scroll, 4); + assert!(view.app.text_selection.has_selection()); + } + + #[test] + fn task_pane_scroll_is_local_to_selected_worker_view() { + let mut app = App::new("parent".into()); + app.replace_internal_worker_snapshots(vec![ + test_internal_worker_snapshot("child-a", "alpha", 1), + test_internal_worker_snapshot("child-b", "beta", 1), + ]); + app.task_pane_scroll = 3; + + app.cycle_worker_view(); + app.scroll_task_pane_down(5); + assert_eq!(app.selected_worker_view().task_pane_scroll, 5); + + app.cycle_worker_view(); + app.scroll_task_pane_down(7); + assert_eq!(app.selected_worker_view().task_pane_scroll, 7); + + app.cycle_worker_view(); + assert_eq!(app.selected_worker_view().worker_name, "parent"); + assert_eq!(app.task_pane_scroll, 3); + assert_eq!(app.internal_workers[0].app.task_pane_scroll, 5); + assert_eq!(app.internal_workers[1].app.task_pane_scroll, 7); + + app.cycle_worker_view(); + app.toggle_task_pane(); + app.toggle_task_pane(); + assert_eq!(app.internal_workers[0].app.task_pane_scroll, 0); + assert_eq!(app.internal_workers[1].app.task_pane_scroll, 7); + assert_eq!(app.task_pane_scroll, 3); + } + #[test] fn snapshot_authoritatively_replaces_internal_worker_views() { let mut app = App::new("parent".into()); diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index 8c32fac8..e7991a9c 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -96,12 +96,12 @@ fn copy_to_terminal_clipboard(out: &mut W, text: &str) -> io::Resu } fn copy_selection_to_writer(app: &mut App, out: &mut W) -> bool { - let Some(text) = app.text_selection.copy_text() else { + let Some(text) = app.selected_worker_view_mut().text_selection.copy_text() else { return false; }; let result = copy_to_terminal_clipboard(out, &text); - app.text_selection.clear(); + app.selected_worker_view_mut().text_selection.clear(); match result { Ok(()) => { app.flash_actionbar_notice( @@ -890,25 +890,27 @@ const WHEEL_LINES: usize = 3; const PANE_SCROLL_LINES: usize = 5; fn handle_mouse(app: &mut App, mouse: MouseEvent) { + let rewind_picker_open = app.rewind_picker.is_some(); + let view = app.selected_worker_view_mut(); match mouse.kind { MouseEventKind::ScrollUp => { - app.text_selection.clear(); - app.scroll.scroll_up(WHEEL_LINES); + view.text_selection.clear(); + view.scroll.scroll_up(WHEEL_LINES); } MouseEventKind::ScrollDown => { - app.text_selection.clear(); - app.scroll.scroll_down(WHEEL_LINES); + view.text_selection.clear(); + view.scroll.scroll_down(WHEEL_LINES); } - MouseEventKind::Down(MouseButton::Left) if app.rewind_picker.is_none() => { - if !app.text_selection.begin_drag(mouse.column, mouse.row) { - app.text_selection.clear(); + MouseEventKind::Down(MouseButton::Left) if !rewind_picker_open => { + if !view.text_selection.begin_drag(mouse.column, mouse.row) { + view.text_selection.clear(); } } - MouseEventKind::Drag(MouseButton::Left) if app.rewind_picker.is_none() => { - app.text_selection.update_drag(mouse.column, mouse.row); + MouseEventKind::Drag(MouseButton::Left) if !rewind_picker_open => { + view.text_selection.update_drag(mouse.column, mouse.row); } - MouseEventKind::Up(MouseButton::Left) if app.rewind_picker.is_none() => { - app.text_selection.finish_drag(mouse.column, mouse.row); + MouseEventKind::Up(MouseButton::Left) if !rewind_picker_open => { + view.text_selection.finish_drag(mouse.column, mouse.row); } _ => {} } @@ -942,31 +944,31 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { // Modifier-key bindings. if let Some(method) = match key.code { KeyCode::Up if shift => { - app.scroll.scroll_up(1); + app.selected_worker_view_mut().scroll.scroll_up(1); Some(None) } KeyCode::Down if shift => { - app.scroll.scroll_down(1); + app.selected_worker_view_mut().scroll.scroll_down(1); Some(None) } KeyCode::Home if ctrl => { - app.scroll.to_top(); + app.selected_worker_view_mut().scroll.to_top(); Some(None) } KeyCode::End if ctrl => { - app.scroll.to_bottom(); + app.selected_worker_view_mut().scroll.to_bottom(); Some(None) } KeyCode::Char('[') if ctrl => { - app.scroll.jump_prev_turn(); + app.selected_worker_view_mut().scroll.jump_prev_turn(); Some(None) } KeyCode::Char(']') if ctrl => { - app.scroll.jump_next_turn(); + app.selected_worker_view_mut().scroll.jump_next_turn(); Some(None) } KeyCode::Char('o') if ctrl => { - app.mode = app.mode.cycle(); + app.cycle_mode(); Some(None) } KeyCode::Char('t') if ctrl => { @@ -1047,7 +1049,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { if app.task_pane_open { app.scroll_task_pane_up(PANE_SCROLL_LINES); } else { - app.scroll.page_up(); + app.selected_worker_view_mut().scroll.page_up(); } return None; } @@ -1055,7 +1057,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { if app.task_pane_open { app.scroll_task_pane_down(PANE_SCROLL_LINES); } else { - app.scroll.page_down(); + app.selected_worker_view_mut().scroll.page_down(); } return None; } @@ -1130,12 +1132,17 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { } } + if key.code == KeyCode::Tab && key.modifiers.is_empty() && app.completion.is_none() { + app.cycle_worker_view(); + return None; + } + if key.modifiers.is_empty() { match key.code { - KeyCode::Esc if app.text_selection.clear() => return None, - KeyCode::Char('y') if app.text_selection.has_selection() => { + KeyCode::Esc if app.selected_worker_view_mut().text_selection.clear() => return None, + KeyCode::Char('y') if app.selected_worker_view().text_selection.has_selection() => { if !copy_selection_to_terminal(app) { - app.text_selection.clear(); + app.selected_worker_view_mut().text_selection.clear(); app.flash_actionbar_notice( "Selection contains no copyable text.", ActionbarNoticeLevel::Warn, @@ -2170,6 +2177,18 @@ mod tests { #[test] fn command_completion_tab_applies_unambiguous_candidate() { let mut app = App::new("agent".to_string()); + app.handle_worker_event(Event::InternalWorker { + worker: protocol::InternalWorkerRef { + session_id: "child-session".into(), + name: "subworker-hoge".into(), + parent_session_id: Some("parent-session".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision: 1, + event: Box::new(Event::Status { + status: WorkerStatus::Running, + }), + }); enter_command_mode(&mut app); type_keys(&mut app, "no"); @@ -2177,6 +2196,7 @@ mod tests { assert!(app.is_command_mode()); assert_eq!(app.command_text(), "noop "); + assert_eq!(app.selected_worker_view().worker_name, "agent"); assert_eq!(input_text(&app), ""); } @@ -2269,6 +2289,89 @@ mod tests { assert_eq!(input_text(&app), ""); } + #[test] + fn tab_cycles_main_and_subworker_view_without_changing_composer() { + let mut app = App::new("agent".to_string()); + type_keys(&mut app, "hello"); + app.handle_worker_event(Event::InternalWorker { + worker: protocol::InternalWorkerRef { + session_id: "child-session".into(), + name: "subworker-hoge".into(), + parent_session_id: Some("parent-session".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision: 1, + event: Box::new(Event::Status { + status: WorkerStatus::Running, + }), + }); + + assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); + assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge"); + assert_eq!(input_text(&app), "hello"); + + assert!(handle_key(&mut app, key(KeyCode::Tab)).is_none()); + assert_eq!(app.selected_worker_view().worker_name, "agent"); + assert_eq!(input_text(&app), "hello"); + } + + #[test] + fn subworker_view_does_not_redirect_parent_worker_controls() { + let mut app = App::new("agent".to_string()); + app.set_worker_status(WorkerStatus::Idle); + app.handle_worker_event(Event::InternalWorker { + worker: protocol::InternalWorkerRef { + session_id: "child-session".into(), + name: "subworker-hoge".into(), + parent_session_id: Some("parent-session".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision: 1, + event: Box::new(Event::Status { + status: WorkerStatus::Running, + }), + }); + handle_key(&mut app, key(KeyCode::Tab)); + assert_eq!(app.selected_worker_view().worker_name, "subworker-hoge"); + + let method = handle_key( + &mut app, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), + ); + + assert!(matches!(method, Some(Method::Shutdown))); + assert_eq!(app.worker_status, WorkerStatus::Idle); + } + + #[test] + fn active_composer_completion_takes_tab_priority_over_worker_view_cycle() { + let mut app = App::new("agent".to_string()); + app.insert_char('@'); + app.insert_char('s'); + let _ = app.refresh_completion(); + app.completion.as_mut().unwrap().entries = vec![protocol::CompletionEntry { + value: "src/main.rs".into(), + is_dir: false, + }]; + app.handle_worker_event(Event::InternalWorker { + worker: protocol::InternalWorkerRef { + session_id: "child-session".into(), + name: "subworker-hoge".into(), + parent_session_id: Some("parent-session".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision: 1, + event: Box::new(Event::Status { + status: WorkerStatus::Running, + }), + }); + + let _ = handle_key(&mut app, key(KeyCode::Tab)); + + assert_eq!(app.selected_worker_view().worker_name, "agent"); + assert_eq!(input_text(&app), "@src/main.rs"); + } + #[test] fn command_completion_does_not_affect_normal_composer_without_popup() { let mut app = App::new("agent".to_string()); diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 5f0ac086..63706fe2 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -27,7 +27,9 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use protocol::{AlertLevel, CompletionEntry, Greeting, Segment, WorkerEvent}; -use crate::app::{ActionbarNoticeLevel, App, CompletionState, alert_source_label, fmt_tokens}; +use crate::app::{ + ActionbarNoticeLevel, App, CompletionState, WorkerViewTab, alert_source_label, fmt_tokens, +}; use crate::block::{Block, CompactEvent, ThinkingBlock, ThinkingState}; use crate::command::CommandCandidate; use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore}; @@ -52,7 +54,9 @@ pub fn draw(frame: &mut Frame, app: &mut App) { app.input .apply_cursor_viewport(&mut input_render, input_height); } - let mini_view_h = task_mini_view_height(&app.task_store); + let tabs = app.worker_view_tabs(); + let show_tabs = tabs.len() > 1; + let mini_view_h = task_mini_view_height(&app.selected_worker_view().task_store, show_tabs); // One blank row separates the history tail from the mini-view so // the latest message doesn't visually crash into the task summary. // Folds away with the mini-view when there are no tasks. @@ -69,11 +73,26 @@ pub fn draw(frame: &mut Frame, app: &mut App) { ]) .split(area); - draw_history(frame, app, chunks[0]); + let selected_index = app.selected_internal_worker_index(); + if let Some(index) = selected_index { + let task_pane_open = app.task_pane_open; + let view = app.internal_workers[index].app.as_mut(); + view.task_pane_open = task_pane_open; + draw_history(frame, view, chunks[0]); + } else { + draw_history(frame, app, chunks[0]); + } if mini_view_h > 0 { - draw_task_mini_view(frame, &app.task_store, chunks[2]); + draw_task_mini_view( + frame, + &app.selected_worker_view().task_store, + &tabs, + chunks[2], + ); } draw_separator(frame, chunks[3]); + // Status/composer/control surfaces remain parent-owned. View selection changes + // only transcript/task presentation and never implies SubWorker control. draw_status(frame, app, chunks[4]); draw_input(frame, app, &input_render, chunks[5]); draw_actionbar(frame, app, chunks[6]); @@ -89,19 +108,19 @@ pub fn draw(frame: &mut Frame, app: &mut App) { /// 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() { +/// Height the mini-view section occupies. Returns 0 only when there are +/// neither tasks nor Worker-view tabs, so SubWorker selection remains +/// available even when the selected task store is empty. +fn task_mini_view_height(store: &TaskStore, show_tabs: bool) -> u16 { + if store.is_empty() && !show_tabs { return 0; } let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE); - // active rows + 1 summary line + // active rows + 1 summary/tab line (active_shown as u16).saturating_add(1) } -fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) { +fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, tabs: &[WorkerViewTab], area: Rect) { if area.height == 0 || area.width == 0 { return; } @@ -123,7 +142,7 @@ fn draw_task_mini_view(frame: &mut Frame, store: &TaskStore, area: Rect) { lines.push(mini_view_active_line(entry, inner.width)); shown += 1; } - lines.push(mini_view_summary_line(store.counts(), inner.width)); + lines.push(mini_view_summary_line(store.counts(), tabs, inner.width)); Paragraph::new(lines) .block(outer_block) @@ -146,8 +165,8 @@ fn mini_view_active_line(entry: &TaskEntry, width: u16) -> Line<'static> { ]) } -fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> { - let text = format!( +fn mini_view_summary_line(counts: TaskCounts, tabs: &[WorkerViewTab], width: u16) -> Line<'static> { + let summary = format!( "{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}", counts.total(), counts.pending, @@ -155,8 +174,79 @@ fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> { counts.completed, counts.deleted, ); - let shown = truncate_with_ellipsis(&text, width as usize); - Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray))) + if tabs.len() <= 1 { + let shown = truncate_with_ellipsis(&summary, width as usize); + return Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray))); + } + + let tabs_width = worker_view_tabs_width(tabs); + let width = width as usize; + if tabs_width >= width { + let selected = tabs.iter().find(|tab| tab.selected).unwrap_or(&tabs[0]); + if width <= 4 { + return Line::from(Span::styled( + truncate_with_ellipsis(&selected.label, width), + worker_view_selected_tab_style(), + )); + } + let shown = truncate_with_ellipsis(&selected.label, width.saturating_sub(4)); + let selected_width = UnicodeWidthStr::width(shown.as_str()); + return Line::from(vec![ + Span::raw(" ".repeat(width.saturating_sub(selected_width.saturating_add(4)))), + Span::styled("[ ", Style::default().fg(Color::DarkGray)), + Span::styled(shown, worker_view_selected_tab_style()), + Span::styled(" ]", Style::default().fg(Color::DarkGray)), + ]); + } + + let summary_budget = width.saturating_sub(tabs_width + 1); + let shown = truncate_with_ellipsis(&summary, summary_budget); + let shown_width = UnicodeWidthStr::width(shown.as_str()); + let padding = width.saturating_sub(shown_width + tabs_width); + let mut spans = vec![ + Span::styled(shown, Style::default().fg(Color::DarkGray)), + Span::raw(" ".repeat(padding)), + ]; + spans.extend(worker_view_tab_spans(tabs)); + Line::from(spans) +} + +fn worker_view_tabs_text(tabs: &[WorkerViewTab]) -> String { + format!( + "[ {} ]", + tabs.iter() + .map(|tab| tab.label.as_str()) + .collect::>() + .join(" | ") + ) +} + +fn worker_view_tabs_width(tabs: &[WorkerViewTab]) -> usize { + UnicodeWidthStr::width(worker_view_tabs_text(tabs).as_str()) +} + +fn worker_view_selected_tab_style() -> Style { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) +} + +fn worker_view_tab_spans(tabs: &[WorkerViewTab]) -> Vec> { + let dim = Style::default().fg(Color::DarkGray); + let selected = worker_view_selected_tab_style(); + let mut spans = Vec::with_capacity(tabs.len().saturating_mul(2).saturating_add(1)); + spans.push(Span::styled("[ ", dim)); + for (index, tab) in tabs.iter().enumerate() { + if index > 0 { + spans.push(Span::styled(" | ", dim)); + } + spans.push(Span::styled( + tab.label.clone(), + if tab.selected { selected } else { dim }, + )); + } + spans.push(Span::styled(" ]", dim)); + spans } /// Two-character status marker + the style to render it with. Mirrors @@ -387,28 +477,6 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout { i += 1; } - for internal in &app.internal_workers { - logical.push((Line::from(""), false)); - logical.push(( - Line::from(vec![ - Span::styled("SubWorker ", Style::default().bold()), - Span::raw(internal.worker.name.clone()), - Span::styled( - format!(" {:?}", internal.app.worker_status), - Style::default().fg(Color::DarkGray), - ), - ]), - false, - )); - let child_width = width.saturating_sub(2).max(1); - let child_history = compute_history(&internal.app, child_width); - logical.extend(child_history.rows.into_iter().map(|row| { - let mut spans = vec![Span::raw(" ")]; - spans.extend(row.line.spans); - (Line::from(spans), row.selectable) - })); - } - // Step 2: pre-wrap every logical line to char-based terminal rows so // scroll math is exact. Track the logical → wrapped mapping so // turn-start indices get translated into wrapped-row coordinates. @@ -1985,6 +2053,93 @@ mod tests { use protocol::WorkerStatus; use std::time::{Duration, Instant}; + #[test] + fn task_summary_right_aligns_worker_tabs_and_highlights_selection() { + let tabs = vec![ + WorkerViewTab { + label: "main".into(), + selected: false, + }, + WorkerViewTab { + label: "subworker-hoge".into(), + selected: true, + }, + WorkerViewTab { + label: "subworker-fuga".into(), + selected: false, + }, + ]; + + let line = mini_view_summary_line(TaskCounts::default(), &tabs, 96); + let text = line + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + + assert_eq!(UnicodeWidthStr::width(text.as_str()), 96); + assert!(text.ends_with("[ main | subworker-hoge | subworker-fuga ]")); + let selected = line + .spans + .iter() + .find(|span| span.content == "subworker-hoge") + .expect("selected tab span"); + assert_eq!(selected.style.fg, Some(Color::Cyan)); + assert!(selected.style.add_modifier.contains(Modifier::BOLD)); + + let narrow = mini_view_summary_line(TaskCounts::default(), &tabs, 20); + let narrow_text = narrow + .spans + .iter() + .map(|span| span.content.as_ref()) + .collect::(); + assert_eq!(UnicodeWidthStr::width(narrow_text.as_str()), 20); + assert!(narrow_text.ends_with("[ subworker-hoge ]")); + } + + #[test] + fn selected_worker_view_history_is_not_appended_to_main_history() { + let mut app = App::new("main".into()); + app.handle_worker_event(protocol::Event::TextDelta { + text: "main transcript".into(), + }); + app.handle_worker_event(protocol::Event::InternalWorker { + worker: protocol::InternalWorkerRef { + session_id: "child-session".into(), + name: "subworker-hoge".into(), + parent_session_id: Some("parent-session".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }, + revision: 1, + event: Box::new(protocol::Event::TextDelta { + text: "child transcript".into(), + }), + }); + + let main = compute_history(&app, 80) + .rows + .into_iter() + .map(|row| row.line.to_string()) + .collect::(); + assert!(main.contains("main transcript")); + assert!(!main.contains("child transcript")); + + app.cycle_worker_view(); + let child = compute_history(app.selected_worker_view(), 80) + .rows + .into_iter() + .map(|row| row.line.to_string()) + .collect::(); + assert!(!child.contains("main transcript")); + assert!(child.contains("child transcript")); + } + + #[test] + fn worker_tabs_keep_mini_view_visible_without_tasks() { + assert_eq!(task_mini_view_height(&TaskStore::new(), false), 0); + assert_eq!(task_mini_view_height(&TaskStore::new(), true), 1); + } + #[test] fn queue_status_text_includes_count_and_preview() { let mut app = App::new("test".into());