feat: switch TUI SubWorker views
This commit is contained in:
+337
-7
@@ -233,6 +233,12 @@ pub struct InternalWorkerView {
|
|||||||
pub app: Box<App>,
|
pub app: Box<App>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct WorkerViewTab {
|
||||||
|
pub label: String,
|
||||||
|
pub selected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
@@ -281,8 +287,11 @@ pub struct App {
|
|||||||
/// replayable conversation rows during segment rotation.
|
/// replayable conversation rows during segment rotation.
|
||||||
run_error_messages: Vec<String>,
|
run_error_messages: Vec<String>,
|
||||||
/// Presentation-only Internal Worker projections keyed by session identity.
|
/// 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<InternalWorkerView>,
|
pub internal_workers: Vec<InternalWorkerView>,
|
||||||
|
/// 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<String>,
|
||||||
pub scroll: Scroll,
|
pub scroll: Scroll,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub cache: FileCache,
|
pub cache: FileCache,
|
||||||
@@ -361,6 +370,7 @@ impl App {
|
|||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
run_error_messages: Vec::new(),
|
run_error_messages: Vec::new(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
|
selected_internal_worker_session_id: None,
|
||||||
scroll: Scroll::default(),
|
scroll: Scroll::default(),
|
||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
cache: FileCache::new(),
|
cache: FileCache::new(),
|
||||||
@@ -445,16 +455,98 @@ impl App {
|
|||||||
pub fn toggle_task_pane(&mut self) {
|
pub fn toggle_task_pane(&mut self) {
|
||||||
self.task_pane_open = !self.task_pane_open;
|
self.task_pane_open = !self.task_pane_open;
|
||||||
if !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<WorkerViewTab> {
|
||||||
|
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<usize> {
|
||||||
|
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) {
|
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) {
|
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) {
|
pub fn set_worker_status(&mut self, status: WorkerStatus) {
|
||||||
@@ -1738,6 +1830,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
||||||
|
// 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() {
|
if self.rewind_submit_pending() {
|
||||||
self.push_command_diagnostic(
|
self.push_command_diagnostic(
|
||||||
"rewind is already applying; wait for the Worker response",
|
"rewind is already applying; wait for the Worker response",
|
||||||
@@ -1998,14 +2093,65 @@ impl App {
|
|||||||
/// produced. Followed by `Event::Entry` updates for anything
|
/// produced. Followed by `Event::Entry` updates for anything
|
||||||
/// committed after the snapshot.
|
/// committed after the snapshot.
|
||||||
fn replace_internal_worker_snapshots(&mut self, snapshots: Vec<InternalWorkerSnapshot>) {
|
fn replace_internal_worker_snapshots(&mut self, snapshots: Vec<InternalWorkerSnapshot>) {
|
||||||
|
let mode = self.mode;
|
||||||
|
let mut previous = std::mem::take(&mut self.internal_workers);
|
||||||
self.internal_workers = snapshots
|
self.internal_workers = snapshots
|
||||||
.into_iter()
|
.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();
|
.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());
|
let mut app = App::new(snapshot.worker.name.clone());
|
||||||
|
app.mode = mode;
|
||||||
app.restore_entries(&snapshot.entries, None);
|
app.restore_entries(&snapshot.entries, None);
|
||||||
app.apply_in_flight_snapshot(snapshot.in_flight);
|
app.apply_in_flight_snapshot(snapshot.in_flight);
|
||||||
app.set_worker_status(snapshot.status);
|
app.set_worker_status(snapshot.status);
|
||||||
@@ -2036,10 +2182,12 @@ impl App {
|
|||||||
let target = if let Some(index) = index {
|
let target = if let Some(index) = index {
|
||||||
&mut self.internal_workers[index]
|
&mut self.internal_workers[index]
|
||||||
} else {
|
} else {
|
||||||
|
let mut app = App::new(worker.name.clone());
|
||||||
|
app.mode = self.mode;
|
||||||
self.internal_workers.push(InternalWorkerView {
|
self.internal_workers.push(InternalWorkerView {
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
revision: 0,
|
revision: 0,
|
||||||
app: Box::new(App::new(worker.name.clone())),
|
app: Box::new(app),
|
||||||
});
|
});
|
||||||
self.internal_workers.last_mut().unwrap()
|
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]
|
#[test]
|
||||||
fn snapshot_authoritatively_replaces_internal_worker_views() {
|
fn snapshot_authoritatively_replaces_internal_worker_views() {
|
||||||
let mut app = App::new("parent".into());
|
let mut app = App::new("parent".into());
|
||||||
|
|||||||
+128
-25
@@ -96,12 +96,12 @@ fn copy_to_terminal_clipboard<W: io::Write>(out: &mut W, text: &str) -> io::Resu
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn copy_selection_to_writer<W: io::Write>(app: &mut App, out: &mut W) -> bool {
|
fn copy_selection_to_writer<W: io::Write>(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;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
let result = copy_to_terminal_clipboard(out, &text);
|
let result = copy_to_terminal_clipboard(out, &text);
|
||||||
app.text_selection.clear();
|
app.selected_worker_view_mut().text_selection.clear();
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
app.flash_actionbar_notice(
|
app.flash_actionbar_notice(
|
||||||
@@ -890,25 +890,27 @@ const WHEEL_LINES: usize = 3;
|
|||||||
const PANE_SCROLL_LINES: usize = 5;
|
const PANE_SCROLL_LINES: usize = 5;
|
||||||
|
|
||||||
fn handle_mouse(app: &mut App, mouse: MouseEvent) {
|
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 {
|
match mouse.kind {
|
||||||
MouseEventKind::ScrollUp => {
|
MouseEventKind::ScrollUp => {
|
||||||
app.text_selection.clear();
|
view.text_selection.clear();
|
||||||
app.scroll.scroll_up(WHEEL_LINES);
|
view.scroll.scroll_up(WHEEL_LINES);
|
||||||
}
|
}
|
||||||
MouseEventKind::ScrollDown => {
|
MouseEventKind::ScrollDown => {
|
||||||
app.text_selection.clear();
|
view.text_selection.clear();
|
||||||
app.scroll.scroll_down(WHEEL_LINES);
|
view.scroll.scroll_down(WHEEL_LINES);
|
||||||
}
|
}
|
||||||
MouseEventKind::Down(MouseButton::Left) if app.rewind_picker.is_none() => {
|
MouseEventKind::Down(MouseButton::Left) if !rewind_picker_open => {
|
||||||
if !app.text_selection.begin_drag(mouse.column, mouse.row) {
|
if !view.text_selection.begin_drag(mouse.column, mouse.row) {
|
||||||
app.text_selection.clear();
|
view.text_selection.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
MouseEventKind::Drag(MouseButton::Left) if app.rewind_picker.is_none() => {
|
MouseEventKind::Drag(MouseButton::Left) if !rewind_picker_open => {
|
||||||
app.text_selection.update_drag(mouse.column, mouse.row);
|
view.text_selection.update_drag(mouse.column, mouse.row);
|
||||||
}
|
}
|
||||||
MouseEventKind::Up(MouseButton::Left) if app.rewind_picker.is_none() => {
|
MouseEventKind::Up(MouseButton::Left) if !rewind_picker_open => {
|
||||||
app.text_selection.finish_drag(mouse.column, mouse.row);
|
view.text_selection.finish_drag(mouse.column, mouse.row);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -942,31 +944,31 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
// Modifier-key bindings.
|
// Modifier-key bindings.
|
||||||
if let Some(method) = match key.code {
|
if let Some(method) = match key.code {
|
||||||
KeyCode::Up if shift => {
|
KeyCode::Up if shift => {
|
||||||
app.scroll.scroll_up(1);
|
app.selected_worker_view_mut().scroll.scroll_up(1);
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Down if shift => {
|
KeyCode::Down if shift => {
|
||||||
app.scroll.scroll_down(1);
|
app.selected_worker_view_mut().scroll.scroll_down(1);
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Home if ctrl => {
|
KeyCode::Home if ctrl => {
|
||||||
app.scroll.to_top();
|
app.selected_worker_view_mut().scroll.to_top();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::End if ctrl => {
|
KeyCode::End if ctrl => {
|
||||||
app.scroll.to_bottom();
|
app.selected_worker_view_mut().scroll.to_bottom();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('[') if ctrl => {
|
KeyCode::Char('[') if ctrl => {
|
||||||
app.scroll.jump_prev_turn();
|
app.selected_worker_view_mut().scroll.jump_prev_turn();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char(']') if ctrl => {
|
KeyCode::Char(']') if ctrl => {
|
||||||
app.scroll.jump_next_turn();
|
app.selected_worker_view_mut().scroll.jump_next_turn();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('o') if ctrl => {
|
KeyCode::Char('o') if ctrl => {
|
||||||
app.mode = app.mode.cycle();
|
app.cycle_mode();
|
||||||
Some(None)
|
Some(None)
|
||||||
}
|
}
|
||||||
KeyCode::Char('t') if ctrl => {
|
KeyCode::Char('t') if ctrl => {
|
||||||
@@ -1047,7 +1049,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
if app.task_pane_open {
|
if app.task_pane_open {
|
||||||
app.scroll_task_pane_up(PANE_SCROLL_LINES);
|
app.scroll_task_pane_up(PANE_SCROLL_LINES);
|
||||||
} else {
|
} else {
|
||||||
app.scroll.page_up();
|
app.selected_worker_view_mut().scroll.page_up();
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1055,7 +1057,7 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
if app.task_pane_open {
|
if app.task_pane_open {
|
||||||
app.scroll_task_pane_down(PANE_SCROLL_LINES);
|
app.scroll_task_pane_down(PANE_SCROLL_LINES);
|
||||||
} else {
|
} else {
|
||||||
app.scroll.page_down();
|
app.selected_worker_view_mut().scroll.page_down();
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -1130,12 +1132,17 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if key.code == KeyCode::Tab && key.modifiers.is_empty() && app.completion.is_none() {
|
||||||
|
app.cycle_worker_view();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
if key.modifiers.is_empty() {
|
if key.modifiers.is_empty() {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc if app.text_selection.clear() => return None,
|
KeyCode::Esc if app.selected_worker_view_mut().text_selection.clear() => return None,
|
||||||
KeyCode::Char('y') if app.text_selection.has_selection() => {
|
KeyCode::Char('y') if app.selected_worker_view().text_selection.has_selection() => {
|
||||||
if !copy_selection_to_terminal(app) {
|
if !copy_selection_to_terminal(app) {
|
||||||
app.text_selection.clear();
|
app.selected_worker_view_mut().text_selection.clear();
|
||||||
app.flash_actionbar_notice(
|
app.flash_actionbar_notice(
|
||||||
"Selection contains no copyable text.",
|
"Selection contains no copyable text.",
|
||||||
ActionbarNoticeLevel::Warn,
|
ActionbarNoticeLevel::Warn,
|
||||||
@@ -2170,6 +2177,18 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn command_completion_tab_applies_unambiguous_candidate() {
|
fn command_completion_tab_applies_unambiguous_candidate() {
|
||||||
let mut app = App::new("agent".to_string());
|
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);
|
enter_command_mode(&mut app);
|
||||||
type_keys(&mut app, "no");
|
type_keys(&mut app, "no");
|
||||||
|
|
||||||
@@ -2177,6 +2196,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(app.is_command_mode());
|
assert!(app.is_command_mode());
|
||||||
assert_eq!(app.command_text(), "noop ");
|
assert_eq!(app.command_text(), "noop ");
|
||||||
|
assert_eq!(app.selected_worker_view().worker_name, "agent");
|
||||||
assert_eq!(input_text(&app), "");
|
assert_eq!(input_text(&app), "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2269,6 +2289,89 @@ mod tests {
|
|||||||
assert_eq!(input_text(&app), "");
|
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]
|
#[test]
|
||||||
fn command_completion_does_not_affect_normal_composer_without_popup() {
|
fn command_completion_does_not_affect_normal_composer_without_popup() {
|
||||||
let mut app = App::new("agent".to_string());
|
let mut app = App::new("agent".to_string());
|
||||||
|
|||||||
+193
-38
@@ -27,7 +27,9 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
|||||||
|
|
||||||
use protocol::{AlertLevel, CompletionEntry, Greeting, Segment, WorkerEvent};
|
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::block::{Block, CompactEvent, ThinkingBlock, ThinkingState};
|
||||||
use crate::command::CommandCandidate;
|
use crate::command::CommandCandidate;
|
||||||
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
use crate::task::{TaskCounts, TaskEntry, TaskStatus, TaskStore};
|
||||||
@@ -52,7 +54,9 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
|||||||
app.input
|
app.input
|
||||||
.apply_cursor_viewport(&mut input_render, input_height);
|
.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
|
// One blank row separates the history tail from the mini-view so
|
||||||
// the latest message doesn't visually crash into the task summary.
|
// the latest message doesn't visually crash into the task summary.
|
||||||
// Folds away with the mini-view when there are no tasks.
|
// 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);
|
.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 {
|
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]);
|
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_status(frame, app, chunks[4]);
|
||||||
draw_input(frame, app, &input_render, chunks[5]);
|
draw_input(frame, app, &input_render, chunks[5]);
|
||||||
draw_actionbar(frame, app, chunks[6]);
|
draw_actionbar(frame, app, chunks[6]);
|
||||||
@@ -89,19 +108,19 @@ pub fn draw(frame: &mut Frame, app: &mut App) {
|
|||||||
/// the summary.
|
/// the summary.
|
||||||
const MINI_VIEW_MAX_ACTIVE: usize = 3;
|
const MINI_VIEW_MAX_ACTIVE: usize = 3;
|
||||||
|
|
||||||
/// Height the mini-view section occupies. Returns 0 when there are no
|
/// Height the mini-view section occupies. Returns 0 only when there are
|
||||||
/// tasks at all, so the section collapses cleanly into surrounding
|
/// neither tasks nor Worker-view tabs, so SubWorker selection remains
|
||||||
/// layout — there's no point reserving rows for an empty store.
|
/// available even when the selected task store is empty.
|
||||||
fn task_mini_view_height(store: &TaskStore) -> u16 {
|
fn task_mini_view_height(store: &TaskStore, show_tabs: bool) -> u16 {
|
||||||
if store.is_empty() {
|
if store.is_empty() && !show_tabs {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
let active_shown = store.counts().active().min(MINI_VIEW_MAX_ACTIVE);
|
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)
|
(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 {
|
if area.height == 0 || area.width == 0 {
|
||||||
return;
|
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));
|
lines.push(mini_view_active_line(entry, inner.width));
|
||||||
shown += 1;
|
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)
|
Paragraph::new(lines)
|
||||||
.block(outer_block)
|
.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> {
|
fn mini_view_summary_line(counts: TaskCounts, tabs: &[WorkerViewTab], width: u16) -> Line<'static> {
|
||||||
let text = format!(
|
let summary = format!(
|
||||||
"{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}",
|
"{} task(s) — pending: {}, inprogress: {}, completed: {}, deleted: {}",
|
||||||
counts.total(),
|
counts.total(),
|
||||||
counts.pending,
|
counts.pending,
|
||||||
@@ -155,8 +174,79 @@ fn mini_view_summary_line(counts: TaskCounts, width: u16) -> Line<'static> {
|
|||||||
counts.completed,
|
counts.completed,
|
||||||
counts.deleted,
|
counts.deleted,
|
||||||
);
|
);
|
||||||
let shown = truncate_with_ellipsis(&text, width as usize);
|
if tabs.len() <= 1 {
|
||||||
Line::from(Span::styled(shown, Style::default().fg(Color::DarkGray)))
|
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::<Vec<_>>()
|
||||||
|
.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<Span<'static>> {
|
||||||
|
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
|
/// 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;
|
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
|
// Step 2: pre-wrap every logical line to char-based terminal rows so
|
||||||
// scroll math is exact. Track the logical → wrapped mapping so
|
// scroll math is exact. Track the logical → wrapped mapping so
|
||||||
// turn-start indices get translated into wrapped-row coordinates.
|
// turn-start indices get translated into wrapped-row coordinates.
|
||||||
@@ -1985,6 +2053,93 @@ mod tests {
|
|||||||
use protocol::WorkerStatus;
|
use protocol::WorkerStatus;
|
||||||
use std::time::{Duration, Instant};
|
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::<String>();
|
||||||
|
|
||||||
|
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::<String>();
|
||||||
|
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::<String>();
|
||||||
|
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::<String>();
|
||||||
|
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]
|
#[test]
|
||||||
fn queue_status_text_includes_count_and_preview() {
|
fn queue_status_text_includes_count_and_preview() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
|
|||||||
Reference in New Issue
Block a user