mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
agentydragon(tasks): implement command snippet truncation in session approval labels
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
+++
|
||||
id = "28"
|
||||
title = "Include Command Snippet in Session-Scoped Approval Label"
|
||||
status = "Not started"
|
||||
status = "In progress"
|
||||
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 `<snippet>` 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
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
27
codex-cli/src/utils/string-utils.ts
Normal file
27
codex-cli/src/utils/string-utils.ts
Normal file
@@ -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)`;
|
||||
}
|
||||
39
codex-cli/tests/string-utils.test.ts
Normal file
39
codex-cli/tests/string-utils.test.ts
Normal file
@@ -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\)$/);
|
||||
});
|
||||
});
|
||||
@@ -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)"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user