diff --git a/codex-cli/src/components/chat/image-picker-overlay.tsx b/codex-cli/src/components/chat/image-picker-overlay.tsx index c2431856cc..ea134e0a85 100644 --- a/codex-cli/src/components/chat/image-picker-overlay.tsx +++ b/codex-cli/src/components/chat/image-picker-overlay.tsx @@ -44,6 +44,19 @@ export default function ImagePickerOverlay({ // Keep track of currently highlighted item so can act synchronously. const highlighted = useRef(items[0] ?? null); + // Ensure we only invoke `onPick` / `onCancel` / `onChangeDir` once for the + // life‑time of the overlay. Depending on the environment a single + // key‑press can bubble through *three* different handlers (raw `data` event, + // `useInput`, plus `SelectInput`\'s `onSelect`). Without this guard the + // parent component would receive duplicate attachments. + const actedRef = useRef(false); + + function perform(action: () => void) { + if (actedRef.current) return; + actedRef.current = true; + action(); + } + // DEBUG: log all raw data when DEBUG_OVERLAY enabled (useful for tests) const { stdin: inkStdin } = useStdin(); React.useEffect(() => { @@ -68,19 +81,21 @@ export default function ImagePickerOverlay({ const item = highlighted.current; if (!item) return; - if (item.value === "__UP__") { - onChangeDir(path.dirname(cwd)); - } else if (item.label.endsWith("/")) { - onChangeDir(item.value); - } else { - onPick(item.value); - } + perform(() => { + if (item.value === "__UP__") { + onChangeDir(path.dirname(cwd)); + } else if (item.label.endsWith("/")) { + onChangeDir(item.value); + } else { + onPick(item.value); + } + }); return; } // ESC (\u001B) or Backspace (\x7f) if (str === "\u001b" || str === "\x7f") { - onCancel(); + perform(onCancel); } } if (inkStdin) inkStdin.on('data', onData); @@ -99,9 +114,9 @@ export default function ImagePickerOverlay({ // eslint-disable-next-line no-console console.log('[overlay] root useInput', JSON.stringify(input), key.return); } - if (key.escape || key.backspace || input === "\u007f") { + if (key.escape || key.backspace || input === "\u007f") { if (process.env.DEBUG_OVERLAY) console.log('[overlay] cancel'); - onCancel(); + perform(onCancel); } else if (key.return) { // Act on the currently highlighted item synchronously so tests that // simulate a bare "\r" keypress without triggering SelectInput’s @@ -117,13 +132,15 @@ export default function ImagePickerOverlay({ console.log('[overlay] return on', item.label, item.value); } - if (item.value === "__UP__") { - onChangeDir(path.dirname(cwd)); - } else if (item.label.endsWith("/")) { - onChangeDir(item.value); - } else { - onPick(item.value); - } + perform(() => { + if (item.value === "__UP__") { + onChangeDir(path.dirname(cwd)); + } else if (item.label.endsWith("/")) { + onChangeDir(item.value); + } else { + onPick(item.value); + } + }); } }, { isActive: true }, diff --git a/codex-cli/src/components/chat/terminal-chat-input.tsx b/codex-cli/src/components/chat/terminal-chat-input.tsx index 92df2600b7..8c3c0ab0a9 100644 --- a/codex-cli/src/components/chat/terminal-chat-input.tsx +++ b/codex-cli/src/components/chat/terminal-chat-input.tsx @@ -468,8 +468,10 @@ export default function TerminalChatInput({ // Remove trailing '@' sentinel from draft input setInput((prev) => (prev.endsWith("@") ? prev.slice(0, -1) : prev)); - // Track attachment separately - setAttachedImages((prev) => [...prev, filePath]); + // Track attachment separately, but avoid duplicates + setAttachedImages((prev) => + prev.includes(filePath) ? prev : [...prev, filePath], + ); if (process.env.DEBUG_TCI) { // eslint-disable-next-line no-console diff --git a/codex-cli/tests/attachment-preview.test.tsx b/codex-cli/tests/attachment-preview.test.tsx index 99b3f90f39..7e414076d4 100644 --- a/codex-cli/tests/attachment-preview.test.tsx +++ b/codex-cli/tests/attachment-preview.test.tsx @@ -71,7 +71,8 @@ describe("Chat input attachment preview", () => { await type(stdin, "@", flush); await type(stdin, "\r", flush); // choose first - expect(lastFrameStripped()).toContain("foo.png"); + const frame1 = lastFrameStripped(); + expect(frame1.match(/foo\.png/g)?.length ?? 0).toBe(1); await type(stdin, "\x15", flush); // Ctrl+U diff --git a/codex-cli/tests/backspace-delete-image.test.tsx b/codex-cli/tests/backspace-delete-image.test.tsx index 752e93ee44..d9475631b6 100644 --- a/codex-cli/tests/backspace-delete-image.test.tsx +++ b/codex-cli/tests/backspace-delete-image.test.tsx @@ -69,8 +69,8 @@ describe("Backspace deletes attached image", () => { await type(stdin, "@", flush); console.log('AFTER @', lastFrameStripped()); await type(stdin, "\r", flush); - console.log('FRAME1', lastFrameStripped()); - expect(lastFrameStripped()).toContain("bar.png"); + const frame1 = lastFrameStripped(); + expect(frame1.match(/bar\.png/g)?.length ?? 0).toBe(1); await type(stdin, "\x7f", flush);