ctr-g to clear images

This commit is contained in:
Eason Goodale
2025-04-26 23:16:47 -07:00
parent 1a0f4a5e93
commit 41ef530683
4 changed files with 66 additions and 8 deletions

View File

@@ -150,6 +150,15 @@ export default function TerminalChatInput({
// even though the real TTY environment works fine. Mirroring the
// behaviour for the raw data path keeps production logic untouched
// while ensuring the unit tests observe the same outcome.
// Ctrl+G (0x07) clear only attached images, keep draft text intact.
if (str === "\x07" && attachedImages.length > 0) {
setAttachedImages([]);
return; // prevent further handling
}
// Ctrl+U (0x15) traditional “clear line”. We allow Ink's TextInput
// default behaviour to wipe the draft, but we ALSO clear attachments so
// the two stay in sync.
if (str === "\x15" && attachedImages.length > 0) {
setAttachedImages([]);
}
@@ -198,7 +207,10 @@ export default function TerminalChatInput({
}
// Slash command navigation: up/down to select, Tab to cycle, Enter to run.
if (!confirmationPrompt && !loading && input.trim().startsWith("/")) {
const trimmedSlash = input.trim();
const isSlashCmd = /^[\/][a-zA-Z]+$/.test(trimmedSlash);
if (!confirmationPrompt && !loading && isSlashCmd) {
const prefix = input.trim();
const matches = SLASH_COMMANDS.filter((cmd: SlashCommand) =>
cmd.command.startsWith(prefix),
@@ -739,7 +751,7 @@ export default function TerminalChatInput({
}
return (
<Box flexDirection="column" paddingX={1} marginBottom={1}>
<Text color="gray">attached images (ctrl+u to clear):</Text>
<Text color="gray">attached images (ctrl+g to clear):</Text>
{attachedImages.map((p, i) => (
<Text key={i} color="cyan">{` ${path.basename(p)}`}</Text>
))}
@@ -769,7 +781,8 @@ export default function TerminalChatInput({
showCursor
value={input}
onChange={(rawValue) => {
let value = rawValue; // will be replaced after extraction
// Strip any raw control-G char so it never shows up.
let value = rawValue.replace(/\x07/g, "");
// --------------------------------------------------------
// Detect freshly-dropped image paths _while the user is
@@ -778,7 +791,14 @@ export default function TerminalChatInput({
const { paths: newlyDropped, text: cleaned } = extractImagePaths(rawValue);
value = cleaned; // do not trim spaces preserve exact typing
value = cleaned;
// If the extraction removed everything (e.g., user only pasted
// a file path followed by a space) we dont want to leave a
// dangling "/ " or other whitespace artefacts in the draft.
if (value.trim().length === 0) {
value = "";
}
if (newlyDropped.length > 0) {
setAttachedImages((prev) => {
@@ -816,7 +836,12 @@ export default function TerminalChatInput({
)}
</Box>
{/* Slash command autocomplete suggestions */}
{input.trim().startsWith("/") && (
{(() => {
const trimmed = input.trim();
const showSlash =
trimmed.startsWith("/") && /^[\/][a-zA-Z]+$/.test(trimmed);
return showSlash;
})() && (
<Box flexDirection="column" paddingX={2} marginBottom={1}>
{SLASH_COMMANDS.filter((cmd: SlashCommand) =>
cmd.command.startsWith(input.trim()),

View File

@@ -70,5 +70,11 @@ export function extractImagePaths(input: string): ExtractResult {
return "";
});
// Remove any leftover leading slash that was immediately followed by the
// matched path (e.g. "/Users/foo.png → '/ '" after replacement). We only
// strip it when it's followed by whitespace or end-of-string so normal
// typing like "/help" is untouched.
text = text.replace(/(^|\s)\/(?=\s|$)/g, "$1");
return { paths, text };
}

View File

@@ -80,7 +80,7 @@ describe("Chat input attachment preview", () => {
const frame1 = lastFrameStripped();
expect(frame1.match(/foo\.png/g)?.length ?? 0).toBe(1);
await type(stdin, "\x15", flush); // Ctrl+U
await type(stdin, "\x07", flush); // Ctrl+G (clear images only)
expect(lastFrameStripped()).not.toContain("foo.png");

View File

@@ -13,7 +13,8 @@ import { renderTui } from "./ui-test-helpers.js";
// Mocks keep in sync with other TerminalChatInput UI tests
// ---------------------------------------------------------------------------
const createInputItemMock = vi.fn(async (_text: string, _imgs: Array<string>) => ({}));
// mock without type annotations to avoid Vitest transform TS errors in JS test
const createInputItemMock = vi.fn(async () => ({}));
vi.mock("../src/utils/input-utils.js", () => ({
createInputItem: createInputItemMock,
@@ -100,10 +101,36 @@ describe("Drag-and-drop image attachment", () => {
// createInputItem should have been called with the dropped image path
expect(createInputItemMock).toHaveBeenCalled();
const lastCall = createInputItemMock.mock.calls.at(-1);
const calls = createInputItemMock.mock.calls;
const lastCall = calls[calls.length - 1];
expect(lastCall?.[1]).toEqual(["dropped.png"]);
cleanup();
process.chdir(orig);
});
it("does NOT show slash-command overlay for absolute paths", async () => {
const orig = process.cwd();
process.chdir(TMP);
const { stdin, flush, lastFrameStripped, cleanup } = renderTui(
React.createElement(TerminalChatInput, props()),
);
await flush();
// absolute path starting with '/'
const absPath = path.join(TMP, "dropped.png");
await type(stdin, `${absPath} `, flush);
await flush();
const frame = lastFrameStripped();
// Should contain attachment preview but NOT typical slash-command suggestion like "/help"
expect(frame).toContain("dropped.png");
expect(frame).not.toContain("/help");
cleanup();
process.chdir(orig);
});
});