mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
[tui] Truncate hook context in conversation history (#31252)
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: history
|
||||
---
|
||||
• SessionStart hook (stopped)
|
||||
hook context: This hook context is intentionally long enough to wrap across
|
||||
several terminal rows while keeping the complete value available in the
|
||||
… +2 lines (ctrl + t to view transcript)
|
||||
stop: The hook stopped this turn for an important reason.
|
||||
This second line must remain visible in full.
|
||||
@@ -3888,6 +3888,41 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn long_hook_context_is_truncated_with_transcript_hint_snapshot() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
handle_hook_completed(
|
||||
&mut chat,
|
||||
hook_completed_run(
|
||||
"session-start:0:/tmp/hooks.json",
|
||||
codex_app_server_protocol::HookEventName::SessionStart,
|
||||
codex_app_server_protocol::HookRunStatus::Stopped,
|
||||
vec![
|
||||
codex_app_server_protocol::HookOutputEntry {
|
||||
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
|
||||
text: "This hook context is intentionally long enough to wrap across several terminal rows while keeping the complete value available in the transcript overlay. The main conversation should stay compact even when a hook injects a large block of instructions for the model."
|
||||
.to_string(),
|
||||
},
|
||||
codex_app_server_protocol::HookOutputEntry {
|
||||
kind: codex_app_server_protocol::HookOutputEntryKind::Stop,
|
||||
text: "The hook stopped this turn for an important reason.\nThis second line must remain visible in full."
|
||||
.to_string(),
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
let history = drain_insert_history(&mut rx)
|
||||
.iter()
|
||||
.map(|lines| lines_to_single_string(lines))
|
||||
.collect::<String>();
|
||||
assert_chatwidget_snapshot!(
|
||||
"long_hook_context_is_truncated_with_transcript_hint",
|
||||
history
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_start_hook_events_render_snapshot() {
|
||||
assert_hook_events_snapshot(
|
||||
|
||||
@@ -12,6 +12,7 @@ use crate::motion::activity_indicator;
|
||||
use crate::render::highlight::highlight_bash_to_lines;
|
||||
use crate::render::line_utils::prefix_lines;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::ui_consts::TRANSCRIPT_HINT;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::adaptive_wrap_line;
|
||||
use crate::wrapping::adaptive_wrap_lines;
|
||||
@@ -32,7 +33,6 @@ use unicode_width::UnicodeWidthStr;
|
||||
pub(crate) const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50;
|
||||
const MAX_INTERACTION_PREVIEW_CHARS: usize = 80;
|
||||
const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript";
|
||||
|
||||
pub(crate) struct OutputLinesParams {
|
||||
pub(crate) line_limit: usize,
|
||||
|
||||
@@ -12,11 +12,16 @@
|
||||
//! 4. Completed runs only persist when they have output or a non-success status.
|
||||
use super::HistoryCell;
|
||||
use super::plain_lines;
|
||||
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
|
||||
use crate::motion::MotionMode;
|
||||
use crate::motion::ReducedMotionIndicator;
|
||||
use crate::motion::activity_indicator;
|
||||
use crate::motion::shimmer_text;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::ui_consts::TRANSCRIPT_HINT;
|
||||
use crate::wrapping::RtOptions;
|
||||
use crate::wrapping::word_wrap_line;
|
||||
use codex_app_server_protocol::HookEventName;
|
||||
use codex_app_server_protocol::HookOutputEntry;
|
||||
use codex_app_server_protocol::HookOutputEntryKind;
|
||||
@@ -50,6 +55,7 @@ const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600);
|
||||
|
||||
const HOOK_OUTPUT_INDENT: &str = " ";
|
||||
const HOOK_OUTPUT_BODY_INDENT: &str = " ";
|
||||
const HOOK_CONTEXT_MAX_DISPLAY_ROWS: usize = 3;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HookRunCell {
|
||||
@@ -294,11 +300,9 @@ impl HookCell {
|
||||
run.reveal_running_after_delayed_redraw_for_test(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for HookCell {
|
||||
/// Builds viewport lines while coalescing adjacent visible-running hooks.
|
||||
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
|
||||
/// Builds hook lines for either the bounded main viewport or the full transcript overlay.
|
||||
fn output_lines(&self, width: u16, render_full_context: bool) -> Vec<Line<'static>> {
|
||||
let mut lines = Vec::new();
|
||||
let mut running_group: Option<RunningHookGroup> = None;
|
||||
for run in &self.runs {
|
||||
@@ -313,7 +317,12 @@ impl HistoryCell for HookCell {
|
||||
push_running_hook_group(&mut lines, &group, self.animations_enabled);
|
||||
}
|
||||
push_hook_line_separator(&mut lines);
|
||||
run.push_display_lines(&mut lines, self.animations_enabled);
|
||||
run.push_display_lines(
|
||||
&mut lines,
|
||||
self.animations_enabled,
|
||||
width,
|
||||
render_full_context,
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -338,14 +347,21 @@ impl HistoryCell for HookCell {
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Hook transcript output matches viewport output.
|
||||
impl HistoryCell for HookCell {
|
||||
/// Builds viewport lines while coalescing adjacent visible-running hooks.
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.output_lines(width, /*render_full_context*/ false)
|
||||
}
|
||||
|
||||
/// The transcript overlay preserves complete hook context hidden by the viewport preview.
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
self.display_lines(width)
|
||||
self.output_lines(width, /*render_full_context*/ true)
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
plain_lines(self.display_lines(u16::MAX))
|
||||
plain_lines(self.output_lines(u16::MAX, /*render_full_context*/ true))
|
||||
}
|
||||
|
||||
/// Produces a coarse cache key for transcript overlays while hook animations are active.
|
||||
@@ -420,7 +436,13 @@ impl HookRunCell {
|
||||
}
|
||||
|
||||
/// Appends the lines for a single, ungrouped hook run.
|
||||
fn push_display_lines(&self, lines: &mut Vec<Line<'static>>, animations_enabled: bool) {
|
||||
fn push_display_lines(
|
||||
&self,
|
||||
lines: &mut Vec<Line<'static>>,
|
||||
animations_enabled: bool,
|
||||
width: u16,
|
||||
render_full_context: bool,
|
||||
) {
|
||||
let label = hook_event_label(self.event_name);
|
||||
match &self.state {
|
||||
HookRunState::VisibleRunning { start_time, .. }
|
||||
@@ -446,17 +468,10 @@ impl HookRunCell {
|
||||
.into(),
|
||||
);
|
||||
for entry in entries {
|
||||
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());
|
||||
}
|
||||
if !render_full_context && entry.kind == HookOutputEntryKind::Context {
|
||||
lines.extend(hook_context_preview_lines(&entry.text, width));
|
||||
} else {
|
||||
push_full_hook_output_entry(lines, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -465,6 +480,80 @@ impl HookRunCell {
|
||||
}
|
||||
}
|
||||
|
||||
fn push_full_hook_output_entry(lines: &mut Vec<Line<'static>>, entry: &HookOutputEntry) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hook_context_preview_lines(text: &str, width: u16) -> Vec<Line<'static>> {
|
||||
let width = usize::from(width.max(1));
|
||||
let mut wrapped = Vec::new();
|
||||
let mut source_lines = text.split('\n');
|
||||
let first_line = source_lines.next().unwrap_or_default();
|
||||
push_wrapped_hook_context_line(
|
||||
&mut wrapped,
|
||||
first_line,
|
||||
width,
|
||||
Line::from(format!(
|
||||
"{HOOK_OUTPUT_INDENT}{}",
|
||||
hook_output_prefix(HookOutputEntryKind::Context)
|
||||
)),
|
||||
);
|
||||
for line in source_lines {
|
||||
if line.is_empty() {
|
||||
wrapped.push("".into());
|
||||
} else {
|
||||
push_wrapped_hook_context_line(
|
||||
&mut wrapped,
|
||||
line,
|
||||
width,
|
||||
Line::from(HOOK_OUTPUT_BODY_INDENT),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if wrapped.len() <= HOOK_CONTEXT_MAX_DISPLAY_ROWS {
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
let retained_rows = HOOK_CONTEXT_MAX_DISPLAY_ROWS - 1;
|
||||
let omitted_rows = wrapped.len() - retained_rows;
|
||||
wrapped.truncate(retained_rows);
|
||||
let hint = vec![
|
||||
HOOK_OUTPUT_BODY_INDENT.into(),
|
||||
format!("… +{omitted_rows} lines ({TRANSCRIPT_HINT})").dim(),
|
||||
]
|
||||
.into();
|
||||
wrapped.push(truncate_line_with_ellipsis_if_overflow(hint, width));
|
||||
wrapped
|
||||
}
|
||||
|
||||
fn push_wrapped_hook_context_line(
|
||||
output: &mut Vec<Line<'static>>,
|
||||
text: &str,
|
||||
width: usize,
|
||||
initial_indent: Line<'static>,
|
||||
) {
|
||||
let line = Line::from(text.to_string());
|
||||
let wrapped = word_wrap_line(
|
||||
&line,
|
||||
RtOptions::new(width)
|
||||
.initial_indent(initial_indent)
|
||||
.subsequent_indent(Line::from(HOOK_OUTPUT_BODY_INDENT)),
|
||||
);
|
||||
push_owned_lines(&wrapped, output);
|
||||
}
|
||||
|
||||
impl HookRunState {
|
||||
/// Creates the hidden initial state for a live hook run.
|
||||
fn pending(start_time: Instant) -> Self {
|
||||
@@ -760,14 +849,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_hook_multiline_context_preserves_display_and_raw_lines() {
|
||||
fn completed_hook_short_multiline_context_preserves_display_transcript_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(),
|
||||
text: "## Working Memory Recall\n\nSource: Codex compaction".to_string(),
|
||||
}],
|
||||
);
|
||||
let expected = vec![
|
||||
@@ -775,13 +863,87 @@ mod tests {
|
||||
" 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.transcript_lines(/*width*/ 80)), expected);
|
||||
assert_eq!(line_texts(&cell.raw_lines()), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_hook_long_single_line_context_is_truncated_only_in_display() {
|
||||
let full_context = format!(
|
||||
"{}tail-marker",
|
||||
"context words that should wrap across the terminal width ".repeat(8)
|
||||
);
|
||||
let cell = completed_hook_cell(
|
||||
HookEventName::SessionStart,
|
||||
HookRunStatus::Completed,
|
||||
vec![HookOutputEntry {
|
||||
kind: HookOutputEntryKind::Context,
|
||||
text: full_context.clone(),
|
||||
}],
|
||||
);
|
||||
|
||||
let display_lines = cell.display_lines(/*width*/ 80);
|
||||
let display = line_texts(&display_lines);
|
||||
assert_eq!(display.len(), 4);
|
||||
assert_eq!(
|
||||
Paragraph::new(Text::from(display_lines[1..].to_vec()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.line_count(/*width*/ 80),
|
||||
HOOK_CONTEXT_MAX_DISPLAY_ROWS
|
||||
);
|
||||
assert!(
|
||||
display
|
||||
.iter()
|
||||
.any(|line| line.contains("ctrl + t to view transcript")),
|
||||
"expected truncated context to advertise the transcript: {display:?}"
|
||||
);
|
||||
assert!(display.iter().all(|line| !line.contains("tail-marker")));
|
||||
|
||||
let expected_full = vec![
|
||||
"• SessionStart hook (completed)".to_string(),
|
||||
format!(" hook context: {full_context}"),
|
||||
];
|
||||
assert_eq!(
|
||||
line_texts(&cell.transcript_lines(/*width*/ 80)),
|
||||
expected_full
|
||||
);
|
||||
assert_eq!(line_texts(&cell.raw_lines()), expected_full);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_hook_non_context_entries_are_not_truncated() {
|
||||
for kind in [
|
||||
HookOutputEntryKind::Warning,
|
||||
HookOutputEntryKind::Stop,
|
||||
HookOutputEntryKind::Feedback,
|
||||
HookOutputEntryKind::Error,
|
||||
] {
|
||||
let cell = completed_hook_cell(
|
||||
HookEventName::UserPromptSubmit,
|
||||
HookRunStatus::Stopped,
|
||||
vec![HookOutputEntry {
|
||||
kind,
|
||||
text: "first\nsecond\nthird\nfourth\nfifth".to_string(),
|
||||
}],
|
||||
);
|
||||
|
||||
let display = line_texts(&cell.display_lines(/*width*/ 20));
|
||||
assert!(
|
||||
display.iter().any(|line| line == " fifth"),
|
||||
"expected {kind:?} output to remain complete: {display:?}"
|
||||
);
|
||||
assert!(
|
||||
display
|
||||
.iter()
|
||||
.all(|line| !line.contains("ctrl + t to view transcript")),
|
||||
"did not expect a transcript hint for {kind:?}: {display:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_hook_multiline_warning_prefixes_first_line_only() {
|
||||
let cell = completed_hook_cell(
|
||||
|
||||
@@ -9,3 +9,4 @@
|
||||
/// - User history lines account for this many columns (e.g., "▌ ") when wrapping.
|
||||
pub(crate) const LIVE_PREFIX_COLS: u16 = 2;
|
||||
pub(crate) const FOOTER_INDENT_COLS: usize = LIVE_PREFIX_COLS as usize;
|
||||
pub(crate) const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript";
|
||||
|
||||
Reference in New Issue
Block a user