mini-coder 0.5.11 → 0.5.13

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.
@@ -17,22 +17,31 @@ import { getOAuthProviders } from "@mariozechner/pi-ai/oauth";
17
17
  import { getErrorMessage } from "../errors.ts";
18
18
  import type { AppState } from "../index.ts";
19
19
  import { getAvailableModels, saveOAuthCredentials } from "../index.ts";
20
- import { COMMANDS } from "../input.ts";
20
+ import { COMMANDS, SKILL_COMMAND } from "../input.ts";
21
+ import { connectMcpServer, disconnectMcpServer } from "../mcp.ts";
21
22
  import {
22
- computeContextTokens,
23
- computeStats,
23
+ clearConversationState,
24
24
  forkSession,
25
25
  listPromptHistory,
26
26
  listSessions,
27
27
  loadMessages,
28
+ replaceConversationState,
28
29
  type SessionListEntry,
29
30
  type UiInfoFormat,
30
31
  undoLastTurn,
31
32
  } from "../session.ts";
32
33
  import { updateSettings } from "../settings.ts";
34
+ import { clearQueuedUserMessages } from "../submit.ts";
35
+ import { collapseWhitespace, truncateText } from "../text.ts";
33
36
  import { getTodoItems } from "../tools.ts";
34
- import { buildHelpText, COMMAND_DESCRIPTIONS } from "./help.ts";
37
+ import {
38
+ buildHelpText,
39
+ COMMAND_DESCRIPTIONS,
40
+ SKILL_REFERENCE_DESCRIPTION,
41
+ SKILL_REFERENCE_LABEL,
42
+ } from "./help.ts";
35
43
  import { type ActiveOverlay, OVERLAY_MAX_VISIBLE } from "./overlay.ts";
44
+ import type { UiRenderPriority } from "./runtime.ts";
36
45
  import { abbreviatePath } from "./status.ts";
37
46
 
38
47
  /** Effort levels available for selection. */
@@ -45,9 +54,9 @@ const EFFORT_LEVELS: { label: string; value: ThinkingLevel }[] = [
45
54
 
46
55
  /** Runtime hooks injected from the stateful UI module. */
47
56
  interface UiCommandRuntime {
48
- /** Open an overlay and trigger a re-render. */
57
+ /** Open an overlay. */
49
58
  openOverlay: (overlay: ActiveOverlay) => void;
50
- /** Dismiss the active overlay and trigger a re-render. */
59
+ /** Dismiss the active overlay. */
51
60
  dismissOverlay: () => void;
52
61
  /** Update the current input draft. */
53
62
  setInputValue: (value: string) => void;
@@ -64,14 +73,30 @@ interface UiCommandRuntime {
64
73
  ) => void;
65
74
  /** Re-enable stick-to-bottom behavior for the conversation log. */
66
75
  scrollConversationToBottom: () => void;
67
- /** Trigger a UI re-render. */
68
- render: () => void;
76
+ /** Schedule a UI re-render. */
77
+ requestRender: (priority?: UiRenderPriority) => void;
69
78
  /** Reload prompt/session context at a boundary like `/new`. */
70
79
  reloadPromptContext: (state: AppState) => Promise<void>;
71
80
  /** Open a URL in the user's default browser. */
72
81
  openInBrowser: (url: string) => void;
73
82
  }
74
83
 
84
+ interface UiCommandDeps {
85
+ /** Connect a configured MCP server on demand. */
86
+ connectMcpServer: (
87
+ server: AppState["mcpServers"][number],
88
+ ) => Promise<string[]>;
89
+ /** Disconnect an MCP server and clear its imported tools. */
90
+ disconnectMcpServer: (
91
+ server: AppState["mcpServers"][number],
92
+ ) => Promise<void>;
93
+ }
94
+
95
+ const defaultUiCommandDeps: UiCommandDeps = {
96
+ connectMcpServer,
97
+ disconnectMcpServer,
98
+ };
99
+
75
100
  /** Public command actions consumed by `ui.ts` and unit tests. */
76
101
  interface UiCommandController {
77
102
  /** Apply a model selection and persist it to settings. */
@@ -142,7 +167,7 @@ export function formatRelativeDate(date: Date, now = new Date()): string {
142
167
  * @returns A single-line preview string.
143
168
  */
144
169
  export function formatPromptHistoryPreview(text: string): string {
145
- return text.replace(/\s+/g, " ").trim();
170
+ return collapseWhitespace(text);
146
171
  }
147
172
 
148
173
  const HISTORY_PREVIEW_MAX_CHARS = 32;
@@ -150,26 +175,6 @@ const HISTORY_CWD_MAX_CHARS = 18;
150
175
  const SESSION_PREVIEW_MAX_CHARS = 27;
151
176
  const SESSION_MODEL_MAX_CHARS = 17;
152
177
 
153
- function truncateTrailingText(text: string, maxChars: number): string {
154
- if (text.length <= maxChars) {
155
- return text;
156
- }
157
- if (maxChars <= 1) {
158
- return "…";
159
- }
160
- return `${text.slice(0, maxChars - 1)}…`;
161
- }
162
-
163
- function truncateLeadingText(text: string, maxChars: number): string {
164
- if (text.length <= maxChars) {
165
- return text;
166
- }
167
- if (maxChars <= 1) {
168
- return "…";
169
- }
170
- return `…${text.slice(text.length - (maxChars - 1))}`;
171
- }
172
-
173
178
  /**
174
179
  * Format a prompt-history row for the Select overlay.
175
180
  *
@@ -183,13 +188,14 @@ export function formatPromptHistoryLabel(
183
188
  cwd: string,
184
189
  date: string,
185
190
  ): string {
186
- const preview = truncateTrailingText(
191
+ const preview = truncateText(
187
192
  formatPromptHistoryPreview(text),
188
193
  HISTORY_PREVIEW_MAX_CHARS,
189
194
  );
190
- const displayCwd = truncateLeadingText(
195
+ const displayCwd = truncateText(
191
196
  abbreviatePath(cwd),
192
197
  HISTORY_CWD_MAX_CHARS,
198
+ "start",
193
199
  );
194
200
  return `${preview} · ${displayCwd} · ${date}`;
195
201
  }
@@ -207,11 +213,11 @@ export function formatSessionLabel(
207
213
  date: string,
208
214
  isCurrent: boolean,
209
215
  ): string {
210
- const preview = truncateTrailingText(
216
+ const preview = truncateText(
211
217
  session.firstUserPreview ?? "No messages yet",
212
218
  SESSION_PREVIEW_MAX_CHARS,
213
219
  );
214
- const model = truncateTrailingText(
220
+ const model = truncateText(
215
221
  session.model ?? "no model",
216
222
  SESSION_MODEL_MAX_CHARS,
217
223
  );
@@ -229,14 +235,117 @@ interface OverlayItem {
229
235
  filterText: string;
230
236
  }
231
237
 
238
+ function formatSkillLabel(skill: AppState["skills"][number]): string {
239
+ return skill.description
240
+ ? `${skill.name} · ${skill.description}`
241
+ : skill.name;
242
+ }
243
+
244
+ function formatToolCount(count: number): string {
245
+ return `${count} tool${count === 1 ? "" : "s"}`;
246
+ }
247
+
248
+ function formatMcpServerLabel(server: AppState["mcpServers"][number]): string {
249
+ return `${server.name} · ${server.enabled ? "on" : "off"} · ${formatToolCount(server.tools.length)}`;
250
+ }
251
+
252
+ function formatMcpToggleMessage(
253
+ server: AppState["mcpServers"][number],
254
+ toolCount: number,
255
+ ): string {
256
+ const action = server.enabled ? "Enabled" : "Disabled";
257
+ const delta = `${server.enabled ? "+" : "-"}${toolCount}`;
258
+ const toolSuffix = toolCount === 1 ? "tool" : "tools";
259
+ return `${action} MCP server "${server.name}" (${delta} ${toolSuffix}).`;
260
+ }
261
+
262
+ function isRepoLocalMcpServer(state: AppState, serverName: string): boolean {
263
+ return (
264
+ state.repoSettings.mcp?.servers?.some(
265
+ (entry) => entry.name === serverName,
266
+ ) ?? false
267
+ );
268
+ }
269
+
270
+ function persistMcpServerSettings(
271
+ state: AppState,
272
+ server: AppState["mcpServers"][number],
273
+ ): void {
274
+ if (isRepoLocalMcpServer(state, server.name)) {
275
+ return;
276
+ }
277
+
278
+ state.settings = updateSettings(state.settingsPath, {
279
+ mcp: {
280
+ servers: [
281
+ {
282
+ name: server.name,
283
+ url: server.url,
284
+ enabled: server.enabled,
285
+ },
286
+ ],
287
+ },
288
+ });
289
+ }
290
+
291
+ async function toggleMcpServer(
292
+ server: AppState["mcpServers"][number],
293
+ state: AppState,
294
+ runtime: UiCommandRuntime,
295
+ deps: UiCommandDeps,
296
+ ): Promise<void> {
297
+ try {
298
+ if (server.enabled) {
299
+ const toolCount = server.tools.length;
300
+ await deps.disconnectMcpServer(server);
301
+ server.enabled = false;
302
+ persistMcpServerSettings(state, server);
303
+ runtime.appendInfoMessage(
304
+ formatMcpToggleMessage(server, toolCount),
305
+ state,
306
+ );
307
+ runtime.requestRender("normal");
308
+ return;
309
+ }
310
+
311
+ const warnings = await deps.connectMcpServer(server);
312
+ if (!server.connected) {
313
+ for (const warning of warnings) {
314
+ runtime.appendInfoMessage(warning, state);
315
+ }
316
+ runtime.requestRender("normal");
317
+ return;
318
+ }
319
+
320
+ server.enabled = true;
321
+ persistMcpServerSettings(state, server);
322
+ runtime.appendInfoMessage(
323
+ formatMcpToggleMessage(server, server.tools.length),
324
+ state,
325
+ );
326
+ for (const warning of warnings) {
327
+ runtime.appendInfoMessage(warning, state);
328
+ }
329
+ runtime.requestRender("normal");
330
+ } catch (error) {
331
+ runtime.appendInfoMessage(
332
+ `MCP server "${server.name}": ${getErrorMessage(error)}`,
333
+ state,
334
+ );
335
+ runtime.requestRender("normal");
336
+ }
337
+ }
338
+
232
339
  /**
233
340
  * Create the UI command controller bound to the current UI runtime hooks.
234
341
  *
235
342
  * @param runtime - State mutation and rendering hooks owned by `ui.ts`.
343
+ * @param deps - Internal MCP command dependencies, mainly for tests.
236
344
  * @returns Command actions for slash commands and overlays.
237
345
  */
238
346
  export function createCommandController(
239
347
  runtime: UiCommandRuntime,
348
+ deps: UiCommandDeps = defaultUiCommandDeps,
240
349
  ): UiCommandController {
241
350
  const openSelectOverlay = (
242
351
  state: AppState,
@@ -252,20 +361,52 @@ export function createCommandController(
252
361
  focused: true,
253
362
  highlightColor: state.theme.accentText,
254
363
  onSelect,
364
+ onKeyPress: (key) => {
365
+ if (key === "escape") {
366
+ runtime.dismissOverlay();
367
+ return;
368
+ }
369
+ return false;
370
+ },
255
371
  onBlur: runtime.dismissOverlay,
256
372
  });
257
373
 
258
374
  runtime.openOverlay({ select, title });
259
375
  };
260
376
 
261
- const showCommandAutocomplete = (state: AppState): void => {
262
- const items = COMMANDS.map((command) => ({
263
- label: `/${command} ${COMMAND_DESCRIPTIONS[command] ?? ""}`,
264
- value: command,
265
- filterText: command,
377
+ const handleSkillCommand = (state: AppState): void => {
378
+ const items = state.skills.map((skill) => ({
379
+ label: formatSkillLabel(skill),
380
+ value: skill.name,
381
+ filterText: `${skill.name} ${skill.description ?? ""}`,
266
382
  }));
267
383
 
268
- runtime.setInputValue("");
384
+ openSelectOverlay(
385
+ state,
386
+ "Select a skill",
387
+ items,
388
+ "type to filter skills...",
389
+ (skillName) => {
390
+ runtime.setInputValue(`/${SKILL_COMMAND}:${skillName}`);
391
+ runtime.dismissOverlay();
392
+ },
393
+ );
394
+ };
395
+
396
+ const showCommandAutocomplete = (state: AppState): void => {
397
+ const items: OverlayItem[] = [
398
+ ...COMMANDS.map((command) => ({
399
+ label: `/${command} ${COMMAND_DESCRIPTIONS[command] ?? ""}`,
400
+ value: command,
401
+ filterText: command,
402
+ })),
403
+ {
404
+ label: `${SKILL_REFERENCE_LABEL} ${SKILL_REFERENCE_DESCRIPTION}`,
405
+ value: SKILL_COMMAND,
406
+ filterText: `${SKILL_COMMAND} ${SKILL_REFERENCE_LABEL}`,
407
+ },
408
+ ];
409
+
269
410
  openSelectOverlay(
270
411
  state,
271
412
  "Commands",
@@ -273,6 +414,12 @@ export function createCommandController(
273
414
  "type to filter commands...",
274
415
  (value) => {
275
416
  runtime.dismissOverlay();
417
+ if (value === SKILL_COMMAND) {
418
+ handleSkillCommand(state);
419
+ return;
420
+ }
421
+
422
+ runtime.setInputValue("");
276
423
  handleCommand(value, state);
277
424
  },
278
425
  );
@@ -393,17 +540,17 @@ export function createCommandController(
393
540
  items,
394
541
  "type to filter sessions...",
395
542
  (sessionId) => {
543
+ clearQueuedUserMessages(state);
396
544
  if (sessionId !== currentSessionId) {
397
545
  const picked = sessions.find((session) => session.id === sessionId);
398
546
  if (picked) {
399
547
  state.session = picked;
400
- state.messages = loadMessages(state.db, picked.id);
401
- state.stats = computeStats(state.messages);
402
- state.contextTokens = computeContextTokens(state.messages);
548
+ replaceConversationState(state, loadMessages(state.db, picked.id));
403
549
  runtime.scrollConversationToBottom();
404
550
  }
405
551
  }
406
552
  runtime.dismissOverlay();
553
+ runtime.requestRender("normal");
407
554
  },
408
555
  );
409
556
  };
@@ -412,13 +559,12 @@ export function createCommandController(
412
559
  if (state.running) {
413
560
  return;
414
561
  }
562
+ clearQueuedUserMessages(state);
415
563
  state.session = null;
416
- state.messages = [];
417
- state.stats = { totalInput: 0, totalOutput: 0, totalCost: 0 };
418
- state.contextTokens = 0;
564
+ clearConversationState(state);
419
565
  await runtime.reloadPromptContext(state);
420
566
  runtime.scrollConversationToBottom();
421
- runtime.render();
567
+ runtime.requestRender("normal");
422
568
  };
423
569
 
424
570
  const handleForkCommand = (state: AppState): void => {
@@ -427,13 +573,13 @@ export function createCommandController(
427
573
  }
428
574
  const forked = forkSession(state.db, state.session.id);
429
575
  state.session = forked;
430
- state.messages = loadMessages(state.db, forked.id);
431
- state.stats = computeStats(state.messages);
432
- state.contextTokens = computeContextTokens(state.messages);
576
+ replaceConversationState(state, loadMessages(state.db, forked.id));
433
577
  runtime.appendInfoMessage("Forked session.", state);
434
578
  };
435
579
 
436
580
  const handleUndoCommand = async (state: AppState): Promise<void> => {
581
+ clearQueuedUserMessages(state);
582
+
437
583
  if (state.running && state.abortController) {
438
584
  state.abortController.abort();
439
585
  }
@@ -447,11 +593,9 @@ export function createCommandController(
447
593
  }
448
594
  const removed = undoLastTurn(state.db, state.session.id);
449
595
  if (removed) {
450
- state.messages = loadMessages(state.db, state.session.id);
451
- state.stats = computeStats(state.messages);
452
- state.contextTokens = computeContextTokens(state.messages);
596
+ replaceConversationState(state, loadMessages(state.db, state.session.id));
453
597
  runtime.scrollConversationToBottom();
454
- runtime.render();
598
+ runtime.requestRender("normal");
455
599
  }
456
600
  };
457
601
 
@@ -460,7 +604,6 @@ export function createCommandController(
460
604
  state.settings = updateSettings(state.settingsPath, {
461
605
  showReasoning: state.showReasoning,
462
606
  });
463
- runtime.render();
464
607
  };
465
608
 
466
609
  const handleVerboseCommand = (state: AppState): void => {
@@ -468,7 +611,34 @@ export function createCommandController(
468
611
  state.settings = updateSettings(state.settingsPath, {
469
612
  verbose: state.verbose,
470
613
  });
471
- runtime.render();
614
+ };
615
+
616
+ const handleMcpCommand = (state: AppState): void => {
617
+ if (state.mcpServers.length === 0) {
618
+ return;
619
+ }
620
+
621
+ const items = state.mcpServers.map((server) => ({
622
+ label: formatMcpServerLabel(server),
623
+ value: server.name,
624
+ filterText: `${server.name} ${server.url} ${server.enabled ? "on" : "off"}`,
625
+ }));
626
+
627
+ openSelectOverlay(
628
+ state,
629
+ "Toggle MCP servers",
630
+ items,
631
+ "type to filter MCP servers...",
632
+ (serverName) => {
633
+ const server = state.mcpServers.find(
634
+ (entry) => entry.name === serverName,
635
+ );
636
+ runtime.dismissOverlay();
637
+ if (server) {
638
+ void toggleMcpServer(server, state, runtime, deps);
639
+ }
640
+ },
641
+ );
472
642
  };
473
643
 
474
644
  const performLogin = async (
@@ -637,12 +807,18 @@ export function createCommandController(
637
807
  case "verbose":
638
808
  handleVerboseCommand(state);
639
809
  return true;
810
+ case "mcp":
811
+ handleMcpCommand(state);
812
+ return true;
640
813
  case "todo":
641
814
  handleTodoCommand(state);
642
815
  return true;
643
816
  case "help":
644
817
  handleHelpCommand(state);
645
818
  return true;
819
+ case SKILL_COMMAND:
820
+ handleSkillCommand(state);
821
+ return true;
646
822
  default:
647
823
  return false;
648
824
  }