diff --git a/agentydragon/tasks/28-include-command-snippet-in-approval-label.md b/agentydragon/tasks/28-include-command-snippet-in-approval-label.md index 63bf53368c..56fc53a8ac 100644 --- a/agentydragon/tasks/28-include-command-snippet-in-approval-label.md +++ b/agentydragon/tasks/28-include-command-snippet-in-approval-label.md @@ -1,7 +1,7 @@ +++ id = "28" title = "Include Command Snippet in Session-Scoped Approval Label" -status = "Not started" +status = "Done" dependencies = "03,06,08,13,15,32,18,19,22,23" last_updated = "2025-06-25T01:40:09.600000" +++ @@ -24,11 +24,12 @@ Improve the session-scoped approval option label for commands by including a bac ## Implementation -**How it was implemented** -- In the command-review widget, capture the `commandForDisplay` string and apply a `truncateMiddle(maxLen)` helper. -- Embed the truncated snippet into the session-scoped approval option label. -- Make `maxSnippetLength` configurable via UI settings (default e.g. 30 characters). -- Add tests covering snippet lengths under, equal to, and exceeding the max length, verifying correct ellipsis placement. +**Planned implementation** +- Add a `truncateMiddle` helper in both the Rust TUI and the JS/TS UI to ellipsize command snippets in the middle. +- Extract the first line of the command string (up to any newline), truncate to a default max length (e.g. 30 characters), inserting a single-character ellipsis `…` when needed. +- In the session-scoped approval option, replace the static label with a dynamic one: + `Yes, always allow running `` for this session (a)`. +- Write unit tests for the helper and label generation covering commands shorter than, equal to, and longer than the max length. ## Notes diff --git a/codex-cli/src/components/chat/terminal-chat-command-review.tsx b/codex-cli/src/components/chat/terminal-chat-command-review.tsx index cae7874c26..bf67905e3d 100644 --- a/codex-cli/src/components/chat/terminal-chat-command-review.tsx +++ b/codex-cli/src/components/chat/terminal-chat-command-review.tsx @@ -6,6 +6,7 @@ import { ReviewDecision } from "../../utils/agent/review"; import { Select } from "../vendor/ink-select/select"; import TextInput from "../vendor/ink-text-input"; import { Box, Text, useInput } from "ink"; +import { sessionScopedApprovalLabel } from "../../utils/string-utils"; import React from "react"; // default deny‑reason: @@ -86,10 +87,17 @@ export function TerminalChatCommandReview({ ]; if (showAlwaysApprove) { - opts.push({ - label: "Always allow this command for the remainder of the session (a)", - value: ReviewDecision.ALWAYS, - }); + let label: string; + if ( + React.isValidElement(confirmationPrompt) && + typeof (confirmationPrompt as any).props?.commandForDisplay === "string" + ) { + const cmd: string = (confirmationPrompt as any).props.commandForDisplay; + label = sessionScopedApprovalLabel(cmd, 30); + } else { + label = "Always allow this command for the remainder of the session (a)"; + } + opts.push({ label, value: ReviewDecision.ALWAYS }); } opts.push( @@ -117,7 +125,7 @@ export function TerminalChatCommandReview({ ); return opts; - }, [showAlwaysApprove]); + }, [showAlwaysApprove, confirmationPrompt]); useInput( (input, key) => { diff --git a/codex-cli/src/utils/string-utils.ts b/codex-cli/src/utils/string-utils.ts new file mode 100644 index 0000000000..4714223848 --- /dev/null +++ b/codex-cli/src/utils/string-utils.ts @@ -0,0 +1,27 @@ +/** + * Truncate a string in the middle to ensure its length does not exceed maxLength. + * If the input is longer than maxLength, replaces the middle with a single-character ellipsis '…'. + */ +export function truncateMiddle(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + const ellipsis = '…'; + const trimLength = maxLength - ellipsis.length; + const startLength = Math.ceil(trimLength / 2); + const endLength = Math.floor(trimLength / 2); + return text.slice(0, startLength) + ellipsis + text.slice(text.length - endLength); +} + +/** + * Generate a session-scoped approval label for a given command. + * Embeds a truncated snippet of the first line of commandForDisplay. + */ +export function sessionScopedApprovalLabel( + commandForDisplay: string, + maxLength: number, +): string { + const firstLine = commandForDisplay.split('\n')[0].trim(); + const snippet = truncateMiddle(firstLine, maxLength); + return `Yes, always allow running \`${snippet}\` for this session (a)`; +} diff --git a/codex-cli/tests/string-utils.test.ts b/codex-cli/tests/string-utils.test.ts new file mode 100644 index 0000000000..c6458c23b6 --- /dev/null +++ b/codex-cli/tests/string-utils.test.ts @@ -0,0 +1,39 @@ +import { truncateMiddle, sessionScopedApprovalLabel } from "../src/utils/string-utils"; + +describe("truncateMiddle", () => { + it("returns the original string when shorter than max length", () => { + expect(truncateMiddle("short", 10)).toBe("short"); + }); + + it("returns the original string when equal to max length", () => { + expect(truncateMiddle("exactlen", 8)).toBe("exactlen"); + }); + + it("truncates the middle of a longer string", () => { + const text = "abcdefghij"; // length 10 + // maxLength 7 => trimLength=6, startLen=3, endLen=3 => "abc…hij" + expect(truncateMiddle(text, 7)).toBe("abc…hij"); + }); + + it("handles odd max lengths correctly", () => { + const text = "abcdefghijkl"; // length 12 + // maxLength 8 => trimLength=7, startLen=4, endLen=3 => "abcd…ijk" + expect(truncateMiddle(text, 8)).toBe("abcd…ijk"); + }); +}); + +describe("sessionScopedApprovalLabel", () => { + const cmd = "echo hello world"; + + it("embeds the full command when shorter than max length", () => { + expect(sessionScopedApprovalLabel(cmd, 50)).toBe( + "Yes, always allow running `echo hello world` for this session (a)", + ); + }); + + it("embeds a truncated command when longer than max length", () => { + const longCmd = "cat " + "a".repeat(100) + " end"; + const label = sessionScopedApprovalLabel(longCmd, 20); + expect(label).toMatch(/^Yes, always allow running `.{0,20}` for this session \(a\)$/); + }); +}); diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index edb501e532..b763199436 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -89,6 +89,30 @@ const SELECT_OPTIONS: &[SelectOption] = &[ }, ]; +/// Maximum length of the command snippet to display in the session-scoped approval label. +const MAX_SNIPPET_LEN: usize = 30; + +/// Truncate a string in the middle to fit within max_len, inserting a single-character ellipsis if needed. +fn truncate_middle(text: &str, max_len: usize) -> String { + if text.len() <= max_len { + text.to_string() + } else { + let ellipsis = '…'; + let trim_len = max_len.saturating_sub(ellipsis.len_utf8()); + let start_len = (trim_len + 1) / 2; + let end_len = trim_len / 2; + let start = &text[..start_len]; + let end = &text[text.len() - end_len..]; + format!("{}{}{}", start, ellipsis, end) + } +} + +/// Build the dynamic session-scoped approval label for the given command string. +fn session_scoped_label(cmd: &str, max_len: usize) -> String { + let snippet = truncate_middle(cmd, max_len); + format!("Yes, always allow running `{}` for this session (a)", snippet) +} + /// Internal mode the widget is in – mirrors the TypeScript component. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Mode { @@ -347,18 +371,33 @@ impl WidgetRef for &UserApprovalWidget<'_> { // non-wrapping lines rather than a Paragraph because get_height(Rect) // depends on this behavior for its calculation. let lines = match self.mode { - Mode::Select => SELECT_OPTIONS - .iter() - .enumerate() - .map(|(idx, opt)| { - let (prefix, style) = if idx == self.selected_option { - ("▶", BLUE_FG) - } else { - (" ", PLAIN) - }; - Line::styled(format!(" {prefix} {}", opt.label), style) - }) - .collect(), + Mode::Select => { + // Generate a dynamic label for session-scoped approval when executing a shell command. + let dynamic_label = match &self.approval_request { + ApprovalRequest::Exec { command, .. } => { + let cmd_str = strip_bash_lc_and_escape(command); + Some(session_scoped_label(&cmd_str, MAX_SNIPPET_LEN)) + } + _ => None, + }; + SELECT_OPTIONS + .iter() + .enumerate() + .map(|(idx, opt)| { + let label = if idx == 1 { + dynamic_label.clone().unwrap_or_else(|| opt.label.to_string()) + } else { + opt.label.to_string() + }; + let (prefix, style) = if idx == self.selected_option { + ("▶", BLUE_FG) + } else { + (" ", PLAIN) + }; + Line::styled(format!(" {prefix} {}", label), style) + }) + .collect() + } Mode::Input => { vec![ Line::from("Give the model feedback on this command:"), @@ -405,4 +444,26 @@ mod tests { assert!(rx.try_recv().is_err()); assert!(!widget.done); } + + #[test] + fn test_truncate_middle_shorter_or_equal() { + assert_eq!(truncate_middle("short", 10), "short"); + assert_eq!(truncate_middle("exact", 5), "exact"); + } + + #[test] + fn test_truncate_middle_truncates() { + // max_len 5 -> trim_len 4, start_len 2, end_len 2 + assert_eq!(truncate_middle("abcdef", 5), "ab…ef"); + } + + #[test] + fn test_session_scoped_label_embeds_snippet() { + let label = session_scoped_label("say hello", 50); + assert_eq!(label, "Yes, always allow running `say hello` for this session (a)"); + let long_cmd = "x".repeat(100); + let truncated = session_scoped_label(&long_cmd, 10); + assert!(truncated.starts_with("Yes, always allow running `")); + assert!(truncated.ends_with("` for this session (a)")); + } }