mini-coder 0.5.6 → 0.5.8

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/input.ts CHANGED
@@ -25,39 +25,18 @@ export interface InputController {
25
25
  onKeyPress: (key: string) => boolean | undefined;
26
26
  }
27
27
 
28
- /** Find the longest common prefix across a list of strings. */
29
- function getLongestCommonPrefix(values: readonly string[]): string {
30
- if (values.length === 0) {
31
- return "";
32
- }
33
-
34
- let prefix = values[0]!;
35
- for (let i = 1; i < values.length && prefix.length > 0; i++) {
36
- const value = values[i]!;
37
- let j = 0;
38
- while (j < prefix.length && j < value.length && prefix[j] === value[j]) {
39
- j++;
40
- }
41
- prefix = prefix.slice(0, j);
42
- }
43
-
44
- return prefix;
28
+ /** A selectable path-autocomplete match for the current input draft. */
29
+ export interface InputPathMatch {
30
+ /** Path label shown in the overlay. */
31
+ label: string;
32
+ /** Full draft value to apply when this match is selected. */
33
+ value: string;
45
34
  }
46
35
 
47
- /**
48
- * Attempt to autocomplete the final path token in the current draft.
49
- *
50
- * @param value - Current input draft.
51
- * @param cwd - Working directory used to resolve relative paths.
52
- * @returns The completed input value, or `null` when no completion is available.
53
- */
54
- export function autocompleteInputPath(
55
- value: string,
56
- cwd: string,
57
- ): string | null {
36
+ function listInputPathMatches(value: string, cwd: string): InputPathMatch[] {
58
37
  const tokenMatch = /(^|\s)(\S+)$/.exec(value);
59
38
  if (!tokenMatch?.[2]) {
60
- return null;
39
+ return [];
61
40
  }
62
41
 
63
42
  const token = tokenMatch[2];
@@ -80,35 +59,50 @@ export function autocompleteInputPath(
80
59
  }
81
60
  })();
82
61
  if (!entries) {
83
- return null;
62
+ return [];
84
63
  }
85
64
 
86
65
  const showHidden = partial.startsWith(".");
87
- const matches = entries
66
+ return entries
88
67
  .filter((entry) => (showHidden ? true : !entry.name.startsWith(".")))
89
68
  .filter((entry) => entry.name.startsWith(partial))
90
- .sort((a, b) => a.name.localeCompare(b.name));
91
-
92
- if (matches.length === 0) {
93
- return null;
94
- }
95
-
96
- let completedName: string | null = null;
97
- if (matches.length === 1) {
98
- const match = matches[0]!;
99
- completedName = `${match.name}${match.isDirectory() ? "/" : ""}`;
100
- } else {
101
- const prefix = getLongestCommonPrefix(matches.map((entry) => entry.name));
102
- if (prefix.length > partial.length) {
103
- completedName = prefix;
104
- }
105
- }
69
+ .sort((a, b) => a.name.localeCompare(b.name))
70
+ .map((entry) => {
71
+ const completedPath = `${dirToken}${entry.name}${entry.isDirectory() ? "/" : ""}`;
72
+ return {
73
+ label: completedPath,
74
+ value: `${value.slice(0, tokenStart)}${completedPath}`,
75
+ };
76
+ });
77
+ }
106
78
 
107
- if (!completedName) {
108
- return null;
109
- }
79
+ /**
80
+ * Find selectable path matches for the final path token in the current draft.
81
+ *
82
+ * @param value - Current input draft.
83
+ * @param cwd - Working directory used to resolve relative paths.
84
+ * @returns Matching path completions with their applied draft values.
85
+ */
86
+ export function findInputPathMatches(
87
+ value: string,
88
+ cwd: string,
89
+ ): InputPathMatch[] {
90
+ return listInputPathMatches(value, cwd);
91
+ }
110
92
 
111
- return `${value.slice(0, tokenStart)}${dirToken}${completedName}`;
93
+ /**
94
+ * Attempt to autocomplete the final path token in the current draft.
95
+ *
96
+ * @param value - Current input draft.
97
+ * @param cwd - Working directory used to resolve relative paths.
98
+ * @returns The completed input value, or `null` when direct completion is unavailable.
99
+ */
100
+ export function autocompleteInputPath(
101
+ value: string,
102
+ cwd: string,
103
+ ): string | null {
104
+ const matches = listInputPathMatches(value, cwd);
105
+ return matches.length === 1 ? matches[0]!.value : null;
112
106
  }
113
107
 
114
108
  /**
@@ -94,6 +94,7 @@ function createTestState(): AppState {
94
94
  running: false,
95
95
  abortController: null,
96
96
  activeTurnPromise: null,
97
+ queuedUserMessages: [],
97
98
  showReasoning: DEFAULT_SHOW_REASONING,
98
99
  verbose: DEFAULT_VERBOSE,
99
100
  versionLabel: "dev",
@@ -106,6 +106,7 @@ function createTestState(): AppState {
106
106
  running: false,
107
107
  abortController: null,
108
108
  activeTurnPromise: null,
109
+ queuedUserMessages: [],
109
110
  showReasoning: DEFAULT_SHOW_REASONING,
110
111
  verbose: DEFAULT_VERBOSE,
111
112
  versionLabel: "dev",
package/src/ui.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { spawn } from "node:child_process";
12
12
  import { platform } from "node:os";
13
+ import { Select } from "@cel-tui/components";
13
14
  import {
14
15
  cel,
15
16
  HStack,
@@ -21,7 +22,11 @@ import {
21
22
  import type { Node } from "@cel-tui/types";
22
23
  import type { AppState } from "./index.ts";
23
24
  import { reloadPromptContext, shutdown } from "./index.ts";
24
- import { appendMessage, createUiMessage } from "./session.ts";
25
+ import {
26
+ appendMessage,
27
+ createUiMessage,
28
+ createUiTodoMessage,
29
+ } from "./session.ts";
25
30
  import type { Theme } from "./theme.ts";
26
31
  import {
27
32
  createUiAgentController,
@@ -37,9 +42,14 @@ import {
37
42
  import type { InputController } from "./ui/input.ts";
38
43
  import {
39
44
  autocompleteInputPath,
45
+ findInputPathMatches,
40
46
  renderInputArea as renderInputAreaNode,
41
47
  } from "./ui/input.ts";
42
- import { type ActiveOverlay, renderOverlay } from "./ui/overlay.ts";
48
+ import {
49
+ type ActiveOverlay,
50
+ OVERLAY_MAX_VISIBLE,
51
+ renderOverlay,
52
+ } from "./ui/overlay.ts";
43
53
  import { renderStatusBar } from "./ui/status.ts";
44
54
 
45
55
  export type { InputController } from "./ui/input.ts";
@@ -286,6 +296,48 @@ function dismissOverlay(): void {
286
296
  cel.render();
287
297
  }
288
298
 
299
+ function openPathAutocompleteOverlay(state: AppState): void {
300
+ const matches = findInputPathMatches(inputValue, state.cwd);
301
+ if (matches.length <= 1) {
302
+ return;
303
+ }
304
+
305
+ const select = Select({
306
+ items: matches.map((match) => ({
307
+ label: match.label,
308
+ value: match.value,
309
+ filterText: match.label,
310
+ })),
311
+ maxVisible: OVERLAY_MAX_VISIBLE,
312
+ placeholder: "type to filter paths...",
313
+ focused: true,
314
+ highlightColor: state.theme.accentText,
315
+ onSelect: (value) => {
316
+ inputValue = value;
317
+ dismissOverlay();
318
+ },
319
+ onBlur: dismissOverlay,
320
+ });
321
+
322
+ openOverlay({ select, title: "Path matches" });
323
+ }
324
+
325
+ function handleTabKeyPress(state: AppState): void {
326
+ if (inputValue.startsWith("/")) {
327
+ commandController.showCommandAutocomplete(state);
328
+ return;
329
+ }
330
+
331
+ const completedInput = autocompleteInputPath(inputValue, state.cwd);
332
+ if (completedInput) {
333
+ inputValue = completedInput;
334
+ cel.render();
335
+ return;
336
+ }
337
+
338
+ openPathAutocompleteOverlay(state);
339
+ }
340
+
289
341
  /**
290
342
  * Render the active overlay when one is open.
291
343
  *
@@ -344,15 +396,7 @@ export function createInputController(state: AppState): InputController {
344
396
  return false;
345
397
  }
346
398
  if (key === "tab") {
347
- if (inputValue.startsWith("/")) {
348
- commandController.showCommandAutocomplete(state);
349
- } else {
350
- const completedInput = autocompleteInputPath(inputValue, state.cwd);
351
- if (completedInput) {
352
- inputValue = completedInput;
353
- cel.render();
354
- }
355
- }
399
+ handleTabKeyPress(state);
356
400
  return false;
357
401
  }
358
402
  },
@@ -426,6 +470,18 @@ function scrollConversationToBottom(): void {
426
470
  stickToBottom = true;
427
471
  }
428
472
 
473
+ function appendUiMessage(
474
+ message: AppState["messages"][number],
475
+ state: AppState,
476
+ ): void {
477
+ if (state.session) {
478
+ appendMessage(state.db, state.session.id, message);
479
+ }
480
+ state.messages.push(message);
481
+ scrollConversationToBottom();
482
+ cel.render();
483
+ }
484
+
429
485
  /**
430
486
  * Append a UI-only info message to the conversation log.
431
487
  *
@@ -436,13 +492,23 @@ function scrollConversationToBottom(): void {
436
492
  * @param state - Application state.
437
493
  */
438
494
  function appendInfoMessage(text: string, state: AppState): void {
439
- const msg = createUiMessage(text);
440
- if (state.session) {
441
- appendMessage(state.db, state.session.id, msg);
442
- }
443
- state.messages.push(msg);
444
- scrollConversationToBottom();
445
- cel.render();
495
+ appendUiMessage(createUiMessage(text), state);
496
+ }
497
+
498
+ /**
499
+ * Append a UI-only todo snapshot to the conversation log.
500
+ *
501
+ * When no persisted session exists yet, the message stays in memory and is
502
+ * backfilled if the user later starts a session by sending a message.
503
+ *
504
+ * @param todos - Todo snapshot to append.
505
+ * @param state - Application state.
506
+ */
507
+ function appendTodoMessage(
508
+ todos: Parameters<typeof createUiTodoMessage>[0],
509
+ state: AppState,
510
+ ): void {
511
+ appendUiMessage(createUiTodoMessage(todos), state);
446
512
  }
447
513
 
448
514
  /** Command controller bound to the module-scoped UI runtime hooks. */
@@ -453,6 +519,7 @@ const commandController = createCommandController({
453
519
  inputValue = value;
454
520
  },
455
521
  appendInfoMessage,
522
+ appendTodoMessage,
456
523
  scrollConversationToBottom,
457
524
  render: () => {
458
525
  cel.render();