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:
@@ -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"));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ===");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (é)
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user