feat(tui): release-quality transcript pass + persistent sub-agent visibility

Consolidates the uncommitted 2026-08-04 transcript/feel lane (owner
report: "the sub agents still aren't showing up in the top bar so they
aren't inspectable"). Each change is verified against measured data or a
real PTY frame, not taste:

- history/constants.rs: tool-card budgets now sit at the knee of measured
  coverage over 53 real sessions (5,470 tool results): command echo 3->6
  rows (45%->70% whole), output 12->20 (50%->72%), success previews show
  6 rows instead of nothing, header summary 56->72 chars, head/tail
  windows 4/4->10/6, and the summary-card cap is a named constant at 6.
- history.rs: successful `run` cards preview their output instead of
  collapsing to a bare header; failures keep the full budget.
- scrolling.rs/transcript.rs: block separators inside tool-card rail
  groups carry the rail glyph and a copy prefix, so copied text round-
  trips without the rail and without losing content.
- widgets/mod.rs: composer wrapping breaks on word boundaries, never
  through a word (lossless; hard-breaks only tokens with no break point).
- phase_strip.rs: toasts get the width actually left on the row (min
  32), so "Delegated coordination unavailable — an…" no longer truncates
  the diagnosis away; ledger chips are budgeted first, order unchanged.
- ui.rs: coordination toast leads with the human fact ("Another CodeWhale
  session in this workspace owns delegated coordination"), pid/path stay
  in the detail view.
- compaction.rs: continuation headings read as product copy ("Task, in
  progress" / "Latest request") instead of credential-redaction prose.
- history/thinking.rs: collapsed reasoning previews no longer mangle
  identifiers into "…" (#4146/#4148 scrub removed; verbatim body, line
  budget only). The two pinned tests were re-based onto the new contract
  (verbatim collapse, affordance only when truncated).
- tests: qa_pty legs re-baselined to shipped keys/grammar; new
  work_bar_subagents_pty.rs drives a real PTY with a loopback provider to
  prove work-bar rows appear and open their detail on click/Enter.

Verified: cargo test -p codewhale-tui --bin codewhale-tui = 9773 passed,
0 failed, 9 ignored; cargo test -p codewhale-tui --test qa_pty = 41
passed, 0 failed, 2 ignored.
This commit is contained in:
Hmbown
2026-08-04 18:24:58 -07:00
parent bb0be84939
commit ff97641b74
16 changed files with 1691 additions and 212 deletions
+4 -6
View File
@@ -2202,7 +2202,7 @@ fn build_continuation_block(messages: &[Message], pinned_indices: &BTreeSet<usiz
if let Some(contract) = first_user.as_deref() {
let _ = write!(
body,
"### Working contract (first user request, credential-redacted quote)\n\n{}\n\n",
"### Task, in progress\n\n{}\n\n",
quote_verbatim(contract, CONTINUATION_CONTRACT_MAX_CHARS)
);
}
@@ -2212,7 +2212,7 @@ fn build_continuation_block(messages: &[Message], pinned_indices: &BTreeSet<usiz
{
let _ = write!(
body,
"### Active intent (most recent user request, credential-redacted quote)\n\n{}\n\n",
"### Latest request\n\n{}\n\n",
quote_verbatim(intent, CONTINUATION_CONTRACT_MAX_CHARS)
);
}
@@ -2755,11 +2755,9 @@ mod tests {
// Intent: both the original working contract and the latest ask survive
// after the credential-redaction boundary.
assert!(block.contains("Working contract (first user request, credential-redacted quote)"));
assert!(block.contains("### Task, in progress"));
assert!(block.contains("releases are blocked until login tests pass"));
assert!(
block.contains("Active intent (most recent user request, credential-redacted quote)")
);
assert!(block.contains("### Latest request"));
assert!(block.contains("re-run only the login tests"));
// Decisions: the accepted approach and its rationale are carried forward.
assert!(block.contains("Decisions already made"));
+6 -1
View File
@@ -3832,7 +3832,12 @@ fn resize_preserves_scrolled_transcript_position() {
app.handle_resize(120, 40);
let meta = vec![TranscriptLineMeta::Spacer; 240];
let meta = vec![
TranscriptLineMeta::Spacer {
copy_prefix_width: 0
};
240
];
let (_, top) = app.viewport.transcript_scroll.resolve_top(&meta, 200);
assert_eq!(top, 42);
assert_eq!(app.viewport.pending_scroll_delta, 0);
+21 -7
View File
@@ -40,7 +40,7 @@ use checklist::{ChecklistChange, ChecklistItemSnapshot, ChecklistSnapshot};
use constants::{
ASSISTANT_GLYPH, FOREGROUND_SHELL_WAIT_HINT, TOOL_CARD_SUMMARY_LINES, TOOL_COMMAND_LINE_LIMIT,
TOOL_DONE_SYMBOL, TOOL_FAILED_SYMBOL, TOOL_HEADER_SUMMARY_LIMIT, TOOL_OUTPUT_LINE_LIMIT,
TRANSCRIPT_RAIL, USER_GLYPH,
TOOL_SUCCESS_OUTPUT_PREVIEW_LINES, TOOL_SUMMARY_CARD_LINES, TRANSCRIPT_RAIL, USER_GLYPH,
};
#[cfg(test)]
use constants::{TOOL_RUNNING_SYMBOLS, TOOL_STATUS_SYMBOL_MS};
@@ -386,8 +386,8 @@ impl HistoryCell {
),
HistoryCell::Tool(cell) if !options.show_tool_details && !cell.is_failed() => {
let mut lines = cell.lines_with_motion(width, options.low_motion);
if lines.len() > 2 {
lines.truncate(2);
if lines.len() > TOOL_SUMMARY_CARD_LINES {
lines.truncate(TOOL_SUMMARY_CARD_LINES);
lines.push(details_affordance_line(
&crate::tui::key_shortcuts::tool_details_shortcut_action_hint("details"),
Style::default().fg(palette::TEXT_MUTED).italic(),
@@ -911,10 +911,14 @@ impl ExecCell {
return wrap_card_rail(lines);
}
// A successful shell call is rarely worth its full body — collapse it
// to the single header line in live mode. The bottom shell strip owns
// live/background detail, failures stay fully verbose so errors remain
// visible, and Transcript mode keeps everything for the pager/clipboard.
// A successful shell call does not earn its full body in live mode —
// failures stay fully verbose so errors remain visible, and Transcript
// mode keeps everything for the pager/clipboard. But it does earn a
// glimpse: collapsing success to the bare header meant a `run` card
// showed literally nothing of what the command produced, and you had
// to expand every single one to find out whether anything happened.
// `TOOL_SUCCESS_OUTPUT_PREVIEW_LINES` rows show roughly half of real
// successful runs in full and the opening of the rest.
if mode == RenderMode::Live
&& self
.output
@@ -925,6 +929,16 @@ impl ExecCell {
return wrap_card_rail(lines);
}
if mode == RenderMode::Live && self.status == ToolStatus::Success {
if self.interaction.is_none()
&& let Some(output) = self.output.as_ref().or(self.live_output.as_ref())
{
lines.extend(render_exec_output_mode(
output,
width,
TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
mode,
));
}
if let Some(duration_ms) = self.duration_ms
&& duration_ms >= 1000
{
+90 -6
View File
@@ -1,11 +1,74 @@
//! Shared constants for history transcript rendering.
//!
//! ## How the live tool-card budgets were chosen
//!
//! The caps below were measured, not guessed. The sample is 53 real saved
//! sessions from `~/.codewhale/sessions` — 5,470 tool results, 4,001 of them
//! `Bash` (the "run" cards) and 3,777 `Bash` commands.
//!
//! Observed `Bash` result length, in source lines:
//! `p25=3 p50=9 p75=25 p90=60 p95=113 max=1161`.
//!
//! Observed `Bash` command length: `p50=251 chars`, `p90=1404` — i.e. the
//! median command is multi-line once wrapped, not a one-liner.
//!
//! Each cap sits at the knee of its own coverage curve: the point past which
//! more rows buy very little more content. Going further chases a long tail
//! that a single card should never try to hold — that is what the details
//! pager is for.
/// Wrapped rows of the *command* echoed inside a live tool card.
///
/// Coverage of real `Bash` commands shown whole, at an 80-column terminal:
/// `3 → 45%`, `4 → 58%`, **`6 → 70%`**, `8 → 75%`, `10 → 77%`.
/// Six is the knee: +25 points over the old cap of 3, where 8 adds only 4
/// more and 10 only 2. At 3 the *median* command was clipped, which is the
/// "run cards never show enough" complaint at its source.
pub(super) const TOOL_COMMAND_LINE_LIMIT: usize = 6;
/// Wrapped rows of tool *output* shown in a live card before the details
/// affordance takes over.
///
/// Fraction of real `Bash` results shown whole: `8 → 50%`, `12 → 60%`,
/// `16 → 68%`, **`20 → 72%`**, `24 → 75%`, `32 → 80%`.
/// Twenty covers three quarters of real results while still leaving half of
/// a 40-row terminal for everything else; 24 buys under three points for
/// four more rows.
pub(super) const TOOL_OUTPUT_LINE_LIMIT: usize = 20;
/// Rows of output a *successful* live `run` card shows before the details
/// affordance takes over.
///
/// This used to be zero: success collapsed to the bare header, so a card told
/// you a command finished but nothing at all about what it produced. Against
/// the sampled corpus (3,465 `Bash` results with no error marker,
/// `p25=3 p50=8 p75=26`), a six-row preview shows ~45% of successful runs in
/// their entirety and the opening of the rest. Eight rows would reach ~51%,
/// but it spends two more rows on *every* successful card, and the transcript
/// now also spends a separator row between blocks. Failures are unaffected —
/// they keep the full `TOOL_OUTPUT_LINE_LIMIT` budget, because an error you
/// cannot read is the expensive one.
pub(super) const TOOL_SUCCESS_OUTPUT_PREVIEW_LINES: usize = 6;
pub(super) const TOOL_COMMAND_LINE_LIMIT: usize = 3;
pub(super) const TOOL_OUTPUT_LINE_LIMIT: usize = 12;
pub(super) const TOOL_TEXT_LIMIT: usize = 300;
pub(super) const TOOL_HEADER_SUMMARY_LIMIT: usize = 56;
pub(super) const TOOL_OUTPUT_HEAD_LINES: usize = 4;
pub(super) const TOOL_OUTPUT_TAIL_LINES: usize = 4;
/// Characters of the summary shown after `·` in a tool-card header. Real
/// commands run far longer than any header (p50 = 251 chars), so this is a
/// glance budget, not a fit budget — the header line is width-clipped
/// downstream regardless. 72 keeps the header inside an 80-column terminal
/// while showing meaningfully more of the command on a wide one.
pub(super) const TOOL_HEADER_SUMMARY_LIMIT: usize = 72;
/// Contiguous rows taken from the start of a truncated output.
///
/// `p50` of a real `Bash` result is 9 source lines, so a 10-row head shows
/// the whole opening of a median result rather than a fragment of it.
pub(super) const TOOL_OUTPUT_HEAD_LINES: usize = 10;
/// Contiguous rows taken from the end of a truncated output — where exit
/// status, totals, and error summaries land. Head + tail = 16 of the 20-row
/// budget, leaving 4 rows for importance-ranked lines from the middle.
pub(super) const TOOL_OUTPUT_TAIL_LINES: usize = 6;
#[cfg(test)]
pub(super) const TOOL_RUNNING_SYMBOLS: [&str; 8] = crate::tui::spinner::BRAILLE_SPINNER_FRAMES;
#[cfg(test)]
@@ -21,7 +84,28 @@ pub(super) const ASSISTANT_GLYPH: &str = crate::tui::glyphs::CURRENT;
/// detail rows, and affordance lines. Dimmed so it guides the eye without
/// competing with content.
pub(super) const TRANSCRIPT_RAIL: &str = crate::tui::glyphs::TRANSCRIPT_RAIL;
pub(super) const TOOL_CARD_SUMMARY_LINES: usize = 4;
/// Total rendered rows a non-failed tool card keeps when `show_tool_details`
/// is off — the shipped default, so this is the cap almost every user
/// actually sees.
///
/// It was an unnamed literal `2`: header plus a single row, then an "expand"
/// affordance. Three rows spent to learn that *something* ran. Every other
/// budget in this file was invisible underneath it. Six rows is a header, up
/// to four rows of real content, and the affordance — enough to answer "what
/// did that do?" without opening anything, and still a card rather than a
/// wall. Failures are excluded from this path entirely and keep their full
/// budget.
pub(super) const TOOL_SUMMARY_CARD_LINES: usize = 6;
/// Total rendered rows a non-failed tool card keeps in calm mode — also on by
/// default, and applied *after* the `show_tool_details` summary cap above.
///
/// It was 4, i.e. stricter than the summary cap, which inverted the two: a
/// user who turned tool details *on* while leaving calm mode alone saw fewer
/// rows than one who left both at their defaults. Calm mode is about quiet,
/// not about hiding, so it bounds the card at the header plus the full
/// successful-run preview plus the expand affordance.
pub(super) const TOOL_CARD_SUMMARY_LINES: usize = TOOL_SUCCESS_OUTPUT_PREVIEW_LINES + 2;
pub(super) const TOOL_DONE_SYMBOL: &str = crate::tui::glyphs::DONE;
pub(super) const TOOL_FAILED_SYMBOL: &str = crate::tui::glyphs::FAILED;
/// Compact Ctrl+B affordance for foreground shell waits in the live transcript.
+170 -41
View File
@@ -1,3 +1,7 @@
use super::constants::{
TOOL_OUTPUT_HEAD_LINES, TOOL_OUTPUT_LINE_LIMIT, TOOL_OUTPUT_TAIL_LINES,
TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
};
use super::{
ASSISTANT_GLYPH, ExecCell, ExecSource, GenericToolCell, HistoryCell, McpToolCell,
PlanUpdateCell, REASONING_CURSOR, REASONING_OPENER, REASONING_RAIL, TOOL_RUNNING_SYMBOLS,
@@ -2472,18 +2476,22 @@ fn completed_short_thinking_without_summary_stays_visible_in_live_view() {
}
#[test]
fn completed_reasoning_receipt_hides_internal_function_names_until_expanded() {
// #4146/#4148: a completed-reasoning receipt in the default (collapsed)
// transcript must not expose internal function names; the full body —
// identifiers intact — stays reachable on expand and in the transcript.
fn completed_reasoning_receipt_shows_verbatim_body_and_expands() {
// The old #4146/#4148 scrub could not tell CodeWhale's identifiers from
// the user's, and in a coding harness the user's dominate: it rendered
// `short_dated_radar.py` as `….py`, `data/market_data/` as `data/…/`, and
// every env var and module name as a bare `…`, which made the default
// reasoning view unreadable. It also protected nothing — the full body
// was always one keypress away on Space/Ctrl+O. A reasoning receipt now
// shows the model's own words verbatim; only the line budget truncates.
let cell = HistoryCell::Thinking {
content: "I will call refresh_catalog_cache to refresh the model list.".to_string(),
streaming: false,
duration_secs: Some(1.0),
};
// Default collapsed view: identifier scrubbed, prose preserved, and the
// expand affordance offered.
// Default collapsed view: the identifier is shown, not scrubbed, and a
// short body needs no expand affordance.
let collapsed = cell.lines_with_options(
80,
TranscriptRenderOptions {
@@ -2493,21 +2501,49 @@ fn completed_reasoning_receipt_hides_internal_function_names_until_expanded() {
);
let collapsed_text = lines_text(&collapsed);
assert!(
!collapsed_text.contains("refresh_catalog_cache"),
"internal function name must not leak by default: {collapsed_text}"
collapsed_text.contains("refresh_catalog_cache"),
"reasoning must be verbatim in the collapsed receipt: {collapsed_text}"
);
assert!(
collapsed_text.contains("refresh the model list"),
"surrounding prose must still read: {collapsed_text}"
);
assert!(
collapsed_text.contains("Ctrl+O:detail"),
"collapsed receipt must offer the expand affordance: {collapsed_text}"
!collapsed_text.contains("Ctrl+O:detail"),
"a short completed receipt fits the budget and needs no affordance: {collapsed_text}"
);
// Expanded view (Space toggles the fold relative to the default): the full
// identifier is restored.
let expanded = cell.lines_with_options_folded(
// A long body truncates at the line budget and offers the expand
// affordance; expanding restores every line, identifiers intact.
let long_body = (1..=20)
.map(|i| format!("step {i:02}: refresh_catalog_cache iteration"))
.collect::<Vec<_>>()
.join("\n");
let long_cell = HistoryCell::Thinking {
content: long_body.clone(),
streaming: false,
duration_secs: Some(1.0),
};
let long_collapsed = long_cell.lines_with_options(
80,
TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
},
);
let long_collapsed_text = lines_text(&long_collapsed);
assert!(
long_collapsed_text.contains("Space:expand · Ctrl+O:detail"),
"a truncated receipt must offer the expand affordance: {long_collapsed_text}"
);
assert!(
long_collapsed_text.contains("refresh_catalog_cache"),
"the shown head must keep identifiers verbatim: {long_collapsed_text}"
);
// Expanded view (Space toggles the fold relative to the default): every
// line is restored.
let expanded = long_cell.lines_with_options_folded(
80,
TranscriptRenderOptions {
low_motion: true,
@@ -2515,22 +2551,27 @@ fn completed_reasoning_receipt_hides_internal_function_names_until_expanded() {
},
true,
);
assert!(
lines_text(&expanded).contains("refresh_catalog_cache"),
"expanded reasoning must restore the full identifier"
);
// Transcript / pager / clipboard keeps the full, un-redacted body.
assert!(
lines_text(&cell.transcript_lines(80)).contains("refresh_catalog_cache"),
"transcript must keep the full identifier"
);
let expanded_text = lines_text(&expanded);
for i in 1..=20 {
assert!(
expanded_text.contains(&format!("step {i:02}: refresh_catalog_cache iteration")),
"expanded reasoning must restore every line ({i}): {expanded_text}"
);
}
}
#[test]
fn thinking_default_expanded_inverts_but_preserves_the_space_toggle() {
// A 20-line body guarantees the collapsed fold actually truncates, so the
// Space toggle is observable: default-expanded shows everything, Space
// collapses to the 10-line budget with the expand affordance, and both
// states show the model's identifiers verbatim (no #4146/#4148 scrub).
let long_body = (1..=20)
.map(|i| format!("step {i:02}: refresh_catalog_cache iteration"))
.collect::<Vec<_>>()
.join("\n");
let cell = HistoryCell::Thinking {
content: "I will call refresh_catalog_cache to refresh the model list.".to_string(),
content: long_body.clone(),
streaming: false,
duration_secs: Some(1.0),
};
@@ -2541,21 +2582,94 @@ fn thinking_default_expanded_inverts_but_preserves_the_space_toggle() {
};
let expanded = cell.lines_with_options_folded(80, options, false);
assert!(
lines_text(&expanded).contains("refresh_catalog_cache"),
"the configured default must show the full reasoning body"
);
let expanded_text = lines_text(&expanded);
for i in 1..=20 {
assert!(
expanded_text.contains(&format!("step {i:02}: refresh_catalog_cache iteration")),
"the configured default must show the full reasoning body ({i}): {expanded_text}"
);
}
let collapsed = cell.lines_with_options_folded(80, options, true);
let collapsed_text = lines_text(&collapsed);
assert!(
!collapsed_text.contains("refresh_catalog_cache"),
"Space must still collapse a default-expanded reasoning cell"
collapsed_text.contains("refresh_catalog_cache"),
"Space must still collapse a default-expanded reasoning cell, verbatim: {collapsed_text}"
);
assert!(
collapsed_text.contains("Ctrl+O:detail"),
collapsed_text.contains("Space:expand · Ctrl+O:detail"),
"the collapsed state must retain the full-reasoning affordance"
);
assert!(
!collapsed_text.contains("step 20:"),
"the collapsed fold must truncate the long body"
);
}
/// The live card must spend the whole output budget it advertises.
///
/// `selected_output_indices` fills head + tail, then tops up from lines that
/// look important (error / warning / path). Plain output — a list of names, a
/// table, a clean build log — matches none of those, so the top-up found
/// nothing and the card silently forfeited the rest of its budget: it showed
/// `head + tail` rows and reported the remainder as "omitted". That is the
/// "even truncated mode over-truncates" complaint.
#[test]
fn live_tool_output_spends_its_whole_line_budget_on_unremarkable_output() {
let total_output_lines = 40usize;
// Deliberately bland: no error/warning keywords, no slashes, no dots, so
// `output_importance_rank` returns None for every single line.
let output = (0..total_output_lines)
.map(|i| format!("row {i:02} plain content"))
.collect::<Vec<_>>()
.join("\n");
let cell = HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "list_things".to_string(),
status: ToolStatus::Failed,
output: Some(output),
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: None,
duration_ms: Some(120),
stale_elapsed_since_output_ms: None,
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
}));
let live = cell.lines_with_options(
80,
TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
},
);
let live_text = lines_text(&live);
let shown = (0..total_output_lines)
.filter(|i| live_text.contains(&format!("row {i:02} plain content")))
.count();
assert_eq!(
shown, TOOL_OUTPUT_LINE_LIMIT,
"a live card promising {TOOL_OUTPUT_LINE_LIMIT} output rows must show \
{TOOL_OUTPUT_LINE_LIMIT}, not stop at head+tail: {live_text}"
);
// The shown region stays readable: a contiguous head, then the tail.
for i in 0..TOOL_OUTPUT_HEAD_LINES {
assert!(
live_text.contains(&format!("row {i:02} plain content")),
"head row {i} missing: {live_text}"
);
}
for i in (total_output_lines - TOOL_OUTPUT_TAIL_LINES)..total_output_lines {
assert!(
live_text.contains(&format!("row {i:02} plain content")),
"tail row {i} missing: {live_text}"
);
}
}
#[test]
@@ -2624,10 +2738,12 @@ fn tool_exec_live_caps_failed_output_transcript_does_not() {
}
#[test]
fn tool_exec_live_collapses_successful_command() {
// A *successful* exec is rarely interesting — live mode collapses it to
// the single header line (no command body, no output). Transcript mode
// still records everything for the pager/clipboard.
fn tool_exec_live_previews_successful_command_without_its_full_body() {
// A *successful* exec does not earn its full body in live mode — no
// command echo, and only `TOOL_SUCCESS_OUTPUT_PREVIEW_LINES` of output.
// It used to collapse to the bare header, which meant a run card told you
// a command finished and nothing whatsoever about what it produced.
// Transcript mode still records everything for the pager/clipboard.
let output = (0..30usize)
.map(|i| format!("output line {i:02}"))
.collect::<Vec<_>>()
@@ -2657,14 +2773,27 @@ fn tool_exec_live_collapses_successful_command() {
));
let transcript_text = lines_text(&cell.transcript_lines(80));
// Live: header only — no output body, no omission marker.
assert!(
!live_text.contains("output line 00"),
"successful exec must not render its output body in live mode: {live_text}"
// Live: a bounded preview from the top of the output.
let previewed = (0..30usize)
.filter(|i| live_text.contains(&format!("output line {i:02}")))
.count();
assert_eq!(
previewed, TOOL_SUCCESS_OUTPUT_PREVIEW_LINES,
"a successful exec should preview exactly \
{TOOL_SUCCESS_OUTPUT_PREVIEW_LINES} output rows: {live_text}"
);
assert!(
!live_text.contains("lines omitted"),
"collapsed exec must not show an omission marker: {live_text}"
live_text.contains("output line 00"),
"the preview reads from the top of the output: {live_text}"
);
assert!(
!live_text.contains("output line 29"),
"a successful exec must not render its full body in live mode: {live_text}"
);
assert!(
!live_text.contains("command:"),
"a successful exec still skips the command echo; the header carries \
the summary: {live_text}"
);
// Transcript still has the full output.
assert!(transcript_text.contains("output line 00"));
+9 -58
View File
@@ -75,52 +75,6 @@ fn extract_explicit_reasoning_summary(text: &str) -> Option<String> {
None
}
/// Redact internal code identifiers from a collapsed reasoning preview so
/// implementation details don't leak into the default transcript
/// (#4146/#4148). Each `snake_case` token (e.g. `refresh_catalog_cache`,
/// `agent_id`, `DEEPSEEK_API_KEY`) collapses to a single `…` so the
/// surrounding prose still reads; the full, un-redacted body remains
/// available on expand (Space) or in the reasoning detail pager (Ctrl+O) and in the pager/clipboard transcript.
fn redact_internal_identifiers(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut token = String::new();
for ch in text.chars() {
if ch.is_ascii_alphanumeric() || ch == '_' {
token.push(ch);
continue;
}
push_identifier_token(&mut out, &mut token);
out.push(ch);
}
push_identifier_token(&mut out, &mut token);
out
}
/// Flush a scanned word token into `out`, replacing it with `…` when it reads
/// as an internal code identifier. No-op on an empty token.
fn push_identifier_token(out: &mut String, token: &mut String) {
if token.is_empty() {
return;
}
if looks_like_internal_identifier(token) {
out.push('\u{2026}');
} else {
out.push_str(token);
}
token.clear();
}
/// A token reads as an internal code identifier when it is a `snake_case`
/// run: it contains an underscore, has at least one letter, and is otherwise
/// only ASCII alphanumerics/underscores. Ordinary prose words never match.
fn looks_like_internal_identifier(token: &str) -> bool {
token.contains('_')
&& token.chars().any(|ch| ch.is_ascii_alphabetic())
&& token
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
}
pub(super) fn render_thinking(
content: &str,
width: u16,
@@ -208,18 +162,15 @@ pub(crate) fn render_thinking_with_highlight(
} else {
content.to_string()
};
// #4146/#4148: completed reasoning collapses to a quiet receipt in the
// default transcript — scrub internal code identifiers (function names
// like `refresh_catalog_cache`, raw agent ids) so implementation details
// don't leak. Streaming reasoning stays verbatim (the user is watching it
// think) and the expanded / pager / clipboard transcript keeps the full,
// un-redacted body. The redaction changes `body_text`, which trips the
// affordance below so the user still sees the "Ctrl+O:detail" hint.
let body_text = if collapsed && !streaming {
redact_internal_identifiers(&body_text)
} else {
body_text
};
// #4146/#4148 used to scrub snake_case tokens out of the collapsed
// reasoning here, to keep CodeWhale's own internals out of the transcript.
// Removed: the rule could not tell our identifiers from the user's, and in
// a coding harness the user's dominate. It rendered `short_dated_radar.py`
// as `….py`, `data/market_data/` as `data/…/`, and every env var and
// module name as a bare `…`, which made the default reasoning view
// unreadable. It also protected nothing — the full body was always one
// keypress away on Space/Ctrl+O — so the only thing it reliably did was
// damage the surface people actually read.
let mut rendered = if body_text.trim().is_empty() {
Vec::new()
} else {
+13
View File
@@ -476,6 +476,19 @@ fn selected_output_indices(rows: &[OutputRow], line_limit: usize) -> Vec<usize>
}
}
// The importance pass only fires on lines that look like errors, warnings
// or paths. Plain output — a list of names, a table, a build log with
// nothing alarming in it — matches none of them, so the card used to show
// `head + tail` rows and silently forfeit the rest of its budget. A
// 20-line command then rendered 16 rows and claimed the other four were
// "omitted". Spend whatever is left by growing the head downward, which
// keeps the shown region contiguous and readable top-down.
let mut next = head;
while selected.len() < line_limit.min(total) && next < total {
selected.insert(next);
next += 1;
}
selected.into_iter().collect()
}
+60 -30
View File
@@ -149,32 +149,10 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
));
}
if tier != ShellTier::Compact
&& let Some(toast) = status_toast.filter(|toast| {
// Completion may land in the same event drain as an approval
// denial. Keep unresolved attention/error receipts visible after
// `done`; only routine informational completion copy yields to the
// stable done marker.
let survives_completion = matches!(
toast.level,
crate::tui::app::StatusToastLevel::Warning
| crate::tui::app::StatusToastLevel::Error
);
(phase != ShellPhase::Done || survives_completion)
&& !toast.text.trim().is_empty()
&& toast.text.trim() != phase_label.as_ref()
})
{
left.push(Span::styled(
" · ",
Style::default().fg(app.ui_theme.text_dim),
));
left.push(Span::styled(
truncate_to_width(toast.text.trim(), 40),
Style::default().fg(crate::tui::ui::status_color(toast.level)),
));
}
// The ledger chips are built before the toast so the toast can be given
// whatever width is genuinely left over. They are appended after it, so
// the visual order is unchanged.
let mut tail: Vec<Span<'static>> = Vec::new();
let chip = app.cumulative_usage_chip();
if tier != ShellTier::Compact
&& let Some(amount) = match &chip {
@@ -185,11 +163,11 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
_ => None,
}
{
left.push(Span::styled(
tail.push(Span::styled(
" · ",
Style::default().fg(app.ui_theme.text_dim),
));
left.push(Span::styled(
tail.push(Span::styled(
amount,
Style::default().fg(app.ui_theme.text_muted),
));
@@ -199,11 +177,11 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
&& app.status_items.contains(&crate::config::StatusItem::Cache)
&& let Some(pct) = session_cache_hit_percentage(app)
{
left.push(Span::styled(
tail.push(Span::styled(
" · ",
Style::default().fg(app.ui_theme.text_dim),
));
left.push(Span::styled(
tail.push(Span::styled(
format!("cache {pct}%"),
Style::default().fg(app.ui_theme.text_muted),
));
@@ -237,6 +215,48 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
let right_width = right_text.width();
let available = usize::from(area.width);
if tier != ShellTier::Compact
&& let Some(toast) = status_toast.filter(|toast| {
// Completion may land in the same event drain as an approval
// denial. Keep unresolved attention/error receipts visible after
// `done`; only routine informational completion copy yields to the
// stable done marker.
let survives_completion = matches!(
toast.level,
crate::tui::app::StatusToastLevel::Warning
| crate::tui::app::StatusToastLevel::Error
);
(phase != ShellPhase::Done || survives_completion)
&& !toast.text.trim().is_empty()
&& toast.text.trim() != phase_label.as_ref()
})
{
// The budget used to be a flat 40 columns no matter how wide the
// terminal was, which cut a warning whose entire job is to explain an
// unexpected state down to `Delegated coordination unavailable — an…`.
// Spend the row that actually exists: everything left after the phase
// marker, the ledger chips, the key hints, and a gap between them.
let toast_budget = available
.saturating_sub(
span_width(&left)
+ TOAST_SEPARATOR_WIDTH
+ span_width(&tail)
+ right_width
+ TOAST_RIGHT_GAP,
)
.max(TOAST_MIN_WIDTH);
left.push(Span::styled(
" · ",
Style::default().fg(app.ui_theme.text_dim),
));
left.push(Span::styled(
truncate_to_width(toast.text.trim(), toast_budget),
Style::default().fg(crate::tui::ui::status_color(toast.level)),
));
}
left.extend(tail);
let left_width = span_width(&left);
if right_width > 0 && left_width + right_width < available {
left.push(Span::raw(" ".repeat(available - left_width - right_width)));
@@ -248,6 +268,16 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
Paragraph::new(Line::from(left)).render(area, buf);
}
/// Width of the ` · ` separator painted before the toast.
const TOAST_SEPARATOR_WIDTH: usize = 3;
/// Blank columns kept between the toast and the right-aligned key hints, so
/// the two never read as one run-on sentence.
const TOAST_RIGHT_GAP: usize = 2;
/// Floor for the toast budget. Below this the strip is too narrow to say
/// anything useful either way, and clamping keeps the arithmetic from
/// collapsing the toast to nothing on a cramped terminal.
const TOAST_MIN_WIDTH: usize = 24;
#[cfg(test)]
mod tests {
use super::*;
+10 -5
View File
@@ -41,7 +41,10 @@ pub enum TranscriptLineMeta {
copy_prefix_width: usize,
copy_separator_after: CopyLineSeparator,
},
Spacer,
/// A block separator row inserted between two cells. Usually empty, but
/// separators inside a tool-card rail group carry the rail glyph so the
/// card box survives the gap — hence a copy prefix to strip.
Spacer { copy_prefix_width: usize },
}
impl TranscriptLineMeta {
@@ -54,7 +57,7 @@ impl TranscriptLineMeta {
line_in_cell,
..
} => Some((cell_index, line_in_cell)),
TranscriptLineMeta::Spacer => None,
TranscriptLineMeta::Spacer { .. } => None,
}
}
@@ -65,7 +68,7 @@ impl TranscriptLineMeta {
copy_separator_after,
..
} => copy_separator_after,
TranscriptLineMeta::Spacer => CopyLineSeparator::Newline,
TranscriptLineMeta::Spacer { .. } => CopyLineSeparator::Newline,
}
}
@@ -75,7 +78,7 @@ impl TranscriptLineMeta {
TranscriptLineMeta::CellLine {
copy_prefix_width, ..
} => copy_prefix_width,
TranscriptLineMeta::Spacer => 0,
TranscriptLineMeta::Spacer { copy_prefix_width } => copy_prefix_width,
}
}
}
@@ -311,7 +314,9 @@ mod tests {
meta.push(cell_line(cell, line));
}
if cell + 1 < cell_count {
meta.push(TranscriptLineMeta::Spacer);
meta.push(TranscriptLineMeta::Spacer {
copy_prefix_width: 0,
});
}
}
meta
+101 -32
View File
@@ -59,6 +59,10 @@ struct CachedCell {
/// Whether this cell's rendered output was empty (e.g. Thinking hidden).
/// Cached so we can skip empty cells without re-rendering.
is_empty: bool,
/// Whether the cell's last rendered line is blank. A cell that already
/// ends on a blank row must not also receive a separator row after it —
/// two stacked blanks look worse than none.
ends_blank: bool,
/// Semantic role used by the transcript's explicit boundary matrix.
/// Keeping the role in the cache makes spacing independent of rendered
/// strings, theme colors, terminal depth, and animation state.
@@ -117,13 +121,27 @@ impl TranscriptBlockKind {
}
}
/// Strength of a visible boundary. These three levels are the complete
/// Rows a single visible block separation is worth.
///
/// One blank row — never two. The transcript scrolls inside a terminal
/// viewport, so every separator row is a row of content the reader loses.
/// One row is enough to read two blocks as two paragraphs; two rows halve
/// the visible transcript for no extra legibility. `Turn` at `Spacious` is
/// the sole deliberate exception, and it is opt-in.
const BLOCK_SEPARATOR_ROWS: usize = 1;
/// Strength of a visible boundary. These four levels are the complete
/// transcript spacing vocabulary: no blanket per-cell padding is added.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TranscriptBoundary {
/// Two cells are one response/activity group.
/// Two cells are literally continuation of one another — successive
/// reasoning segments, or successive prose blocks of one answer.
Joined,
/// Compact transition into or out of tools, Work, or notices.
/// Two cells sit inside one tool-card rail group. Separated by a rail
/// spacer (`│`) rather than a bare blank row so the card box survives.
GroupedTool,
/// Transition between response phases, or into/out of tools, Work, or
/// notices.
Activity,
/// A human turn boundary; always visible, even at compact density.
Turn,
@@ -384,6 +402,7 @@ impl TranscriptViewCache {
copy_separators: Arc::new(Vec::new()),
copy_prefix_widths: Arc::new(Vec::new()),
is_empty: true,
ends_blank: false,
kind: TranscriptBlockKind::Answer,
is_tool_groupable: false,
incremental_markdown: Some(Box::default()),
@@ -427,6 +446,7 @@ impl TranscriptViewCache {
}
cached.revision = current_rev;
cached.is_empty = cached.lines.is_empty();
cached.ends_blank = last_line_is_blank(&cached.lines);
cached.kind = TranscriptBlockKind::Answer;
cached.is_tool_groupable = false;
// The hot-tail style also changes on the preceding settled
@@ -453,6 +473,7 @@ impl TranscriptViewCache {
copy_separators.push(rendered_line.copy_separator_after);
}
let is_empty = lines.is_empty();
let ends_blank = last_line_is_blank(&lines);
new_per_cell.push(CachedCell {
revision: current_rev,
lines: Arc::new(lines),
@@ -460,6 +481,7 @@ impl TranscriptViewCache {
copy_separators: Arc::new(copy_separators),
copy_prefix_widths: Arc::new(copy_prefix_widths),
is_empty,
ends_blank,
kind: TranscriptBlockKind::for_cell(cell),
is_tool_groupable,
incremental_markdown: None,
@@ -531,7 +553,7 @@ impl TranscriptViewCache {
.iter()
.position(|meta| match meta {
TranscriptLineMeta::CellLine { cell_index, .. } => *cell_index >= first_cell,
TranscriptLineMeta::Spacer => false,
TranscriptLineMeta::Spacer { .. } => false,
})
.unwrap_or(self.lines.len());
self.lines.truncate(truncate_at);
@@ -658,12 +680,18 @@ impl TranscriptViewCache {
}
if let Some(next) = next_visible_cell(&self.per_cell, cell_index) {
let spacer_rows = spacer_rows_between(cached, next, spacing);
for _ in 0..spacer_rows {
self.lines.push(Line::from(""));
let separator = separator_between(cached, next, spacing);
let rail = separator
.railed
.then_some(crate::tui::widgets::tool_card::CardRail::Middle);
for _ in 0..separator.rows {
let line = line_with_group_rail(&Line::from(""), rail, usize::from(self.width));
let copy_prefix_width = compute_rail_prefix_width(&line);
self.rail_prefix_widths.push(copy_prefix_width);
self.lines.push(line);
self.line_links.push(Vec::new());
self.line_meta.push(TranscriptLineMeta::Spacer);
self.rail_prefix_widths.push(0);
self.line_meta
.push(TranscriptLineMeta::Spacer { copy_prefix_width });
}
}
}
@@ -721,19 +749,51 @@ fn strip_cell_local_tool_rail(line: &mut Line<'static>) {
}
}
fn spacer_rows_between(
/// Whether a cell's own render already ends on a visually blank row.
fn last_line_is_blank(lines: &[Line<'static>]) -> bool {
lines
.last()
.is_some_and(|line| line.spans.iter().all(|span| span.content.trim().is_empty()))
}
/// One block separation: how many rows, and whether those rows carry the
/// tool-card rail. Kept as one value so the flatten loop cannot emit the row
/// count from one rule and the decoration from another.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct BlockSeparator {
rows: usize,
railed: bool,
}
fn separator_between(
current: &CachedCell,
next: &CachedCell,
spacing: TranscriptSpacing,
) -> usize {
spacer_rows_for_boundary(
transcript_boundary(
current.kind,
next.kind,
same_tool_activity_group(current, next),
),
spacing,
)
) -> BlockSeparator {
let boundary = transcript_boundary(
current.kind,
next.kind,
same_tool_activity_group(current, next),
);
let mut rows = spacer_rows_for_boundary(boundary, spacing);
// Never stack two blank rows. A cell whose own render already ends on a
// blank line has paid for the separation; adding another on top reads as
// a hole, and at Spacious it would be a three-row gap.
if !current.ends_blank {
return BlockSeparator {
rows,
railed: boundary == TranscriptBoundary::GroupedTool,
};
}
if boundary == TranscriptBoundary::GroupedTool {
// A railed spacer is not a blank row — the rail must continue.
return BlockSeparator { rows, railed: true };
}
rows = rows.saturating_sub(1);
BlockSeparator {
rows,
railed: false,
}
}
/// Adjacent tool cells share one rail only when they represent the same kind
@@ -751,7 +811,9 @@ fn transcript_boundary(
) -> TranscriptBoundary {
if same_tool_group {
debug_assert_eq!(current, next);
return TranscriptBoundary::Joined;
// Two distinct tool calls that happen to share a rail are still two
// things the reader has to tell apart. Give them a rail spacer.
return TranscriptBoundary::GroupedTool;
}
// A user block is the only unambiguous turn delimiter available to the
@@ -762,19 +824,23 @@ fn transcript_boundary(
return TranscriptBoundary::Turn;
}
// Reasoning and answer prose are phases of one model response. Joining
// them also keeps the row budget stable when streaming reasoning settles
// into the final answer.
if matches!(
(current, next),
(
TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer,
// Successive cells of the *same* model phase are one block split across
// cells — consecutive reasoning segments, or an answer whose settled and
// streaming halves live in separate cells. Blank rows appearing between
// those mid-stream would jitter the row budget, so keep them joined.
if current == next
&& matches!(
current,
TranscriptBlockKind::Reasoning | TranscriptBlockKind::Answer
)
) {
{
return TranscriptBoundary::Joined;
}
// Everything else — including reasoning handing off to answer prose — is
// a boundary the reader needs to see. Reasoning running straight into the
// answer with no blank row was the specific density complaint this matrix
// exists to answer.
TranscriptBoundary::Activity
}
@@ -784,12 +850,15 @@ const fn spacer_rows_for_boundary(
) -> usize {
match (boundary, spacing) {
(TranscriptBoundary::Joined, _) => 0,
(TranscriptBoundary::Activity, TranscriptSpacing::Compact) => 0,
(TranscriptBoundary::Activity, _) => 1,
(
TranscriptBoundary::GroupedTool | TranscriptBoundary::Activity,
TranscriptSpacing::Compact,
) => 0,
(TranscriptBoundary::GroupedTool | TranscriptBoundary::Activity, _) => BLOCK_SEPARATOR_ROWS,
(TranscriptBoundary::Turn, TranscriptSpacing::Compact | TranscriptSpacing::Comfortable) => {
1
BLOCK_SEPARATOR_ROWS
}
(TranscriptBoundary::Turn, TranscriptSpacing::Spacious) => 2,
(TranscriptBoundary::Turn, TranscriptSpacing::Spacious) => BLOCK_SEPARATOR_ROWS + 1,
}
}
+109 -12
View File
@@ -85,9 +85,9 @@ fn spacer_rows_after_cell(cache: &TranscriptViewCache, target_cell: usize) -> us
saw_target = true;
spacer_rows = 0;
}
TranscriptLineMeta::Spacer if saw_target => spacer_rows += 1,
TranscriptLineMeta::Spacer { .. } if saw_target => spacer_rows += 1,
TranscriptLineMeta::CellLine { .. } if saw_target => break,
TranscriptLineMeta::Spacer | TranscriptLineMeta::CellLine { .. } => {}
TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => {}
}
}
spacer_rows
@@ -501,27 +501,41 @@ fn adjacent_tool_cells_render_as_one_railed_group() {
);
assert!(
!lines.iter().any(String::is_empty),
"adjacent tool cells should not be separated by blank spacer rows: {lines:?}"
"adjacent tool cells must never be separated by a bare blank row — that \
would tear the card box open: {lines:?}"
);
// They are separated, though: by a rail-carrying spacer, so two distinct
// commands read as two blocks without the group losing its outline.
assert!(
lines.iter().any(|line| line.trim_end() == "\u{2502}"),
"distinct tool cells inside one rail group need a rail spacer between \
them: {lines:?}"
);
}
#[test]
fn semantic_boundary_matrix_has_three_deliberate_rhythm_levels() {
fn semantic_boundary_matrix_has_four_deliberate_rhythm_levels() {
use TranscriptBlockKind::{Answer, DurableWork, Notice, Reasoning, ToolAction, User};
use TranscriptBoundary::{Activity, Joined, Turn};
use TranscriptBoundary::{Activity, GroupedTool, Joined, Turn};
let cases = [
(User, Answer, false, Turn),
(User, ToolAction, false, Turn),
(DurableWork, User, false, Turn),
(Reasoning, Answer, false, Joined),
(Answer, Reasoning, false, Joined),
// Reasoning handing off to the answer is a phase change the reader
// has to see. Running the two together with no blank row is the
// density complaint this matrix exists to answer.
(Reasoning, Answer, false, Activity),
(Answer, Reasoning, false, Activity),
// Successive cells of the *same* phase are one block split across
// cells; a blank row there would jitter mid-stream.
(Answer, Answer, false, Joined),
(Reasoning, Reasoning, false, Joined),
(Answer, ToolAction, false, Activity),
(ToolAction, Reasoning, false, Activity),
(Notice, DurableWork, false, Activity),
(ToolAction, ToolAction, true, Joined),
(DurableWork, DurableWork, true, Joined),
(ToolAction, ToolAction, true, GroupedTool),
(DurableWork, DurableWork, true, GroupedTool),
(ToolAction, DurableWork, false, Activity),
];
@@ -557,6 +571,46 @@ fn semantic_boundary_matrix_has_three_deliberate_rhythm_levels() {
spacer_rows_for_boundary(Activity, TranscriptSpacing::Spacious),
1
);
assert_eq!(
spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Compact),
0,
"compact density buys its density by spending no separator rows"
);
assert_eq!(
spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Comfortable),
1
);
assert_eq!(
spacer_rows_for_boundary(GroupedTool, TranscriptSpacing::Spacious),
1,
"one row is the whole vocabulary above compact — never two"
);
}
/// Separation is one row or none. Nothing in the matrix may produce a
/// double blank, because a scrolling terminal cannot afford it.
#[test]
fn no_boundary_ever_spends_more_than_one_row_below_spacious_turns() {
use TranscriptBoundary::{Activity, GroupedTool, Joined, Turn};
for boundary in [Joined, GroupedTool, Activity, Turn] {
for spacing in [
TranscriptSpacing::Compact,
TranscriptSpacing::Comfortable,
TranscriptSpacing::Spacious,
] {
let rows = spacer_rows_for_boundary(boundary, spacing);
let allowed = if boundary == Turn && spacing == TranscriptSpacing::Spacious {
2
} else {
BLOCK_SEPARATOR_ROWS
};
assert!(
rows <= allowed,
"{boundary:?} at {spacing:?} spent {rows} rows (max {allowed})"
);
}
}
}
#[test]
@@ -621,7 +675,7 @@ fn durable_work_starts_a_new_activity_rail_without_wasting_compact_rows() {
.map(|span| span.content.as_ref())
.collect::<String>(),
),
TranscriptLineMeta::Spacer | TranscriptLineMeta::CellLine { .. } => None,
TranscriptLineMeta::Spacer { .. } | TranscriptLineMeta::CellLine { .. } => None,
})
.collect::<Vec<_>>()
};
@@ -651,7 +705,11 @@ fn durable_work_starts_a_new_activity_rail_without_wasting_compact_rows() {
..TranscriptRenderOptions::default()
},
);
assert_eq!(spacer_rows_after_cell(&comfortable, 0), 0);
assert_eq!(
spacer_rows_after_cell(&comfortable, 0),
1,
"two distinct commands sharing a rail still need one row between them"
);
assert_eq!(
spacer_rows_after_cell(&comfortable, 1),
1,
@@ -791,7 +849,10 @@ fn transcript_rhythm_is_width_and_reduced_motion_invariant() {
user_cell("Proceed to the final verification."),
];
let revisions = vec![1u64; cells.len()];
let expected = [1, 0, 1, 1, 1, 1, 0];
// user | reasoning | answer | tool | work | answer | user.
// Every seam is one row: the reasoning→answer seam (index 1) used to be
// the one place the transcript ran two blocks together.
let expected = [1, 1, 1, 1, 1, 1, 0];
for width in [40, 80, 100, 140] {
for low_motion in [false, true] {
@@ -1282,3 +1343,39 @@ fn folded_thinking_with_collapsed_cells_uses_original_indices() {
"folded cell via index map should render fewer lines: folded={folded_filtered} expanded={expanded_filtered}"
);
}
#[test]
fn zz_dump_spacing() {
let cells = vec![
user_cell("add spacing to the transcript"),
HistoryCell::Thinking {
content: "The user wants vertical rhythm. I should look at the transcript cache first."
.to_string(),
streaming: false,
duration_secs: Some(3.0),
},
assistant_cell("I'll start by reading the renderer.", false),
exec_tool_cell_with_output(
"rg -n spacer crates/tui".to_string().as_str(),
"crates/tui/src/tui/transcript.rs:661\ncrates/tui/src/tui/transcript.rs:724"
.to_string(),
),
exec_tool_cell_with_output("cargo fmt --all", "".to_string()),
assistant_cell(
"Done — spacing is centralized in the transcript cache.",
false,
),
];
let revisions = vec![1u64; 6];
let mut cache = TranscriptViewCache::new();
let opts = TranscriptRenderOptions {
low_motion: true,
..TranscriptRenderOptions::default()
};
cache.ensure(&cells, &revisions, 80, opts);
println!("=== BEGIN DUMP (comfortable) ===");
for (i, l) in plain_lines(&cache).iter().enumerate() {
println!("{i:3} |{}|", l.trim_end());
}
println!("=== END DUMP ===");
}
+14 -4
View File
@@ -7575,13 +7575,23 @@ fn apply_coordination_detail_projection(
.unwrap_or("another Codewhale process owns delegated coordination for this workspace");
let same_process_handover =
note.contains(crate::tools::subagent::COORDINATION_SAME_PROCESS_HANDOVER);
let message = format!(
"Delegated coordination unavailable — {note}. Job rows still settle locally; durable fleet state is owned elsewhere."
);
// The strip is one row. The old copy opened with the diagnosis
// ("Delegated coordination unavailable — ") and buried the cause
// behind a `{note}` carrying a pid, an absolute workspace path, and an
// errno, so a truncated strip showed `Delegated coordination
// unavailable — an…` and taught the user nothing. Lead with the fact
// that explains it — a second session is open here — and leave the pid
// and path to the coordination detail view, which already renders
// `process_lock_note` in full.
let message = if note.contains(crate::tools::subagent::COORDINATION_LOCK_TIMEOUT_MARKER) {
"Timed out claiming delegated coordination for this workspace — job rows still settle locally.".to_string()
} else {
"Another CodeWhale session in this workspace owns delegated coordination — job rows still settle locally.".to_string()
};
let already = app
.sticky_status
.as_ref()
.is_some_and(|toast| toast.text.contains("Delegated coordination unavailable"));
.is_some_and(|toast| toast.text.contains("delegated coordination"));
if !already && !same_process_handover {
app.set_sticky_status(
message,
+19 -2
View File
@@ -842,9 +842,26 @@ fn coordination_handover_within_this_process_does_not_toast() {
.sticky_status
.as_ref()
.expect("a genuinely foreign owner still warns");
// The strip is one row and truncates from the right, so the *opening* of
// the message has to carry the fact. The old copy opened with the
// diagnosis and buried the cause behind a pid, a path and an errno, which
// truncated to `Delegated coordination unavailable — an…`.
assert!(
toast.text.contains("Delegated coordination unavailable"),
"{}",
toast
.text
.starts_with("Another CodeWhale session in this workspace"),
"the toast must lead with the fact that explains the state: {}",
toast.text
);
assert!(
!toast.text.contains("pid 4242") && !toast.text.contains("/ws"),
"pid and workspace path belong in the coordination detail view, not \
in a one-row toast: {}",
toast.text
);
assert!(
toast.text.chars().count() <= 110,
"a one-row toast that cannot fit the row teaches nothing: {}",
toast.text
);
}
+108 -8
View File
@@ -4007,6 +4007,24 @@ pub fn wrap_input_lines_for_mouse(input: &str, width: usize) -> Vec<(usize, Stri
lines_with_indices
}
/// Wrap composer text to `width` display columns, breaking at word boundaries
/// where one is available.
///
/// This used to break strictly on the grapheme that crossed the margin, so a
/// wrapped sentence split mid-word — `…Write the file onl` / `y after the…`.
/// The text was never lost, but a line ending in a severed word reads exactly
/// like content that was cut off, which is what it was reported as.
///
/// Two invariants the callers depend on and this must not break:
///
/// * **Nothing is added or removed.** Concatenating the returned lines
/// reproduces `text` exactly. `wrap_input_lines_internal` walks the wrapped
/// lines accumulating `chars().count()` to map cursor and mouse positions
/// back into the raw buffer, so a dropped break character would silently
/// desynchronise the caret. The space a line breaks on therefore stays at
/// the end of the preceding line rather than being swallowed.
/// * **Every line fits.** A word longer than `width` — a URL, a path, a
/// base64 blob — has no usable break point and still breaks hard.
fn wrap_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![text.to_string()];
@@ -4018,29 +4036,51 @@ fn wrap_text(text: &str, width: usize) -> Vec<String> {
let mut lines = Vec::new();
let mut current = String::new();
let mut current_width = 0;
// Byte offset in `current` just past the most recent space, and the
// display width up to that point. `None` while the line holds no usable
// break point — a leading space is not one, since breaking there would
// emit an empty line and make no progress.
let mut break_at: Option<(usize, usize)> = None;
// Flush `current` up to its break point (if any), carrying the remainder
// onto the next line.
macro_rules! flush {
() => {{
match break_at.take() {
Some((byte, _)) if byte < current.len() => {
let remainder = current.split_off(byte);
lines.push(std::mem::replace(&mut current, remainder));
current_width = current.width();
}
_ => {
lines.push(std::mem::take(&mut current));
current_width = 0;
}
}
}};
}
for grapheme in text.graphemes(true) {
if grapheme == "\n" {
lines.push(current);
current = String::new();
break_at = None;
lines.push(std::mem::take(&mut current));
current_width = 0;
continue;
}
let grapheme_width = grapheme.width();
if current_width + grapheme_width > width && current_width != 0 {
lines.push(current);
current = String::new();
current_width = 0;
flush!();
}
current.push_str(grapheme);
current_width += grapheme_width;
if grapheme == " " && !current.trim_start().is_empty() {
break_at = Some((current.len(), current_width));
}
if current_width >= width {
lines.push(current);
current = String::new();
current_width = 0;
flush!();
}
}
@@ -4613,6 +4653,66 @@ mod tests {
assert_eq!(col, 2);
}
/// Composer wrapping breaks between words, not through them. A line
/// ending in a severed word (`…Write the file onl`) reads exactly like
/// content that was cut off, which is how it was reported.
#[test]
fn composer_wraps_on_word_boundaries_without_losing_a_character() {
let text = "Mark inferences as inferences. A short PRD where each \
section decides something beats a long one.";
for width in [20usize, 33, 47, 60, 79] {
let lines = wrap_text(text, width);
assert_eq!(
lines.concat(),
text,
"wrapping must be lossless at width={width}: {lines:?}"
);
for line in &lines {
assert!(
line.width() <= width,
"line exceeds width={width}: {line:?}"
);
}
// No line may end in the middle of a word: either it ends the
// text, or it ends on whitespace.
for line in lines.iter().take(lines.len().saturating_sub(1)) {
assert!(
line.is_empty() || line.ends_with(' '),
"wrapped line broke mid-word at width={width}: {line:?}"
);
}
}
}
/// A token with no break point in it still has to fit the terminal, so it
/// breaks hard. Losslessness holds there too.
#[test]
fn composer_hard_breaks_words_longer_than_the_line() {
let text = "see https://example.com/a/very/long/path/that/never/breaks?x=1 now";
let lines = wrap_text(text, 24);
assert_eq!(lines.concat(), text, "{lines:?}");
for line in &lines {
assert!(line.width() <= 24, "line exceeds width: {line:?}");
}
assert!(
lines.len() > 2,
"an unbreakable token must still be split across lines: {lines:?}"
);
}
/// Wide characters have no spaces to break on; the width accounting must
/// still hold. This repo patches `unicode-width` for CJK, so measure the
/// wrapped output rather than trusting char counts.
#[test]
fn composer_wrapping_respects_wide_character_width() {
let text = "中文字符串没有空格可以换行";
let lines = wrap_text(text, 7);
assert_eq!(lines.concat(), text, "{lines:?}");
for line in &lines {
assert!(line.width() <= 7, "line exceeds width: {line:?}");
}
}
#[test]
fn cursor_with_combining_marks() {
// "e\u0301" is 'e' with combining acute accent (é)
+444
View File
@@ -4824,3 +4824,447 @@ fn semantic_activity_motion_crosses_reasoning_reading_and_tool_use_in_a_real_uni
Ok(())
}
/// SSE fixture for the transcript-rhythm probe: one turn that reasons, says
/// something, and runs a shell command; then a closing turn.
fn spawn_transcript_rhythm_fixture(
shell_command: String,
) -> 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 reasoning = format!(
"data: {}\n\n",
serde_json::json!({
"id": "chatcmpl-rhythm",
"object": "chat.completion.chunk",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"delta": {"reasoning_content":
"PROBEREASONING the listing command is the one to run here."},
"finish_reason": null
}]
})
);
let prose = format!(
"data: {}\n\n",
serde_json::json!({
"id": "chatcmpl-rhythm",
"object": "chat.completion.chunk",
"model": "deepseek-v4-pro",
"choices": [{
"index": 0,
"delta": {"content": "PROBEANSWERA Running the listing now."},
"finish_reason": null
}]
})
);
let call = pty_tool_call_sse(
"call_rhythm_probe",
"Bash",
serde_json::json!({
"action": "run",
"command": shell_command,
"timeout_ms": 60_000
}),
);
let first = format!("{reasoning}{prose}{call}");
let second = pty_text_sse("PROBEANSWERB That is the full listing.");
let handle = std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(60);
let mut chat_index = 0usize;
while chat_index < 2 && Instant::now() < deadline {
let Ok((mut stream, _)) = listener.accept() else {
std::thread::sleep(Duration::from_millis(10));
continue;
};
let Ok(request) = read_http_request(&mut stream) else {
continue;
};
let request_line = request.lines().next().unwrap_or_default();
if request_line.starts_with("GET ") && request_line.contains("/models") {
let body = serde_json::json!({
"object": "list",
"data": [{"id": "deepseek-v4-pro", "object": "model"}]
})
.to_string();
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
);
let _ = stream.flush();
continue;
}
if !(request_line.starts_with("POST ") && request_line.contains("/chat/completions")) {
continue;
}
let body = if chat_index == 0 { &first } else { &second };
let _ = stream.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.as_bytes(),
);
let _ = stream.flush();
chat_index += 1;
}
});
Ok((format!("http://{address}"), handle))
}
/// Boot a probe session against the rhythm fixture and return the settled
/// frame rows plus its debug dump.
fn run_transcript_rhythm_probe(
show_tool_details: bool,
total_rows: usize,
) -> anyhow::Result<(Vec<String>, String, Vec<String>)> {
let ws = make_sealed_workspace()?;
let shell_command = format!(
"for i in $(seq 0 {}); do printf 'row %02d plain content\\n' \"$i\"; done",
total_rows - 1
);
let (base_url, server) = spawn_transcript_rhythm_fixture(shell_command)?;
let codewhale_home = ws.home().join(".codewhale");
let codex_home = ws.home().join(".codex");
std::fs::create_dir_all(&codex_home)?;
std::fs::write(
codewhale_home.join("config.toml"),
"allow_shell = true\n\n[retry]\nenabled = false\n\n[update]\ncheck_for_updates = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
)?;
std::fs::write(
codewhale_home.join("settings.toml"),
format!(
// `show_thinking = true` is the shape the complaint was made
// about: the owner could see the reasoning body, and it ran
// straight into the answer underneath it.
"locale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"full-access\"\nshow_thinking = true\nshow_tool_details = {show_tool_details}\ntranscript_spacing = \"comfortable\"\ncomposer_border = true\n"
),
)?;
std::fs::write(
codex_home.join("models_cache.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"fetched_at": chrono::Utc::now(),
"models": [{"slug": "deepseek-v4-pro", "priority": 1}]
}))?,
)?;
let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
.cwd(ws.workspace())
.clear_env()
.seal_home(ws.home())
.env("CODEWHALE_HOME", codewhale_home.to_string_lossy())
.env(
"DEEPSEEK_CONFIG_PATH",
codewhale_home.join("config.toml").to_string_lossy(),
)
.env("CODEX_HOME", codex_home.to_string_lossy())
.env("CODEWHALE_PROVIDER", "deepseek")
.env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
.env("DEEPSEEK_BASE_URL", &base_url)
.env("CODEWHALE_BASE_URL", &base_url)
.env("DEEPSEEK_MODEL", "deepseek-v4-pro")
.env("CODEWHALE_MODEL", "deepseek-v4-pro")
.env("NO_ANIMATIONS", "1")
.env("RUST_LOG", "warn")
.args([
"--workspace",
ws.workspace().to_str().expect("utf-8 workspace path"),
"--no-project-config",
"--skip-onboarding",
"--yolo",
])
// Tall enough that the whole probe turn lands on one frame; the
// assertions are about the rows between blocks, not about scrolling.
.size(64, 100)
.spawn()?;
enter_launch_session(&mut h)?;
h.paste(TRANSCRIPT_RHYTHM_PROMPT)?;
h.wait_for_text(TRANSCRIPT_RHYTHM_PROMPT, KEY_TIMEOUT)?;
h.send(keys::key::enter())?;
let dump = wait_for_frame_dump(
&mut h,
|frame| frame.contains("PROBEANSWERB"),
Duration::from_secs(30),
)?;
let frame = h.frame();
write_real_pty_evidence(
&format!("transcript-rhythm-details-{show_tool_details}"),
"size=64x100 spacing=comfortable show_thinking=true",
&frame,
)?;
if std::env::var_os("CODEWHALE_QA_PRINT_FRAME").is_some() {
println!("--- show_tool_details={show_tool_details}\n{dump}");
}
let rows: Vec<String> = (0..frame.rows()).map(|y| frame.row(y)).collect();
let painted: Vec<String> = rows.clone();
let _ = h.shutdown();
drop(server);
Ok((rows, dump, painted))
}
const TRANSCRIPT_RHYTHM_PROMPT: &str = "list the rows";
/// Transcript vertical rhythm + live run-card budget, measured on real
/// terminal output rather than on a renderer unit test.
///
/// The owner's complaint had two halves and both are properties of painted
/// rows, so both are asserted on a parsed PTY frame:
///
/// * consecutive blocks ran together with no blank row — a reasoning block
/// flowing straight into the answer that followed it;
/// * the run cards showed so little of their output that even the truncated
/// view could not tell you what happened. A *successful* run showed its
/// header and nothing else at all.
///
/// The shell command here really runs (`--yolo`), so the card under assertion
/// is a real one carrying real output.
///
/// Set `CODEWHALE_QA_EVIDENCE_DIR` to capture the frame dumps,
/// `CODEWHALE_QA_PRINT_FRAME=1` to print them.
#[test]
fn transcript_blocks_are_separated_and_run_cards_show_real_output() -> anyhow::Result<()> {
let _guard = qa_pty_test_lock();
// Twenty-four rows of unremarkable output: nothing an importance filter
// would rescue, which is the case the head/tail split served worst.
let total_rows = 24usize;
// Leg A — shipped defaults (`show_tool_details = false`). This is what
// almost every user sees, and it is the frame the complaint was about.
let (rows, dump, painted) = run_transcript_rhythm_probe(false, total_rows)?;
let painted_contains =
|needle: &str, rows: &[String]| rows.iter().any(|row| row.contains(needle));
let row_of = |needle: &str| {
rows.iter()
.position(|row| row.contains(needle))
.unwrap_or_else(|| panic!("missing {needle} in frame:\n{dump}"))
};
let blank_between = |a: usize, b: usize| {
rows[a.min(b) + 1..a.max(b)]
.iter()
.any(|row| row.trim().is_empty())
};
// 1. Reasoning must not run straight into the answer that follows it.
// This is the specific seam the owner pointed at.
let reasoning = row_of("PROBEREASONING");
let answer_a = row_of("PROBEANSWERA");
assert!(
answer_a > reasoning,
"the answer should follow the reasoning:\n{dump}"
);
assert!(
blank_between(reasoning, answer_a),
"a reasoning block and the answer after it need a blank row between \
them:\n{dump}"
);
// 2. Assistant prose must not run straight into the tool card.
let card = row_of("row 00 plain content");
assert!(
blank_between(answer_a, card),
"assistant prose and the tool card below it need a blank row:\n{dump}"
);
// 3. The user's own turn stays a visible seam.
let user = row_of(TRANSCRIPT_RHYTHM_PROMPT);
assert!(
blank_between(user, reasoning),
"the user turn needs a visible seam:\n{dump}"
);
// 4. Nowhere does a second separator row stack on the first. A scrolling
// terminal cannot afford a two-row gap and it reads as a hole.
let last = row_of("PROBEANSWERB");
assert!(
!rows[user..=last]
.windows(2)
.any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty()),
"no two separator rows may stack:\n{dump}"
);
// 5. A *successful* run card used to paint its header and nothing else.
// On shipped defaults it must now carry real output rows.
let shown = (0..total_rows)
.filter(|i| painted_contains(&format!("row {i:02} plain content"), &painted))
.count();
assert!(
shown >= 4,
"a successful run card on shipped defaults painted {shown} output \
rows; it used to paint none and must now show enough to tell what \
happened:\n{dump}"
);
// Leg B — `show_tool_details = true`, where the card spends the full
// output budget rather than the summary cap.
let (_rows, detail_dump, detail_painted) = run_transcript_rhythm_probe(true, total_rows)?;
let detailed = (0..total_rows)
.filter(|i| painted_contains(&format!("row {i:02} plain content"), &detail_painted))
.count();
assert!(
detailed > shown,
"show_tool_details must reveal more than the summary card \
({detailed} vs {shown}):\n{detail_dump}"
);
assert!(
detailed >= 6,
"a detailed run card painted only {detailed} output rows:\n{detail_dump}"
);
Ok(())
}
/// Two of the owner's reported display defects, both measured on real
/// terminal output.
///
/// 1. **The composer looked like it was cutting text off.** It was not losing
/// anything — it broke lines on whatever grapheme crossed the margin, so a
/// wrapped sentence split mid-word (`…Write the file onl` / `y after…`),
/// which reads exactly like truncation. Assert that no wrapped composer
/// line ends inside a word and that every word survives.
///
/// 2. **A one-row toast was cut to uselessness.** Opening a second CodeWhale
/// in the same workspace loses the coordination flock and raises a sticky
/// warning. At a flat 40-column budget it painted `Delegated coordination
/// unavailable — an…`. Here a real second session is booted against the
/// same workspace and the strip must actually say what happened.
#[test]
fn composer_wraps_between_words_and_the_lock_toast_stays_legible() -> anyhow::Result<()> {
let _guard = qa_pty_test_lock();
// --- Leg 1: composer wrapping.
let (ws, mut h) = boot_minimal()?;
h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
let typed = "Mark inferences as inferences. A short PRD where each section decides something beats a long one that merely describes. Write the file only after the outline is agreed.";
h.paste(typed)?;
h.wait_for_text("outline is agreed", KEY_TIMEOUT)?;
let composer_dump = h.frame().debug_dump();
write_real_pty_evidence_dump("composer-wrap", "size=40x140", &composer_dump)?;
if std::env::var_os("CODEWHALE_QA_PRINT_FRAME").is_some() {
println!("--- composer wrap\n{composer_dump}");
}
let frame = h.frame();
let rows: Vec<String> = (0..frame.rows()).map(|y| frame.row(y)).collect();
// The composer rows are the ones carrying the typed text.
let composer_rows: Vec<&String> = rows
.iter()
.filter(|row| {
typed
.split(' ')
.any(|word| word.len() > 6 && row.contains(word))
})
.collect();
assert!(
composer_rows.len() > 1,
"the probe text must wrap to more than one row:\n{composer_dump}"
);
// Every word of the input survives somewhere on the frame, whole.
for word in typed.split(' ').filter(|word| word.len() > 3) {
let word = word.trim_end_matches(['.', ',']);
assert!(
rows.iter().any(|row| row.contains(word)),
"word {word:?} was split across the wrap and no row holds it \
whole:\n{composer_dump}"
);
}
let _ = h.shutdown();
drop(ws);
// --- Leg 2: the one-row status toast budget.
//
// The coordination-lock warning the owner hit is one instance of a
// general defect: every sticky toast was truncated to a flat 40 columns
// no matter how wide the terminal was. A failed `/load` raises a long
// sticky warning through the same strip, and reproduces it in two
// keystrokes without needing two sessions racing a flock. The lock
// warning's own copy is pinned in
// `tui::ui::tests::coordination_lock_loss_warns_only_for_a_foreign_owner`.
let ws = make_sealed_workspace()?;
let session_path = ws.workspace().join("broken-session.json");
std::fs::write(
&session_path,
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": 1,
"metadata": {
"id": "pty-broken",
"title": "Broken",
"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,
// A non-empty legacy Work view with no graph fails validation and
// raises a long, explanatory warning — exactly the class of
// message a 40-column budget destroys.
"work_state": {
"todos": {"items": [], "completion_pct": 0, "in_progress_id": null},
"plan": {"objective": "", "items": []}
}
}))?,
)?;
let (_ws2, mut h) = spawn_minimal_with_env(ws, &[])?;
h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
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())?;
let toast_dump = wait_for_frame_dump(
&mut h,
|frame| frame.contains("Failed to restore session"),
Duration::from_secs(10),
)?;
write_real_pty_evidence_dump("status-toast-budget", "size=40x140", &toast_dump)?;
if std::env::var_os("CODEWHALE_QA_PRINT_FRAME").is_some() {
println!("--- status toast\n{toast_dump}");
}
let frame = h.frame();
let strip = (0..frame.rows())
.map(|y| frame.row(y))
.find(|row| row.contains("Failed to restore session"))
.expect("the strip must carry the warning");
let warning = strip
.split_once("Failed to restore session")
.map(|(_, tail)| format!("Failed to restore session{tail}"))
.unwrap_or_default();
let warning = warning
.split(" ")
.next()
.unwrap_or_default()
.trim()
.to_string();
assert!(
warning.chars().count() > 40,
"the strip painted {} columns of a long warning on a 140-column \
terminal; the flat 40-column budget was the defect:\n{toast_dump}",
warning.chars().count()
);
assert!(
warning.contains("Work Graph"),
"the truncated warning must still reach the part that explains it: \
{warning:?}\n{toast_dump}"
);
let _ = h.shutdown();
Ok(())
}
+513
View File
@@ -0,0 +1,513 @@
//! Owner report (2026-08-04): "the sub agents still aren't showing up in the
//! top bar so they aren't inspectable."
//!
//! Static reading of `work_surface/model.rs` says the rows are built and are
//! durable, so this probe refuses to reason about it: every assertion below is
//! made against a real pseudo-terminal frame produced by the real event loop,
//! with a loopback provider that dispatches genuine `agent` tool calls.
//!
//! The contract under test (`crates/tui/AGENTS.md`, "rows are objects"): every
//! work-bar row is a door — click it and the world behind it opens — and
//! keyboard Enter opens the same door a click does.
#![cfg(unix)]
#[path = "support/qa_harness/mod.rs"]
mod qa_harness;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow};
use qa_harness::harness::{Harness, SealedWorkspace, make_sealed_workspace};
use qa_harness::keys;
use serde_json::{Value, json};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate};
const BOOT_TIMEOUT: Duration = Duration::from_secs(20);
const INTERACTION_TIMEOUT: Duration = Duration::from_secs(20);
const PASTE_GUARD_SETTLE: Duration = Duration::from_millis(180);
const COMPOSER_READY_TEXT: &str = "Write a task";
const MODEL: &str = "deepseek-v4-pro";
/// The user prompt that triggers the fan-out. Only ever present in a *parent*
/// request, so the responder can tell parent from child without guessing.
const PARENT_PROMPT: &str = "spawn the work-bar probe workers now";
/// The objective handed to each child. Also the text the work-bar row shows.
const CHILD_MARKER: &str = "workbarprobe";
static WORK_BAR_PTY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn sse_chunk(value: Value) -> String {
format!(
"data: {}\n\n",
serde_json::to_string(&value).expect("SSE JSON")
)
}
fn text_sse(text: &str) -> String {
[
sse_chunk(json!({
"id": "chatcmpl-workbar",
"object": "chat.completion.chunk",
"model": MODEL,
"choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}]
})),
sse_chunk(json!({
"id": "chatcmpl-workbar",
"object": "chat.completion.chunk",
"model": MODEL,
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}
})),
"data: [DONE]\n\n".to_string(),
]
.join("")
}
fn agent_tool_call_sse(count: usize) -> String {
let tool_calls = (1..=count)
.map(|worker| {
json!({
"index": worker - 1,
"id": format!("call_workbar_{worker}"),
"type": "function",
"function": {
"name": "agent",
"arguments": serde_json::to_string(&json!({
"message": format!("{CHILD_MARKER}{worker} keep working"),
"agent_type": "explorer",
// Explicit fresh context: a forked child would carry the
// parent prompt into its own requests and defeat the
// parent/child discrimination in the responder.
"fork_context": false,
"session_name": format!("workbar-{worker}")
}))
.expect("agent arguments")
}
})
})
.collect::<Vec<_>>();
[
sse_chunk(json!({
"id": "chatcmpl-workbar-fanout",
"object": "chat.completion.chunk",
"model": MODEL,
"choices": [{"index": 0, "delta": {"tool_calls": tool_calls}, "finish_reason": null}]
})),
sse_chunk(json!({
"id": "chatcmpl-workbar-fanout",
"object": "chat.completion.chunk",
"model": MODEL,
"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
"usage": {"prompt_tokens": 20, "completion_tokens": 12, "total_tokens": 32}
})),
"data: [DONE]\n\n".to_string(),
]
.join("")
}
fn sse_response(body: String) -> ResponseTemplate {
ResponseTemplate::new(200)
.insert_header("content-type", "text/event-stream")
.insert_header("cache-control", "no-cache")
.set_body_string(body)
}
fn json_response(value: Value) -> ResponseTemplate {
ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_json(value)
}
async fn mount_models(server: &MockServer) {
Mock::given(method("GET"))
.and(path("/v1/models"))
.respond_with(json_response(json!({
"object": "list",
"data": [{"id": MODEL, "object": "model"}]
})))
.mount(server)
.await;
}
/// Dispatches one fan-out on the first parent turn, then answers the parent
/// plainly. `child_hold` decides whether the workers stay running (a long
/// delay) or finish immediately.
struct ProbeResponder {
child_requests: Arc<AtomicUsize>,
parent_turns: Arc<AtomicUsize>,
workers: usize,
child_hold: Duration,
}
impl Respond for ProbeResponder {
fn respond(&self, request: &Request) -> ResponseTemplate {
let raw = request
.body_json::<Value>()
.unwrap_or(Value::Null)
.to_string();
if raw.contains(CHILD_MARKER) && !raw.contains(PARENT_PROMPT) {
self.child_requests.fetch_add(1, Ordering::SeqCst);
return sse_response(text_sse("workbar child receipt")).set_delay(self.child_hold);
}
if raw.contains(PARENT_PROMPT) {
if self.parent_turns.fetch_add(1, Ordering::SeqCst) == 0 {
return sse_response(agent_tool_call_sse(self.workers));
}
return sse_response(text_sse("workbar parent wrapped up"));
}
sse_response(text_sse("unexpected-request"))
}
}
fn tui_builder(ws: &SealedWorkspace, server_uri: &str) -> qa_harness::harness::HarnessBuilder {
Harness::builder(Harness::cargo_bin("codewhale-tui"))
.cwd(ws.workspace())
.clear_env()
.seal_home(ws.home())
.env("RUST_LOG", "warn")
.env("NO_ANIMATIONS", "1")
.env("CODEWHALE_PROVIDER", "deepseek")
.env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
.env("DEEPSEEK_BASE_URL", server_uri.to_string())
.env("DEEPSEEK_MODEL", MODEL)
.args([
"--workspace",
ws.workspace().to_str().expect("utf-8 workspace path"),
"--no-project-config",
"--skip-onboarding",
"--mouse-capture",
"--yolo",
"--max-subagents",
"2",
])
.size(42, 150)
}
fn wait_for_counter(
harness: &mut Harness,
counter: &AtomicUsize,
expected: usize,
timeout: Duration,
) -> Result<()> {
let deadline = Instant::now() + timeout;
loop {
harness.pump();
if counter.load(Ordering::SeqCst) >= expected {
return Ok(());
}
if let Some(code) = harness.wait_for_exit(Duration::from_millis(0)) {
return Err(anyhow!(
"codewhale-tui exited with {code} before the counter reached {expected}\n{}",
harness.debug_dump()
));
}
if Instant::now() >= deadline {
return Err(anyhow!(
"counter did not reach {expected} within {timeout:?}; observed {}\n{}",
counter.load(Ordering::SeqCst),
harness.debug_dump()
));
}
std::thread::sleep(Duration::from_millis(40));
}
}
fn type_and_submit(harness: &mut Harness, text: &str) -> Result<()> {
harness.send(keys::key::text(text))?;
harness.wait_for_text(text, Duration::from_secs(5))?;
std::thread::sleep(PASTE_GUARD_SETTLE);
harness.pump();
harness.send(keys::key::enter())?;
Ok(())
}
fn is_divider_row(frame: &qa_harness::Frame, y: u16) -> bool {
frame
.row(y)
.chars()
.filter(|&c| c == '─' || c == '━')
.count()
>= 40
}
/// The `▾ Subagents N` group header the Top strip paints above its worker
/// rows. Its absence *is* the owner-reported bug, so it is the anchor every
/// other strip probe hangs off rather than a screen-wide text search.
fn subagents_header_row(harness: &mut Harness) -> Option<u16> {
let frame = harness.frame();
(0..frame.rows()).find(|&y| frame.row(y).contains("Subagents") && !is_divider_row(frame, y))
}
/// Every row painted in the work bar, header included.
fn work_bar_text(harness: &mut Harness) -> String {
let frame = harness.frame();
let rows = frame.rows();
// The strip sits between the ocean header rule and the transcript rule.
let dividers: Vec<u16> = (0..rows).filter(|&y| is_divider_row(frame, y)).collect();
let (start, end) = match dividers.as_slice() {
[first, second, ..] => (first.saturating_add(1), *second),
_ => (0, rows),
};
(start..end).map(|y| frame.row(y)).collect::<Vec<_>>().join("\n")
}
/// A worker row inside the strip: the rows the `Subagents` header owns, up to
/// the strip's closing rule. Returns `(row, column)` for a real SGR click.
fn work_bar_worker_row(harness: &mut Harness) -> Option<(u16, u16)> {
let header = subagents_header_row(harness)?;
let frame = harness.frame();
let rows = frame.rows();
(header.saturating_add(1)..rows)
.take_while(|&y| !is_divider_row(frame, y))
.find_map(|y| {
let text = frame.row(y);
let trimmed = text.trim_start();
if trimmed.is_empty() {
return None;
}
let col = u16::try_from(text.len() - trimmed.len()).ok()?;
Some((y, col.saturating_add(2)))
})
}
fn session_with_todos(ws: &SealedWorkspace, count: usize) -> Result<std::path::PathBuf> {
let session_path = ws.workspace().join("workbar-session.json");
let todos = (0..count)
.map(|index| {
json!({
"id": index + 1,
"content": format!("todo-workbar-{index:02}"),
"status": if index == 0 { "in_progress" } else { "pending" }
})
})
.collect::<Vec<_>>();
std::fs::write(
&session_path,
serde_json::to_vec_pretty(&json!({
"schema_version": 1,
"metadata": {
"id": "pty-workbar",
"title": "Work bar sub-agent probe",
"created_at": "2026-08-04T00:00:00Z",
"updated_at": "2026-08-04T00:00:00Z",
"message_count": 0,
"total_tokens": 0,
"model": MODEL,
"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": []}
}
}))?,
)?;
Ok(session_path)
}
/// Baseline: with nothing competing for strip rows, a running sub-agent must
/// appear in the top bar, a real SGR click must open its detail, and keyboard
/// Enter must open the same door.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn work_bar_lists_a_running_subagent_and_opens_it_by_click_and_enter() -> Result<()> {
let _guard = WORK_BAR_PTY_LOCK.lock().await;
let server = MockServer::start().await;
mount_models(&server).await;
let child_requests = Arc::new(AtomicUsize::new(0));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ProbeResponder {
child_requests: Arc::clone(&child_requests),
parent_turns: Arc::new(AtomicUsize::new(0)),
workers: 2,
child_hold: Duration::from_secs(25),
})
.mount(&server)
.await;
let ws = make_sealed_workspace()?;
std::fs::write(
ws.home().join(".codewhale").join("config.toml"),
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
)?;
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
type_and_submit(&mut tui, PARENT_PROMPT)?;
wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?;
tui.wait_for(
|frame| frame.text().contains("Subagents"),
Duration::from_secs(10),
)?;
let strip = work_bar_text(&mut tui);
assert!(
strip.contains("Subagents"),
"the `Subagents` header is not inside the top work bar:\n{strip}\n---full---\n{}",
tui.debug_dump()
);
let (row, col) = work_bar_worker_row(&mut tui).ok_or_else(|| {
anyhow!(
"no running sub-agent row rendered in the top work bar\n{}",
tui.debug_dump()
)
})?;
// Click the row: the door must open.
tui.send(keys::mouse::click(row, col))?;
tui.wait_for_text("Agent Details", Duration::from_secs(5))
.map_err(|_| {
anyhow!(
"clicking the sub-agent row did not open its detail\n{}",
tui.debug_dump()
)
})?;
// Close, then prove keyboard parity: Alt+W focuses the strip, End selects
// the last selectable row (a worker), Enter opens the same detail.
tui.send(keys::key::esc())?;
tui.wait_for(
|frame| !frame.text().contains("Agent Details"),
Duration::from_secs(5),
)?;
tui.send(keys::key::alt('w'))?;
tui.send(b"\x1b[F")?; // End
tui.send(keys::key::enter())?;
tui.wait_for_text("Agent Details", Duration::from_secs(5))
.map_err(|_| {
anyhow!(
"Enter on the selected work-bar row did not open the detail a click opens\n{}",
tui.debug_dump()
)
})?;
let _ = tui.shutdown();
Ok(())
}
/// The dogfood shape: a session already carrying a to-do list, then a fan-out.
/// Sub-agents must remain visible and clickable in the top bar — a strip that
/// spends every row it has on to-dos and pushes the workers off the bottom is
/// exactly the owner-reported failure.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn work_bar_still_shows_subagents_when_todos_are_present() -> Result<()> {
let _guard = WORK_BAR_PTY_LOCK.lock().await;
let server = MockServer::start().await;
mount_models(&server).await;
let child_requests = Arc::new(AtomicUsize::new(0));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ProbeResponder {
child_requests: Arc::clone(&child_requests),
parent_turns: Arc::new(AtomicUsize::new(0)),
workers: 2,
child_hold: Duration::from_secs(25),
})
.mount(&server)
.await;
let ws = make_sealed_workspace()?;
std::fs::write(
ws.home().join(".codewhale").join("config.toml"),
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
)?;
let session_path = session_with_todos(&ws, 8)?;
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
tui.send(keys::key::text(&format!(
"/load {}",
session_path.to_string_lossy()
)))?;
tui.wait_for_idle(Duration::from_millis(150), Duration::from_secs(3))?;
tui.send(keys::key::enter())?;
tui.wait_for_text("todo-workbar-00", Duration::from_secs(10))?;
type_and_submit(&mut tui, PARENT_PROMPT)?;
wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?;
tui.wait_for_idle(Duration::from_millis(250), Duration::from_secs(6))?;
let strip = work_bar_text(&mut tui);
let worker = work_bar_worker_row(&mut tui);
assert!(
worker.is_some(),
"a running sub-agent is not reachable in the top work bar while to-dos \
occupy it — the strip painted only:\n{strip}\n---full---\n{}",
tui.debug_dump()
);
let (row, col) = worker.expect("checked above");
tui.send(keys::mouse::click(row, col))?;
tui.wait_for_text("Agent Details", Duration::from_secs(5))
.map_err(|_| {
anyhow!(
"clicking the sub-agent row did not open its detail\n{}",
tui.debug_dump()
)
})?;
let _ = tui.shutdown();
Ok(())
}
/// A finished agent must stay in the bar and stay clickable — quiet
/// completion, not eviction.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn work_bar_keeps_a_finished_subagent_clickable() -> Result<()> {
let _guard = WORK_BAR_PTY_LOCK.lock().await;
let server = MockServer::start().await;
mount_models(&server).await;
let child_requests = Arc::new(AtomicUsize::new(0));
Mock::given(method("POST"))
.and(path("/v1/chat/completions"))
.respond_with(ProbeResponder {
child_requests: Arc::clone(&child_requests),
parent_turns: Arc::new(AtomicUsize::new(0)),
workers: 1,
child_hold: Duration::from_millis(0),
})
.mount(&server)
.await;
let ws = make_sealed_workspace()?;
std::fs::write(
ws.home().join(".codewhale").join("config.toml"),
"[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n",
)?;
let mut tui = tui_builder(&ws, &server.uri()).spawn()?;
tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
type_and_submit(&mut tui, PARENT_PROMPT)?;
wait_for_counter(&mut tui, &child_requests, 1, INTERACTION_TIMEOUT)?;
// Let the child settle terminal and the parent turn finish.
tui.wait_for_idle(Duration::from_millis(300), Duration::from_secs(10))?;
let strip = work_bar_text(&mut tui);
let worker = work_bar_worker_row(&mut tui);
assert!(
worker.is_some(),
"a finished sub-agent disappeared from the top work bar; the strip \
painted only:\n{strip}\n---full---\n{}",
tui.debug_dump()
);
let (row, col) = worker.expect("checked above");
tui.send(keys::mouse::click(row, col))?;
tui.wait_for_text("Agent Details", Duration::from_secs(5))
.map_err(|_| {
anyhow!(
"a finished sub-agent row is no longer a door\n{}",
tui.debug_dump()
)
})?;
let _ = tui.shutdown();
Ok(())
}