Skip to content
Merged
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
31 changes: 22 additions & 9 deletions src/components/ToolSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,36 @@ export type ToolSelectSentinel =
| typeof CONTINUE_SENTINEL;
export type ToolSelectValue = Tool | ToolSelectSentinel;

const TOOLS: { label: string; value: ToolSelectValue; locked?: boolean }[] = [
const TOOLS: {
label: string;
value: ToolSelectValue;
locked?: boolean;
hidden?: boolean;
}[] = [
// CoDev Code is the flagship agent — always installed and configured, so its
// row is shown pre-checked and can't be toggled off. Kept at index 0 so the
// optional agents keep their positions.
{ label: "CoDev Code", value: "codev-code", locked: true },
{ label: "Claude Code", value: "claude-code" },
{ label: "Codex", value: "codex" },
{ label: "OpenCode", value: "opencode" },
// Codex and OpenCode are temporarily withheld from the selection UI. All the
// underlying configure/install/update logic still handles them end-to-end —
// they're only hidden from users until we're ready to surface them. Flip
// `hidden` off (or delete it) to bring the rows back.
{ label: "Codex", value: "codex", hidden: true },
{ label: "OpenCode", value: "opencode", hidden: true },
{ label: "Claude Code (extension)", value: CLAUDE_CODE_EXT_SENTINEL },
{ label: "Continue (extension)", value: CONTINUE_SENTINEL },
];

// Rows actually rendered and navigable. Hidden tools are dropped from the UI
// only; everything downstream (Configure, restore, update) is untouched.
const VISIBLE_TOOLS = TOOLS.filter((t) => !t.hidden);

// Locked tools are emitted on every confirm regardless of the mutable
// selection, and always lead the emitted list.
const LOCKED_VALUES: ToolSelectValue[] = TOOLS.filter((t) => t.locked).map(
(t) => t.value,
);
const LOCKED_VALUES: ToolSelectValue[] = VISIBLE_TOOLS.filter(
(t) => t.locked,
).map((t) => t.value);

interface ToolSelectProps {
onConfirm: (tools: ToolSelectValue[]) => void;
Expand All @@ -51,9 +64,9 @@ export function ToolSelect({
if (key.upArrow) {
setCursor((c) => Math.max(0, c - 1));
} else if (key.downArrow) {
setCursor((c) => Math.min(TOOLS.length - 1, c + 1));
setCursor((c) => Math.min(VISIBLE_TOOLS.length - 1, c + 1));
} else if (input === " ") {
const tool = TOOLS[cursor];
const tool = VISIBLE_TOOLS[cursor];
// Locked rows (CoDev Code) are always included and can't be toggled.
if (!tool || tool.locked) return;
setSelected((prev) => {
Expand All @@ -79,7 +92,7 @@ export function ToolSelect({

return (
<Box flexDirection="column">
{TOOLS.map((tool, i) => {
{VISIBLE_TOOLS.map((tool, i) => {
const isSelected = tool.locked || selected.has(tool.value);
const isCursor = !readOnly && cursor === i;
return (
Expand Down
28 changes: 17 additions & 11 deletions tests/InstallApp.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@
await waitForFrame(frames, "Happy coding");

const history = allFrames(frames);
expect(history).toContain("Happy coding");

Check failure on line 424 in tests/InstallApp.test.tsx

View workflow job for this annotation

GitHub Actions / matrix (windows-latest)

tests/InstallApp.test.tsx > InstallApp fail-stop invariant > successful flow reaches the done screen

AssertionError: expected ' ▄\n █▀▀▀ █▀▀█ █▀▀█ █▀▀█…' to contain 'Happy coding' - Expected + Received - Happy coding + ▄ + █▀▀▀ █▀▀█ █▀▀█ █▀▀█ █ █ + █ █ █ █ █ █▀▀▀ █ █ + ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ + AI Coding Agent Hub v0.5.3 + + ┌ CoDev + + ▄ + █▀▀▀ █▀▀█ █▀▀█ █▀▀█ █ █ + █ █ █ █ █ █▀▀▀ █ █ + ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ + AI Coding Agent Hub v0.5.3 + + ┌ CoDev + │ + ◇ Checking your environment + │ ✓ 2 environment checks passed + │ + ◆ Select the AI agent(s) to install (↑/↓ to move, Space to select, Enter to confirm) + │ ■ CoDev Code (always installed) + │ □ Claude Code + │ □ Claude Code (extension) + │ □ Continue (extension) + + ▄ + █▀▀▀ █▀▀█ █▀▀█ █▀▀█ █ █ + █ █ █ █ █ █▀▀▀ █ █ + ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ + AI Coding Agent Hub v0.5.3 + + ┌ CoDev + │ + ◇ Checking your environment + │ ✓ 2 environment checks passed + │ + ◆ Select the AI agent(s) to install (↑/↓ to move, Space to select, Enter to confirm) + │ ■ CoDev Code (always installed) + │ □ Claude Code + │ □ Claude Code (extension) + │ □ Continue (extension) + + ▄ + █▀▀▀ █▀▀█ █▀▀█ █▀▀█ █ █ + █ █ █ █ █ █▀▀▀ █ █ + ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ + AI Coding Agent Hub v0.5.3 + + ┌ CoDev + │ + ◇ Checking your environment + │ ✓ 2 environment checks passed + │ + ◆ Select the AI agent(s) to install (↑/↓ to move, Space to select, Enter to confirm) + │ ■ CoDev Code (always installed) + │ ■ Claude Code + │ □ Claude Code (extension) + │ □ Continue (extension) + + ▄ + █▀▀▀ █▀▀█ █▀▀█ █▀▀█ █ █ + █ █ █ █ █ █▀▀▀ █ █ + ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ + AI Coding Agent Hub v0.5.3 + + ┌ CoDev + │ + ◇ Checking your environment + │ ✓ 2 environment checks passed + │ + ◇ Select the AI agent(s) to install + │ ■ CoDev Code (always installed) + │ ■ Claude Code + │ □ Claude Code (extension) + │ □ Continue (extension) + │ + ◆ Heads up — CoDev will change your settings. + │ To revert to your pre-CoDev state, run codevhub restore codev and codevhub restore claude. + │ + │ Continue? [y/N] ▌ + + ▄ + █▀▀▀ █▀▀█ █▀▀█ █▀▀█ █ █ + █ █ █ █ █ █▀▀▀ █ █ + ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ + AI Coding Agent Hub v0.5.3 + + ┌ CoDev + │ + ◇ Checking your environment + │ ✓ 2 environment checks passed + │ + ◇ Select the AI agent(s) to install + │ ■ CoDev Code (always installed) + │ ■ Claude Code + │ □ Claude Code (extension) + │ □ Continue (extension) + │ + ◇ Heads up — CoDev will change your settings. + │ To revert to your pre-CoDev state, run codevhub restore codev and codevhub restore claude. + │ + ◆ Login + │ ⠋ Starting sign-in... + + ▄ + █▀▀▀ █▀▀█ █▀▀█ █▀▀█ █ █ + █ █ █ █ █ █▀▀▀ █ █ + ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ + AI Coding Agent Hub v0.5.3 + + ┌ CoDev + │ + ◇ Checking your environment + │ ✓ 2 environment checks passed + │ + ◇ Select the AI agent(s) to install + │ ■ CoDev Code (always installed) + │ ■ Claude Code + │ □ Claude Code (extension) + │ □ Continue (extension) + │ + ◇ Heads up — CoDev will change your settings. + ��
expect(configureSpy).toHaveBeenCalledWith({
apiKey: "sk-test-123",
model: "m-alpha",
Expand Down Expand Up @@ -504,7 +504,9 @@
expect(history).toContain("Happy coding");
});

test("Codex selection routes to configureCodex and reaches done", async () => {
// Parked while Codex is hidden from ToolSelect (still fully wired downstream —
// just not user-selectable). Un-skip when the Codex row is surfaced again.
test.skip("Codex selection routes to configureCodex and reaches done", async () => {
stubExecFile(() => ({ stdout: "ok" }));
stubModels();
vi.spyOn(auth, "login").mockResolvedValue(fakeAuth());
Expand Down Expand Up @@ -1064,9 +1066,10 @@

const { stdin, frames } = render(<InstallApp />);

// Pick the Claude Code (extension) row (5th, index 4).
// Pick the Claude Code (extension) row (3rd, index 2 — Codex/OpenCode
// are hidden, so it sits right below Claude Code).
await waitForFrame(frames, "Select the AI agent(s) to install");
for (let i = 0; i < 4; i++) {
for (let i = 0; i < 2; i++) {
stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 30));
}
Expand Down Expand Up @@ -1096,8 +1099,9 @@
});

test("Claude Code CLI + extension share the backup kind: single configure call, both install tasks scheduled", async () => {
// Picks Claude Code CLI (2nd row) AND Claude Code (extension) (5th
// row), then VS Code in the merged sub-select. Asserts:
// Picks Claude Code CLI (2nd row) AND Claude Code (extension) (3rd
// row — Codex/OpenCode are hidden), then VS Code in the merged sub-
// select. Asserts:
// - `configureClaudeCode` runs exactly once (shared BackupKind).
// - Both the npm install task (@anthropic-ai/claude-code) and the
// extension install task (anthropic.claude-code (VS Code)) appear.
Expand Down Expand Up @@ -1126,15 +1130,14 @@
const { stdin, frames } = render(<InstallApp />);

await waitForFrame(frames, "Select the AI agent(s) to install");
// Row 1 (Claude Code CLI) — toggle, then arrow down to row 4 and toggle.
// Row 1 (Claude Code CLI) — toggle, then arrow down to row 2 (Claude
// Code (extension), now directly below) and toggle.
stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 30));
stdin.write(" ");
await new Promise((r) => setTimeout(r, 30));
for (let i = 0; i < 3; i++) {
stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 30));
}
stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 30));
stdin.write(" ");
await new Promise((r) => setTimeout(r, 30));
stdin.write("\r");
Expand Down Expand Up @@ -1200,7 +1203,10 @@
});
});

test("partial install failure: survivor advances to Configure, failed tool is dropped", async () => {
// Parked while Codex is hidden from ToolSelect: this exercises the
// survivor-advances fail-stop via a Codex selection that's no longer
// user-reachable. Un-skip when the Codex row is surfaced again.
test.skip("partial install failure: survivor advances to Configure, failed tool is dropped", async () => {
// User selects both Claude Code and Codex. The codex npm install
// hard-fails ("disk full"); the claude-code one succeeds. Pre-change
// behavior was to park at install-failed and force the user to Ctrl-C.
Expand Down
54 changes: 27 additions & 27 deletions tests/components/ToolSelect.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,29 @@ afterEach(() => {
});

describe("ToolSelect", () => {
test("renders all tool options", () => {
test("renders the visible tool options", () => {
const onConfirm = vi.fn();
const { lastFrame } = render(<ToolSelect onConfirm={onConfirm} />);

const output = lastFrame() ?? "";
expect(output).toContain("CoDev Code");
expect(output).toContain("Claude Code");
expect(output).toContain("OpenCode");
expect(output).toContain("Codex");
expect(output).toContain("Claude Code (extension)");
expect(output).toContain("Continue (extension)");
});

test("hides Codex and OpenCode from the selection UI", () => {
// Both are temporarily withheld from users — still fully wired downstream,
// just not rendered or selectable here. Remove the `hidden` flag in
// ToolSelect's TOOLS array to bring them back.
const onConfirm = vi.fn();
const { lastFrame } = render(<ToolSelect onConfirm={onConfirm} />);

const output = lastFrame() ?? "";
expect(output).not.toContain("Codex");
expect(output).not.toContain("OpenCode");
});

test("emits the `claude-code-ext` sentinel when the Claude Code (extension) row is picked", async () => {
// The extension rows are editor-agnostic; the merged editor sub-
// select runs next. ToolSelect emits a sentinel that InstallApp
Expand All @@ -28,8 +38,10 @@ describe("ToolSelect", () => {
const onConfirm = vi.fn();
const { stdin } = render(<ToolSelect onConfirm={onConfirm} />);

// Four down-arrows to reach the 5th (Claude Code (extension)) row.
for (let i = 0; i < 4; i++) {
// Two down-arrows to reach the 3rd (Claude Code (extension)) row —
// Codex and OpenCode are hidden, so the extension rows sit right below
// Claude Code.
for (let i = 0; i < 2; i++) {
stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 50));
}
Expand All @@ -45,8 +57,10 @@ describe("ToolSelect", () => {
const onConfirm = vi.fn();
const { stdin } = render(<ToolSelect onConfirm={onConfirm} />);

// Five down-arrows to reach the 6th (Continue (extension)) row.
for (let i = 0; i < 5; i++) {
// Three down-arrows to reach the 4th (Continue (extension)) row —
// Codex and OpenCode are hidden, so it sits directly below the Claude
// Code (extension) row.
for (let i = 0; i < 3; i++) {
stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 50));
}
Expand All @@ -63,8 +77,8 @@ describe("ToolSelect", () => {
const { lastFrame } = render(<ToolSelect onConfirm={onConfirm} />);

const output = lastFrame() ?? "";
// Exactly one filled box — the always-on CoDev Code row — and the five
// optional agents render unchecked.
// Exactly one filled box — the always-on CoDev Code row — and the
// remaining optional agents render unchecked.
expect((output.match(/■/g) ?? []).length).toBe(1);
expect(output).toContain("□");
expect(output).toContain("(always installed)");
Expand Down Expand Up @@ -139,7 +153,9 @@ describe("ToolSelect", () => {
const onConfirm = vi.fn();
const { stdin } = render(<ToolSelect onConfirm={onConfirm} />);

// Down to Claude Code (row 1), select, down to Codex (row 2), select.
// Down to Claude Code (row 1), select, down to Claude Code (extension)
// (row 2), select. Codex/OpenCode are hidden, so the extension row is
// the next selectable option below Claude Code.
stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 50));
stdin.write(" ");
Expand All @@ -156,26 +172,10 @@ describe("ToolSelect", () => {
expect(onConfirm).toHaveBeenCalledWith([
"codev-code",
"claude-code",
"codex",
"claude-code-ext",
]);
});

test("can select Codex by moving cursor down twice", async () => {
const onConfirm = vi.fn();
const { stdin } = render(<ToolSelect onConfirm={onConfirm} />);

stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 50));
stdin.write("\x1B[B");
await new Promise((r) => setTimeout(r, 50));
stdin.write(" ");
await new Promise((r) => setTimeout(r, 50));
stdin.write("\r");
await new Promise((r) => setTimeout(r, 50));

expect(onConfirm).toHaveBeenCalledWith(["codev-code", "codex"]);
});

test("can deselect a tool", async () => {
const onConfirm = vi.fn();
const { lastFrame, stdin } = render(<ToolSelect onConfirm={onConfirm} />);
Expand Down
Loading