@akiojin/gwt 4.1.0 → 4.2.0
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.
- package/README.md +28 -3
- package/dist/claude.d.ts +2 -0
- package/dist/claude.d.ts.map +1 -1
- package/dist/claude.js +2 -0
- package/dist/claude.js.map +1 -1
- package/dist/cli/ui/components/App.d.ts.map +1 -1
- package/dist/cli/ui/components/App.js +8 -5
- package/dist/cli/ui/components/App.js.map +1 -1
- package/dist/cli/ui/components/common/Select.d.ts +3 -1
- package/dist/cli/ui/components/common/Select.d.ts.map +1 -1
- package/dist/cli/ui/components/common/Select.js +13 -2
- package/dist/cli/ui/components/common/Select.js.map +1 -1
- package/dist/cli/ui/utils/modelOptions.d.ts +4 -0
- package/dist/cli/ui/utils/modelOptions.d.ts.map +1 -1
- package/dist/cli/ui/utils/modelOptions.js +19 -0
- package/dist/cli/ui/utils/modelOptions.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +54 -15
- package/dist/index.js.map +1 -1
- package/dist/utils/prompt.d.ts +12 -0
- package/dist/utils/prompt.d.ts.map +1 -1
- package/dist/utils/prompt.js +60 -10
- package/dist/utils/prompt.js.map +1 -1
- package/dist/worktree.d.ts +14 -0
- package/dist/worktree.d.ts.map +1 -1
- package/dist/worktree.js +33 -2
- package/dist/worktree.js.map +1 -1
- package/package.json +2 -2
- package/src/claude.ts +2 -0
- package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +2 -1
- package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +38 -8
- package/src/cli/ui/__tests__/components/App.test.tsx +4 -3
- package/src/cli/ui/__tests__/components/ModelSelectorScreen.initial.test.tsx +1 -0
- package/src/cli/ui/__tests__/components/common/Select.test.tsx +45 -0
- package/src/cli/ui/components/App.tsx +15 -4
- package/src/cli/ui/components/common/Select.tsx +14 -1
- package/src/cli/ui/utils/modelOptions.test.ts +12 -0
- package/src/cli/ui/utils/modelOptions.ts +19 -0
- package/src/index.ts +70 -14
- package/src/utils/__tests__/prompt.test.ts +72 -35
- package/src/utils/prompt.ts +79 -10
- package/src/worktree.ts +48 -1
|
@@ -35,10 +35,10 @@ vi.mock("../../hooks/useScreenState.js", () => ({
|
|
|
35
35
|
useScreenState: (...args: unknown[]) => useScreenStateMock(...args),
|
|
36
36
|
}));
|
|
37
37
|
|
|
38
|
-
vi.mock("../../../../worktree.
|
|
38
|
+
vi.mock("../../../../worktree.ts", async () => {
|
|
39
39
|
const actual = await vi.importActual<
|
|
40
|
-
typeof import("../../../../worktree.
|
|
41
|
-
>("../../../../worktree.
|
|
40
|
+
typeof import("../../../../worktree.ts")
|
|
41
|
+
>("../../../../worktree.ts");
|
|
42
42
|
return {
|
|
43
43
|
...actual,
|
|
44
44
|
getMergedPRWorktrees: getMergedPRWorktreesMock,
|
|
@@ -48,10 +48,10 @@ vi.mock("../../../../worktree.js", async () => {
|
|
|
48
48
|
};
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
vi.mock("../../../../git.
|
|
51
|
+
vi.mock("../../../../git.ts", async () => {
|
|
52
52
|
const actual =
|
|
53
|
-
await vi.importActual<typeof import("../../../../git.
|
|
54
|
-
"../../../../git.
|
|
53
|
+
await vi.importActual<typeof import("../../../../git.ts")>(
|
|
54
|
+
"../../../../git.ts",
|
|
55
55
|
);
|
|
56
56
|
return {
|
|
57
57
|
...actual,
|
|
@@ -92,7 +92,20 @@ describe("App shortcuts integration", () => {
|
|
|
92
92
|
goBackMock.mockClear();
|
|
93
93
|
resetMock.mockClear();
|
|
94
94
|
useGitDataMock.mockReturnValue({
|
|
95
|
-
branches: [
|
|
95
|
+
branches: [
|
|
96
|
+
{
|
|
97
|
+
name: "feature/add-new-feature",
|
|
98
|
+
type: "local",
|
|
99
|
+
branchType: "feature",
|
|
100
|
+
isCurrent: false,
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
name: "hotfix/urgent-fix",
|
|
104
|
+
type: "local",
|
|
105
|
+
branchType: "hotfix",
|
|
106
|
+
isCurrent: false,
|
|
107
|
+
},
|
|
108
|
+
],
|
|
96
109
|
worktrees: [
|
|
97
110
|
{
|
|
98
111
|
branch: "feature/existing",
|
|
@@ -197,6 +210,8 @@ describe("App shortcuts integration", () => {
|
|
|
197
210
|
|
|
198
211
|
it("displays per-branch cleanup indicators and waits before clearing results", async () => {
|
|
199
212
|
vi.useFakeTimers();
|
|
213
|
+
const originalNodeEnv = process.env.NODE_ENV;
|
|
214
|
+
process.env.NODE_ENV = "production";
|
|
200
215
|
|
|
201
216
|
try {
|
|
202
217
|
const onExit = vi.fn();
|
|
@@ -234,8 +249,22 @@ describe("App shortcuts integration", () => {
|
|
|
234
249
|
throw new Error("BranchListScreen props missing");
|
|
235
250
|
}
|
|
236
251
|
|
|
252
|
+
await act(async () => {
|
|
253
|
+
initialProps.onToggleSelect?.("feature/add-new-feature");
|
|
254
|
+
initialProps.onToggleSelect?.("hotfix/urgent-fix");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await act(async () => {
|
|
258
|
+
await Promise.resolve();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
const selectedProps = branchListProps.at(-1);
|
|
262
|
+
expect(selectedProps?.selectedBranches).toEqual([
|
|
263
|
+
"feature/add-new-feature",
|
|
264
|
+
"hotfix/urgent-fix",
|
|
265
|
+
]);
|
|
237
266
|
act(() => {
|
|
238
|
-
|
|
267
|
+
selectedProps?.onCleanupCommand?.();
|
|
239
268
|
});
|
|
240
269
|
|
|
241
270
|
await act(async () => {
|
|
@@ -296,6 +325,7 @@ describe("App shortcuts integration", () => {
|
|
|
296
325
|
),
|
|
297
326
|
).toBe(false);
|
|
298
327
|
} finally {
|
|
328
|
+
process.env.NODE_ENV = originalNodeEnv;
|
|
299
329
|
vi.useRealTimers();
|
|
300
330
|
}
|
|
301
331
|
});
|
|
@@ -251,6 +251,7 @@ describe("App", () => {
|
|
|
251
251
|
});
|
|
252
252
|
|
|
253
253
|
describe("BranchActionSelectorScreen integration", () => {
|
|
254
|
+
// TODO: replace placeholder assertions with real integration checks.
|
|
254
255
|
it("should show BranchActionSelectorScreen after branch selection", () => {
|
|
255
256
|
useGitDataMock.mockImplementation(() => ({
|
|
256
257
|
branches: mockBranches,
|
|
@@ -263,7 +264,7 @@ describe("App", () => {
|
|
|
263
264
|
const onExit = vi.fn();
|
|
264
265
|
const { container } = render(<App onExit={onExit} />);
|
|
265
266
|
|
|
266
|
-
//
|
|
267
|
+
// TODO: verify BranchActionSelectorScreen appears
|
|
267
268
|
expect(container).toBeDefined();
|
|
268
269
|
});
|
|
269
270
|
|
|
@@ -279,7 +280,7 @@ describe("App", () => {
|
|
|
279
280
|
const onExit = vi.fn();
|
|
280
281
|
const { container } = render(<App onExit={onExit} />);
|
|
281
282
|
|
|
282
|
-
//
|
|
283
|
+
// TODO: verify navigation to AIToolSelectorScreen
|
|
283
284
|
expect(container).toBeDefined();
|
|
284
285
|
});
|
|
285
286
|
|
|
@@ -295,7 +296,7 @@ describe("App", () => {
|
|
|
295
296
|
const onExit = vi.fn();
|
|
296
297
|
const { container } = render(<App onExit={onExit} />);
|
|
297
298
|
|
|
298
|
-
//
|
|
299
|
+
// TODO: verify navigation to BranchCreatorScreen
|
|
299
300
|
expect(container).toBeDefined();
|
|
300
301
|
});
|
|
301
302
|
});
|
|
@@ -54,6 +54,7 @@ describe("ModelSelectorScreen initial selection", () => {
|
|
|
54
54
|
|
|
55
55
|
await waitFor(() => expect(selectMocks.length).toBeGreaterThan(0));
|
|
56
56
|
const modelSelect = selectMocks.at(-1);
|
|
57
|
+
expect(modelSelect).toBeDefined();
|
|
57
58
|
const index = modelSelect.initialIndex as number;
|
|
58
59
|
// codex-cli models: ["", gpt-5.2-codex, gpt-5.1-codex-max, gpt-5.1-codex-mini, gpt-5.2]
|
|
59
60
|
// (index 0 = Default/Auto, index 2 = gpt-5.1-codex-max)
|
|
@@ -12,6 +12,9 @@ interface TestItem {
|
|
|
12
12
|
value: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
const delay = (ms = 0): Promise<void> =>
|
|
16
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
|
|
15
18
|
describe("Select", () => {
|
|
16
19
|
const mockItems: TestItem[] = [
|
|
17
20
|
{ label: "Option 1", value: "opt1" },
|
|
@@ -281,4 +284,46 @@ describe("Select", () => {
|
|
|
281
284
|
expect(onSelect).not.toHaveBeenCalled();
|
|
282
285
|
});
|
|
283
286
|
});
|
|
287
|
+
|
|
288
|
+
describe('Space/Escape handlers', () => {
|
|
289
|
+
it('should call onSpace with the currently highlighted item', async () => {
|
|
290
|
+
const onSelect = vi.fn();
|
|
291
|
+
const onSpace = vi.fn();
|
|
292
|
+
const { stdin } = render(
|
|
293
|
+
<Select items={mockItems} onSelect={onSelect} onSpace={onSpace} />
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
stdin.write(' ');
|
|
297
|
+
await delay(10);
|
|
298
|
+
|
|
299
|
+
expect(onSpace).toHaveBeenCalledTimes(1);
|
|
300
|
+
expect(onSpace).toHaveBeenCalledWith(mockItems[0]);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it('should not trigger onSpace when disabled', async () => {
|
|
304
|
+
const onSelect = vi.fn();
|
|
305
|
+
const onSpace = vi.fn();
|
|
306
|
+
const { stdin } = render(
|
|
307
|
+
<Select items={mockItems} onSelect={onSelect} onSpace={onSpace} disabled />
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
stdin.write(' ');
|
|
311
|
+
await delay(10);
|
|
312
|
+
|
|
313
|
+
expect(onSpace).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('should call onEscape when escape key is pressed', async () => {
|
|
317
|
+
const onSelect = vi.fn();
|
|
318
|
+
const onEscape = vi.fn();
|
|
319
|
+
const { stdin } = render(
|
|
320
|
+
<Select items={mockItems} onSelect={onSelect} onEscape={onEscape} />
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
stdin.write('\u001B');
|
|
324
|
+
await delay(30);
|
|
325
|
+
|
|
326
|
+
expect(onEscape).toHaveBeenCalledTimes(1);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
284
329
|
});
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
import {
|
|
54
54
|
getDefaultInferenceForModel,
|
|
55
55
|
getDefaultModelOption,
|
|
56
|
+
normalizeModelId,
|
|
56
57
|
} from "../utils/modelOptions.js";
|
|
57
58
|
import {
|
|
58
59
|
resolveContinueSessionId,
|
|
@@ -386,10 +387,15 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
386
387
|
}
|
|
387
388
|
}
|
|
388
389
|
|
|
390
|
+
const normalizedModel = normalizeModelId(
|
|
391
|
+
entry.toolId as AITool,
|
|
392
|
+
entry.model ?? null,
|
|
393
|
+
);
|
|
394
|
+
|
|
389
395
|
return {
|
|
390
396
|
toolId: entry.toolId as AITool,
|
|
391
397
|
toolLabel: entry.toolLabel,
|
|
392
|
-
model:
|
|
398
|
+
model: normalizedModel ?? null,
|
|
393
399
|
inferenceLevel: (entry.reasoningLevel ??
|
|
394
400
|
sessionData?.reasoningLevel ??
|
|
395
401
|
null) as InferenceLevel | null,
|
|
@@ -1069,6 +1075,7 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
1069
1075
|
if (selectedBranch && selectedTool) {
|
|
1070
1076
|
const defaultModel = getDefaultModelOption(selectedTool);
|
|
1071
1077
|
const resolvedModel = selectedModel?.model ?? defaultModel?.id ?? null;
|
|
1078
|
+
const normalizedModel = normalizeModelId(selectedTool, resolvedModel);
|
|
1072
1079
|
const resolvedInference =
|
|
1073
1080
|
selectedModel?.inferenceLevel ??
|
|
1074
1081
|
getDefaultInferenceForModel(defaultModel ?? undefined);
|
|
@@ -1080,7 +1087,7 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
1080
1087
|
tool: selectedTool,
|
|
1081
1088
|
mode: executionMode,
|
|
1082
1089
|
skipPermissions: skip,
|
|
1083
|
-
...(
|
|
1090
|
+
...(normalizedModel !== undefined ? { model: normalizedModel } : {}),
|
|
1084
1091
|
...(resolvedInference !== undefined
|
|
1085
1092
|
? { inferenceLevel: resolvedInference }
|
|
1086
1093
|
: {}),
|
|
@@ -1122,10 +1129,14 @@ export function App({ onExit, loadingIndicatorDelay = 300 }: AppProps) {
|
|
|
1122
1129
|
|
|
1123
1130
|
setSelectedTool(selected.toolId);
|
|
1124
1131
|
setPreferredToolId(selected.toolId);
|
|
1132
|
+
const normalizedQuickStartModel = normalizeModelId(
|
|
1133
|
+
selected.toolId as AITool,
|
|
1134
|
+
selected.model ?? null,
|
|
1135
|
+
);
|
|
1125
1136
|
setSelectedModel(
|
|
1126
|
-
|
|
1137
|
+
normalizedQuickStartModel
|
|
1127
1138
|
? ({
|
|
1128
|
-
model:
|
|
1139
|
+
model: normalizedQuickStartModel,
|
|
1129
1140
|
inferenceLevel: selected.inferenceLevel ?? undefined,
|
|
1130
1141
|
} as ModelSelectionResult)
|
|
1131
1142
|
: null,
|
|
@@ -28,6 +28,8 @@ export interface SelectProps<T extends SelectItem = SelectItem> {
|
|
|
28
28
|
// Optional controlled component props for cursor position
|
|
29
29
|
selectedIndex?: number;
|
|
30
30
|
onSelectedIndexChange?: (index: number) => void;
|
|
31
|
+
onSpace?: (item: T) => void;
|
|
32
|
+
onEscape?: () => void;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -47,7 +49,9 @@ function arePropsEqual<T extends SelectItem = SelectItem>(
|
|
|
47
49
|
prevProps.onSelect !== nextProps.onSelect ||
|
|
48
50
|
prevProps.onSelectedIndexChange !== nextProps.onSelectedIndexChange ||
|
|
49
51
|
prevProps.renderIndicator !== nextProps.renderIndicator ||
|
|
50
|
-
prevProps.renderItem !== nextProps.renderItem
|
|
52
|
+
prevProps.renderItem !== nextProps.renderItem ||
|
|
53
|
+
prevProps.onSpace !== nextProps.onSpace ||
|
|
54
|
+
prevProps.onEscape !== nextProps.onEscape
|
|
51
55
|
) {
|
|
52
56
|
return false;
|
|
53
57
|
}
|
|
@@ -93,6 +97,8 @@ const SelectComponent = <T extends SelectItem = SelectItem>({
|
|
|
93
97
|
renderItem,
|
|
94
98
|
selectedIndex: externalSelectedIndex,
|
|
95
99
|
onSelectedIndexChange,
|
|
100
|
+
onSpace,
|
|
101
|
+
onEscape,
|
|
96
102
|
}: SelectProps<T>) => {
|
|
97
103
|
// Support both controlled and uncontrolled modes
|
|
98
104
|
const [internalSelectedIndex, setInternalSelectedIndex] =
|
|
@@ -177,6 +183,13 @@ const SelectComponent = <T extends SelectItem = SelectItem>({
|
|
|
177
183
|
if (selectedItem && !disabled) {
|
|
178
184
|
onSelect(selectedItem);
|
|
179
185
|
}
|
|
186
|
+
} else if (input === ' ' && onSpace) {
|
|
187
|
+
const selectedItem = items[selectedIndex];
|
|
188
|
+
if (selectedItem && !disabled) {
|
|
189
|
+
onSpace(selectedItem);
|
|
190
|
+
}
|
|
191
|
+
} else if (key.escape && onEscape) {
|
|
192
|
+
onEscape();
|
|
180
193
|
}
|
|
181
194
|
// All other keys are ignored and will propagate to parent components
|
|
182
195
|
});
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
getModelOptions,
|
|
4
4
|
getDefaultInferenceForModel,
|
|
5
5
|
getDefaultModelOption,
|
|
6
|
+
normalizeModelId,
|
|
6
7
|
} from "./modelOptions.js";
|
|
7
8
|
|
|
8
9
|
const byId = (tool: string) => getModelOptions(tool).map((m) => m.id);
|
|
@@ -74,6 +75,17 @@ describe("modelOptions", () => {
|
|
|
74
75
|
]);
|
|
75
76
|
});
|
|
76
77
|
|
|
78
|
+
it("normalizes known Claude model typos and casing", () => {
|
|
79
|
+
expect(normalizeModelId("claude-code", "opuss")).toBe("opus");
|
|
80
|
+
expect(normalizeModelId("claude-code", "Opus")).toBe("opus");
|
|
81
|
+
expect(normalizeModelId("claude-code", "sonnet")).toBe("sonnet");
|
|
82
|
+
expect(normalizeModelId("claude-code", null)).toBeNull();
|
|
83
|
+
expect(normalizeModelId("claude-code", undefined)).toBeNull();
|
|
84
|
+
expect(normalizeModelId("claude-code", "")).toBeNull();
|
|
85
|
+
expect(normalizeModelId("claude-code", " ")).toBeNull();
|
|
86
|
+
expect(normalizeModelId("claude-code", " opus ")).toBe("opus");
|
|
87
|
+
});
|
|
88
|
+
|
|
77
89
|
it("returns no models for unsupported tools", () => {
|
|
78
90
|
expect(byId("unknown-tool")).toEqual([]);
|
|
79
91
|
});
|
|
@@ -2,6 +2,7 @@ import type { AITool, InferenceLevel, ModelOption } from "../types.js";
|
|
|
2
2
|
|
|
3
3
|
const CODEX_BASE_LEVELS: InferenceLevel[] = ["high", "medium", "low"];
|
|
4
4
|
const CODEX_MAX_LEVELS: InferenceLevel[] = ["xhigh", "high", "medium", "low"];
|
|
5
|
+
const CLAUDE_MODEL_ALIASES = new Set(["opus", "sonnet", "haiku"]);
|
|
5
6
|
|
|
6
7
|
const MODEL_OPTIONS: Record<string, ModelOption[]> = {
|
|
7
8
|
"claude-code": [
|
|
@@ -130,3 +131,21 @@ export function getDefaultInferenceForModel(
|
|
|
130
131
|
const levels = getInferenceLevelsForModel(model);
|
|
131
132
|
return levels[0];
|
|
132
133
|
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Normalize a model identifier for consistent display and persistence.
|
|
137
|
+
*/
|
|
138
|
+
export function normalizeModelId(
|
|
139
|
+
tool: AITool,
|
|
140
|
+
model?: string | null,
|
|
141
|
+
): string | null {
|
|
142
|
+
if (model === null || model === undefined) return model ?? null;
|
|
143
|
+
const trimmed = model.trim();
|
|
144
|
+
if (!trimmed) return null;
|
|
145
|
+
if (tool === "claude-code") {
|
|
146
|
+
const lower = trimmed.toLowerCase();
|
|
147
|
+
if (lower === "opuss") return "opus";
|
|
148
|
+
if (CLAUDE_MODEL_ALIASES.has(lower)) return lower;
|
|
149
|
+
}
|
|
150
|
+
return trimmed;
|
|
151
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
fetchAllRemotes,
|
|
8
8
|
pullFastForward,
|
|
9
9
|
getBranchDivergenceStatuses,
|
|
10
|
+
hasUncommittedChanges,
|
|
11
|
+
hasUnpushedCommits,
|
|
12
|
+
getUncommittedChangesCount,
|
|
13
|
+
getUnpushedCommitsCount,
|
|
14
|
+
pushBranchToRemote,
|
|
10
15
|
GitError,
|
|
11
16
|
} from "./git.js";
|
|
12
17
|
import { launchClaudeCode } from "./claude.js";
|
|
@@ -23,10 +28,10 @@ import {
|
|
|
23
28
|
import chalk from "chalk";
|
|
24
29
|
import type { SelectionResult } from "./cli/ui/components/App.js";
|
|
25
30
|
import {
|
|
26
|
-
worktreeExists,
|
|
27
31
|
isProtectedBranchName,
|
|
28
32
|
switchToProtectedBranch,
|
|
29
33
|
WorktreeError,
|
|
34
|
+
resolveWorktreePathForBranch,
|
|
30
35
|
} from "./worktree.js";
|
|
31
36
|
import {
|
|
32
37
|
getTerminalStreams,
|
|
@@ -44,16 +49,18 @@ import {
|
|
|
44
49
|
import { getPackageVersion } from "./utils.js";
|
|
45
50
|
import { findLatestClaudeSessionId } from "./utils/session.js";
|
|
46
51
|
import { resolveContinueSessionId } from "./cli/ui/utils/continueSession.js";
|
|
52
|
+
import { normalizeModelId } from "./cli/ui/utils/modelOptions.js";
|
|
47
53
|
import {
|
|
48
54
|
installDependenciesForWorktree,
|
|
49
55
|
DependencyInstallError,
|
|
50
56
|
type DependencyInstallResult,
|
|
51
57
|
} from "./services/dependency-installer.js";
|
|
52
|
-
import { waitForEnter } from "./utils/prompt.js";
|
|
58
|
+
import { confirmYesNo, waitForEnter } from "./utils/prompt.js";
|
|
53
59
|
|
|
54
60
|
const ERROR_PROMPT = chalk.yellow(
|
|
55
61
|
"Review the error details, then press Enter to continue.",
|
|
56
62
|
);
|
|
63
|
+
const POST_SESSION_DELAY_MS = 3000;
|
|
57
64
|
|
|
58
65
|
// Category: cli
|
|
59
66
|
const appLogger = createLogger({ category: "cli" });
|
|
@@ -299,9 +306,10 @@ export async function handleAIToolWorkflow(
|
|
|
299
306
|
} = selectionResult;
|
|
300
307
|
|
|
301
308
|
const branchLabel = displayName ?? branch;
|
|
309
|
+
const normalizedModel = normalizeModelId(tool, model ?? null);
|
|
302
310
|
const modelInfo =
|
|
303
|
-
|
|
304
|
-
? `, model=${
|
|
311
|
+
normalizedModel || inferenceLevel
|
|
312
|
+
? `, model=${normalizedModel ?? "default"}${inferenceLevel ? `/${inferenceLevel}` : ""}`
|
|
305
313
|
: "";
|
|
306
314
|
printInfo(
|
|
307
315
|
`Selected: ${branchLabel} with ${tool} (${mode} mode${modelInfo}, skipPermissions: ${skipPermissions})`,
|
|
@@ -328,7 +336,16 @@ export async function handleAIToolWorkflow(
|
|
|
328
336
|
ensureOptions.isNewBranch = !localExists;
|
|
329
337
|
}
|
|
330
338
|
|
|
331
|
-
const
|
|
339
|
+
const existingWorktreeResolution =
|
|
340
|
+
await resolveWorktreePathForBranch(branch);
|
|
341
|
+
const existingWorktree = existingWorktreeResolution.path;
|
|
342
|
+
if (!existingWorktree && existingWorktreeResolution.mismatch) {
|
|
343
|
+
const actualBranch =
|
|
344
|
+
existingWorktreeResolution.mismatch.actualBranch ?? "unknown";
|
|
345
|
+
printWarning(
|
|
346
|
+
`Worktree mismatch detected: ${existingWorktreeResolution.mismatch.path} is checked out to '${actualBranch}'. Creating or reusing the correct worktree for '${branch}'.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
332
349
|
|
|
333
350
|
const isProtectedBranch =
|
|
334
351
|
isProtectedBranchName(branch) ||
|
|
@@ -543,7 +560,7 @@ export async function handleAIToolWorkflow(
|
|
|
543
560
|
lastUsedTool: tool,
|
|
544
561
|
toolLabel: toolConfig.displayName ?? tool,
|
|
545
562
|
mode,
|
|
546
|
-
model:
|
|
563
|
+
model: normalizedModel ?? null,
|
|
547
564
|
reasoningLevel: inferenceLevel ?? null,
|
|
548
565
|
skipPermissions: skipPermissions ?? null,
|
|
549
566
|
timestamp: Date.now(),
|
|
@@ -602,8 +619,8 @@ export async function handleAIToolWorkflow(
|
|
|
602
619
|
envOverrides: sharedEnv,
|
|
603
620
|
sessionId: resumeSessionId,
|
|
604
621
|
};
|
|
605
|
-
if (
|
|
606
|
-
launchOptions.model =
|
|
622
|
+
if (normalizedModel) {
|
|
623
|
+
launchOptions.model = normalizedModel;
|
|
607
624
|
}
|
|
608
625
|
launchResult = await launchClaudeCode(worktreePath, launchOptions);
|
|
609
626
|
} else if (tool === "codex-cli") {
|
|
@@ -625,8 +642,8 @@ export async function handleAIToolWorkflow(
|
|
|
625
642
|
envOverrides: sharedEnv,
|
|
626
643
|
sessionId: resumeSessionId,
|
|
627
644
|
};
|
|
628
|
-
if (
|
|
629
|
-
launchOptions.model =
|
|
645
|
+
if (normalizedModel) {
|
|
646
|
+
launchOptions.model = normalizedModel;
|
|
630
647
|
}
|
|
631
648
|
if (inferenceLevel) {
|
|
632
649
|
launchOptions.reasoningEffort = inferenceLevel as CodexReasoningEffort;
|
|
@@ -650,8 +667,8 @@ export async function handleAIToolWorkflow(
|
|
|
650
667
|
envOverrides: sharedEnv,
|
|
651
668
|
sessionId: resumeSessionId,
|
|
652
669
|
};
|
|
653
|
-
if (
|
|
654
|
-
launchOptions.model =
|
|
670
|
+
if (normalizedModel) {
|
|
671
|
+
launchOptions.model = normalizedModel;
|
|
655
672
|
}
|
|
656
673
|
launchResult = await launchGeminiCLI(worktreePath, launchOptions);
|
|
657
674
|
} else {
|
|
@@ -738,7 +755,7 @@ export async function handleAIToolWorkflow(
|
|
|
738
755
|
lastUsedTool: tool,
|
|
739
756
|
toolLabel: toolConfig.displayName ?? tool,
|
|
740
757
|
mode,
|
|
741
|
-
model:
|
|
758
|
+
model: normalizedModel ?? null,
|
|
742
759
|
reasoningLevel: inferenceLevel ?? null,
|
|
743
760
|
skipPermissions: skipPermissions ?? null,
|
|
744
761
|
timestamp: Date.now(),
|
|
@@ -746,8 +763,47 @@ export async function handleAIToolWorkflow(
|
|
|
746
763
|
lastSessionId: finalSessionId,
|
|
747
764
|
});
|
|
748
765
|
|
|
766
|
+
try {
|
|
767
|
+
const [hasUncommitted, hasUnpushed] = await Promise.all([
|
|
768
|
+
hasUncommittedChanges(worktreePath),
|
|
769
|
+
hasUnpushedCommits(worktreePath, branch),
|
|
770
|
+
]);
|
|
771
|
+
|
|
772
|
+
if (hasUncommitted) {
|
|
773
|
+
const uncommittedCount = await getUncommittedChangesCount(worktreePath);
|
|
774
|
+
const countLabel =
|
|
775
|
+
uncommittedCount > 0 ? ` (${uncommittedCount}件)` : "";
|
|
776
|
+
printWarning(`未コミットの変更があります${countLabel}。`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (hasUnpushed) {
|
|
780
|
+
const unpushedCount = await getUnpushedCommitsCount(
|
|
781
|
+
worktreePath,
|
|
782
|
+
branch,
|
|
783
|
+
);
|
|
784
|
+
const countLabel = unpushedCount > 0 ? ` (${unpushedCount}件)` : "";
|
|
785
|
+
const shouldPush = await confirmYesNo(
|
|
786
|
+
`未プッシュのコミットがあります${countLabel}。プッシュしますか?`,
|
|
787
|
+
{ defaultValue: false },
|
|
788
|
+
);
|
|
789
|
+
if (shouldPush) {
|
|
790
|
+
printInfo(`Pushing origin/${branch}...`);
|
|
791
|
+
try {
|
|
792
|
+
await pushBranchToRemote(worktreePath, branch);
|
|
793
|
+
printInfo(`Push completed for ${branch}.`);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
const details =
|
|
796
|
+
error instanceof Error ? error.message : String(error);
|
|
797
|
+
printWarning(`Push failed for ${branch}: ${details}`);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
} catch (error) {
|
|
802
|
+
const details = error instanceof Error ? error.message : String(error);
|
|
803
|
+
printWarning(`Failed to check git status after session: ${details}`);
|
|
804
|
+
}
|
|
749
805
|
// Small buffer before returning to branch list to avoid abrupt screen swap
|
|
750
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
806
|
+
await new Promise((resolve) => setTimeout(resolve, POST_SESSION_DELAY_MS));
|
|
751
807
|
printInfo("Session completed successfully. Returning to main menu...");
|
|
752
808
|
return;
|
|
753
809
|
} catch (error) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PassThrough } from "node:stream";
|
|
2
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
3
3
|
|
|
4
4
|
// Shared mock target to avoid hoisting issues
|
|
5
5
|
const terminalStreams: Record<string, unknown> = {};
|
|
@@ -16,16 +16,32 @@ const withTimeout = <T>(promise: Promise<T>, ms = 500): Promise<T> =>
|
|
|
16
16
|
),
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
+
const resetTerminalStreams = () => {
|
|
20
|
+
vi.resetModules();
|
|
21
|
+
for (const key of Object.keys(terminalStreams)) {
|
|
22
|
+
delete terminalStreams[key];
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const setupTerminalStreams = (isTTY: boolean) => {
|
|
27
|
+
const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
|
|
28
|
+
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
|
|
29
|
+
Object.defineProperty(stdin, "isTTY", { value: isTTY, configurable: true });
|
|
30
|
+
const exitRawMode = vi.fn();
|
|
31
|
+
Object.assign(terminalStreams, {
|
|
32
|
+
stdin,
|
|
33
|
+
stdout,
|
|
34
|
+
stderr: stdout,
|
|
35
|
+
usingFallback: false,
|
|
36
|
+
exitRawMode,
|
|
37
|
+
});
|
|
38
|
+
return { stdin, stdout, exitRawMode };
|
|
39
|
+
};
|
|
40
|
+
|
|
19
41
|
describe("waitForEnter", () => {
|
|
20
42
|
it("uses terminal stdin/stdout and resolves after newline on TTY", async () => {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
delete terminalStreams[key];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
|
|
27
|
-
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
|
|
28
|
-
Object.defineProperty(stdin, "isTTY", { value: true });
|
|
43
|
+
resetTerminalStreams();
|
|
44
|
+
const { stdin, exitRawMode } = setupTerminalStreams(true);
|
|
29
45
|
|
|
30
46
|
let resumed = false;
|
|
31
47
|
let paused = false;
|
|
@@ -41,16 +57,6 @@ describe("waitForEnter", () => {
|
|
|
41
57
|
return originalPause();
|
|
42
58
|
}) as typeof stdin.pause;
|
|
43
59
|
|
|
44
|
-
const exitRawMode = vi.fn();
|
|
45
|
-
|
|
46
|
-
Object.assign(terminalStreams, {
|
|
47
|
-
stdin,
|
|
48
|
-
stdout,
|
|
49
|
-
stderr: stdout,
|
|
50
|
-
usingFallback: false,
|
|
51
|
-
exitRawMode,
|
|
52
|
-
});
|
|
53
|
-
|
|
54
60
|
const { waitForEnter } = await import("../prompt.js");
|
|
55
61
|
|
|
56
62
|
const waiting = withTimeout(waitForEnter("prompt"), 200);
|
|
@@ -63,22 +69,8 @@ describe("waitForEnter", () => {
|
|
|
63
69
|
});
|
|
64
70
|
|
|
65
71
|
it("returns immediately on non-TTY stdin", async () => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
delete terminalStreams[key];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const stdin = new PassThrough() as unknown as NodeJS.ReadStream;
|
|
72
|
-
const stdout = new PassThrough() as unknown as NodeJS.WriteStream;
|
|
73
|
-
Object.defineProperty(stdin, "isTTY", { value: false });
|
|
74
|
-
|
|
75
|
-
Object.assign(terminalStreams, {
|
|
76
|
-
stdin,
|
|
77
|
-
stdout,
|
|
78
|
-
stderr: stdout,
|
|
79
|
-
usingFallback: false,
|
|
80
|
-
exitRawMode: vi.fn(),
|
|
81
|
-
});
|
|
72
|
+
resetTerminalStreams();
|
|
73
|
+
setupTerminalStreams(false);
|
|
82
74
|
|
|
83
75
|
const { waitForEnter } = await import("../prompt.js");
|
|
84
76
|
|
|
@@ -87,3 +79,48 @@ describe("waitForEnter", () => {
|
|
|
87
79
|
expect(Date.now() - start).toBeLessThan(50);
|
|
88
80
|
});
|
|
89
81
|
});
|
|
82
|
+
|
|
83
|
+
describe("confirmYesNo", () => {
|
|
84
|
+
let stdin: NodeJS.ReadStream;
|
|
85
|
+
let exitRawMode: ReturnType<typeof vi.fn>;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
resetTerminalStreams();
|
|
89
|
+
const setup = setupTerminalStreams(true);
|
|
90
|
+
stdin = setup.stdin;
|
|
91
|
+
exitRawMode = setup.exitRawMode;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("resolves true when user inputs y on TTY", async () => {
|
|
95
|
+
const { confirmYesNo } = await import("../prompt.js");
|
|
96
|
+
|
|
97
|
+
const waiting = withTimeout(confirmYesNo("push?"), 200);
|
|
98
|
+
stdin.write("y\n");
|
|
99
|
+
|
|
100
|
+
await expect(waiting).resolves.toBe(true);
|
|
101
|
+
expect(exitRawMode).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("uses default value when input is empty on TTY", async () => {
|
|
105
|
+
const { confirmYesNo } = await import("../prompt.js");
|
|
106
|
+
|
|
107
|
+
const waiting = withTimeout(
|
|
108
|
+
confirmYesNo("push?", { defaultValue: true }),
|
|
109
|
+
200,
|
|
110
|
+
);
|
|
111
|
+
stdin.write("\n");
|
|
112
|
+
|
|
113
|
+
await expect(waiting).resolves.toBe(true);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("returns default immediately on non-TTY stdin", async () => {
|
|
117
|
+
Object.defineProperty(stdin, "isTTY", { value: false });
|
|
118
|
+
|
|
119
|
+
const { confirmYesNo } = await import("../prompt.js");
|
|
120
|
+
|
|
121
|
+
const start = Date.now();
|
|
122
|
+
const result = await confirmYesNo("push?", { defaultValue: false });
|
|
123
|
+
expect(result).toBe(false);
|
|
124
|
+
expect(Date.now() - start).toBeLessThan(50);
|
|
125
|
+
});
|
|
126
|
+
});
|