@akiojin/gwt 4.12.0 → 4.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/bin/gwt.js +35 -9
  2. package/dist/claude.d.ts.map +1 -1
  3. package/dist/claude.js +67 -14
  4. package/dist/claude.js.map +1 -1
  5. package/dist/cli/ui/App.solid.d.ts.map +1 -1
  6. package/dist/cli/ui/App.solid.js +40 -8
  7. package/dist/cli/ui/App.solid.js.map +1 -1
  8. package/dist/cli/ui/components/solid/WizardController.d.ts.map +1 -1
  9. package/dist/cli/ui/components/solid/WizardController.js +48 -2
  10. package/dist/cli/ui/components/solid/WizardController.js.map +1 -1
  11. package/dist/cli/ui/components/solid/WizardSteps.d.ts +5 -0
  12. package/dist/cli/ui/components/solid/WizardSteps.d.ts.map +1 -1
  13. package/dist/cli/ui/components/solid/WizardSteps.js +29 -6
  14. package/dist/cli/ui/components/solid/WizardSteps.js.map +1 -1
  15. package/dist/cli/ui/utils/installedVersionCache.d.ts +33 -0
  16. package/dist/cli/ui/utils/installedVersionCache.d.ts.map +1 -0
  17. package/dist/cli/ui/utils/installedVersionCache.js +59 -0
  18. package/dist/cli/ui/utils/installedVersionCache.js.map +1 -0
  19. package/dist/cli/ui/utils/modelOptions.d.ts.map +1 -1
  20. package/dist/cli/ui/utils/modelOptions.js +16 -0
  21. package/dist/cli/ui/utils/modelOptions.js.map +1 -1
  22. package/dist/codex.d.ts.map +1 -1
  23. package/dist/codex.js +63 -22
  24. package/dist/codex.js.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +5 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/logging/reader.d.ts +2 -1
  29. package/dist/logging/reader.d.ts.map +1 -1
  30. package/dist/logging/reader.js +37 -1
  31. package/dist/logging/reader.js.map +1 -1
  32. package/dist/opentui/index.solid.js +298 -264
  33. package/dist/services/codingAgentResolver.d.ts.map +1 -1
  34. package/dist/services/codingAgentResolver.js +8 -6
  35. package/dist/services/codingAgentResolver.js.map +1 -1
  36. package/dist/shared/codingAgentConstants.d.ts +3 -0
  37. package/dist/shared/codingAgentConstants.d.ts.map +1 -1
  38. package/dist/shared/codingAgentConstants.js +66 -0
  39. package/dist/shared/codingAgentConstants.js.map +1 -1
  40. package/dist/utils/bun-runtime.d.ts +12 -0
  41. package/dist/utils/bun-runtime.d.ts.map +1 -0
  42. package/dist/utils/bun-runtime.js +13 -0
  43. package/dist/utils/bun-runtime.js.map +1 -0
  44. package/dist/utils/session/parsers/codex.d.ts.map +1 -1
  45. package/dist/utils/session/parsers/codex.js +0 -1
  46. package/dist/utils/session/parsers/codex.js.map +1 -1
  47. package/package.json +1 -1
  48. package/src/claude.ts +79 -15
  49. package/src/cli/ui/App.solid.tsx +55 -6
  50. package/src/cli/ui/__tests__/solid/AppSolid.cleanup.test.tsx +91 -0
  51. package/src/cli/ui/__tests__/solid/components/WizardController.test.tsx +63 -0
  52. package/src/cli/ui/__tests__/solid/components/WizardSteps.test.tsx +91 -1
  53. package/src/cli/ui/components/solid/WizardController.tsx +65 -1
  54. package/src/cli/ui/components/solid/WizardSteps.tsx +55 -10
  55. package/src/cli/ui/utils/__tests__/installedVersionCache.test.ts +46 -0
  56. package/src/cli/ui/utils/installedVersionCache.ts +84 -0
  57. package/src/cli/ui/utils/modelOptions.test.ts +6 -0
  58. package/src/cli/ui/utils/modelOptions.ts +16 -0
  59. package/src/codex.ts +78 -22
  60. package/src/index.ts +5 -0
  61. package/src/logging/reader.ts +48 -1
  62. package/src/services/codingAgentResolver.ts +12 -5
  63. package/src/shared/codingAgentConstants.ts +73 -0
  64. package/src/utils/bun-runtime.ts +29 -0
  65. package/src/utils/session/parsers/codex.ts +0 -1
@@ -486,6 +486,97 @@ describe("AppSolid unsafe selection confirm", () => {
486
486
  }
487
487
  });
488
488
 
489
+ it("shows confirm when safety check is pending", async () => {
490
+ let resolveStatus: ((value: unknown[]) => void) | null = null;
491
+ const pendingPromise = new Promise<unknown[]>((resolve) => {
492
+ resolveStatus = resolve;
493
+ });
494
+ const getCleanupStatusMock = mock(async () => pendingPromise);
495
+
496
+ mock.module?.("../../../../worktree.js", () => ({
497
+ listAdditionalWorktrees: mock(async () => []),
498
+ repairWorktrees: mock(async () => ({
499
+ repairedCount: 0,
500
+ failedCount: 0,
501
+ failures: [],
502
+ })),
503
+ removeWorktree: mock(async () => {}),
504
+ getCleanupStatus: getCleanupStatusMock,
505
+ isProtectedBranchName: mock(() => false),
506
+ }));
507
+
508
+ mock.module?.("../../../../git.js", () => ({
509
+ getRepositoryRoot: mock(async () => "/repo"),
510
+ getAllBranches: mock(async () => []),
511
+ getLocalBranches: mock(async () => []),
512
+ getCurrentBranch: mock(async () => "main"),
513
+ deleteBranch: mock(async () => {}),
514
+ }));
515
+
516
+ mock.module?.("../../../../config/index.js", () => ({
517
+ getConfig: mock(async () => ({ defaultBaseBranch: "main" })),
518
+ getLastToolUsageMap: mock(async () => new Map()),
519
+ loadSession: mock(async () => null),
520
+ }));
521
+
522
+ mock.module?.("../../../../config/tools.js", () => ({
523
+ getAllCodingAgents: mock(async () => [
524
+ { id: "codex-cli", displayName: "Codex CLI" },
525
+ ]),
526
+ }));
527
+
528
+ mock.module?.("../../../../config/profiles.js", () => ({
529
+ loadProfiles: mock(async () => ({ profiles: {}, activeProfile: null })),
530
+ createProfile: mock(async () => {}),
531
+ updateProfile: mock(async () => {}),
532
+ deleteProfile: mock(async () => {}),
533
+ setActiveProfile: mock(async () => {}),
534
+ }));
535
+
536
+ const { AppSolid } = await import("../../App.solid.js");
537
+
538
+ const branch = createBranch({
539
+ name: "feature/pending",
540
+ label: "feature/pending",
541
+ value: "feature/pending",
542
+ });
543
+ const stats = makeStats({ localCount: 1, worktreeCount: 1 });
544
+
545
+ const testSetup = await testRender(
546
+ () => (
547
+ <AppSolid
548
+ branches={[branch]}
549
+ stats={stats}
550
+ version={null}
551
+ toolStatuses={[]}
552
+ />
553
+ ),
554
+ { width: 80, height: 24 },
555
+ );
556
+ await testSetup.renderOnce();
557
+ await new Promise((resolve) => setTimeout(resolve, 0));
558
+ await testSetup.renderOnce();
559
+
560
+ try {
561
+ await testSetup.mockInput.typeText(" ");
562
+ await testSetup.renderOnce();
563
+
564
+ let frame = testSetup.captureCharFrame();
565
+ expect(frame).toContain("Safety check in progress. Select anyway?");
566
+
567
+ await testSetup.mockInput.typeText("n");
568
+ await testSetup.renderOnce();
569
+
570
+ frame = testSetup.captureCharFrame();
571
+ expect(frame).toContain("[ ] w");
572
+ expect(frame).toContain("feature/pending");
573
+ expect(frame).not.toContain("Safety check in progress. Select anyway?");
574
+ } finally {
575
+ resolveStatus?.([]);
576
+ testSetup.renderer.destroy();
577
+ }
578
+ });
579
+
489
580
  it("does not propagate Enter from confirm to branch selection", async () => {
490
581
  const getCleanupStatusMock = mock(async () => [
491
582
  {
@@ -0,0 +1,63 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import { describe, expect, it, mock } from "bun:test";
3
+ import { testRender } from "@opentui/solid";
4
+ import type { ToolSessionEntry } from "../../../../config/index.js";
5
+
6
+ mock.module("../../../utils/versionFetcher.js", () => ({
7
+ fetchInstalledVersionForAgent: mock(async () => null),
8
+ createInstalledOption: (version: string) => ({
9
+ label: `installed (${version})`,
10
+ value: "installed",
11
+ }),
12
+ versionInfoToSelectItem: (v: { version: string }) => ({
13
+ label: v.version,
14
+ value: v.version,
15
+ }),
16
+ }));
17
+
18
+ describe("WizardController", () => {
19
+ it("keeps version selection visible after agent select", async () => {
20
+ const history: ToolSessionEntry[] = [];
21
+
22
+ const { WizardController } =
23
+ await import("../../../components/solid/WizardController.js");
24
+
25
+ const testSetup = await testRender(
26
+ () => (
27
+ <WizardController
28
+ visible
29
+ selectedBranchName="feature/test"
30
+ history={history}
31
+ onClose={() => {}}
32
+ onComplete={() => {}}
33
+ onResume={() => {}}
34
+ onStartNew={() => {}}
35
+ />
36
+ ),
37
+ { width: 80, height: 24 },
38
+ );
39
+ await testSetup.renderOnce();
40
+
41
+ try {
42
+ let frame = testSetup.captureCharFrame();
43
+ expect(frame).toContain("What would you like to do?");
44
+
45
+ // Open existing worktree
46
+ testSetup.mockInput.pressEnter();
47
+ await testSetup.renderOnce();
48
+
49
+ frame = testSetup.captureCharFrame();
50
+ expect(frame).toContain("Select coding agent:");
51
+
52
+ // Select default agent (Enter)
53
+ testSetup.mockInput.pressEnter();
54
+ await testSetup.renderOnce();
55
+
56
+ frame = testSetup.captureCharFrame();
57
+ expect(frame).toContain("Select version:");
58
+ expect(frame).not.toContain("Select Model:");
59
+ } finally {
60
+ testSetup.renderer.destroy();
61
+ }
62
+ });
63
+ });
@@ -1,15 +1,24 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { describe, expect, it } from "bun:test";
2
+ import { beforeEach, describe, expect, it } from "bun:test";
3
3
  import { testRender } from "@opentui/solid";
4
4
  import {
5
5
  BranchTypeStep,
6
6
  BranchNameStep,
7
7
  AgentSelectStep,
8
+ VersionSelectStep,
8
9
  ModelSelectStep,
9
10
  ReasoningLevelStep,
10
11
  ExecutionModeStep,
11
12
  SkipPermissionsStep,
12
13
  } from "../../../components/solid/WizardSteps.js";
14
+ import {
15
+ clearInstalledVersionCache,
16
+ setInstalledVersionCache,
17
+ } from "../../../utils/installedVersionCache.js";
18
+ import {
19
+ clearVersionCache,
20
+ setVersionCache,
21
+ } from "../../../utils/versionCache.js";
13
22
 
14
23
  // T405: ブランチタイプ選択ステップのテスト
15
24
  describe("BranchTypeStep", () => {
@@ -128,6 +137,65 @@ describe("AgentSelectStep", () => {
128
137
  });
129
138
  });
130
139
 
140
+ // T407a: バージョン選択ステップのテスト
141
+ describe("VersionSelectStep", () => {
142
+ beforeEach(() => {
143
+ clearInstalledVersionCache();
144
+ clearVersionCache();
145
+ });
146
+
147
+ it("renders installed option from cache", async () => {
148
+ setInstalledVersionCache("claude-code", {
149
+ version: "1.2.3",
150
+ path: "/usr/local/bin/claude",
151
+ });
152
+ setVersionCache("claude-code", []);
153
+
154
+ const testSetup = await testRender(
155
+ () => (
156
+ <VersionSelectStep
157
+ agentId="claude-code"
158
+ onSelect={() => {}}
159
+ onBack={() => {}}
160
+ />
161
+ ),
162
+ { width: 60, height: 20 },
163
+ );
164
+ await testSetup.renderOnce();
165
+
166
+ try {
167
+ const frame = testSetup.captureCharFrame();
168
+ expect(frame).toContain("Select version");
169
+ expect(frame).toContain("installed@1.2.3");
170
+ expect(frame).toContain("latest");
171
+ } finally {
172
+ testSetup.renderer.destroy();
173
+ }
174
+ });
175
+
176
+ it("does not render installed option when cache is empty", async () => {
177
+ const testSetup = await testRender(
178
+ () => (
179
+ <VersionSelectStep
180
+ agentId="claude-code"
181
+ onSelect={() => {}}
182
+ onBack={() => {}}
183
+ />
184
+ ),
185
+ { width: 60, height: 20 },
186
+ );
187
+ await testSetup.renderOnce();
188
+
189
+ try {
190
+ const frame = testSetup.captureCharFrame();
191
+ expect(frame).toContain("latest");
192
+ expect(frame).not.toContain("installed@");
193
+ } finally {
194
+ testSetup.renderer.destroy();
195
+ }
196
+ });
197
+ });
198
+
131
199
  // T408: モデル選択ステップのテスト
132
200
  describe("ModelSelectStep", () => {
133
201
  it("renders model options for Claude Code", async () => {
@@ -150,6 +218,28 @@ describe("ModelSelectStep", () => {
150
218
  testSetup.renderer.destroy();
151
219
  }
152
220
  });
221
+
222
+ it("renders model options for OpenCode", async () => {
223
+ const testSetup = await testRender(
224
+ () => (
225
+ <ModelSelectStep
226
+ agentId="opencode"
227
+ onSelect={() => {}}
228
+ onBack={() => {}}
229
+ />
230
+ ),
231
+ { width: 60, height: 20 },
232
+ );
233
+ await testSetup.renderOnce();
234
+
235
+ try {
236
+ const frame = testSetup.captureCharFrame();
237
+ expect(frame).toContain("Default (Auto)");
238
+ expect(frame).toContain("Custom");
239
+ } finally {
240
+ testSetup.renderer.destroy();
241
+ }
242
+ });
153
243
  });
154
244
 
155
245
  // T409: 推論レベル選択ステップ(Codexのみ)のテスト
@@ -1,5 +1,11 @@
1
1
  /** @jsxImportSource @opentui/solid */
2
- import { createSignal, createEffect, createMemo, Show } from "solid-js";
2
+ import {
3
+ createSignal,
4
+ createEffect,
5
+ createMemo,
6
+ onCleanup,
7
+ Show,
8
+ } from "solid-js";
3
9
  import { useKeyboard } from "@opentui/solid";
4
10
  import type { ToolSessionEntry } from "../../../../config/index.js";
5
11
  import type { CodingAgentId, InferenceLevel } from "../../types.js";
@@ -13,6 +19,7 @@ import {
13
19
  AgentSelectStep,
14
20
  VersionSelectStep,
15
21
  ModelSelectStep,
22
+ ModelInputStep,
16
23
  ReasoningLevelStep,
17
24
  ExecutionModeStep,
18
25
  SkipPermissionsStep,
@@ -53,6 +60,7 @@ type WizardStep =
53
60
  | "agent-select"
54
61
  | "version-select"
55
62
  | "model-select"
63
+ | "model-input"
56
64
  | "reasoning-level"
57
65
  | "execution-mode"
58
66
  | "skip-permissions";
@@ -87,9 +95,11 @@ export function WizardController(props: WizardControllerProps) {
87
95
  >(undefined);
88
96
  const [executionMode, setExecutionMode] =
89
97
  createSignal<ExecutionMode>("normal");
98
+ const [versionSelectionReady, setVersionSelectionReady] = createSignal(false);
90
99
 
91
100
  // キー伝播防止: ステップ遷移直後は focused を無効にする
92
101
  const [isTransitioning, setIsTransitioning] = createSignal(true);
102
+ let versionSelectionTimer: ReturnType<typeof setTimeout> | null = null;
93
103
 
94
104
  // Reset state when wizard becomes visible
95
105
  function getInitialStep(): WizardStep {
@@ -168,6 +178,28 @@ export function WizardController(props: WizardControllerProps) {
168
178
  startTransition();
169
179
  };
170
180
 
181
+ createEffect(() => {
182
+ const currentStep = step();
183
+ if (versionSelectionTimer) {
184
+ clearTimeout(versionSelectionTimer);
185
+ versionSelectionTimer = null;
186
+ }
187
+ if (currentStep === "version-select") {
188
+ setVersionSelectionReady(false);
189
+ versionSelectionTimer = setTimeout(() => {
190
+ setVersionSelectionReady(true);
191
+ }, 50);
192
+ return;
193
+ }
194
+ setVersionSelectionReady(false);
195
+ });
196
+
197
+ onCleanup(() => {
198
+ if (versionSelectionTimer) {
199
+ clearTimeout(versionSelectionTimer);
200
+ }
201
+ });
202
+
171
203
  // Determine if reasoning level step is needed
172
204
  const needsReasoningLevel = createMemo(() => {
173
205
  return selectedAgent() === "codex-cli";
@@ -213,12 +245,20 @@ export function WizardController(props: WizardControllerProps) {
213
245
  };
214
246
 
215
247
  const handleVersionSelect = (version: string) => {
248
+ if (!versionSelectionReady() || step() !== "version-select") {
249
+ return;
250
+ }
216
251
  // "installed" を明示的に保存し、未指定時は後方互換で "latest" にフォールバックできるようにする
217
252
  setSelectedVersion(version);
218
253
  goToStep("model-select");
219
254
  };
220
255
 
221
256
  const handleModelSelect = (model: string) => {
257
+ const agent = selectedAgent();
258
+ if (agent === "opencode" && model === "__custom__") {
259
+ goToStep("model-input");
260
+ return;
261
+ }
222
262
  setSelectedModel(model);
223
263
  if (needsReasoningLevel()) {
224
264
  goToStep("reasoning-level");
@@ -227,6 +267,19 @@ export function WizardController(props: WizardControllerProps) {
227
267
  }
228
268
  };
229
269
 
270
+ const handleModelInputSubmit = (value: string) => {
271
+ const trimmed = value.trim();
272
+ if (!trimmed) {
273
+ return;
274
+ }
275
+ setSelectedModel(trimmed);
276
+ if (needsReasoningLevel()) {
277
+ goToStep("reasoning-level");
278
+ } else {
279
+ goToStep("execution-mode");
280
+ }
281
+ };
282
+
230
283
  const handleReasoningLevelSelect = (level: string) => {
231
284
  setReasoningLevel(level as InferenceLevel);
232
285
  goToStep("execution-mode");
@@ -346,6 +399,17 @@ export function WizardController(props: WizardControllerProps) {
346
399
  );
347
400
  }
348
401
 
402
+ if (currentStep === "model-input") {
403
+ return (
404
+ <ModelInputStep
405
+ agentId={selectedAgent() ?? "claude-code"}
406
+ onSubmit={handleModelInputSubmit}
407
+ onBack={goBack}
408
+ focused={focused}
409
+ />
410
+ );
411
+ }
412
+
349
413
  if (currentStep === "reasoning-level") {
350
414
  return (
351
415
  <ReasoningLevelStep
@@ -2,7 +2,7 @@
2
2
  import { TextAttributes } from "@opentui/core";
3
3
  import type { SelectRenderable } from "@opentui/core";
4
4
  import { useKeyboard } from "@opentui/solid";
5
- import { createEffect, createResource, createSignal } from "solid-js";
5
+ import { createEffect, createSignal } from "solid-js";
6
6
  import { SelectInput, type SelectInputItem } from "./SelectInput.js";
7
7
  import { TextInput } from "./TextInput.js";
8
8
  import { getModelOptions } from "../../utils/modelOptions.js";
@@ -12,10 +12,10 @@ import { getAgentTerminalColor } from "../../../../utils/coding-agent-colors.js"
12
12
  import { getVersionCache } from "../../utils/versionCache.js";
13
13
  import { selectionStyle } from "../../core/theme.js";
14
14
  import {
15
- fetchInstalledVersionForAgent,
16
15
  versionInfoToSelectItem,
17
16
  createInstalledOption,
18
17
  } from "../../utils/versionFetcher.js";
18
+ import { getInstalledVersionCache } from "../../utils/installedVersionCache.js";
19
19
 
20
20
  /**
21
21
  * WizardSteps - ウィザードの各ステップコンポーネント
@@ -441,6 +441,54 @@ export function ModelSelectStep(props: ModelSelectStepProps) {
441
441
  );
442
442
  }
443
443
 
444
+ // T408b: カスタムモデル入力ステップ
445
+ export interface ModelInputStepProps extends StepProps {
446
+ agentId: CodingAgentId;
447
+ onSubmit: (value: string) => void;
448
+ }
449
+
450
+ export function ModelInputStep(props: ModelInputStepProps) {
451
+ const [value, setValue] = createSignal("");
452
+ const scroll = useWizardScroll();
453
+ const placeholder = props.agentId === "opencode" ? "provider/model" : "model";
454
+
455
+ createEffect(() => {
456
+ if (props.focused === false) {
457
+ return;
458
+ }
459
+ if (!scroll) {
460
+ return;
461
+ }
462
+ scroll.ensureLineVisible(2);
463
+ });
464
+
465
+ const handleSubmit = (next: string) => {
466
+ const trimmed = next.trim();
467
+ if (!trimmed) {
468
+ return;
469
+ }
470
+ props.onSubmit(trimmed);
471
+ };
472
+
473
+ return (
474
+ <box flexDirection="column">
475
+ <text fg="cyan" attributes={TextAttributes.BOLD}>
476
+ Enter custom model:
477
+ </text>
478
+ <text> </text>
479
+ <TextInput
480
+ value={value()}
481
+ onChange={setValue}
482
+ onSubmit={handleSubmit}
483
+ placeholder={placeholder}
484
+ focused={props.focused ?? true}
485
+ />
486
+ <text> </text>
487
+ <text attributes={TextAttributes.DIM}>[Enter] Submit [Esc] Back</text>
488
+ </box>
489
+ );
490
+ }
491
+
444
492
  // T409: 推論レベル選択ステップ(Codexのみ)
445
493
  export interface ReasoningLevelStepProps extends StepProps {
446
494
  onSelect: (level: string) => void;
@@ -642,14 +690,11 @@ export function VersionSelectStep(props: VersionSelectStepProps) {
642
690
  return cached.map(versionInfoToSelectItem);
643
691
  };
644
692
 
645
- // インストール済み情報を取得 (still needs async fetch for local command check)
646
- const [installedOption] = createResource(
647
- () => props.agentId,
648
- async (agentId: string) => {
649
- const installed = await fetchInstalledVersionForAgent(agentId);
650
- return installed ? createInstalledOption(installed) : null;
651
- },
652
- );
693
+ // インストール済み情報は起動時にキャッシュ済み(FR-017)
694
+ const installedOption = () => {
695
+ const installed = getInstalledVersionCache(props.agentId);
696
+ return installed ? createInstalledOption(installed) : null;
697
+ };
653
698
 
654
699
  // 全オプション(installed + latest + cached versions)
655
700
  const allOptions = (): SelectInputItem[] => {
@@ -0,0 +1,46 @@
1
+ import { beforeEach, describe, expect, it } from "bun:test";
2
+ import {
3
+ clearInstalledVersionCache,
4
+ getInstalledVersionCache,
5
+ prefetchInstalledVersions,
6
+ setInstalledVersionCache,
7
+ } from "../installedVersionCache.js";
8
+
9
+ describe("installedVersionCache", () => {
10
+ beforeEach(() => {
11
+ clearInstalledVersionCache();
12
+ });
13
+
14
+ it("stores and retrieves installed versions", () => {
15
+ setInstalledVersionCache("claude-code", {
16
+ version: "1.0.0",
17
+ path: "/usr/local/bin/claude",
18
+ });
19
+
20
+ const cached = getInstalledVersionCache("claude-code");
21
+ expect(cached).toEqual({
22
+ version: "1.0.0",
23
+ path: "/usr/local/bin/claude",
24
+ });
25
+ });
26
+
27
+ it("prefetches installed versions with custom fetcher", async () => {
28
+ await prefetchInstalledVersions(["claude-code", "codex-cli"], async (id) =>
29
+ id === "claude-code" ? { version: "2.0.0", path: "/opt/claude" } : null,
30
+ );
31
+
32
+ expect(getInstalledVersionCache("claude-code")).toEqual({
33
+ version: "2.0.0",
34
+ path: "/opt/claude",
35
+ });
36
+ expect(getInstalledVersionCache("codex-cli")).toBeNull();
37
+ });
38
+
39
+ it("stores null when fetcher throws", async () => {
40
+ await prefetchInstalledVersions(["gemini-cli"], async () => {
41
+ throw new Error("boom");
42
+ });
43
+
44
+ expect(getInstalledVersionCache("gemini-cli")).toBeNull();
45
+ });
46
+ });
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Installed version cache for coding agents (FR-017)
3
+ *
4
+ * Caches locally installed agent versions detected at startup
5
+ * to avoid async fetch during wizard navigation.
6
+ */
7
+
8
+ import type { InstalledVersionInfo } from "./versionFetcher.js";
9
+ import { fetchInstalledVersionForAgent } from "./versionFetcher.js";
10
+
11
+ /**
12
+ * In-memory cache for installed versions
13
+ * Maps agentId -> InstalledVersionInfo | null
14
+ */
15
+ const installedVersionCache = new Map<string, InstalledVersionInfo | null>();
16
+
17
+ /**
18
+ * Type for installed version fetch function (injectable for testing)
19
+ */
20
+ export type InstalledVersionFetcher = (
21
+ agentId: string,
22
+ ) => Promise<InstalledVersionInfo | null>;
23
+
24
+ /**
25
+ * Get cached installed version for an agent
26
+ * @returns Cached info or null if not installed/unknown
27
+ */
28
+ export function getInstalledVersionCache(
29
+ agentId: string,
30
+ ): InstalledVersionInfo | null {
31
+ const cached = installedVersionCache.get(agentId);
32
+ return cached ?? null;
33
+ }
34
+
35
+ /**
36
+ * Set installed version cache (for testing or direct population)
37
+ */
38
+ export function setInstalledVersionCache(
39
+ agentId: string,
40
+ installed: InstalledVersionInfo | null,
41
+ ): void {
42
+ installedVersionCache.set(agentId, installed);
43
+ }
44
+
45
+ /**
46
+ * Clear installed version cache
47
+ */
48
+ export function clearInstalledVersionCache(): void {
49
+ installedVersionCache.clear();
50
+ }
51
+
52
+ /**
53
+ * Prefetch installed versions for multiple agents in parallel
54
+ * This should be called at application startup
55
+ *
56
+ * @param agentIds - List of agent IDs to prefetch
57
+ * @param fetchFn - Optional custom fetch function (for testing)
58
+ */
59
+ export async function prefetchInstalledVersions(
60
+ agentIds: string[],
61
+ fetchFn?: InstalledVersionFetcher,
62
+ ): Promise<void> {
63
+ const fetcher =
64
+ fetchFn ??
65
+ (async (agentId: string) => fetchInstalledVersionForAgent(agentId));
66
+
67
+ const results = await Promise.allSettled(
68
+ agentIds.map(async (agentId) => {
69
+ try {
70
+ const installed = await fetcher(agentId);
71
+ setInstalledVersionCache(agentId, installed);
72
+ } catch {
73
+ setInstalledVersionCache(agentId, null);
74
+ }
75
+ }),
76
+ );
77
+
78
+ // Swallow any rejections to keep startup non-blocking
79
+ for (const result of results) {
80
+ if (result.status === "rejected") {
81
+ // no-op
82
+ }
83
+ }
84
+ }
@@ -75,6 +75,12 @@ describe("modelOptions", () => {
75
75
  ]);
76
76
  });
77
77
 
78
+ it("includes OpenCode default and custom options", () => {
79
+ expect(byId("opencode")).toEqual(["", "__custom__"]);
80
+ const defaultModel = getDefaultModelOption("opencode");
81
+ expect(defaultModel?.id).toBe("");
82
+ });
83
+
78
84
  it("normalizes known Claude model typos and casing", () => {
79
85
  expect(normalizeModelId("claude-code", "opuss")).toBe("opus");
80
86
  expect(normalizeModelId("claude-code", "Opus")).toBe("opus");
@@ -103,6 +103,19 @@ const MODEL_OPTIONS: Record<string, ModelOption[]> = {
103
103
  description: "Fastest for simple tasks",
104
104
  },
105
105
  ],
106
+ opencode: [
107
+ {
108
+ id: "",
109
+ label: "Default (Auto)",
110
+ description: "Use OpenCode default model",
111
+ isDefault: true,
112
+ },
113
+ {
114
+ id: "__custom__",
115
+ label: "Custom (provider/model)",
116
+ description: "Enter a provider/model identifier",
117
+ },
118
+ ],
106
119
  };
107
120
 
108
121
  export function getModelOptions(tool: CodingAgentId): ModelOption[] {
@@ -144,6 +157,9 @@ export function normalizeModelId(
144
157
  if (model === null || model === undefined) return model ?? null;
145
158
  const trimmed = model.trim();
146
159
  if (!trimmed) return null;
160
+ if (tool === "opencode" && trimmed === "__custom__") {
161
+ return null;
162
+ }
147
163
  if (tool === "claude-code") {
148
164
  const lower = trimmed.toLowerCase();
149
165
  if (lower === "opuss") return "opus";