fix(tui): work-bar rows are persistent, labeled, clickable objects in every panel
Owner-reported 0.9.4 release blockers (HANDOFF-ALL-ISSUES-2026-08-04 A1+A2): the to-do list lost its status words and click-to-select, and sub-agents had no permanent home after spawn. Root causes were three separate cuts, two of which predate the 08-04 rebuild: - has_live_item (5db74a09d) emptied the whole Top projection the moment no row was live, killing settled to-dos, finished workers, and the goal title with them; 7ca247aad's 4s recent-only TTL + user-turn force-hide evicted the same rows on side placements. -2011b9b11dropped the status word from pending to-do rows (conflating the state label with the redundant 'plan step' kind label), and7b20ef513stopped painting the agent status word entirely. - 2baf1627b's panel unification wiped every hitbox in non-Tasks panels, so a user whose rail_panel migrated to pinned/agents (any classic sidebar_focus of pinned/work/plan/todos) could not click a single work-bar row. The fix makes the work bar a standing register of the session's work: - Persistence: plan-step and worker rows are durable in ordered_rows — exempt from the recent-only TTL, the user-turn force-hide, and the all-settled collapse. Transient operation/activity receipts keep their #4688/#4690 lifetimes. has_live_item is gone; the goal title survives settled work. Quiet completion (FINISH entry 36), not eviction. - Labels: to-do rows always carry their state in the /task digest vocabulary (pending / in progress / completed / cancelled); agent rows regain a status-word column (new AgentRowFacts::status), degraded only when the row is down to the objective alone. - Clicks: Agents and Pinned panels now route through the same WorkRow/hitbox machinery as Tasks (visible_rows_for_panel), so click and Enter open the row's world in every panel and placement, including finished agents. Context stays a fact list. A click after the pager closed itself reopens the detail instead of being swallowed by the stale opened owner. - The four doc-comments that would have re-derived the regression are corrected in place (project_visible contract, graph_node_row label note, render_panel scope, panels.rs auto-fit note), plus a row-lifetime section in the module header. Verified: cargo test -p codewhale-tui --bin codewhale-tui (9719 passed, 0 failed; the one engine-test failure in a prior run was a parallelism flake that passes in isolation and in the rerun), clippy --all-targets -Dwarnings clean, cargo fmt applied. New coverage: persistence across TTL+user turns, status-word projection+paint, per-panel hitbox dispatch (finished agent detail opens; Pinned to-do opens inspector), stale-opened reopen, and three real-PTY probes (goal-title click, mid-stream click, and a pinned-panel click that fails on the pre-fix binary). Authored with agent assistance (Claude); regression brief from codewhale-ops reports 2026-08-04.
This commit is contained in:
+14
-1
@@ -24,7 +24,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
An empty panel now collapses like the Tasks panel always has, and the settings
|
||||
migration no longer folds the default `sidebar_focus = "auto"` into a pinned
|
||||
always-on strip, which had silently handed that panel to every user who had a
|
||||
settings file at all.
|
||||
settings file at all. (An *empty* panel collapses; a panel holding settled
|
||||
to-dos or finished workers is not empty — see the standing-register entry
|
||||
below.)
|
||||
- The work bar is a standing register again: settled to-dos and finished
|
||||
sub-agents keep their rows for the rest of the session instead of being
|
||||
evicted four seconds after the work settles (or on the next user turn), the
|
||||
active goal title stays up with them, to-do rows say their state in words
|
||||
(pending / in progress / completed / cancelled), and sub-agent rows carry a
|
||||
status-word column next to the type. Every work row is a door in every rail
|
||||
panel and placement: to-dos and sub-agents in the Agents and Pinned panels
|
||||
now route through the same row machinery as Tasks, so click and Enter open
|
||||
the row's world (work inspector / agent details — finished agents included)
|
||||
instead of doing nothing. A click after the detail pager closed itself
|
||||
reopens the detail rather than being swallowed by a stale toggle.
|
||||
- The rail strip yields its rows to the transcript when the terminal cannot
|
||||
seat both, so the idle ocean survives at 24 rows instead of being evicted.
|
||||
- `code_execution` and `js_execution` no longer describe themselves to the model
|
||||
|
||||
@@ -19967,6 +19967,15 @@ mod work_surface {
|
||||
);
|
||||
agent.nickname = Some(AGENT_MARK.to_string());
|
||||
app.subagent_cache.push(agent);
|
||||
// A fan-out, not a single worker: the yield tests need the panel's
|
||||
// natural height (heading + rows + divider) to sit above the
|
||||
// TOP_HEIGHT_MIN clamp, or "the strip yielded rows" becomes
|
||||
// unobservable now that the Agents panel row projection is more
|
||||
// compact than the old line list.
|
||||
app.subagent_cache.push(make_subagent(
|
||||
"agent_rail_probe_b",
|
||||
crate::tools::subagent::SubAgentStatus::Running,
|
||||
));
|
||||
assert!(
|
||||
should_render_empty_state(&app),
|
||||
"the Agents fixture must leave the session idle — the ocean it is \
|
||||
|
||||
@@ -5,7 +5,7 @@ use crate::tui::app::{App, SidebarRowAction};
|
||||
use super::interaction::{activate_primary, claim_focus, close_opened, release_focus};
|
||||
use super::model::{
|
||||
SIDE_WIDTH_MAX, SIDE_WIDTH_MIN, TOP_HEIGHT_MAX, TOP_HEIGHT_MIN, WorkRow, WorkRowId,
|
||||
WorkSurfacePlacement, project_visible,
|
||||
WorkSurfacePlacement, visible_rows_for_panel,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -19,7 +19,9 @@ pub struct MouseOutcome {
|
||||
/// a local stop arm / open detail first). Plain printable input always returns
|
||||
/// ownership to the composer instead of becoming a hidden panel shortcut.
|
||||
pub fn handle_key(app: &mut App, key: KeyEvent) -> Option<Option<SidebarRowAction>> {
|
||||
let rows = project_visible(app);
|
||||
// Keyboard and mouse share one row source per panel: Enter on the
|
||||
// selected row must open the same world a click would.
|
||||
let rows = visible_rows_for_panel(app);
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -40,8 +40,15 @@ pub fn activate_primary(
|
||||
primary: Option<SidebarRowAction>,
|
||||
) -> Option<SidebarRowAction> {
|
||||
if app.work_surface.opened.as_ref() == Some(row_id) {
|
||||
// Toggle-close only while the detail is actually on screen. When the
|
||||
// pager closed itself (q/Esc inside it), `opened` is a stale owner —
|
||||
// swallowing the click here would make the row look dead, so fall
|
||||
// through and reopen instead.
|
||||
let detail_on_screen = app.view_stack.top_kind() == Some(ModalKind::Pager);
|
||||
close_opened(app);
|
||||
return None;
|
||||
if detail_on_screen {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
app.work_surface.selected = Some(row_id.clone());
|
||||
let action = primary?;
|
||||
@@ -95,8 +102,21 @@ mod tests {
|
||||
};
|
||||
assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
|
||||
assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
|
||||
assert!(activate_primary(&mut app, &row, Some(open)).is_none());
|
||||
// With the detail pager on screen, the second activation toggles it
|
||||
// closed; with no pager on screen (it closed itself), the activation
|
||||
// reopens instead of going dead.
|
||||
app.view_stack.push(crate::tui::pager::PagerView::from_text(
|
||||
"Agent".to_string(),
|
||||
"body",
|
||||
40,
|
||||
));
|
||||
assert!(activate_primary(&mut app, &row, Some(open.clone())).is_none());
|
||||
assert!(app.work_surface.opened.is_none());
|
||||
assert!(activate_primary(&mut app, &row, Some(open.clone())).is_some());
|
||||
assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
|
||||
// Pager already gone (closed from inside): reopen, don't swallow.
|
||||
assert!(activate_primary(&mut app, &row, Some(open)).is_some());
|
||||
assert_eq!(app.work_surface.opened.as_ref(), Some(&row));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -30,10 +30,30 @@
|
||||
//!
|
||||
//! Shared rules: content drives size; the setting is a ceiling, never padding;
|
||||
//! empty work is not a rail. Top never paints a chrome panel title (a checklist
|
||||
//! reads as a checklist). Side rails keep a muted title because a full-height
|
||||
//! column among other chrome needs naming. Narrow hosts that cannot fit a side
|
||||
//! reads as a checklist); side rails are named by their content's own heading
|
||||
//! row (`Work · …`, `▾ Subagents N`, `Goal: …`) except Context, which keeps a
|
||||
//! muted panel title over its fact list. Narrow hosts that cannot fit a side
|
||||
//! column fall back to Top, where height auto-fit takes over.
|
||||
//!
|
||||
//! ## Row lifetime
|
||||
//!
|
||||
//! The strip is a standing register of this session's work, not a live-only
|
||||
//! view. A to-do or sub-agent row appears when the work exists and stays for
|
||||
//! the rest of the session after it settles — completion is quiet (glyph,
|
||||
//! tone, frozen receipt), never an eviction, and the active goal title
|
||||
//! outlives the work under it. Only transient receipts (aggregated file
|
||||
//! activity, settled operations) expire on the #4688/#4690 lifetimes.
|
||||
//! Auto-fit and the row budget decide how many rows are *visible* at once;
|
||||
//! they never decide membership.
|
||||
//!
|
||||
//! ## Rows are objects — in every panel
|
||||
//!
|
||||
//! Tasks, Agents, and Pinned all render through one row/hitbox pipeline:
|
||||
//! every visible work row is selectable, hoverable, and clickable, and its
|
||||
//! primary action opens the row's world (agent details / work inspector).
|
||||
//! Keyboard Enter and mouse click dispatch identically. Context is the one
|
||||
//! line-list panel; it holds facts, not rows.
|
||||
//!
|
||||
//! Height is decided once per frame by [`render::height`]; the row budget it is
|
||||
//! given comes from `crate::tui::ui::rail_row_budget`, which is its only
|
||||
//! production caller.
|
||||
@@ -1232,7 +1252,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
fleet_row(&rows),
|
||||
" ▸ general-purpose Streaming dead-code removal \
|
||||
" ▸ general-purpose running Streaming dead-code removal \
|
||||
12m 33s · ↓ 111.9k tokens"
|
||||
);
|
||||
// The group header the strip already had stays put.
|
||||
@@ -1335,21 +1355,27 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn fleet_row_drops_tokens_then_elapsed_then_type_as_the_surface_narrows() {
|
||||
// Settled degradation order. The objective is the last thing to go and
|
||||
// every column truncates rather than wrapping.
|
||||
// Settled degradation order: tokens first, then elapsed, then the
|
||||
// type and status columns together. The objective is the last thing
|
||||
// to go and every column truncates rather than wrapping. The status
|
||||
// word outlives the whole receipt — a fleet row that cannot say its
|
||||
// state in words has lost the fact the strip exists to show.
|
||||
let mut app = fleet_app(Some(111_900));
|
||||
let medium = fleet_row(&render_rows(&mut app, 62, 4));
|
||||
let medium = fleet_row(&render_rows(&mut app, 72, 4));
|
||||
assert!(medium.contains("12m 33s"), "{medium}");
|
||||
assert!(!medium.contains("tokens"), "{medium}");
|
||||
assert!(medium.contains("general-purpose"), "{medium}");
|
||||
assert!(medium.contains("running"), "{medium}");
|
||||
|
||||
let narrow = fleet_row(&render_rows(&mut app, 44, 4));
|
||||
let narrow = fleet_row(&render_rows(&mut app, 56, 4));
|
||||
assert!(!narrow.contains("tokens"), "{narrow}");
|
||||
assert!(!narrow.contains("12m 33s"), "{narrow}");
|
||||
assert!(narrow.contains("general-purpose"), "{narrow}");
|
||||
assert!(narrow.contains("running"), "{narrow}");
|
||||
|
||||
let tight = fleet_row(&render_rows(&mut app, 28, 4));
|
||||
assert!(!tight.contains("general-purpose"), "{tight}");
|
||||
assert!(!tight.contains("running"), "{tight}");
|
||||
assert!(tight.contains("Streaming"), "{tight}");
|
||||
|
||||
for line in [&medium, &narrow, &tight] {
|
||||
@@ -1699,8 +1725,10 @@ mod tests {
|
||||
|
||||
/// Render-level smoke coverage for the ported rail panels — reinstates
|
||||
/// the sidebar render smoke tests removed with the classic shell
|
||||
/// (739616787). Placement decides chrome: side rails keep a muted panel
|
||||
/// title; Top never spends a row on one (content is self-evident).
|
||||
/// (739616787). Top never spends a row on panel chrome (content is
|
||||
/// self-evident). Side rails are named by their content's own heading
|
||||
/// row (`▾ Subagents N`, `Goal: …`); Context is the one line-list panel
|
||||
/// and keeps its muted panel title.
|
||||
#[test]
|
||||
fn rail_panels_render_in_all_placements() {
|
||||
for panel in [
|
||||
@@ -1784,10 +1812,36 @@ mod tests {
|
||||
rail.is_some() || strip > 0,
|
||||
"{panel:?} in {placement:?} should reserve a rail"
|
||||
);
|
||||
assert!(
|
||||
text.contains(panel.title()),
|
||||
"{panel:?} in {placement:?} should render its muted title; got: {text}"
|
||||
);
|
||||
// Work-row panels are named by their content heading;
|
||||
// only the Context fact list keeps a panel title.
|
||||
match panel {
|
||||
super::RailPanel::Agents => {
|
||||
assert!(
|
||||
text.contains("Subagents 1"),
|
||||
"{panel:?} in {placement:?} should render its \
|
||||
Subagents heading; got: {text}"
|
||||
);
|
||||
assert!(
|
||||
!app.work_surface.hitboxes.is_empty(),
|
||||
"{panel:?} in {placement:?} must record hitboxes — \
|
||||
every work row is a door"
|
||||
);
|
||||
}
|
||||
super::RailPanel::Pinned => {
|
||||
assert!(
|
||||
text.contains("Goal: ship the release"),
|
||||
"{panel:?} in {placement:?} should render the goal \
|
||||
heading; got: {text}"
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
assert!(
|
||||
text.contains(panel.title()),
|
||||
"{panel:?} in {placement:?} should render its muted \
|
||||
title; got: {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
super::WorkSurfacePlacement::Off => {}
|
||||
}
|
||||
@@ -2557,8 +2611,200 @@ mod tests {
|
||||
let open = row.primary_action.clone();
|
||||
|
||||
assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some());
|
||||
// The action's pager is on screen, so the second activation is a
|
||||
// toggle-close.
|
||||
app.view_stack.push(crate::tui::pager::PagerView::from_text(
|
||||
"Work · test".to_string(),
|
||||
"body",
|
||||
40,
|
||||
));
|
||||
assert!(super::interaction::activate_primary(&mut app, &row.id, open).is_none());
|
||||
assert!(app.work_surface.opened.is_none());
|
||||
assert_eq!(app.work_surface.selected.as_ref(), Some(&row.id));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_after_the_pager_closed_itself_reopens_instead_of_going_dead() {
|
||||
// q/Esc inside the pager pops it without clearing `opened`. The next
|
||||
// click on that row must reopen its world, not be swallowed by a
|
||||
// stale toggle (owner regression report, 2026-08-04).
|
||||
let mut app = app();
|
||||
add_todos(&mut app, 1);
|
||||
let row = super::model::project(&mut app)
|
||||
.into_iter()
|
||||
.find(|row| row.selectable)
|
||||
.expect("work row");
|
||||
let open = row.primary_action.clone();
|
||||
|
||||
assert!(super::interaction::activate_primary(&mut app, &row.id, open.clone()).is_some());
|
||||
// The pager was closed from inside itself; `opened` is now stale.
|
||||
assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id));
|
||||
assert!(app.view_stack.is_empty());
|
||||
|
||||
let reopened = super::interaction::activate_primary(&mut app, &row.id, open);
|
||||
assert!(
|
||||
reopened.is_some(),
|
||||
"a stale opened owner must not swallow the next activation"
|
||||
);
|
||||
assert_eq!(app.work_surface.opened.as_ref(), Some(&row.id));
|
||||
}
|
||||
|
||||
/// The 2026-08-04 owner regression: the strip is a standing register of
|
||||
/// the session's work. Settled to-dos and finished sub-agents keep their
|
||||
/// rows across the recent-only TTL and across new user turns — quiet
|
||||
/// completion, not eviction — and the strip keeps its height.
|
||||
#[test]
|
||||
fn settled_todos_and_workers_stay_after_ttl_and_user_turns() {
|
||||
let mut app = app();
|
||||
app.current_session_id = Some(SESSION.to_string());
|
||||
{
|
||||
let mut todos = app.todos.try_lock().expect("todos");
|
||||
todos.add("ship the fix".to_string(), TodoStatus::Completed);
|
||||
todos.add("verify the fix".to_string(), TodoStatus::Completed);
|
||||
}
|
||||
app.subagent_cache.push(cached_worker(
|
||||
"agent-settled",
|
||||
"builder",
|
||||
None,
|
||||
None,
|
||||
SubAgentStatus::Completed,
|
||||
));
|
||||
|
||||
app.work_surface.set_presentation_now_ms(0);
|
||||
let first = super::model::project_visible(&mut app);
|
||||
assert!(
|
||||
first.iter().any(|row| row.id.0.starts_with("graph:")),
|
||||
"settled to-dos must be listed: {first:?}"
|
||||
);
|
||||
assert!(
|
||||
first.iter().any(|row| row.id.0.starts_with("worker:")),
|
||||
"finished workers must be listed: {first:?}"
|
||||
);
|
||||
|
||||
app.work_surface
|
||||
.set_presentation_now_ms(super::model::RECENT_ONLY_TTL_MS + 1);
|
||||
app.work_surface.note_user_turn_or_new_operation();
|
||||
let later = super::model::project_visible(&mut app);
|
||||
assert!(
|
||||
later.iter().any(|row| row.id.0.starts_with("graph:")),
|
||||
"a settled to-do must survive the TTL and the next user turn: {later:?}"
|
||||
);
|
||||
assert!(
|
||||
later.iter().any(|row| row.id.0.starts_with("worker:")),
|
||||
"a finished worker must survive the TTL and the next user turn: {later:?}"
|
||||
);
|
||||
assert!(
|
||||
super::height(&mut app, 100, 40, AMPLE_BUDGET) > 0,
|
||||
"the strip must keep its height while it holds settled work"
|
||||
);
|
||||
}
|
||||
|
||||
/// A to-do row says its state in words, in the `/task digest` vocabulary.
|
||||
/// Dropping the words (2011b9b11 conflated them with the redundant kind
|
||||
/// label) was half of owner regression A1.
|
||||
#[test]
|
||||
fn todo_rows_carry_their_status_words() {
|
||||
let mut app = app();
|
||||
add_todos(&mut app, 3);
|
||||
let rows = super::model::project(&mut app);
|
||||
let todo_details: Vec<&str> = rows
|
||||
.iter()
|
||||
.filter(|row| row.id.0.starts_with("graph:"))
|
||||
.map(|row| row.detail.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
todo_details.contains(&"in progress"),
|
||||
"the active step says so in words: {todo_details:?}"
|
||||
);
|
||||
assert!(
|
||||
todo_details.contains(&"pending"),
|
||||
"a pending step is labeled, not blank: {todo_details:?}"
|
||||
);
|
||||
|
||||
// And the words are painted, not just projected.
|
||||
let text = render_text(&mut app, 100, 6);
|
||||
assert!(text.contains("in progress"), "{text}");
|
||||
assert!(text.contains("pending"), "{text}");
|
||||
}
|
||||
|
||||
/// Acceptance for owner regression A2: an agent row is a door in the
|
||||
/// Agents panel too, and a FINISHED agent's world still opens — the
|
||||
/// panel is a standing register, not a live-only view.
|
||||
#[test]
|
||||
fn agents_panel_click_opens_details_even_for_finished_agents() {
|
||||
let mut app = app();
|
||||
app.work_surface.panel = super::RailPanel::Agents;
|
||||
app.current_session_id = Some(SESSION.to_string());
|
||||
app.subagent_cache.push(cached_worker(
|
||||
"agent-finished",
|
||||
"builder",
|
||||
None,
|
||||
None,
|
||||
SubAgentStatus::Completed,
|
||||
));
|
||||
|
||||
let _ = render_text(&mut app, 100, 6);
|
||||
let row_y = app
|
||||
.work_surface
|
||||
.hitboxes
|
||||
.iter()
|
||||
.find(|hit| hit.id.0 == "worker:agent-finished")
|
||||
.expect("finished agent row must keep a hitbox in the Agents panel")
|
||||
.row_y;
|
||||
let action = super::handle_mouse(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 2,
|
||||
row: row_y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
)
|
||||
.action
|
||||
.expect("click on a finished agent row must dispatch its primary action");
|
||||
assert_eq!(
|
||||
action,
|
||||
SidebarRowAction::OpenAgentDetail {
|
||||
agent_id: "agent-finished".to_string()
|
||||
}
|
||||
);
|
||||
crate::tui::mouse_ui::apply_sidebar_row_action(&mut app, action);
|
||||
assert!(
|
||||
!app.view_stack.is_empty(),
|
||||
"the finished agent's details must actually open"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance for owner regression A1: to-do rows are doors in the
|
||||
/// Pinned panel too — clicking one opens the work inspector.
|
||||
#[test]
|
||||
fn pinned_panel_todo_rows_stay_clickable() {
|
||||
let mut app = app();
|
||||
app.work_surface.panel = super::RailPanel::Pinned;
|
||||
add_todos(&mut app, 2);
|
||||
|
||||
let _ = render_text(&mut app, 100, 6);
|
||||
let hit = app
|
||||
.work_surface
|
||||
.hitboxes
|
||||
.iter()
|
||||
.find(|hit| hit.id.0.starts_with("graph:"))
|
||||
.expect("Pinned panel to-do rows must keep hitboxes")
|
||||
.clone();
|
||||
let action = super::handle_mouse(
|
||||
&mut app,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Left),
|
||||
column: 2,
|
||||
row: hit.row_y,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
)
|
||||
.action
|
||||
.expect("click on a Pinned to-do row must dispatch its primary action");
|
||||
assert!(
|
||||
matches!(action, SidebarRowAction::InspectWork { .. }),
|
||||
"a to-do row opens the work inspector: {action:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,6 +140,11 @@ pub(super) struct AgentRowFacts {
|
||||
/// renderer falls back to this when a nickname is too wide for the
|
||||
/// identity column — a name is shown whole or not at all.
|
||||
pub role_label: String,
|
||||
/// The status word (`running`, `completed`, `failed`, …) painted as its
|
||||
/// own column. The glyph carries the same fact for scanning; the word is
|
||||
/// what makes the row legible without memorizing glyph vocabulary
|
||||
/// (owner regression report, 2026-08-04).
|
||||
pub status: String,
|
||||
/// What the agent was sent to do.
|
||||
pub objective: String,
|
||||
/// Wall-clock seconds, frozen once the agent is observed terminal so a
|
||||
@@ -529,6 +534,13 @@ pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
|
||||
/// to-dos first, then current sub-agents. Tool operations, coordination
|
||||
/// receipts, file activity, and generic graph headings never enter this list.
|
||||
///
|
||||
/// "Persistent" is a lifetime promise, not decoration: to-do and sub-agent
|
||||
/// rows survive their own completion and stay on the surface for the rest of
|
||||
/// the session — a completed-only list is still a list, and the strip is a
|
||||
/// standing register of the session's work (GrokBuild's tasks pane), not a
|
||||
/// live-only view. Completion is quiet (glyph, tone, frozen receipt), never
|
||||
/// an eviction. `ordered_rows` enforces the same rule for side placements.
|
||||
///
|
||||
/// On Top, the list is GrokBuild-shaped without losing CodeWhale identity:
|
||||
/// selectable to-dos, then a `▾ Subagents N` group header (when any workers
|
||||
/// are present), then the workers. To-do density still uses the pinned
|
||||
@@ -539,19 +551,7 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
|
||||
return rows;
|
||||
}
|
||||
|
||||
let todo_ids = app
|
||||
.work_surface
|
||||
.cached_graph
|
||||
.as_ref()
|
||||
.map(|snapshot| {
|
||||
snapshot
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| node.kind == NodeKind::PlanStep)
|
||||
.map(|node| format!("graph:{}", node.id.as_str()))
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let todo_ids = plan_step_row_ids(app);
|
||||
let mut todos = Vec::new();
|
||||
let mut agents = Vec::new();
|
||||
for row in rows {
|
||||
@@ -562,15 +562,6 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
|
||||
}
|
||||
}
|
||||
|
||||
let has_live_item = todos
|
||||
.iter()
|
||||
.chain(agents.iter())
|
||||
.any(|row| !matches!(row.tone, WorkTone::Success));
|
||||
if !has_live_item {
|
||||
app.work_surface.latest_rows.clear();
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(todos.len() + agents.len() + 1);
|
||||
out.extend(todos);
|
||||
if !agents.is_empty() {
|
||||
@@ -586,6 +577,86 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Row ids of the plan-step (to-do) nodes in the cached graph.
|
||||
fn plan_step_row_ids(app: &App) -> HashSet<String> {
|
||||
app.work_surface
|
||||
.cached_graph
|
||||
.as_ref()
|
||||
.map(|snapshot| {
|
||||
snapshot
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|node| node.kind == NodeKind::PlanStep)
|
||||
.map(|node| format!("graph:{}", node.id.as_str()))
|
||||
.collect::<HashSet<_>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rows for the selected rail panel, routed through the same row/hitbox
|
||||
/// machinery regardless of panel: every work row a user can see is a door
|
||||
/// (`crates/tui/AGENTS.md`, "rows are objects"), whichever panel it appears
|
||||
/// in.
|
||||
///
|
||||
/// - `Tasks` — the full live projection ([`project_visible`]).
|
||||
/// - `Agents` — the sub-agent rows only, under the `▾ Subagents N` header.
|
||||
/// - `Pinned` — the goal plus the plan-step to-dos, without the workers.
|
||||
/// - `Context` — empty: session facts are a line list, not work rows, and
|
||||
/// render outside the row machinery.
|
||||
pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
|
||||
match app.work_surface.panel {
|
||||
RailPanel::Tasks => project_visible(app),
|
||||
RailPanel::Agents => {
|
||||
let rows = project(app);
|
||||
let agents: Vec<WorkRow> = rows
|
||||
.into_iter()
|
||||
.filter(|row| row.id.0.starts_with("worker:"))
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(agents.len() + 1);
|
||||
if !agents.is_empty() {
|
||||
out.push(section_heading(
|
||||
"agents",
|
||||
&format!("Subagents {}", agents.len()),
|
||||
"",
|
||||
));
|
||||
out.extend(agents);
|
||||
}
|
||||
app.work_surface.latest_rows = out.clone();
|
||||
out
|
||||
}
|
||||
RailPanel::Pinned => {
|
||||
let rows = project(app);
|
||||
let todo_ids = plan_step_row_ids(app);
|
||||
let todos: Vec<WorkRow> = rows
|
||||
.into_iter()
|
||||
.filter(|row| todo_ids.contains(&row.id.0))
|
||||
.collect();
|
||||
let mut out = Vec::with_capacity(todos.len() + 1);
|
||||
// On Top the goal is already the strip title; a side column
|
||||
// repeats it as its first row so the durable goal home survives
|
||||
// in every placement.
|
||||
if app.work_surface.effective_placement != WorkSurfacePlacement::Top
|
||||
&& let Some((objective, paused)) =
|
||||
crate::tui::footer_ui::active_goal_chip_state(app)
|
||||
{
|
||||
let flat = objective.trim().replace(['\n', '\r'], " ");
|
||||
if !flat.is_empty() {
|
||||
let label = if paused {
|
||||
format!("Goal (paused): {flat}")
|
||||
} else {
|
||||
format!("Goal: {flat}")
|
||||
};
|
||||
out.push(section_heading("goal", &label, ""));
|
||||
}
|
||||
}
|
||||
out.extend(todos);
|
||||
app.work_surface.latest_rows = out.clone();
|
||||
out
|
||||
}
|
||||
RailPanel::Context => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify the current session against this process's session-instance
|
||||
/// boot id (#4416), mirroring the `SubAgentManager` prior-session pattern
|
||||
/// (#405). The probe runs once per session id. Persisted row identity is
|
||||
@@ -881,8 +952,14 @@ fn ordered_rows(
|
||||
|
||||
// Live chrome policy:
|
||||
// - actionable: heading + (optional) single activity receipt
|
||||
// - recent-only: heading briefly, then empty
|
||||
// - recent-only: transient receipts collapse after the TTL / next user
|
||||
// turn (#4688), but settled to-dos and sub-agents are DURABLE — they
|
||||
// keep their rows for the session. Quiet completion, not eviction
|
||||
// (owner regression report, 2026-08-04).
|
||||
// - empty: no heading
|
||||
let is_durable =
|
||||
|item: &RankedWorkRow| item.is_plan_step || item.row.id.0.starts_with("worker:");
|
||||
let has_durable = ranked.iter().any(is_durable);
|
||||
if ranked.is_empty() && source_state.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -894,7 +971,8 @@ fn ordered_rows(
|
||||
Vec::new()
|
||||
};
|
||||
}
|
||||
if actionable == 0 && surface.recent_only_suppressed {
|
||||
let suppress_transient_recent = actionable == 0 && surface.recent_only_suppressed;
|
||||
if suppress_transient_recent && !has_durable {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
@@ -906,6 +984,9 @@ fn ordered_rows(
|
||||
if item.row.id.0 == "activity:aggregate" && !show_activity {
|
||||
continue;
|
||||
}
|
||||
if suppress_transient_recent && !is_durable(&item) {
|
||||
continue;
|
||||
}
|
||||
live.push(item.row);
|
||||
}
|
||||
live
|
||||
@@ -1240,6 +1321,7 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
|
||||
// Stamped by `order_agent_seeds`, which is where
|
||||
// the indent and child count become known.
|
||||
role_label: String::new(),
|
||||
status: status.to_string(),
|
||||
objective,
|
||||
elapsed_secs: Some(
|
||||
agent_elapsed_ms(app, &agent.agent_id, agent.duration_ms) / 1_000,
|
||||
@@ -1315,10 +1397,13 @@ fn agent_rows(app: &App) -> Vec<RankedWorkRow> {
|
||||
}),
|
||||
agent: Some(AgentRowFacts {
|
||||
role_label: String::new(),
|
||||
status: status.to_string(),
|
||||
// No manager snapshot yet, so there is no
|
||||
// assignment to quote: the live activity line
|
||||
// is the honest answer to "what is it doing".
|
||||
objective: facts.join(" · "),
|
||||
// The status word itself is the status
|
||||
// column's job, so it is not repeated here.
|
||||
objective: facts[1..].join(" · "),
|
||||
// Neither a duration nor a usage envelope has
|
||||
// been seen for this id. Both render as
|
||||
// nothing rather than as `0s` / `0 tokens`.
|
||||
@@ -1794,16 +1879,15 @@ fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
|
||||
};
|
||||
let state = state_label(node);
|
||||
let kind = kind_label(node.kind);
|
||||
// To-do rows are self-evidently plan steps — the strip's checkbox marks
|
||||
// already say so, so the "plan step" kind label is pure noise there. A
|
||||
// ready step shows no detail at all; other states show just the state.
|
||||
// Non-step nodes keep the state · kind pair.
|
||||
// A to-do row always carries its status word in the detail column, using
|
||||
// the same vocabulary as `/task digest` (pending / in progress /
|
||||
// completed / cancelled). Only the redundant `· plan step` KIND suffix is
|
||||
// dropped — the strip's checkbox marks already say the row is a plan
|
||||
// step, but they do not say its state in words, and dropping the state
|
||||
// itself was the 0.9.4 regression (a pending to-do rendered no label at
|
||||
// all). Non-step nodes keep the state · kind pair.
|
||||
let detail = if node.kind == NodeKind::PlanStep {
|
||||
if node.state == NodeState::Ready {
|
||||
String::new()
|
||||
} else {
|
||||
state.to_string()
|
||||
}
|
||||
todo_state_label(node).to_string()
|
||||
} else {
|
||||
format!("{state} · {kind}")
|
||||
};
|
||||
@@ -1828,6 +1912,17 @@ fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
|
||||
}
|
||||
}
|
||||
|
||||
/// Status word for a plan-step (to-do) row, aligned with the four-state
|
||||
/// To-do vocabulary the `/task digest` text surface uses. Graph-only states
|
||||
/// keep their graph names.
|
||||
fn todo_state_label(node: &WorkNode) -> &'static str {
|
||||
match node.state {
|
||||
NodeState::Ready => "pending",
|
||||
NodeState::Initializing | NodeState::Active => "in progress",
|
||||
_ => state_label(node),
|
||||
}
|
||||
}
|
||||
|
||||
fn state_label(node: &WorkNode) -> &'static str {
|
||||
match node.state {
|
||||
NodeState::Ready => "ready",
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
//! Non-Tasks rail panels, ported from the legacy classic-shell sidebar
|
||||
//! during the 0.9.4 rail unification (spec step 2). Agents, Context, and
|
||||
//! Pinned render as line lists inside the one work-surface rail, in
|
||||
//! whatever placement the user picked; panel selection is orthogonal to
|
||||
//! placement. The Tasks panel is *not* here — it renders through the
|
||||
//! row/hitbox machinery in `render.rs`.
|
||||
//! Line-list rail panels, ported from the legacy classic-shell sidebar
|
||||
//! during the 0.9.4 rail unification (spec step 2). Only **Context** still
|
||||
//! renders this way: its lines are session facts, not work rows, so there
|
||||
//! is nothing to click. Tasks, Agents, and Pinned all render through the
|
||||
//! row/hitbox machinery in `render.rs` — a work row is a selectable,
|
||||
//! clickable object in every panel, and a panel is just a subset of the one
|
||||
//! work list.
|
||||
//!
|
||||
//! On Top placement the strip auto-fits its content the way Tasks always
|
||||
//! did (and the way GrokBuild's tasks pane does): a two-agent fan-out is
|
||||
//! two rows, not a fixed four-row band with a chrome title. The only Top
|
||||
//! title is an active **goal** (not panel names like "Pinned"). Side rails
|
||||
//! keep a muted panel label because a column among other chrome needs
|
||||
//! naming.
|
||||
//! two rows, not a fixed four-row band with a chrome title. Auto-fit
|
||||
//! governs HEIGHT only, never membership — a settled to-do or finished
|
||||
//! sub-agent still occupies a row (quiet completion, not eviction). The
|
||||
//! only Top title is an active **goal** (not panel names like "Pinned").
|
||||
//!
|
||||
//! The line builders themselves still live in `tui::sidebar` (they are
|
||||
//! `pub(crate)` there) while the sidebar module is wound down; the rail is
|
||||
//! their only production caller now.
|
||||
//! `pub(crate)` there) while the sidebar module is wound down. The Agents
|
||||
//! and Pinned arms below are retained for that wind-down but are no longer
|
||||
//! reachable from the rail, which routes those panels through rows.
|
||||
|
||||
use ratatui::text::Line;
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
use ratatui::layout::Rect;
|
||||
|
||||
use crate::tui::app::App;
|
||||
use crate::tui::work_surface::model::{self, RailPanel, WorkSurfacePlacement, project_visible};
|
||||
use crate::tui::work_surface::model::{
|
||||
self, RailPanel, WorkSurfacePlacement, visible_rows_for_panel,
|
||||
};
|
||||
use crate::tui::work_surface::panels;
|
||||
|
||||
use super::{progress_shares_goal_row, top_goal_title, top_todo_progress};
|
||||
@@ -43,10 +45,12 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16)
|
||||
collapse_strip(app);
|
||||
return 0;
|
||||
}
|
||||
// Non-Tasks panels on Top auto-fit like Tasks. Empty projections collapse
|
||||
// to zero — an empty panel is not a panel. Side placements reserve via
|
||||
// `split_chat` and take no top strip.
|
||||
if app.work_surface.panel != RailPanel::Tasks {
|
||||
// The Context fact list on Top auto-fits like the row surface. Empty
|
||||
// projections collapse to zero — an empty panel is not a panel. (Auto-fit
|
||||
// governs HEIGHT only; membership is the model's business, and a settled
|
||||
// to-do or finished sub-agent still occupies a row.) Side placements
|
||||
// reserve via `split_chat` and take no top strip.
|
||||
if app.work_surface.panel == RailPanel::Context {
|
||||
if app.work_surface.effective_placement != WorkSurfacePlacement::Top {
|
||||
return 0;
|
||||
}
|
||||
@@ -80,7 +84,7 @@ pub fn height(app: &mut App, width: u16, terminal_height: u16, rail_budget: u16)
|
||||
return desired.clamp(model::TOP_HEIGHT_MIN, cap);
|
||||
}
|
||||
|
||||
let rows = project_visible(app);
|
||||
let rows = visible_rows_for_panel(app);
|
||||
let goal_rows = u16::from(
|
||||
app.work_surface.effective_placement == WorkSurfacePlacement::Top
|
||||
&& top_goal_title(app).is_some(),
|
||||
@@ -249,7 +253,7 @@ pub fn split_chat(app: &mut App, area: Rect, min_chat_width: u16) -> (Rect, Opti
|
||||
/// Whether a Left/Right rail should reserve columns this frame.
|
||||
fn side_rail_has_content(app: &mut App) -> bool {
|
||||
match app.work_surface.panel {
|
||||
RailPanel::Tasks => !project_visible(app).is_empty(),
|
||||
panel => panels::panel_has_useful_content(app, panel),
|
||||
RailPanel::Context => panels::panel_has_useful_content(app, RailPanel::Context),
|
||||
_ => !visible_rows_for_panel(app).is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::tui::app::{App, SidebarHoverRow, SidebarHoverSection};
|
||||
use crate::tui::ui_text::truncate_line_to_width;
|
||||
|
||||
use super::model::{
|
||||
RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, project_visible,
|
||||
RailPanel, WorkHitbox, WorkRow, WorkSurfacePlacement, WorkTone, visible_rows_for_panel,
|
||||
};
|
||||
|
||||
mod layout;
|
||||
@@ -36,7 +36,7 @@ pub use layout::{height, split_chat};
|
||||
|
||||
use rows::{
|
||||
AGENT_ROLE_GUTTER, AgentRowTier, agent_identity, agent_identity_cap, agent_identity_column,
|
||||
agent_receipt, agent_row_styles, layout_agent_row, row_style,
|
||||
agent_receipt, agent_row_styles, agent_status_column, layout_agent_row, row_style,
|
||||
};
|
||||
|
||||
pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
@@ -74,14 +74,17 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
WorkSurfacePlacement::Off => unreachable!("off placement returned above"),
|
||||
};
|
||||
|
||||
// Non-Tasks panels render as a titled line list and skip the row
|
||||
// machinery (hitboxes, selection, todo ordinals) entirely.
|
||||
if app.work_surface.panel != RailPanel::Tasks {
|
||||
// Context is the one panel that is not a work-row surface: session facts
|
||||
// render as a titled line list with nothing to click. Every other panel
|
||||
// (Tasks, Agents, Pinned) routes through the row machinery below, so its
|
||||
// rows keep hitboxes, selection, and primary actions — a work row is a
|
||||
// door in every panel, not only in Tasks.
|
||||
if app.work_surface.panel == RailPanel::Context {
|
||||
render_panel(frame, area, body_area, app);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut rows = project_visible(app);
|
||||
let mut rows = visible_rows_for_panel(app);
|
||||
if placement == WorkSurfacePlacement::Top {
|
||||
// Literal work list only: selectable to-dos/agents plus the
|
||||
// GrokBuild-style `▾ Subagents N` group header. Generic graph chrome
|
||||
@@ -206,6 +209,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
let visible = rows.iter().skip(start).take(list_rows).collect::<Vec<_>>();
|
||||
let identity_cap = agent_identity_cap(usize::from(content_area.width));
|
||||
let identity_column = agent_identity_column(&visible, identity_cap);
|
||||
let status_column = agent_status_column(&visible);
|
||||
let mut lines = Vec::with_capacity(visible.len().saturating_add(1));
|
||||
let mut hover_rows = Vec::new();
|
||||
let mut hitboxes = Vec::new();
|
||||
@@ -245,17 +249,24 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
UnicodeWidthStr::width(prefix.as_str()),
|
||||
agent_identity(row, identity_cap),
|
||||
identity_column,
|
||||
status_column,
|
||||
facts,
|
||||
);
|
||||
let (normal, muted) = agent_row_styles(app, selected, hovered, opened);
|
||||
let display = format!(
|
||||
"{prefix}{}{}{}{}{}",
|
||||
"{prefix}{}{}{}{}{}{}{}",
|
||||
laid_out.role,
|
||||
if laid_out.role.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
" ".repeat(AGENT_ROLE_GUTTER)
|
||||
},
|
||||
laid_out.status,
|
||||
if laid_out.status.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
" ".repeat(AGENT_ROLE_GUTTER)
|
||||
},
|
||||
laid_out.objective,
|
||||
" ".repeat(laid_out.gap),
|
||||
laid_out.receipt,
|
||||
@@ -267,6 +278,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
muted,
|
||||
));
|
||||
}
|
||||
if !laid_out.status.is_empty() {
|
||||
spans.push(Span::styled(
|
||||
format!("{}{}", laid_out.status, " ".repeat(AGENT_ROLE_GUTTER)),
|
||||
muted,
|
||||
));
|
||||
}
|
||||
spans.push(Span::styled(laid_out.objective.clone(), normal));
|
||||
spans.push(Span::styled(
|
||||
format!("{}{}", " ".repeat(laid_out.gap), laid_out.receipt),
|
||||
@@ -386,10 +403,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Render a non-Tasks rail panel (Agents / Context / Pinned) as a line list
|
||||
/// in the same body area and with the same divider and scrollbar the Tasks
|
||||
/// list would use. Row interactivity (hitboxes, selection, click actions)
|
||||
/// is Tasks-only for now; panels scroll via the shared `scroll_offset`.
|
||||
/// Render the Context panel as a titled line list in the same body area and
|
||||
/// with the same divider and scrollbar the row surface would use. Context is
|
||||
/// the only panel that renders here: its lines are session facts, not work
|
||||
/// rows, so there is nothing to click and no hitboxes to record. Every panel
|
||||
/// that shows work rows (Tasks, Agents, Pinned) goes through the row/hitbox
|
||||
/// machinery in [`render`] instead.
|
||||
fn render_panel(frame: &mut Frame, area: Rect, body_area: Rect, app: &mut App) {
|
||||
let panel = app.work_surface.panel;
|
||||
let placement = app.work_surface.effective_placement;
|
||||
|
||||
@@ -47,6 +47,11 @@ const AGENT_ROW_TIERS: [AgentRowTier; 4] = [
|
||||
pub(super) struct AgentRowText {
|
||||
/// Agent-type column, padded to the shared width. Empty once dropped.
|
||||
pub(super) role: String,
|
||||
/// Status word column (`running`, `completed`, …), padded to the shared
|
||||
/// width. Dropped only with the identity column: a fleet row that cannot
|
||||
/// say its state in words has lost the fact the owner asked for back
|
||||
/// (2026-08-04 regression report).
|
||||
pub(super) status: String,
|
||||
pub(super) objective: String,
|
||||
/// `12m 33s · ↓ 111.9k tokens`. Empty once dropped.
|
||||
pub(super) receipt: String,
|
||||
@@ -122,6 +127,16 @@ pub(super) fn agent_identity_column(rows: &[&WorkRow], cap: usize) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Shared width of the status-word column across the rows painted this frame.
|
||||
/// Statuses come from a fixed vocabulary, so no cap is needed.
|
||||
pub(super) fn agent_status_column(rows: &[&WorkRow]) -> usize {
|
||||
rows.iter()
|
||||
.filter_map(|row| row.agent.as_ref())
|
||||
.map(|facts| UnicodeWidthStr::width(facts.status.as_str()))
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Fit one sub-agent row into `width`, dropping optional columns in
|
||||
/// [`AGENT_ROW_TIERS`] order until the objective has room to say something.
|
||||
/// Every column truncates; nothing ever wraps.
|
||||
@@ -130,6 +145,7 @@ pub(super) fn layout_agent_row(
|
||||
prefix_width: usize,
|
||||
identity: &str,
|
||||
identity_column: usize,
|
||||
status_column: usize,
|
||||
facts: &AgentRowFacts,
|
||||
) -> AgentRowText {
|
||||
for tier in AGENT_ROW_TIERS {
|
||||
@@ -142,11 +158,25 @@ pub(super) fn layout_agent_row(
|
||||
let pad = identity_column.saturating_sub(UnicodeWidthStr::width(identity));
|
||||
format!("{identity}{}", " ".repeat(pad))
|
||||
};
|
||||
// The status word degrades with the identity: it survives the loss of
|
||||
// tokens and elapsed, and yields only when the row is down to the
|
||||
// objective alone.
|
||||
let status = if tier == AgentRowTier::ObjectiveOnly || status_column == 0 {
|
||||
String::new()
|
||||
} else {
|
||||
let pad = status_column.saturating_sub(UnicodeWidthStr::width(facts.status.as_str()));
|
||||
format!("{}{}", facts.status, " ".repeat(pad))
|
||||
};
|
||||
let role_cost = if role.is_empty() {
|
||||
0
|
||||
} else {
|
||||
UnicodeWidthStr::width(role.as_str()).saturating_add(AGENT_ROLE_GUTTER)
|
||||
};
|
||||
let status_cost = if status.is_empty() {
|
||||
0
|
||||
} else {
|
||||
UnicodeWidthStr::width(status.as_str()).saturating_add(AGENT_ROLE_GUTTER)
|
||||
};
|
||||
let receipt_cost = if receipt.is_empty() {
|
||||
0
|
||||
} else {
|
||||
@@ -155,6 +185,7 @@ pub(super) fn layout_agent_row(
|
||||
let budget = width
|
||||
.saturating_sub(prefix_width)
|
||||
.saturating_sub(role_cost)
|
||||
.saturating_sub(status_cost)
|
||||
.saturating_sub(receipt_cost);
|
||||
if budget < AGENT_OBJECTIVE_MIN && tier != AgentRowTier::ObjectiveOnly {
|
||||
continue;
|
||||
@@ -163,10 +194,12 @@ pub(super) fn layout_agent_row(
|
||||
let gap = width
|
||||
.saturating_sub(prefix_width)
|
||||
.saturating_sub(role_cost)
|
||||
.saturating_sub(status_cost)
|
||||
.saturating_sub(UnicodeWidthStr::width(objective.as_str()))
|
||||
.saturating_sub(UnicodeWidthStr::width(receipt.as_str()));
|
||||
return AgentRowText {
|
||||
role,
|
||||
status,
|
||||
objective,
|
||||
receipt,
|
||||
gap,
|
||||
|
||||
@@ -1166,6 +1166,353 @@ fn work_surface_real_rows_own_click_wheel_and_resize() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Owner-reported (2026-08-04): "you cannot click a work-bar row AT ALL".
|
||||
/// The bare-session click test above passes, so this probe reproduces the
|
||||
/// dogfood shape it does not cover: an active goal title occupying the
|
||||
/// strip's header row, then a real SGR click on a to-do row. The click must
|
||||
/// open the row's world (the Work inspector pager), not just move focus.
|
||||
#[test]
|
||||
fn work_surface_rows_stay_clickable_under_a_goal_title() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".deepseek").join("config.toml"),
|
||||
"[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
|
||||
)?;
|
||||
let session_path = ws.workspace().join("goal-click-session.json");
|
||||
let todos = (0..6)
|
||||
.map(|index| {
|
||||
serde_json::json!({
|
||||
"id": index + 1,
|
||||
"content": format!("todo-goal-{index:02}"),
|
||||
"status": if index == 0 { "in_progress" } else { "pending" }
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
std::fs::write(
|
||||
&session_path,
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"metadata": {
|
||||
"id": "pty-goal-click",
|
||||
"title": "Goal-title click probe",
|
||||
"created_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z",
|
||||
"message_count": 0,
|
||||
"total_tokens": 0,
|
||||
"model": "deepseek-v4-pro",
|
||||
"model_provider": "deepseek",
|
||||
"workspace": ws.workspace(),
|
||||
"mode": "agent",
|
||||
"cost": {},
|
||||
"cumulative_turn_secs": 0
|
||||
},
|
||||
"messages": [],
|
||||
"system_prompt": null,
|
||||
"work_state": {
|
||||
"todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
|
||||
"plan": {"objective": "", "items": []}
|
||||
}
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
|
||||
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
ws.workspace().to_str().expect("utf-8 workspace path"),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
"--mouse-capture",
|
||||
"--yolo",
|
||||
])
|
||||
.size(40, 140)
|
||||
.spawn()?;
|
||||
enter_launch_session(&mut h)?;
|
||||
h.send(keys::key::text(&format!(
|
||||
"/load {}",
|
||||
session_path.to_string_lossy()
|
||||
)))?;
|
||||
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("todo-goal-00", KEY_TIMEOUT)?;
|
||||
|
||||
// Declare a goal: the strip now pins `Goal: …` above the rows, which is
|
||||
// the header-offset condition the bare click test never exercises. The
|
||||
// /goal command also fires a turn; the refused base URL fails it fast.
|
||||
h.send(keys::key::text("/goal click-probe objective"))?;
|
||||
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("Goal:", KEY_TIMEOUT)?;
|
||||
h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(5))?;
|
||||
|
||||
let target = "todo-goal-03";
|
||||
let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
|
||||
h.send(keys::mouse::click(row, col))?;
|
||||
h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
|
||||
h.wait_for_text(target, KEY_TIMEOUT)?;
|
||||
|
||||
let _ = h.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A loopback SSE fixture that streams one content chunk, then deliberately
|
||||
/// holds the connection open before finishing, so a PTY scenario can interact
|
||||
/// with the TUI while a turn is genuinely live (`is_loading == true`).
|
||||
fn spawn_slow_stream_fixture(
|
||||
hold: Duration,
|
||||
) -> anyhow::Result<(String, std::thread::JoinHandle<()>)> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
listener.set_nonblocking(true)?;
|
||||
let address = listener.local_addr()?;
|
||||
let handle = std::thread::spawn(move || {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let mut served = 0usize;
|
||||
while served < 3 && Instant::now() < deadline {
|
||||
let Ok((mut stream, _)) = listener.accept() else {
|
||||
std::thread::sleep(Duration::from_millis(10));
|
||||
continue;
|
||||
};
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
|
||||
let mut request = [0u8; 64 * 1024];
|
||||
let _ = stream.read(&mut request);
|
||||
let first = format!(
|
||||
"data: {}\n\n",
|
||||
serde_json::json!({
|
||||
"id":"chatcmpl-slow",
|
||||
"object":"chat.completion.chunk",
|
||||
"model":"deepseek-v4-flash",
|
||||
"choices":[{"index":0,"delta":{"content":"SLOW-STREAM-HOLD"},"finish_reason":null}]
|
||||
})
|
||||
);
|
||||
let rest = [
|
||||
format!(
|
||||
"data: {}\n\n",
|
||||
serde_json::json!({
|
||||
"id":"chatcmpl-slow",
|
||||
"object":"chat.completion.chunk",
|
||||
"model":"deepseek-v4-flash",
|
||||
"choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}
|
||||
})
|
||||
),
|
||||
"data: [DONE]\n\n".to_string(),
|
||||
]
|
||||
.join("");
|
||||
// No Content-Length: the reader must see the first chunk while
|
||||
// the turn is still open, then the tail after the hold.
|
||||
let _ = stream.write_all(
|
||||
format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n{first}"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
let _ = stream.flush();
|
||||
if served == 0 {
|
||||
std::thread::sleep(hold);
|
||||
}
|
||||
let _ = stream.write_all(rest.as_bytes());
|
||||
let _ = stream.flush();
|
||||
served += 1;
|
||||
}
|
||||
});
|
||||
Ok((format!("http://{address}"), handle))
|
||||
}
|
||||
|
||||
/// Second owner-repro probe: click a to-do row while a turn is actively
|
||||
/// streaming. The bare and goal-title probes both pass idle; dogfood clicks
|
||||
/// happen mid-run, so pin the live-turn path too.
|
||||
#[test]
|
||||
fn work_surface_rows_stay_clickable_during_a_live_turn() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".deepseek").join("config.toml"),
|
||||
"[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
|
||||
)?;
|
||||
let session_path = ws.workspace().join("live-click-session.json");
|
||||
let todos = (0..6)
|
||||
.map(|index| {
|
||||
serde_json::json!({
|
||||
"id": index + 1,
|
||||
"content": format!("todo-live-{index:02}"),
|
||||
"status": if index == 0 { "in_progress" } else { "pending" }
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
std::fs::write(
|
||||
&session_path,
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"metadata": {
|
||||
"id": "pty-live-click",
|
||||
"title": "Live-turn click probe",
|
||||
"created_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z",
|
||||
"message_count": 0,
|
||||
"total_tokens": 0,
|
||||
"model": "deepseek-v4-pro",
|
||||
"model_provider": "deepseek",
|
||||
"workspace": ws.workspace(),
|
||||
"mode": "agent",
|
||||
"cost": {},
|
||||
"cumulative_turn_secs": 0
|
||||
},
|
||||
"messages": [],
|
||||
"system_prompt": null,
|
||||
"work_state": {
|
||||
"todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
|
||||
"plan": {"objective": "", "items": []}
|
||||
}
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let (base_url, server) = spawn_slow_stream_fixture(Duration::from_secs(6))?;
|
||||
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
|
||||
.env("DEEPSEEK_BASE_URL", &base_url)
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
ws.workspace().to_str().expect("utf-8 workspace path"),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
"--mouse-capture",
|
||||
"--yolo",
|
||||
])
|
||||
.size(40, 140)
|
||||
.spawn()?;
|
||||
enter_launch_session(&mut h)?;
|
||||
h.send(keys::key::text(&format!(
|
||||
"/load {}",
|
||||
session_path.to_string_lossy()
|
||||
)))?;
|
||||
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("todo-live-00", KEY_TIMEOUT)?;
|
||||
|
||||
// Start a turn; the fixture streams one chunk then holds ~6s.
|
||||
h.send(keys::key::text("hold the stream open"))?;
|
||||
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("SLOW-STREAM-HOLD", Duration::from_secs(10))?;
|
||||
|
||||
// Mid-stream: the turn is live. Click a to-do row and require its world
|
||||
// to open, exactly as it must when idle.
|
||||
let target = "todo-live-03";
|
||||
let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
|
||||
h.send(keys::mouse::click(row, col))?;
|
||||
h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
|
||||
|
||||
let _ = h.shutdown();
|
||||
drop(server);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Third owner-repro probe: a user whose settings select a non-Tasks rail
|
||||
/// panel (the classic sidebar_focus migration lands many upgraders on
|
||||
/// Pinned) must still be able to click a to-do row and have its world open.
|
||||
/// Before the 2026-08-04 fix, non-Tasks panels wiped every hitbox — clicks
|
||||
/// did nothing at all, which is exactly what the owner reported.
|
||||
#[test]
|
||||
fn pinned_panel_rows_stay_clickable_in_a_real_pty() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
let ws = make_sealed_workspace()?;
|
||||
std::fs::write(
|
||||
ws.home().join(".deepseek").join("config.toml"),
|
||||
"[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
|
||||
)?;
|
||||
let settings_dir = ws.home().join(".codewhale");
|
||||
std::fs::create_dir_all(&settings_dir)?;
|
||||
std::fs::write(
|
||||
settings_dir.join("settings.toml"),
|
||||
"rail_panel = \"pinned\"\n",
|
||||
)?;
|
||||
|
||||
let session_path = ws.workspace().join("pinned-click-session.json");
|
||||
let todos = (0..5)
|
||||
.map(|index| {
|
||||
serde_json::json!({
|
||||
"id": index + 1,
|
||||
"content": format!("todo-pinned-{index:02}"),
|
||||
"status": if index == 0 { "in_progress" } else { "pending" }
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
std::fs::write(
|
||||
&session_path,
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"schema_version": 1,
|
||||
"metadata": {
|
||||
"id": "pty-pinned-click",
|
||||
"title": "Pinned-panel click probe",
|
||||
"created_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z",
|
||||
"message_count": 0,
|
||||
"total_tokens": 0,
|
||||
"model": "deepseek-v4-pro",
|
||||
"model_provider": "deepseek",
|
||||
"workspace": ws.workspace(),
|
||||
"mode": "agent",
|
||||
"cost": {},
|
||||
"cumulative_turn_secs": 0
|
||||
},
|
||||
"messages": [],
|
||||
"system_prompt": null,
|
||||
"work_state": {
|
||||
"todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
|
||||
"plan": {"objective": "", "items": []}
|
||||
}
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
|
||||
.cwd(ws.workspace())
|
||||
.clear_env()
|
||||
.seal_home(ws.home())
|
||||
.env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
|
||||
.env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
|
||||
.env("NO_ANIMATIONS", "1")
|
||||
.env("RUST_LOG", "warn")
|
||||
.args([
|
||||
"--workspace",
|
||||
ws.workspace().to_str().expect("utf-8 workspace path"),
|
||||
"--no-project-config",
|
||||
"--skip-onboarding",
|
||||
"--mouse-capture",
|
||||
"--yolo",
|
||||
])
|
||||
.size(40, 140)
|
||||
.spawn()?;
|
||||
enter_launch_session(&mut h)?;
|
||||
h.send(keys::key::text(&format!(
|
||||
"/load {}",
|
||||
session_path.to_string_lossy()
|
||||
)))?;
|
||||
h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
|
||||
h.send(keys::key::enter())?;
|
||||
h.wait_for_text("todo-pinned-00", KEY_TIMEOUT)?;
|
||||
|
||||
let target = "todo-pinned-02";
|
||||
let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
|
||||
h.send(keys::mouse::click(row, col))?;
|
||||
h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
|
||||
h.wait_for_text(target, KEY_TIMEOUT)?;
|
||||
|
||||
let _ = h.shutdown();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_coordination_details_use_typed_persisted_receipts_in_a_unix_pty() -> anyhow::Result<()> {
|
||||
let _guard = qa_pty_test_lock();
|
||||
|
||||
Reference in New Issue
Block a user