Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4022,6 +4022,13 @@ function AppInner({
if (!snap) return;
setPendingChoice(null);
if (choice.kind === "custom") {
if (choice.text !== undefined) {
const gateId = pendingGateIdRef.current;
if (gateId !== null) {
pauseGate.resolve(gateId, { type: "text", text: choice.text });
}
return;
}
setStagedChoiceCustom(snap);
return;
}
Expand Down
64 changes: 60 additions & 4 deletions src/cli/ui/ChoiceConfirm.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
/** Modal picker for `ask_choice` — options + optional "type my own" escape hatch. */

import React from "react";
import { Box, Text } from "ink";
import React, { useRef, useState } from "react";
import { t } from "../../i18n/index.js";
import type { ChoiceOption } from "../../tools/choice.js";
import { SingleSelect } from "./Select.js";
import { ApprovalCard } from "./cards/ApprovalCard.js";
import { useKeystroke } from "./keystroke-context.js";
import { CARD, FG } from "./theme/tokens.js";
import { useTick } from "./ticker.js";

export type ChoiceConfirmChoice =
| { kind: "pick"; optionId: string }
| { kind: "custom" }
| { kind: "custom"; text?: string }
| { kind: "cancel" };

export interface ChoiceConfirmProps {
Expand All @@ -18,10 +22,14 @@ export interface ChoiceConfirmProps {
onChoose: (choice: ChoiceConfirmChoice) => void;
}

const CUSTOM_VALUE = "__custom__";
const CANCEL_VALUE = "__cancel__";
const CUSTOM_VALUE = "__custom__";

function ChoiceConfirmInner({ question, options, allowCustom, onChoose }: ChoiceConfirmProps) {
const [customValue, setCustomValue] = useState("");
const customValueRef = useRef("");
const tick = useTick();
const cursorOn = Math.floor(tick / 4) % 2 === 0;
const items: Array<{ value: string; label: string; hint?: string }> = options.map((opt) => ({
value: opt.id,
label: `${opt.id} · ${opt.title}`,
Expand All @@ -40,18 +48,66 @@ function ChoiceConfirmInner({ question, options, allowCustom, onChoose }: Choice
hint: t("choiceConfirm.cancelDesc"),
});

useKeystroke((ev) => {
if (!allowCustom) return;
if (
ev.upArrow ||
ev.downArrow ||
ev.leftArrow ||
ev.rightArrow ||
ev.return ||
ev.escape ||
ev.tab ||
ev.pageUp ||
ev.pageDown
) {
return;
}
if (ev.paste) {
const next = customValueRef.current + ev.input.replace(/\r?\n/g, " ");
customValueRef.current = next;
setCustomValue(next);
return;
}
if ((ev.backspace || ev.delete) && customValueRef.current.length > 0) {
const next = customValueRef.current.slice(0, -1);
customValueRef.current = next;
setCustomValue(next);
return;
}
if (ev.input && !ev.ctrl && !ev.meta) {
const next = customValueRef.current + ev.input;
customValueRef.current = next;
setCustomValue(next);
}
});

return (
<ApprovalCard tone="info" title={question} metaRight={t("shellConfirm.awaiting")}>
<SingleSelect
initialValue={options[0]?.id}
items={items}
onSubmit={(v) => {
if (v === CUSTOM_VALUE) onChoose({ kind: "custom" });
if (v === CUSTOM_VALUE) onChoose({ kind: "custom", text: customValueRef.current.trim() });
else if (v === CANCEL_VALUE) onChoose({ kind: "cancel" });
else onChoose({ kind: "pick", optionId: v });
}}
onCancel={() => onChoose({ kind: "cancel" })}
/>
{allowCustom ? (
<Box flexDirection="column" marginTop={1}>
<Text color={FG.sub}>{t("planFlow.modes.choice-custom.hint")}</Text>
<Box>
<Text color={CARD.plan.color} bold>
{"› "}
</Text>
<Text>{customValue}</Text>
<Text color={CARD.plan.color} bold>
{cursorOn ? "▍" : " "}
</Text>
</Box>
</Box>
) : null}
</ApprovalCard>
);
}
Expand Down
163 changes: 163 additions & 0 deletions tests/choice-confirm-render.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import React from "react";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ChoiceConfirm } from "../src/cli/ui/ChoiceConfirm.js";
import {
type KeystrokeHandler,
KeystrokeProvider,
makeKeyEvent,
} from "../src/cli/ui/keystroke-context.js";
import { setLanguageRuntime } from "../src/i18n/index.js";
import { render } from "./helpers/ink-test.js";

const OPTIONS = [
{ id: "A", title: "Use the default", summary: "Keep the normal path" },
{ id: "B", title: "Try another route", summary: "Use the fallback" },
];

async function nextFrame(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 0));
}

class FakeReader {
private handlers = new Set<KeystrokeHandler>();

start(): void {}

subscribe(handler: KeystrokeHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}

emit(overrides: Parameters<typeof makeKeyEvent>[0]): void {
const ev = makeKeyEvent(overrides);
for (const handler of [...this.handlers]) handler(ev);
}
}

describe("ChoiceConfirm", () => {
beforeEach(() => setLanguageRuntime("EN"));
afterEach(() => setLanguageRuntime("EN"));

it("renders custom answer input inline when allowCustom is true", () => {
const { lastFrame, unmount } = render(
<ChoiceConfirm
question="Pick one"
options={OPTIONS}
allowCustom={true}
onChoose={() => {}}
/>,
);
const out = lastFrame() ?? "";
unmount();

expect(out).toContain("Free-form reply");
expect(out).toContain("Let me type my own answer");
expect(out).toContain("›");
});

it("submits the highlighted option even when custom text is typed", async () => {
let picked: unknown = null;
const { stdin, unmount } = render(
<ChoiceConfirm
question="Pick one"
options={OPTIONS}
allowCustom={true}
onChoose={(choice) => {
picked = choice;
}}
/>,
);

stdin.write("custom route");
await nextFrame();
stdin.write("\r");
await nextFrame();
unmount();

expect(picked).toEqual({ kind: "pick", optionId: "A" });
});

it("submits typed custom text when the custom answer row is highlighted", async () => {
let picked: unknown = null;
const { stdin, unmount } = render(
<ChoiceConfirm
question="Pick one"
options={OPTIONS}
allowCustom={true}
onChoose={(choice) => {
picked = choice;
}}
/>,
);

stdin.write("custom route");
await nextFrame();
stdin.write("\x1b[B\x1b[B");
await nextFrame();
stdin.write("\r");
await nextFrame();
unmount();

expect(picked).toEqual({ kind: "custom", text: "custom route" });
});

it("submits latest custom text when input and enter arrive in the same chunk", async () => {
let picked: unknown = null;
const reader = new FakeReader();
const { unmount } = render(
<KeystrokeProvider reader={reader}>
<ChoiceConfirm
question="Pick one"
options={OPTIONS}
allowCustom={true}
onChoose={(choice) => {
picked = choice;
}}
/>
</KeystrokeProvider>,
);

await nextFrame();
reader.emit({ downArrow: true });
reader.emit({ downArrow: true });
await nextFrame();
reader.emit({ input: "custom route" });
reader.emit({ return: true });
await nextFrame();
unmount();

expect(picked).toEqual({ kind: "custom", text: "custom route" });
});

it.each([
["backspace", { backspace: true }],
["delete", { delete: true }],
] as const)("applies same-batch %s before submitting custom text", async (_name, eraseKey) => {
let picked: unknown = null;
const reader = new FakeReader();
const { unmount } = render(
<KeystrokeProvider reader={reader}>
<ChoiceConfirm
question="Pick one"
options={OPTIONS}
allowCustom={true}
onChoose={(choice) => {
picked = choice;
}}
/>
</KeystrokeProvider>,
);

await nextFrame();
reader.emit({ downArrow: true });
reader.emit({ downArrow: true });
await nextFrame();
reader.emit({ input: "abc" });
reader.emit(eraseKey);
reader.emit({ return: true });
await nextFrame();
unmount();

expect(picked).toEqual({ kind: "custom", text: "ab" });
});
});