mini-coder 0.5.0 → 0.5.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.
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();
@@ -490,6 +573,7 @@ function renderConversationLog(state: AppState, width: number): Node {
490
573
  gap: CONVERSATION_GAP,
491
574
  overflow: "scroll",
492
575
  scrollbar: true,
576
+ justifyContent: state.messages.length === 0 ? "center" : undefined,
493
577
  scrollOffset: stickToBottom ? Infinity : scrollOffset,
494
578
  onScroll: (offset, maxOffset) => {
495
579
  const wasStickToBottom = stickToBottom;
@@ -539,12 +623,8 @@ export function renderBaseLayout(
539
623
  commandController.showInputHistoryOverlay(state);
540
624
  return;
541
625
  }
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));
626
+ if (isQuitKey(key, inputValue)) {
627
+ requestGracefulExit(state);
548
628
  return;
549
629
  }
550
630
  if (key === "ctrl+z") {
@@ -612,4 +692,9 @@ export function startUI(state: AppState): void {
612
692
  if (state.running) {
613
693
  startDividerAnimation();
614
694
  }
695
+
696
+ // Show warnings from custom provider discovery
697
+ for (const warning of state.startupWarnings) {
698
+ appendInfoMessage(warning, state);
699
+ }
615
700
  }
package/src/version.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Version helpers for the interactive empty-state banner.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+
10
+ /** App name shown in the empty conversation banner. */
11
+ export const APP_NAME = "mini-coder";
12
+
13
+ /** Fallback label used when running from a local checkout instead of a packaged install. */
14
+ export const DEV_VERSION_LABEL = "dev";
15
+
16
+ /**
17
+ * Resolve the version label shown in the empty conversation banner.
18
+ *
19
+ * Packaged installs read their version from the colocated `package.json`.
20
+ * Local checkouts fall back to a simple `dev` label so banner metadata never
21
+ * risks interfering with normal startup.
22
+ *
23
+ * @param appRoot - App root containing `package.json`.
24
+ * @returns A display label such as `v0.5.1` or `dev`.
25
+ */
26
+ export function resolveAppVersionLabel(
27
+ appRoot = join(import.meta.dir, ".."),
28
+ ): string {
29
+ if (existsSync(join(appRoot, ".git"))) {
30
+ return DEV_VERSION_LABEL;
31
+ }
32
+
33
+ const packageJsonPath = join(appRoot, "package.json");
34
+ if (!existsSync(packageJsonPath)) {
35
+ return DEV_VERSION_LABEL;
36
+ }
37
+
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
40
+ version?: unknown;
41
+ };
42
+ return typeof parsed.version === "string" && parsed.version !== ""
43
+ ? `v${parsed.version}`
44
+ : DEV_VERSION_LABEL;
45
+ } catch {
46
+ return DEV_VERSION_LABEL;
47
+ }
48
+ }