From 26dcd1f0eec84107975d9fa079ee3fea85674782 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 10 Sep 2026 10:05:51 +0700 Subject: [PATCH] feat(configure): surface Codex and OpenCode in `codevhub install` / `config` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the temporary hide from #223. The Codex and OpenCode rows return to ToolSelect, the single picker shared by `codevhub install` and `codevhub config`, so new users can select and configure them again. Nothing downstream changes: the Tool union, configureCodex / configureOpenCode, detection, restore, update and the launch commands were never touched by the hide. This removes the `hidden` flag and the derived VISIBLE_TOOLS list, since TOOLS is once again the rendered set. Why now: `codevhub readiness` (#197) offers Claude Code, Codex and OpenCode, but gates each on the agent being CoDev-configured and tells the user to "run `codevhub install`, select this agent". With the rows hidden, that instruction was impossible to follow for anyone who installed after #223 — the OpenCode readiness path and its `--agent opencode` flag were unreachable. Tests: restore the extension-row navigation counts (rows shift down two positions), replace the "hides Codex and OpenCode" assertion with the positive render check, and un-skip the two Codex-selection integration tests in InstallApp.test.tsx — they pass unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0178nPaQqSGVe7Y1fR2ovDTK --- src/components/ToolSelect.tsx | 31 +++++----------- tests/InstallApp.test.tsx | 28 ++++++--------- tests/components/ToolSelect.test.tsx | 54 ++++++++++++++-------------- 3 files changed, 47 insertions(+), 66 deletions(-) diff --git a/src/components/ToolSelect.tsx b/src/components/ToolSelect.tsx index 3e7ea38..2d46f69 100644 --- a/src/components/ToolSelect.tsx +++ b/src/components/ToolSelect.tsx @@ -14,36 +14,23 @@ export type ToolSelectSentinel = | typeof CONTINUE_SENTINEL; export type ToolSelectValue = Tool | ToolSelectSentinel; -const TOOLS: { - label: string; - value: ToolSelectValue; - locked?: boolean; - hidden?: boolean; -}[] = [ +const TOOLS: { label: string; value: ToolSelectValue; locked?: 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" }, - // 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: "Codex", value: "codex" }, + { label: "OpenCode", value: "opencode" }, { 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[] = VISIBLE_TOOLS.filter( - (t) => t.locked, -).map((t) => t.value); +const LOCKED_VALUES: ToolSelectValue[] = TOOLS.filter((t) => t.locked).map( + (t) => t.value, +); interface ToolSelectProps { onConfirm: (tools: ToolSelectValue[]) => void; @@ -64,9 +51,9 @@ export function ToolSelect({ if (key.upArrow) { setCursor((c) => Math.max(0, c - 1)); } else if (key.downArrow) { - setCursor((c) => Math.min(VISIBLE_TOOLS.length - 1, c + 1)); + setCursor((c) => Math.min(TOOLS.length - 1, c + 1)); } else if (input === " ") { - const tool = VISIBLE_TOOLS[cursor]; + const tool = TOOLS[cursor]; // Locked rows (CoDev Code) are always included and can't be toggled. if (!tool || tool.locked) return; setSelected((prev) => { @@ -92,7 +79,7 @@ export function ToolSelect({ return ( - {VISIBLE_TOOLS.map((tool, i) => { + {TOOLS.map((tool, i) => { const isSelected = tool.locked || selected.has(tool.value); const isCursor = !readOnly && cursor === i; return ( diff --git a/tests/InstallApp.test.tsx b/tests/InstallApp.test.tsx index 285ab3c..8199dbe 100644 --- a/tests/InstallApp.test.tsx +++ b/tests/InstallApp.test.tsx @@ -531,9 +531,7 @@ describe("InstallApp fail-stop invariant", () => { expect(history).toContain("Happy coding"); }); - // 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 () => { + test("Codex selection routes to configureCodex and reaches done", async () => { stubExecFile(() => ({ stdout: "ok" })); stubModels(); vi.spyOn(auth, "login").mockResolvedValue(fakeAuth()); @@ -1093,10 +1091,9 @@ describe("InstallApp fail-stop invariant", () => { const { stdin, frames } = render(); - // Pick the Claude Code (extension) row (3rd, index 2 — Codex/OpenCode - // are hidden, so it sits right below Claude Code). + // Pick the Claude Code (extension) row (5th, index 4). await waitForFrame(frames, "Select the AI agent(s) to install"); - for (let i = 0; i < 2; i++) { + for (let i = 0; i < 4; i++) { stdin.write("\x1B[B"); await new Promise((r) => setTimeout(r, 30)); } @@ -1126,9 +1123,8 @@ describe("InstallApp fail-stop invariant", () => { }); 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) (3rd - // row — Codex/OpenCode are hidden), then VS Code in the merged sub- - // select. Asserts: + // Picks Claude Code CLI (2nd row) AND Claude Code (extension) (5th + // row), 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. @@ -1157,14 +1153,15 @@ describe("InstallApp fail-stop invariant", () => { const { stdin, frames } = render(); await waitForFrame(frames, "Select the AI agent(s) to install"); - // Row 1 (Claude Code CLI) — toggle, then arrow down to row 2 (Claude - // Code (extension), now directly below) and toggle. + // Row 1 (Claude Code CLI) — toggle, then arrow down to row 4 and toggle. stdin.write("\x1B[B"); await new Promise((r) => setTimeout(r, 30)); stdin.write(" "); await new Promise((r) => setTimeout(r, 30)); - stdin.write("\x1B[B"); - 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(" "); await new Promise((r) => setTimeout(r, 30)); stdin.write("\r"); @@ -1230,10 +1227,7 @@ describe("InstallApp fail-stop invariant", () => { }); }); - // 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 () => { + test("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. diff --git a/tests/components/ToolSelect.test.tsx b/tests/components/ToolSelect.test.tsx index afac5c9..ed4fe4e 100644 --- a/tests/components/ToolSelect.test.tsx +++ b/tests/components/ToolSelect.test.tsx @@ -7,29 +7,19 @@ afterEach(() => { }); describe("ToolSelect", () => { - test("renders the visible tool options", () => { + test("renders all tool options", () => { const onConfirm = vi.fn(); const { lastFrame } = render(); 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(); - - 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 @@ -38,10 +28,8 @@ describe("ToolSelect", () => { const onConfirm = vi.fn(); const { stdin } = render(); - // 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++) { + // Four down-arrows to reach the 5th (Claude Code (extension)) row. + for (let i = 0; i < 4; i++) { stdin.write("\x1B[B"); await new Promise((r) => setTimeout(r, 50)); } @@ -57,10 +45,8 @@ describe("ToolSelect", () => { const onConfirm = vi.fn(); const { stdin } = render(); - // 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++) { + // Five down-arrows to reach the 6th (Continue (extension)) row. + for (let i = 0; i < 5; i++) { stdin.write("\x1B[B"); await new Promise((r) => setTimeout(r, 50)); } @@ -77,8 +63,8 @@ describe("ToolSelect", () => { const { lastFrame } = render(); const output = lastFrame() ?? ""; - // Exactly one filled box — the always-on CoDev Code row — and the - // remaining optional agents render unchecked. + // Exactly one filled box — the always-on CoDev Code row — and the five + // optional agents render unchecked. expect((output.match(/■/g) ?? []).length).toBe(1); expect(output).toContain("□"); expect(output).toContain("(always installed)"); @@ -153,9 +139,7 @@ describe("ToolSelect", () => { const onConfirm = vi.fn(); const { stdin } = render(); - // 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. + // Down to Claude Code (row 1), select, down to Codex (row 2), select. stdin.write("\x1B[B"); await new Promise((r) => setTimeout(r, 50)); stdin.write(" "); @@ -172,10 +156,26 @@ describe("ToolSelect", () => { expect(onConfirm).toHaveBeenCalledWith([ "codev-code", "claude-code", - "claude-code-ext", + "codex", ]); }); + test("can select Codex by moving cursor down twice", async () => { + const onConfirm = vi.fn(); + const { stdin } = render(); + + 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();