mirror of
https://github.com/openai/codex.git
synced 2026-08-23 13:09:46 +00:00
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:
@@ -7,3 +7,4 @@ started hidden:
|
||||
history:
|
||||
• SessionStart hook (completed)
|
||||
hook context: session context
|
||||
second line
|
||||
|
||||
@@ -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(),
|
||||
}],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user