Render multiline hook output in TUI (#24965)

# Why

Fixes #24529. Completed hook output in the TUI rendered each
`HookOutputEntry` as one ratatui line, so explicit newlines inside hook
output were not shown as separate transcript rows. That made multiline
`SessionStart.additionalContext` hard to inspect even though the
model-facing context path preserved the original text.

# What

- Split completed hook output entries on explicit newlines before
rendering them in `codex-rs/tui/src/history_cell/hook_cell.rs`.
- Keep the hook output prefix, such as `hook context:` or `warning:`, on
the first physical line only.
- Preserve explicit blank lines and render continuation lines with the
hook body indent.
- Add unit coverage for multiline context and warning output, plus a
chatwidget snapshot regression for `SessionStart` history output.

# Testing

- `cargo nextest run -p codex-tui completed_hook_multiline
hook_completed_before_reveal_renders_completed_without_running_flash`
- `just argument-comment-lint -p codex-tui -- --ignore-rust-version
--lib --tests`
This commit is contained in:
Abhinav
2026-05-29 08:12:40 -07:00
committed by GitHub
parent b40ad0d84d
commit 251b2412b2
3 changed files with 88 additions and 11 deletions

View File

@@ -7,3 +7,4 @@ started hidden:
history:
• SessionStart hook (completed)
hook context: session context
second line

View File

@@ -3381,7 +3381,7 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash()
codex_app_server_protocol::HookRunStatus::Completed,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
text: "session context".to_string(),
text: "session context\nsecond line".to_string(),
}],
),
);

View File

@@ -48,6 +48,9 @@ const HOOK_RUN_REVEAL_DELAY: Duration = Duration::from_millis(300);
/// enough to read instead of removing it immediately when the success event arrives.
const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600);
const HOOK_OUTPUT_INDENT: &str = " ";
const HOOK_OUTPUT_BODY_INDENT: &str = " ";
#[derive(Debug)]
struct HookRunCell {
/// Stable protocol id used to match begin/end updates for the same hook invocation.
@@ -443,10 +446,18 @@ impl HookRunCell {
.into(),
);
for entry in entries {
// Output entries are already short hook-authored strings; keep their prefixes
// explicit so warnings/stops/errors remain easy to scan in history.
lines
.push(format!(" {}{}", hook_output_prefix(entry.kind), entry.text).into());
let prefix = hook_output_prefix(entry.kind);
let mut output_lines = entry.text.split('\n');
if let Some(first_line) = output_lines.next() {
lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into());
}
for line in output_lines {
if line.is_empty() {
lines.push("".into());
} else {
lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into());
}
}
}
}
HookRunState::PendingReveal { .. } => {}
@@ -748,6 +759,50 @@ mod tests {
assert!(bullet.style.add_modifier.contains(Modifier::BOLD));
}
#[test]
fn completed_hook_multiline_context_preserves_display_and_raw_lines() {
let cell = completed_hook_cell(
HookEventName::SessionStart,
HookRunStatus::Completed,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Context,
text: "## Working Memory Recall\n\nSource: Codex compaction\nScope: Durable workspace memory"
.to_string(),
}],
);
let expected = vec![
"• SessionStart hook (completed)".to_string(),
" hook context: ## Working Memory Recall".to_string(),
"".to_string(),
" Source: Codex compaction".to_string(),
" Scope: Durable workspace memory".to_string(),
];
assert_eq!(line_texts(&cell.display_lines(/*width*/ 80)), expected);
assert_eq!(line_texts(&cell.raw_lines()), expected);
}
#[test]
fn completed_hook_multiline_warning_prefixes_first_line_only() {
let cell = completed_hook_cell(
HookEventName::PostToolUse,
HookRunStatus::Completed,
vec![HookOutputEntry {
kind: HookOutputEntryKind::Warning,
text: "Heads up\nReview generated files".to_string(),
}],
);
assert_eq!(
line_texts(&cell.display_lines(/*width*/ 80)),
vec![
"• PostToolUse hook (completed)".to_string(),
" warning: Heads up".to_string(),
" Review generated files".to_string(),
]
);
}
#[test]
fn pending_hook_does_not_animate_transcript() {
let cell =
@@ -790,12 +845,7 @@ mod tests {
let rendered: Vec<String> = cell
.display_lines(/*width*/ 80)
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.map(line_text)
.collect();
assert_eq!(
@@ -804,6 +854,32 @@ mod tests {
);
}
fn completed_hook_cell(
event_name: HookEventName,
status: HookRunStatus,
entries: Vec<HookOutputEntry>,
) -> HookCell {
let mut run = hook_run_summary("hook-1");
run.event_name = event_name;
run.status = status;
run.status_message = None;
run.completed_at = Some(2);
run.duration_ms = Some(1);
run.entries = entries;
HookCell::new_completed(run, /*animations_enabled*/ false)
}
fn line_texts(lines: &[Line<'_>]) -> Vec<String> {
lines.iter().map(line_text).collect()
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
fn hook_run_summary(id: &str) -> HookRunSummary {
HookRunSummary {
id: id.to_string(),