@akiojin/gwt 4.1.1 → 4.3.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.
Files changed (135) hide show
  1. package/README.md +28 -3
  2. package/dist/claude.d.ts +4 -0
  3. package/dist/claude.d.ts.map +1 -1
  4. package/dist/claude.js +13 -1
  5. package/dist/claude.js.map +1 -1
  6. package/dist/cli/ui/components/App.d.ts.map +1 -1
  7. package/dist/cli/ui/components/App.js +68 -68
  8. package/dist/cli/ui/components/App.js.map +1 -1
  9. package/dist/cli/ui/components/common/Select.d.ts +3 -1
  10. package/dist/cli/ui/components/common/Select.d.ts.map +1 -1
  11. package/dist/cli/ui/components/common/Select.js +13 -2
  12. package/dist/cli/ui/components/common/Select.js.map +1 -1
  13. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  14. package/dist/cli/ui/components/screens/BranchListScreen.js +6 -1
  15. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  16. package/dist/client/assets/index-ChHC-Puh.css +1 -0
  17. package/dist/client/assets/index-PqK9jkug.js +78 -0
  18. package/dist/client/index.html +2 -2
  19. package/dist/config/builtin-tools.d.ts.map +1 -1
  20. package/dist/config/builtin-tools.js +3 -0
  21. package/dist/config/builtin-tools.js.map +1 -1
  22. package/dist/config/tools.d.ts.map +1 -1
  23. package/dist/config/tools.js +10 -1
  24. package/dist/config/tools.js.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +37 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/launcher.d.ts.map +1 -1
  29. package/dist/launcher.js +15 -0
  30. package/dist/launcher.js.map +1 -1
  31. package/dist/services/aiToolResolver.d.ts.map +1 -1
  32. package/dist/services/aiToolResolver.js +55 -8
  33. package/dist/services/aiToolResolver.js.map +1 -1
  34. package/dist/services/customToolResolver.d.ts.map +1 -1
  35. package/dist/services/customToolResolver.js +22 -17
  36. package/dist/services/customToolResolver.js.map +1 -1
  37. package/dist/utils/prompt.d.ts +12 -0
  38. package/dist/utils/prompt.d.ts.map +1 -1
  39. package/dist/utils/prompt.js +60 -10
  40. package/dist/utils/prompt.js.map +1 -1
  41. package/dist/utils/webui.js +1 -1
  42. package/dist/web/client/src/components/BranchGraph.d.ts +5 -0
  43. package/dist/web/client/src/components/BranchGraph.d.ts.map +1 -1
  44. package/dist/web/client/src/components/BranchGraph.js +35 -108
  45. package/dist/web/client/src/components/BranchGraph.js.map +1 -1
  46. package/dist/web/client/src/components/graph/BranchDetailPanel.d.ts +15 -0
  47. package/dist/web/client/src/components/graph/BranchDetailPanel.d.ts.map +1 -0
  48. package/dist/web/client/src/components/graph/BranchDetailPanel.js +57 -0
  49. package/dist/web/client/src/components/graph/BranchDetailPanel.js.map +1 -0
  50. package/dist/web/client/src/components/graph/BranchNode.d.ts +13 -0
  51. package/dist/web/client/src/components/graph/BranchNode.d.ts.map +1 -0
  52. package/dist/web/client/src/components/graph/BranchNode.js +103 -0
  53. package/dist/web/client/src/components/graph/BranchNode.js.map +1 -0
  54. package/dist/web/client/src/components/graph/ClusterNode.d.ts +13 -0
  55. package/dist/web/client/src/components/graph/ClusterNode.d.ts.map +1 -0
  56. package/dist/web/client/src/components/graph/ClusterNode.js +109 -0
  57. package/dist/web/client/src/components/graph/ClusterNode.js.map +1 -0
  58. package/dist/web/client/src/components/graph/SynapticCanvas.d.ts +17 -0
  59. package/dist/web/client/src/components/graph/SynapticCanvas.d.ts.map +1 -0
  60. package/dist/web/client/src/components/graph/SynapticCanvas.js +94 -0
  61. package/dist/web/client/src/components/graph/SynapticCanvas.js.map +1 -0
  62. package/dist/web/client/src/components/graph/SynapticEdge.d.ts +13 -0
  63. package/dist/web/client/src/components/graph/SynapticEdge.d.ts.map +1 -0
  64. package/dist/web/client/src/components/graph/SynapticEdge.js +113 -0
  65. package/dist/web/client/src/components/graph/SynapticEdge.js.map +1 -0
  66. package/dist/web/client/src/components/graph/graphUtils.d.ts +67 -0
  67. package/dist/web/client/src/components/graph/graphUtils.d.ts.map +1 -0
  68. package/dist/web/client/src/components/graph/graphUtils.js +175 -0
  69. package/dist/web/client/src/components/graph/graphUtils.js.map +1 -0
  70. package/dist/web/client/src/components/graph/index.d.ts +10 -0
  71. package/dist/web/client/src/components/graph/index.d.ts.map +1 -0
  72. package/dist/web/client/src/components/graph/index.js +10 -0
  73. package/dist/web/client/src/components/graph/index.js.map +1 -0
  74. package/dist/web/client/src/lib/websocket.d.ts.map +1 -1
  75. package/dist/web/client/src/lib/websocket.js +2 -1
  76. package/dist/web/client/src/lib/websocket.js.map +1 -1
  77. package/dist/web/client/vite.config.js +1 -1
  78. package/dist/web/server/env/importer.d.ts.map +1 -1
  79. package/dist/web/server/env/importer.js +4 -0
  80. package/dist/web/server/env/importer.js.map +1 -1
  81. package/dist/web/server/index.d.ts.map +1 -1
  82. package/dist/web/server/index.js +9 -0
  83. package/dist/web/server/index.js.map +1 -1
  84. package/dist/web/server/pty/manager.d.ts.map +1 -1
  85. package/dist/web/server/pty/manager.js +24 -1
  86. package/dist/web/server/pty/manager.js.map +1 -1
  87. package/dist/web/server/routes/sessions.d.ts.map +1 -1
  88. package/dist/web/server/routes/sessions.js +7 -0
  89. package/dist/web/server/routes/sessions.js.map +1 -1
  90. package/dist/web/server/tray.d.ts +1 -1
  91. package/dist/web/server/tray.d.ts.map +1 -1
  92. package/dist/web/server/tray.js +52 -34
  93. package/dist/web/server/tray.js.map +1 -1
  94. package/dist/web/server/websocket/handler.d.ts.map +1 -1
  95. package/dist/web/server/websocket/handler.js +4 -0
  96. package/dist/web/server/websocket/handler.js.map +1 -1
  97. package/package.json +6 -3
  98. package/src/claude.ts +15 -1
  99. package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +2 -1
  100. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +142 -8
  101. package/src/cli/ui/__tests__/components/App.test.tsx +4 -3
  102. package/src/cli/ui/__tests__/components/ModelSelectorScreen.initial.test.tsx +1 -0
  103. package/src/cli/ui/__tests__/components/common/Select.test.tsx +45 -0
  104. package/src/cli/ui/components/App.tsx +91 -81
  105. package/src/cli/ui/components/common/Select.tsx +14 -1
  106. package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
  107. package/src/cli/ui/types.ts +1 -1
  108. package/src/config/builtin-tools.ts +3 -0
  109. package/src/config/tools.ts +24 -1
  110. package/src/index.ts +50 -3
  111. package/src/launcher.ts +26 -0
  112. package/src/services/aiToolResolver.ts +75 -9
  113. package/src/services/customToolResolver.ts +32 -17
  114. package/src/utils/__tests__/prompt.test.ts +72 -35
  115. package/src/utils/prompt.ts +79 -10
  116. package/src/utils/webui.ts +1 -1
  117. package/src/web/client/src/components/BranchGraph.tsx +51 -208
  118. package/src/web/client/src/components/graph/BranchDetailPanel.tsx +152 -0
  119. package/src/web/client/src/components/graph/BranchNode.tsx +200 -0
  120. package/src/web/client/src/components/graph/ClusterNode.tsx +211 -0
  121. package/src/web/client/src/components/graph/SynapticCanvas.tsx +171 -0
  122. package/src/web/client/src/components/graph/SynapticEdge.tsx +311 -0
  123. package/src/web/client/src/components/graph/graphUtils.ts +265 -0
  124. package/src/web/client/src/components/graph/index.ts +10 -0
  125. package/src/web/client/src/index.css +314 -29
  126. package/src/web/client/src/lib/websocket.ts +2 -1
  127. package/src/web/client/vite.config.ts +1 -1
  128. package/src/web/server/env/importer.ts +5 -0
  129. package/src/web/server/index.ts +10 -0
  130. package/src/web/server/pty/manager.ts +43 -1
  131. package/src/web/server/routes/sessions.ts +15 -0
  132. package/src/web/server/tray.ts +62 -46
  133. package/src/web/server/websocket/handler.ts +13 -0
  134. package/dist/client/assets/index-DsDNCy5f.css +0 -1
  135. package/dist/client/assets/index-v8smkNOL.js +0 -72
@@ -1,12 +1,16 @@
1
1
  import { execa } from "execa";
2
2
  import { platform } from "os";
3
3
  import { getToolById } from "../config/tools.js";
4
+ import { CLAUDE_CODE_TOOL } from "../config/builtin-tools.js";
4
5
  import {
5
6
  CODEX_DEFAULT_ARGS,
6
7
  CLAUDE_PERMISSION_SKIP_ARGS,
7
8
  } from "../shared/aiToolConstants.js";
8
9
  import { prepareCustomToolExecution } from "./customToolResolver.js";
9
10
  import type { LaunchOptions } from "../types/tools.js";
11
+ import { createLogger } from "../logging/logger.js";
12
+
13
+ const logger = createLogger({ category: "resolver" });
10
14
 
11
15
  const DETECTION_COMMAND = platform() === "win32" ? "where" : "which";
12
16
  const MIN_BUN_MAJOR = 1;
@@ -41,12 +45,32 @@ export class AIToolResolutionError extends Error {
41
45
  async function commandExists(command: string): Promise<boolean> {
42
46
  try {
43
47
  await execa(DETECTION_COMMAND, [command], { shell: true });
48
+ logger.debug({ command, exists: true }, "Command check");
44
49
  return true;
45
50
  } catch {
51
+ logger.debug({ command, exists: false }, "Command check");
46
52
  return false;
47
53
  }
48
54
  }
49
55
 
56
+ /**
57
+ * コマンドのフルパスを取得
58
+ * node-ptyはシェルを経由しないため、フルパスが必要
59
+ */
60
+ async function resolveCommandPath(command: string): Promise<string | null> {
61
+ try {
62
+ const { stdout } = await execa(DETECTION_COMMAND, [command], {
63
+ shell: true,
64
+ });
65
+ const fullPath = stdout.trim().split("\n")[0];
66
+ logger.debug({ command, fullPath }, "Command path resolved");
67
+ return fullPath || null;
68
+ } catch {
69
+ logger.debug({ command, fullPath: null }, "Command path resolution failed");
70
+ return null;
71
+ }
72
+ }
73
+
50
74
  let bunxCheckPromise: Promise<void> | null = null;
51
75
 
52
76
  async function ensureBunxAvailable(): Promise<void> {
@@ -67,6 +91,7 @@ async function ensureBunxAvailable(): Promise<void> {
67
91
  try {
68
92
  const { stdout } = await execa("bun", ["--version"]);
69
93
  const version = stdout.trim();
94
+ logger.debug({ bunVersion: version }, "Bun version detected");
70
95
  const major = parseInt(version.split(".")[0] ?? "0", 10);
71
96
  if (!Number.isFinite(major) || major < MIN_BUN_MAJOR) {
72
97
  throw new AIToolResolutionError(
@@ -144,20 +169,40 @@ export async function resolveClaudeCommand(
144
169
  options: ClaudeCommandOptions = {},
145
170
  ): Promise<ResolvedCommand> {
146
171
  const args = buildClaudeArgs(options);
147
-
148
- if (await commandExists("claude")) {
172
+ const envOverrides = CLAUDE_CODE_TOOL.env
173
+ ? { env: { ...CLAUDE_CODE_TOOL.env } as NodeJS.ProcessEnv }
174
+ : {};
175
+
176
+ // フルパスを取得(node-ptyはシェルを経由しないため必要)
177
+ const claudePath = await resolveCommandPath("claude");
178
+ if (claudePath) {
179
+ logger.info(
180
+ { command: claudePath, usesFallback: false },
181
+ "Claude command resolved",
182
+ );
149
183
  return {
150
- command: "claude",
184
+ command: claudePath,
151
185
  args,
152
186
  usesFallback: false,
187
+ ...envOverrides,
153
188
  };
154
189
  }
155
190
 
156
- await ensureBunxAvailable();
191
+ // bunxへフォールバック
192
+ const bunxPath = await resolveCommandPath("bunx");
193
+ if (!bunxPath) {
194
+ await ensureBunxAvailable(); // エラーをスローする
195
+ }
196
+
197
+ logger.info(
198
+ { command: bunxPath ?? "bunx", usesFallback: true },
199
+ "Claude command resolved (fallback)",
200
+ );
157
201
  return {
158
- command: "bunx",
202
+ command: bunxPath ?? "bunx",
159
203
  args: [CLAUDE_CLI_PACKAGE, ...args],
160
204
  usesFallback: true,
205
+ ...envOverrides,
161
206
  };
162
207
  }
163
208
 
@@ -198,17 +243,32 @@ export async function resolveCodexCommand(
198
243
  ): Promise<ResolvedCommand> {
199
244
  const args = buildCodexArgs(options);
200
245
 
201
- if (await commandExists("codex")) {
246
+ // フルパスを取得(node-ptyはシェルを経由しないため必要)
247
+ const codexPath = await resolveCommandPath("codex");
248
+ if (codexPath) {
249
+ logger.info(
250
+ { command: codexPath, usesFallback: false },
251
+ "Codex command resolved",
252
+ );
202
253
  return {
203
- command: "codex",
254
+ command: codexPath,
204
255
  args,
205
256
  usesFallback: false,
206
257
  };
207
258
  }
208
259
 
209
- await ensureBunxAvailable();
260
+ // bunxへフォールバック
261
+ const bunxPath = await resolveCommandPath("bunx");
262
+ if (!bunxPath) {
263
+ await ensureBunxAvailable(); // エラーをスローする
264
+ }
265
+
266
+ logger.info(
267
+ { command: bunxPath ?? "bunx", usesFallback: true },
268
+ "Codex command resolved (fallback)",
269
+ );
210
270
  return {
211
- command: "bunx",
271
+ command: bunxPath ?? "bunx",
212
272
  args: [CODEX_CLI_PACKAGE, ...args],
213
273
  usesFallback: true,
214
274
  };
@@ -223,6 +283,7 @@ export async function resolveCustomToolCommand(
223
283
  ): Promise<ResolvedCommand> {
224
284
  const tool = await getToolById(options.toolId);
225
285
  if (!tool) {
286
+ logger.error({ toolId: options.toolId }, "Custom tool not found");
226
287
  throw new AIToolResolutionError(
227
288
  "CUSTOM_TOOL_NOT_FOUND",
228
289
  `Custom tool not found: ${options.toolId}`,
@@ -235,6 +296,11 @@ export async function resolveCustomToolCommand(
235
296
 
236
297
  const execution = await prepareCustomToolExecution(tool, options);
237
298
 
299
+ logger.info(
300
+ { toolId: options.toolId, command: execution.command },
301
+ "Custom tool command resolved",
302
+ );
303
+
238
304
  return {
239
305
  command: execution.command,
240
306
  args: execution.args,
@@ -1,5 +1,8 @@
1
1
  import { execa } from "execa";
2
2
  import type { CustomAITool, LaunchOptions } from "../types/tools.js";
3
+ import { createLogger } from "../logging/logger.js";
4
+
5
+ const logger = createLogger({ category: "custom-resolver" });
3
6
 
4
7
  export interface CustomToolExecutionPlan {
5
8
  command: string;
@@ -21,6 +24,7 @@ export async function resolveCommandPath(commandName: string): Promise<string> {
21
24
  );
22
25
  }
23
26
 
27
+ logger.debug({ commandName, resolvedPath }, "Command path resolved");
24
28
  return resolvedPath;
25
29
  } catch (error) {
26
30
  const reason = error instanceof Error ? error.message : String(error);
@@ -55,6 +59,10 @@ export function buildCustomToolArgs(
55
59
  args.push(...options.extraArgs);
56
60
  }
57
61
 
62
+ logger.debug(
63
+ { toolId: tool.id, argsCount: args.length },
64
+ "Custom tool args built",
65
+ );
58
66
  return args;
59
67
  }
60
68
 
@@ -62,37 +70,44 @@ export async function prepareCustomToolExecution(
62
70
  tool: CustomAITool,
63
71
  options: LaunchOptions = {},
64
72
  ): Promise<CustomToolExecutionPlan> {
65
- const args = buildCustomToolArgs(tool, options);
73
+ const baseArgs = buildCustomToolArgs(tool, options);
66
74
  const envOverrides: NodeJS.ProcessEnv | undefined = tool.env
67
75
  ? ({ ...tool.env } as NodeJS.ProcessEnv)
68
76
  : undefined;
69
77
 
78
+ let command: string;
79
+ let args: string[];
80
+
70
81
  switch (tool.type) {
71
82
  case "path": {
72
- return {
73
- command: tool.command,
74
- args,
75
- ...(envOverrides ? { env: envOverrides } : {}),
76
- };
83
+ command = tool.command;
84
+ args = baseArgs;
85
+ break;
77
86
  }
78
87
  case "bunx": {
79
- return {
80
- command: "bunx",
81
- args: [tool.command, ...args],
82
- ...(envOverrides ? { env: envOverrides } : {}),
83
- };
88
+ command = "bunx";
89
+ args = [tool.command, ...baseArgs];
90
+ break;
84
91
  }
85
92
  case "command": {
86
- const resolved = await resolveCommandPath(tool.command);
87
- return {
88
- command: resolved,
89
- args,
90
- ...(envOverrides ? { env: envOverrides } : {}),
91
- };
93
+ command = await resolveCommandPath(tool.command);
94
+ args = baseArgs;
95
+ break;
92
96
  }
93
97
  default: {
94
98
  const exhaustive: never = tool.type;
95
99
  throw new Error(`Unknown custom tool type: ${exhaustive as string}`);
96
100
  }
97
101
  }
102
+
103
+ logger.debug(
104
+ { toolId: tool.id, toolType: tool.type, command, hasEnv: !!envOverrides },
105
+ "Custom tool execution prepared",
106
+ );
107
+
108
+ return {
109
+ command,
110
+ args,
111
+ ...(envOverrides ? { env: envOverrides } : {}),
112
+ };
98
113
  }
@@ -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
- vi.resetModules();
22
- for (const key of Object.keys(terminalStreams)) {
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
- vi.resetModules();
67
- for (const key of Object.keys(terminalStreams)) {
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
+ });
@@ -1,17 +1,26 @@
1
1
  import readline from "node:readline";
2
2
  import { getTerminalStreams } from "./terminal.js";
3
3
 
4
- /**
5
- * Wait for Enter using the same terminal streams as Ink.
6
- * Falls back to no-op on non-interactive stdin to avoid blocking pipelines.
7
- */
8
- export async function waitForEnter(promptMessage: string): Promise<void> {
4
+ type ReadlinePromptOptions<T> = {
5
+ fallback: T;
6
+ onAnswer: (answer: string) => T | undefined;
7
+ shouldSkip?: (terminal: ReturnType<typeof getTerminalStreams>) => boolean;
8
+ };
9
+
10
+ async function runReadlinePrompt<T>(
11
+ promptText: string,
12
+ { fallback, onAnswer, shouldSkip }: ReadlinePromptOptions<T>,
13
+ ): Promise<T> {
9
14
  const terminal = getTerminalStreams();
10
15
  const stdin = terminal.stdin as NodeJS.ReadStream | undefined;
11
16
  const stdout = terminal.stdout as NodeJS.WriteStream | undefined;
12
17
 
13
18
  if (!stdin || typeof stdin.on !== "function" || !stdin.isTTY) {
14
- return;
19
+ return fallback;
20
+ }
21
+
22
+ if (shouldSkip?.(terminal)) {
23
+ return fallback;
15
24
  }
16
25
 
17
26
  terminal.exitRawMode?.();
@@ -30,10 +39,15 @@ export async function waitForEnter(promptMessage: string): Promise<void> {
30
39
  }
31
40
  }
32
41
 
33
- await new Promise<void>((resolve) => {
42
+ return new Promise<T>((resolve) => {
34
43
  const rl = readline.createInterface({ input: stdin, output: stdout });
44
+ let finished = false;
35
45
 
36
46
  const cleanup = () => {
47
+ if (finished) {
48
+ return;
49
+ }
50
+ finished = true;
37
51
  rl.removeAllListeners();
38
52
  rl.close();
39
53
  const remover = (method: "off" | "removeListener") =>
@@ -61,7 +75,7 @@ export async function waitForEnter(promptMessage: string): Promise<void> {
61
75
 
62
76
  const onEnd = () => {
63
77
  cleanup();
64
- resolve();
78
+ resolve(fallback);
65
79
  };
66
80
 
67
81
  rl.on("SIGINT", () => {
@@ -69,12 +83,67 @@ export async function waitForEnter(promptMessage: string): Promise<void> {
69
83
  process.exit(0);
70
84
  });
71
85
 
72
- rl.question(`${promptMessage}\n`, () => {
86
+ rl.question(`${promptText}\n`, (answer) => {
87
+ const result = onAnswer(answer);
73
88
  cleanup();
74
- resolve();
89
+ resolve(result !== undefined ? result : fallback);
75
90
  });
76
91
 
77
92
  stdin.once("end", onEnd);
78
93
  stdin.once("error", onEnd);
79
94
  });
80
95
  }
96
+
97
+ /**
98
+ * Wait for Enter using the same terminal streams as Ink.
99
+ * Falls back to no-op on non-interactive stdin to avoid blocking pipelines.
100
+ */
101
+ export async function waitForEnter(promptMessage: string): Promise<void> {
102
+ await runReadlinePrompt(promptMessage, {
103
+ fallback: undefined,
104
+ onAnswer: () => undefined,
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Prompts the user for a yes/no confirmation in the terminal.
110
+ * Falls back to the default value on non-interactive stdin or fallback terminals.
111
+ *
112
+ * @param promptMessage - The message to display to the user
113
+ * @param options - Configuration options
114
+ * @param options.defaultValue - The default value when input is empty or stdin is non-interactive
115
+ * @returns A promise that resolves to true for yes, false for no
116
+ */
117
+ export async function confirmYesNo(
118
+ promptMessage: string,
119
+ options: { defaultValue?: boolean } = {},
120
+ ): Promise<boolean> {
121
+ const fallback = options.defaultValue ?? false;
122
+
123
+ const suffix =
124
+ options.defaultValue === undefined
125
+ ? "[y/n]"
126
+ : options.defaultValue
127
+ ? "[Y/n]"
128
+ : "[y/N]";
129
+
130
+ const promptText = `${promptMessage} ${suffix}`.trim();
131
+
132
+ return runReadlinePrompt(promptText, {
133
+ fallback,
134
+ shouldSkip: (terminal) => terminal.usingFallback,
135
+ onAnswer: (answer) => {
136
+ const normalized = answer.trim().toLowerCase();
137
+ if (normalized === "y" || normalized === "yes") {
138
+ return true;
139
+ }
140
+ if (normalized === "n" || normalized === "no") {
141
+ return false;
142
+ }
143
+ if (normalized.length === 0 && options.defaultValue !== undefined) {
144
+ return options.defaultValue;
145
+ }
146
+ return undefined;
147
+ },
148
+ });
149
+ }
@@ -2,7 +2,7 @@ import * as net from "node:net";
2
2
 
3
3
  export function resolveWebUiPort(
4
4
  portEnv: string | undefined = process.env.PORT,
5
- defaultPort = 3000,
5
+ defaultPort = 3001,
6
6
  ): number {
7
7
  if (!portEnv) {
8
8
  return defaultPort;