mini-coder 0.5.0 → 0.5.1

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 CHANGED
@@ -83,6 +83,8 @@ Plugins can add more tools, but the core stays intentionally small.
83
83
  | `Tab` | File path autocomplete (or command filter on `/`) |
84
84
  | `Ctrl+R` | Search global raw input history |
85
85
  | `Ctrl+C` | Graceful exit |
86
+ | `Ctrl+D` | Graceful exit (EOF, when input is empty) |
87
+ | `:q` | Graceful exit |
86
88
 
87
89
  ## Headless one-shot mode
88
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/agent.ts CHANGED
@@ -376,6 +376,44 @@ function handleAssistantStreamEvent(
376
376
  }
377
377
  }
378
378
 
379
+ function buildIncompleteAssistantMessage(
380
+ opts: Pick<RunAgentOpts, "model" | "signal">,
381
+ partialAssistantMessage?: AssistantMessage,
382
+ ): AssistantMessage {
383
+ const stopReason = opts.signal?.aborted ? "aborted" : "error";
384
+ const errorMessage =
385
+ stopReason === "aborted"
386
+ ? "Request was aborted"
387
+ : "Stream ended without a final assistant message";
388
+
389
+ return {
390
+ role: "assistant",
391
+ content: partialAssistantMessage
392
+ ? cloneAssistantContent(partialAssistantMessage.content)
393
+ : [],
394
+ api: partialAssistantMessage?.api ?? opts.model.api,
395
+ provider: partialAssistantMessage?.provider ?? opts.model.provider,
396
+ model: partialAssistantMessage?.model ?? opts.model.id,
397
+ usage: partialAssistantMessage?.usage ?? {
398
+ input: 0,
399
+ output: 0,
400
+ cacheRead: 0,
401
+ cacheWrite: 0,
402
+ totalTokens: 0,
403
+ cost: {
404
+ input: 0,
405
+ output: 0,
406
+ cacheRead: 0,
407
+ cacheWrite: 0,
408
+ total: 0,
409
+ },
410
+ },
411
+ stopReason,
412
+ errorMessage,
413
+ timestamp: Date.now(),
414
+ };
415
+ }
416
+
379
417
  async function streamAssistantMessage(
380
418
  opts: Pick<
381
419
  RunAgentOpts,
@@ -394,6 +432,12 @@ async function streamAssistantMessage(
394
432
  buildAgentContext(opts.systemPrompt, opts.messages, opts.tools),
395
433
  buildStreamOptions(opts.apiKey, opts.effort, opts.signal),
396
434
  );
435
+ const streamResult = eventStream.result();
436
+ let settledStreamResult: AssistantMessage | undefined;
437
+ void streamResult.then((message) => {
438
+ settledStreamResult = message;
439
+ });
440
+
397
441
  let assistantMessage: AssistantMessage | undefined;
398
442
  let partialAssistantMessage: AssistantMessage | undefined;
399
443
 
@@ -406,8 +450,12 @@ async function streamAssistantMessage(
406
450
  handleAssistantStreamEvent(event, opts.onEvent) ?? assistantMessage;
407
451
  }
408
452
 
453
+ // `end(result)` resolves the final result without emitting a terminal event.
454
+ await Promise.resolve();
409
455
  const finalAssistantMessage =
410
- assistantMessage ?? (await eventStream.result());
456
+ assistantMessage ??
457
+ settledStreamResult ??
458
+ buildIncompleteAssistantMessage(opts, partialAssistantMessage);
411
459
  if (!partialAssistantMessage) {
412
460
  return finalAssistantMessage;
413
461
  }
package/src/git.ts CHANGED
@@ -23,6 +23,8 @@ export interface GitState {
23
23
  root: string;
24
24
  /** Current branch name (empty string for detached HEAD). */
25
25
  branch: string;
26
+ /** Upstream tracking ref such as `origin/main`, or `null` when none exists. */
27
+ upstream: string | null;
26
28
  /** Number of staged (index) changes. */
27
29
  staged: number;
28
30
  /** Number of unstaged working-tree modifications. */
@@ -135,8 +137,12 @@ export async function getGitState(cwd: string): Promise<GitState | null> {
135
137
  if (root === null) return null;
136
138
 
137
139
  // Run remaining commands in parallel
138
- const [branch, status, revList] = await Promise.all([
140
+ const [branch, upstream, status, revList] = await Promise.all([
139
141
  run(["branch", "--show-current"], cwd),
142
+ run(
143
+ ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
144
+ cwd,
145
+ ),
140
146
  run(["status", "--porcelain"], cwd, false),
141
147
  run(["rev-list", "--left-right", "--count", "HEAD...@{upstream}"], cwd),
142
148
  ]);
@@ -155,6 +161,7 @@ export async function getGitState(cwd: string): Promise<GitState | null> {
155
161
  return {
156
162
  root,
157
163
  branch: branch ?? "",
164
+ upstream,
158
165
  staged,
159
166
  modified,
160
167
  untracked,
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { homedir } from "node:os";
13
- import { join } from "node:path";
13
+ import { dirname, join } from "node:path";
14
14
  import { isDeepStrictEqual } from "node:util";
15
15
  import type {
16
16
  KnownProvider,
@@ -98,24 +98,45 @@ export const MAX_SESSIONS_PER_CWD = 20;
98
98
  /** Maximum raw prompt-history entries to retain globally. */
99
99
  export const MAX_PROMPT_HISTORY = 1_000;
100
100
 
101
+ function getErrorMessage(error: unknown): string {
102
+ return error instanceof Error ? error.message : String(error);
103
+ }
104
+
101
105
  // ---------------------------------------------------------------------------
102
106
  // OAuth credential persistence
103
107
  // ---------------------------------------------------------------------------
104
108
 
105
109
  /** Load saved OAuth credentials from disk. */
106
- function loadOAuthCredentials(): Record<string, OAuthCredentials> {
107
- if (!existsSync(AUTH_PATH)) return {};
110
+ function loadOAuthCredentials(
111
+ path = AUTH_PATH,
112
+ ): Record<string, OAuthCredentials> {
113
+ if (!existsSync(path)) return {};
114
+
115
+ let parsed: unknown;
108
116
  try {
109
- return JSON.parse(readFileSync(AUTH_PATH, "utf-8"));
110
- } catch {
111
- return {};
117
+ parsed = JSON.parse(readFileSync(path, "utf-8")) as unknown;
118
+ } catch (error) {
119
+ throw new Error(
120
+ `Failed to read OAuth credentials ${path}: ${getErrorMessage(error)}`,
121
+ );
112
122
  }
123
+
124
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
125
+ throw new Error(
126
+ `Failed to read OAuth credentials ${path}: expected a JSON object`,
127
+ );
128
+ }
129
+
130
+ return parsed as Record<string, OAuthCredentials>;
113
131
  }
114
132
 
115
133
  /** Save OAuth credentials to disk. */
116
- function saveOAuthCredentials(creds: Record<string, OAuthCredentials>): void {
117
- mkdirSync(DATA_DIR, { recursive: true });
118
- writeFileSync(AUTH_PATH, JSON.stringify(creds, null, 2), "utf-8");
134
+ function saveOAuthCredentials(
135
+ creds: Record<string, OAuthCredentials>,
136
+ path = AUTH_PATH,
137
+ ): void {
138
+ mkdirSync(dirname(path), { recursive: true });
139
+ writeFileSync(path, JSON.stringify(creds, null, 2), "utf-8");
119
140
  }
120
141
 
121
142
  /** Return whether refreshed OAuth credentials differ from the persisted value. */
package/src/prompt.ts CHANGED
@@ -99,10 +99,15 @@ function readAgentsMdFile(dir: string): AgentsMdFile | null {
99
99
  if (!existsSync(filePath)) {
100
100
  return null;
101
101
  }
102
- return {
103
- path: filePath,
104
- content: readFileSync(filePath, "utf-8"),
105
- };
102
+
103
+ try {
104
+ return {
105
+ path: filePath,
106
+ content: readFileSync(filePath, "utf-8"),
107
+ };
108
+ } catch {
109
+ return null;
110
+ }
106
111
  }
107
112
 
108
113
  /**
@@ -152,6 +157,7 @@ export function discoverAgentsMd(
152
157
  *
153
158
  * Fields are omitted when their values are zero. The git line format:
154
159
  * `Git: branch main | 3 staged, 1 modified, 2 untracked | +5 −2 vs origin/main`
160
+ * where the trailing upstream label reflects the repository's actual tracking ref.
155
161
  *
156
162
  * @param state - The git state to format.
157
163
  * @returns Formatted git status line.
@@ -171,7 +177,8 @@ export function formatGitLine(state: GitState): string {
171
177
  const ab: string[] = [];
172
178
  if (state.ahead > 0) ab.push(`+${state.ahead}`);
173
179
  if (state.behind > 0) ab.push(`\u2212${state.behind}`);
174
- parts.push(`${ab.join(" ")} vs origin/${state.branch}`);
180
+ const upstream = state.upstream ? ` vs ${state.upstream}` : "";
181
+ parts.push(`${ab.join(" ")}${upstream}`);
175
182
  }
176
183
 
177
184
  return parts.join(" | ");
package/src/session.ts CHANGED
@@ -10,7 +10,12 @@
10
10
  */
11
11
 
12
12
  import { Database } from "bun:sqlite";
13
- import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
13
+ import type {
14
+ AssistantMessage,
15
+ Message,
16
+ ToolResultMessage,
17
+ UserMessage,
18
+ } from "@mariozechner/pi-ai";
14
19
 
15
20
  // ---------------------------------------------------------------------------
16
21
  // Types
@@ -140,6 +145,21 @@ type MaxTurnRow = { max_turn: number | null };
140
145
  /** Row shape for `SELECT data` queries. */
141
146
  type DataRow = { data: string };
142
147
 
148
+ const EMPTY_ASSISTANT_USAGE: AssistantMessage["usage"] = {
149
+ input: 0,
150
+ output: 0,
151
+ cacheRead: 0,
152
+ cacheWrite: 0,
153
+ totalTokens: 0,
154
+ cost: {
155
+ input: 0,
156
+ output: 0,
157
+ cacheRead: 0,
158
+ cacheWrite: 0,
159
+ total: 0,
160
+ },
161
+ };
162
+
143
163
  /** Row shape returned by `SELECT * FROM prompt_history`. */
144
164
  type PromptHistoryRow = {
145
165
  id: number;
@@ -319,14 +339,189 @@ function getMultipartUserPreview(
319
339
  return collapsePreviewText(text);
320
340
  }
321
341
 
342
+ function isTextContentBlock(
343
+ value: unknown,
344
+ ): value is { type: "text"; text: string } {
345
+ const record = toRecord(value);
346
+ return record?.type === "text" && typeof record.text === "string";
347
+ }
348
+
349
+ function isImageContentBlock(
350
+ value: unknown,
351
+ ): value is { type: "image"; data: string; mimeType: string } {
352
+ const record = toRecord(value);
353
+ return (
354
+ record?.type === "image" &&
355
+ typeof record.data === "string" &&
356
+ typeof record.mimeType === "string"
357
+ );
358
+ }
359
+
360
+ function isThinkingContentBlock(
361
+ value: unknown,
362
+ ): value is Extract<AssistantMessage["content"][number], { type: "thinking" }> {
363
+ const record = toRecord(value);
364
+ return record?.type === "thinking" && typeof record.thinking === "string";
365
+ }
366
+
367
+ function isToolCallContentBlock(
368
+ value: unknown,
369
+ ): value is Extract<AssistantMessage["content"][number], { type: "toolCall" }> {
370
+ const record = toRecord(value);
371
+ return (
372
+ record?.type === "toolCall" &&
373
+ typeof record.id === "string" &&
374
+ typeof record.name === "string" &&
375
+ toRecord(record.arguments) !== null
376
+ );
377
+ }
378
+
379
+ function isAssistantUsage(value: unknown): value is AssistantMessage["usage"] {
380
+ const usageRecord = toRecord(value);
381
+ const costRecord = toRecord(usageRecord?.cost);
382
+ return (
383
+ usageRecord !== null &&
384
+ costRecord !== null &&
385
+ readFiniteNumber(usageRecord, "input") !== null &&
386
+ readFiniteNumber(usageRecord, "output") !== null &&
387
+ readFiniteNumber(usageRecord, "cacheRead") !== null &&
388
+ readFiniteNumber(usageRecord, "cacheWrite") !== null &&
389
+ readFiniteNumber(usageRecord, "totalTokens") !== null &&
390
+ readFiniteNumber(costRecord, "input") !== null &&
391
+ readFiniteNumber(costRecord, "output") !== null &&
392
+ readFiniteNumber(costRecord, "cacheRead") !== null &&
393
+ readFiniteNumber(costRecord, "cacheWrite") !== null &&
394
+ readFiniteNumber(costRecord, "total") !== null
395
+ );
396
+ }
397
+
398
+ function isStopReason(value: unknown): value is AssistantMessage["stopReason"] {
399
+ return (
400
+ value === "stop" ||
401
+ value === "length" ||
402
+ value === "toolUse" ||
403
+ value === "error" ||
404
+ value === "aborted"
405
+ );
406
+ }
407
+
408
+ function isUserMessageRecord(value: unknown): value is UserMessage {
409
+ const record = toRecord(value);
410
+ if (!record || record.role !== "user") {
411
+ return false;
412
+ }
413
+
414
+ return (
415
+ readFiniteNumber(record, "timestamp") !== null &&
416
+ (typeof record.content === "string" ||
417
+ (Array.isArray(record.content) &&
418
+ record.content.every(
419
+ (block) => isTextContentBlock(block) || isImageContentBlock(block),
420
+ )))
421
+ );
422
+ }
423
+
424
+ function parseAssistantMessageRecord(value: unknown): AssistantMessage | null {
425
+ const record = toRecord(value);
426
+ if (!record || record.role !== "assistant") {
427
+ return null;
428
+ }
429
+
430
+ const timestamp = readFiniteNumber(record, "timestamp");
431
+ if (
432
+ !Array.isArray(record.content) ||
433
+ !record.content.every(
434
+ (block) =>
435
+ isTextContentBlock(block) ||
436
+ isThinkingContentBlock(block) ||
437
+ isToolCallContentBlock(block),
438
+ ) ||
439
+ typeof record.api !== "string" ||
440
+ typeof record.provider !== "string" ||
441
+ typeof record.model !== "string" ||
442
+ !isStopReason(record.stopReason) ||
443
+ (record.errorMessage !== undefined &&
444
+ typeof record.errorMessage !== "string") ||
445
+ timestamp === null
446
+ ) {
447
+ return null;
448
+ }
449
+
450
+ return {
451
+ role: "assistant",
452
+ content: record.content,
453
+ api: record.api,
454
+ provider: record.provider,
455
+ model: record.model,
456
+ usage: isAssistantUsage(record.usage)
457
+ ? record.usage
458
+ : structuredClone(EMPTY_ASSISTANT_USAGE),
459
+ stopReason: record.stopReason,
460
+ ...(typeof record.errorMessage === "string"
461
+ ? { errorMessage: record.errorMessage }
462
+ : {}),
463
+ timestamp,
464
+ };
465
+ }
466
+
467
+ function isToolResultMessageRecord(value: unknown): value is ToolResultMessage {
468
+ const record = toRecord(value);
469
+ if (!record || record.role !== "toolResult") {
470
+ return false;
471
+ }
472
+
473
+ return (
474
+ typeof record.toolCallId === "string" &&
475
+ typeof record.toolName === "string" &&
476
+ typeof record.isError === "boolean" &&
477
+ Array.isArray(record.content) &&
478
+ record.content.every(
479
+ (block) => isTextContentBlock(block) || isImageContentBlock(block),
480
+ ) &&
481
+ readFiniteNumber(record, "timestamp") !== null
482
+ );
483
+ }
484
+
485
+ function isUiMessageRecord(value: unknown): value is UiMessage {
486
+ const record = toRecord(value);
487
+ if (!record || record.role !== "ui") {
488
+ return false;
489
+ }
490
+
491
+ return (
492
+ record.kind === "info" &&
493
+ typeof record.content === "string" &&
494
+ readFiniteNumber(record, "timestamp") !== null
495
+ );
496
+ }
497
+
498
+ function parsePersistedMessage(data: string): PersistedMessage | null {
499
+ let parsed: unknown;
500
+ try {
501
+ parsed = JSON.parse(data) as unknown;
502
+ } catch {
503
+ return null;
504
+ }
505
+
506
+ if (
507
+ isUserMessageRecord(parsed) ||
508
+ isToolResultMessageRecord(parsed) ||
509
+ isUiMessageRecord(parsed)
510
+ ) {
511
+ return parsed;
512
+ }
513
+
514
+ return parseAssistantMessageRecord(parsed);
515
+ }
516
+
322
517
  /** Read the first-user preview cached by the session-list query. */
323
518
  function readFirstUserPreview(messageData: string | null): string | null {
324
519
  if (!messageData) {
325
520
  return null;
326
521
  }
327
522
 
328
- const message = JSON.parse(messageData) as PersistedMessage;
329
- if (message.role !== "user") {
523
+ const message = parsePersistedMessage(messageData);
524
+ if (!message || message.role !== "user") {
330
525
  return null;
331
526
  }
332
527
 
@@ -434,7 +629,7 @@ export function filterModelMessages(
434
629
  }
435
630
 
436
631
  function toRecord(value: unknown): Record<string, unknown> | null {
437
- return typeof value === "object" && value !== null
632
+ return typeof value === "object" && value !== null && !Array.isArray(value)
438
633
  ? (value as Record<string, unknown>)
439
634
  : null;
440
635
  }
@@ -620,7 +815,8 @@ export function appendMessage(
620
815
  * Load all messages for a session in insertion order.
621
816
  *
622
817
  * Messages are deserialized from their JSON representation back into
623
- * persisted app messages. The ordering matches the original append order
818
+ * persisted app messages. Invalid rows are skipped so corrupted session data
819
+ * does not crash the app. The ordering matches the original append order
624
820
  * (by autoincrement `id`), preserving the conversation flow.
625
821
  *
626
822
  * @param db - Open database handle.
@@ -633,7 +829,16 @@ export function loadMessages(
633
829
  sessionId: string,
634
830
  ): PersistedMessage[] {
635
831
  const rows = db.query<DataRow, [string]>(SQL.loadMessages).all(sessionId);
636
- return rows.map((row) => JSON.parse(row.data) as PersistedMessage);
832
+ const messages: PersistedMessage[] = [];
833
+
834
+ for (const row of rows) {
835
+ const message = parsePersistedMessage(row.data);
836
+ if (message) {
837
+ messages.push(message);
838
+ }
839
+ }
840
+
841
+ return messages;
637
842
  }
638
843
 
639
844
  // ---------------------------------------------------------------------------
package/src/settings.ts CHANGED
@@ -51,10 +51,15 @@ const THINKING_LEVELS = new Set<ThinkingLevel>([
51
51
  "xhigh",
52
52
  ]);
53
53
 
54
+ function getErrorMessage(error: unknown): string {
55
+ return error instanceof Error ? error.message : String(error);
56
+ }
57
+
54
58
  /**
55
59
  * Load and validate user settings from disk.
56
60
  *
57
- * Invalid or missing files are treated as empty settings.
61
+ * Missing files are treated as empty settings. Invalid JSON or unreadable files
62
+ * fail with a descriptive error instead of silently discarding saved state.
58
63
  *
59
64
  * @param path - Absolute path to `settings.json`.
60
65
  * @returns The validated settings object.
@@ -67,8 +72,10 @@ export function loadSettings(path: string): UserSettings {
67
72
  try {
68
73
  const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
69
74
  return sanitizeSettings(raw);
70
- } catch {
71
- return {};
75
+ } catch (error) {
76
+ throw new Error(
77
+ `Failed to read settings ${path}: ${getErrorMessage(error)}`,
78
+ );
72
79
  }
73
80
  }
74
81
 
package/src/skills.ts CHANGED
@@ -183,13 +183,17 @@ function readSkill(basePath: string, entry: string): Skill | null {
183
183
  return null;
184
184
  }
185
185
 
186
- const content = readFileSync(skillPath, "utf-8");
187
- const frontmatter = parseFrontmatter(content);
188
- return {
189
- name: frontmatter.name ?? entry,
190
- description: frontmatter.description ?? "",
191
- path: skillPath,
192
- };
186
+ try {
187
+ const content = readFileSync(skillPath, "utf-8");
188
+ const frontmatter = parseFrontmatter(content);
189
+ return {
190
+ name: frontmatter.name ?? entry,
191
+ description: frontmatter.description ?? "",
192
+ path: skillPath,
193
+ };
194
+ } catch {
195
+ return null;
196
+ }
193
197
  }
194
198
 
195
199
  /**
@@ -23,4 +23,22 @@ describe("ui/help", () => {
23
23
  "/verbose Toggle verbose tool rendering (currently off)",
24
24
  );
25
25
  });
26
+
27
+ test("buildHelpText includes the current Escape input-focus note", () => {
28
+ const helpState: HelpRenderState = {
29
+ providers: new Map(),
30
+ model: null,
31
+ agentsMd: [],
32
+ skills: [],
33
+ plugins: [],
34
+ showReasoning: DEFAULT_SHOW_REASONING,
35
+ verbose: false,
36
+ };
37
+
38
+ const text = buildHelpText(helpState);
39
+
40
+ expect(text).toContain("Escape blurs the input first");
41
+ expect(text).toContain("Tab re-focuses the input");
42
+ expect(text).toContain("Escape again interrupts the current turn");
43
+ });
26
44
  });
package/src/ui/help.ts CHANGED
@@ -77,6 +77,12 @@ export function buildHelpText(state: HelpRenderState): string {
77
77
  }
78
78
 
79
79
  const providerNames = Array.from(state.providers.keys());
80
+ lines.push("");
81
+ lines.push("Note:");
82
+ lines.push(" While a turn is running, Escape blurs the input first.");
83
+ lines.push(" Tab re-focuses the input.");
84
+ lines.push(" Escape again interrupts the current turn.");
85
+
80
86
  lines.push("");
81
87
  lines.push(
82
88
  providerNames.length > 0
@@ -137,6 +137,7 @@ describe("ui/status", () => {
137
137
  state.git = {
138
138
  root: state.cwd,
139
139
  branch: "main",
140
+ upstream: "origin/main",
140
141
  staged: 1,
141
142
  modified: 2,
142
143
  untracked: 3,
package/src/ui.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  * @module
9
9
  */
10
10
 
11
- import { exec } from "node:child_process";
11
+ import { spawn } from "node:child_process";
12
12
  import { platform } from "node:os";
13
13
  import {
14
14
  cel,
@@ -57,6 +57,20 @@ const PULSE_WIDTH = 5;
57
57
  /** Maximum number of committed messages rendered before older history is chunked. */
58
58
  const CONVERSATION_CHUNK_MESSAGES = 50;
59
59
 
60
+ /** Centralized interactive quit rules for keypresses and submitted input. */
61
+ const QUIT_RULES: Readonly<{
62
+ /** Submitted raw inputs that trigger graceful quit. */
63
+ inputs: ReadonlySet<string>;
64
+ /** Keypresses that always trigger graceful quit. */
65
+ keysAlways: ReadonlySet<string>;
66
+ /** Keypresses that trigger graceful quit only when input is empty. */
67
+ keysWhenEmptyInput: ReadonlySet<string>;
68
+ }> = {
69
+ inputs: new Set([":q"]),
70
+ keysAlways: new Set(["ctrl+c"]),
71
+ keysWhenEmptyInput: new Set(["ctrl+d"]),
72
+ };
73
+
60
74
  // ---------------------------------------------------------------------------
61
75
  // UI state (module-scoped, not in AppState)
62
76
  // ---------------------------------------------------------------------------
@@ -92,6 +106,23 @@ let stdinWasRaw = false;
92
106
  /** Active overlay for interactive commands (/model, /effort, etc.). */
93
107
  let activeOverlay: ActiveOverlay | null = null;
94
108
 
109
+ /** Determine whether a raw input line should trigger a graceful quit. */
110
+ export function isQuitInput(raw: string): boolean {
111
+ const trimmed = raw.trim();
112
+ return QUIT_RULES.inputs.has(trimmed);
113
+ }
114
+
115
+ /** Determine whether a keypress should trigger a graceful quit. */
116
+ export function isQuitKey(key: string, input: string): boolean {
117
+ if (QUIT_RULES.keysAlways.has(key)) {
118
+ return true;
119
+ }
120
+ if (input === "" && QUIT_RULES.keysWhenEmptyInput.has(key)) {
121
+ return true;
122
+ }
123
+ return false;
124
+ }
125
+
95
126
  /**
96
127
  * Reset all module-scoped UI state.
97
128
  *
@@ -299,6 +330,14 @@ export function createInputController(state: AppState): InputController {
299
330
  onKeyPress: (key) => {
300
331
  if (key === "enter") {
301
332
  const raw = inputValue;
333
+
334
+ if (isQuitInput(raw)) {
335
+ inputValue = "";
336
+ cel.render();
337
+ requestGracefulExit(state);
338
+ return false;
339
+ }
340
+
302
341
  inputValue = "";
303
342
  cel.render();
304
343
  handleInput(raw, state);
@@ -338,10 +377,49 @@ export function renderInputArea(
338
377
  // Runtime helpers and controllers
339
378
  // ---------------------------------------------------------------------------
340
379
 
341
- /** Open a URL in the user's default browser. */
342
- function openInBrowser(url: string): void {
343
- const cmd = platform() === "darwin" ? "open" : "xdg-open";
344
- exec(`${cmd} ${JSON.stringify(url)}`);
380
+ /** Browser process handle used by the platform opener helper. */
381
+ interface BrowserOpenProcess {
382
+ /** Detach the browser opener so the app does not wait on it. */
383
+ unref: () => void;
384
+ /** Optional error listener used by real child-process implementations. */
385
+ on?: (event: "error", listener: (error: Error) => void) => void;
386
+ }
387
+
388
+ /** Optional runtime overrides for browser launching. */
389
+ interface OpenInBrowserRuntime {
390
+ /** Platform used to choose the opener binary. */
391
+ platform?: NodeJS.Platform;
392
+ /** Process launcher used for tests. */
393
+ spawn?: (
394
+ command: string,
395
+ args: string[],
396
+ options: { detached: boolean; stdio: "ignore" },
397
+ ) => BrowserOpenProcess;
398
+ }
399
+
400
+ /** Open a URL in the user's default browser without invoking a shell. */
401
+ export function openInBrowser(
402
+ url: string,
403
+ runtime?: OpenInBrowserRuntime,
404
+ ): void {
405
+ const command =
406
+ (runtime?.platform ?? platform()) === "darwin" ? "open" : "xdg-open";
407
+ const launch =
408
+ runtime?.spawn ??
409
+ ((
410
+ cmd: string,
411
+ args: string[],
412
+ options: { detached: boolean; stdio: "ignore" },
413
+ ) => {
414
+ return spawn(cmd, args, options);
415
+ });
416
+ const child = launch(command, [url], {
417
+ detached: true,
418
+ stdio: "ignore",
419
+ });
420
+
421
+ child.on?.("error", () => {});
422
+ child.unref();
345
423
  }
346
424
 
347
425
  function scrollConversationToBottom(): void {
@@ -418,6 +496,11 @@ async function gracefulExit(state: AppState): Promise<void> {
418
496
  process.exit(0);
419
497
  }
420
498
 
499
+ /** Request a graceful exit and hard-fail if shutdown errors. */
500
+ function requestGracefulExit(state: AppState): void {
501
+ gracefulExit(state).catch(() => process.exit(1));
502
+ }
503
+
421
504
  /** Restore the terminal to the shell before suspending. */
422
505
  function suspendTerminalUi(): void {
423
506
  stopDividerAnimation();
@@ -539,12 +622,8 @@ export function renderBaseLayout(
539
622
  commandController.showInputHistoryOverlay(state);
540
623
  return;
541
624
  }
542
- if (key === "ctrl+c") {
543
- gracefulExit(state).catch(() => process.exit(1));
544
- return;
545
- }
546
- if (key === "ctrl+d" && inputValue === "") {
547
- gracefulExit(state).catch(() => process.exit(1));
625
+ if (isQuitKey(key, inputValue)) {
626
+ requestGracefulExit(state);
548
627
  return;
549
628
  }
550
629
  if (key === "ctrl+z") {