mini-coder 0.5.12 → 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,7 +17,8 @@ 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
23
  clearConversationState,
23
24
  forkSession,
@@ -30,9 +31,15 @@ import {
30
31
  undoLastTurn,
31
32
  } from "../session.ts";
32
33
  import { updateSettings } from "../settings.ts";
34
+ import { clearQueuedUserMessages } from "../submit.ts";
33
35
  import { collapseWhitespace, truncateText } from "../text.ts";
34
36
  import { getTodoItems } from "../tools.ts";
35
- 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";
36
43
  import { type ActiveOverlay, OVERLAY_MAX_VISIBLE } from "./overlay.ts";
37
44
  import type { UiRenderPriority } from "./runtime.ts";
38
45
  import { abbreviatePath } from "./status.ts";
@@ -74,6 +81,22 @@ interface UiCommandRuntime {
74
81
  openInBrowser: (url: string) => void;
75
82
  }
76
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
+
77
100
  /** Public command actions consumed by `ui.ts` and unit tests. */
78
101
  interface UiCommandController {
79
102
  /** Apply a model selection and persist it to settings. */
@@ -212,14 +235,117 @@ interface OverlayItem {
212
235
  filterText: string;
213
236
  }
214
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
+
215
339
  /**
216
340
  * Create the UI command controller bound to the current UI runtime hooks.
217
341
  *
218
342
  * @param runtime - State mutation and rendering hooks owned by `ui.ts`.
343
+ * @param deps - Internal MCP command dependencies, mainly for tests.
219
344
  * @returns Command actions for slash commands and overlays.
220
345
  */
221
346
  export function createCommandController(
222
347
  runtime: UiCommandRuntime,
348
+ deps: UiCommandDeps = defaultUiCommandDeps,
223
349
  ): UiCommandController {
224
350
  const openSelectOverlay = (
225
351
  state: AppState,
@@ -235,20 +361,52 @@ export function createCommandController(
235
361
  focused: true,
236
362
  highlightColor: state.theme.accentText,
237
363
  onSelect,
364
+ onKeyPress: (key) => {
365
+ if (key === "escape") {
366
+ runtime.dismissOverlay();
367
+ return;
368
+ }
369
+ return false;
370
+ },
238
371
  onBlur: runtime.dismissOverlay,
239
372
  });
240
373
 
241
374
  runtime.openOverlay({ select, title });
242
375
  };
243
376
 
244
- const showCommandAutocomplete = (state: AppState): void => {
245
- const items = COMMANDS.map((command) => ({
246
- label: `/${command} ${COMMAND_DESCRIPTIONS[command] ?? ""}`,
247
- value: command,
248
- 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 ?? ""}`,
249
382
  }));
250
383
 
251
- 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
+
252
410
  openSelectOverlay(
253
411
  state,
254
412
  "Commands",
@@ -256,6 +414,12 @@ export function createCommandController(
256
414
  "type to filter commands...",
257
415
  (value) => {
258
416
  runtime.dismissOverlay();
417
+ if (value === SKILL_COMMAND) {
418
+ handleSkillCommand(state);
419
+ return;
420
+ }
421
+
422
+ runtime.setInputValue("");
259
423
  handleCommand(value, state);
260
424
  },
261
425
  );
@@ -376,6 +540,7 @@ export function createCommandController(
376
540
  items,
377
541
  "type to filter sessions...",
378
542
  (sessionId) => {
543
+ clearQueuedUserMessages(state);
379
544
  if (sessionId !== currentSessionId) {
380
545
  const picked = sessions.find((session) => session.id === sessionId);
381
546
  if (picked) {
@@ -394,6 +559,7 @@ export function createCommandController(
394
559
  if (state.running) {
395
560
  return;
396
561
  }
562
+ clearQueuedUserMessages(state);
397
563
  state.session = null;
398
564
  clearConversationState(state);
399
565
  await runtime.reloadPromptContext(state);
@@ -412,6 +578,8 @@ export function createCommandController(
412
578
  };
413
579
 
414
580
  const handleUndoCommand = async (state: AppState): Promise<void> => {
581
+ clearQueuedUserMessages(state);
582
+
415
583
  if (state.running && state.abortController) {
416
584
  state.abortController.abort();
417
585
  }
@@ -445,6 +613,34 @@ export function createCommandController(
445
613
  });
446
614
  };
447
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
+ );
642
+ };
643
+
448
644
  const performLogin = async (
449
645
  provider: OAuthProviderInterface,
450
646
  state: AppState,
@@ -611,12 +807,18 @@ export function createCommandController(
611
807
  case "verbose":
612
808
  handleVerboseCommand(state);
613
809
  return true;
810
+ case "mcp":
811
+ handleMcpCommand(state);
812
+ return true;
614
813
  case "todo":
615
814
  handleTodoCommand(state);
616
815
  return true;
617
816
  case "help":
618
817
  handleHelpCommand(state);
619
818
  return true;
819
+ case SKILL_COMMAND:
820
+ handleSkillCommand(state);
821
+ return true;
620
822
  default:
621
823
  return false;
622
824
  }
@@ -55,17 +55,6 @@ function collectTextNodes(node: Node | null): TextNode[] {
55
55
  return node.children.flatMap((child) => collectTextNodes(child));
56
56
  }
57
57
 
58
- function findTextNode(node: Node | null, content: string): TextNode {
59
- const textNode = collectTextNodes(node).find(
60
- (text) => text.content === content,
61
- );
62
- expect(textNode).toBeDefined();
63
- if (!textNode) {
64
- throw new Error(`Missing text node: ${content}`);
65
- }
66
- return textNode;
67
- }
68
-
69
58
  function makeAssistantToolCallMessage(): AssistantMessage {
70
59
  return {
71
60
  role: "assistant",
@@ -146,7 +135,7 @@ describe("ui/conversation", () => {
146
135
  expect(lines.join("\n")).not.toContain('"path"');
147
136
  });
148
137
 
149
- test("shell tool-call previews use cel-tui syntax scopes for custom colors", () => {
138
+ test("shell tool-call previews render the command instead of raw JSON", () => {
150
139
  const node = renderAssistantMessage(
151
140
  {
152
141
  content: [
@@ -169,9 +158,220 @@ describe("ui/conversation", () => {
169
158
  },
170
159
  );
171
160
 
172
- expect(findTextNode(node, "true").props.fgColor).toBe(
173
- DEFAULT_THEME.secondaryAccentText,
161
+ const lines = collectRenderedLines(node);
162
+ expect(lines).toContain("│ shell ->");
163
+ expect(lines.join("\n")).toContain('if true; then echo "$HOME"; fi');
164
+ expect(lines.join("\n")).not.toContain('"command"');
165
+ });
166
+
167
+ test("compact shell tool-call previews only render the visible tail slice", () => {
168
+ const command = Array.from(
169
+ { length: 14 },
170
+ (_, index) => `echo line ${index + 1}`,
171
+ ).join("\n");
172
+
173
+ const compactLines = collectRenderedLines(
174
+ renderAssistantMessage(
175
+ {
176
+ content: [
177
+ {
178
+ type: "toolCall",
179
+ id: "call-shell",
180
+ name: "shell",
181
+ arguments: { command },
182
+ },
183
+ ],
184
+ },
185
+ {
186
+ showReasoning: true,
187
+ verbose: false,
188
+ theme: DEFAULT_THEME,
189
+ cwd: "/tmp/project",
190
+ previewWidth: 80,
191
+ },
192
+ ),
193
+ );
194
+ const verboseLines = collectRenderedLines(
195
+ renderAssistantMessage(
196
+ {
197
+ content: [
198
+ {
199
+ type: "toolCall",
200
+ id: "call-shell",
201
+ name: "shell",
202
+ arguments: { command },
203
+ },
204
+ ],
205
+ },
206
+ {
207
+ showReasoning: true,
208
+ verbose: true,
209
+ theme: DEFAULT_THEME,
210
+ cwd: "/tmp/project",
211
+ previewWidth: 80,
212
+ },
213
+ ),
174
214
  );
215
+
216
+ expect(compactLines).toContain("│ shell ->");
217
+ expect(compactLines).toContain("│ echo line 14");
218
+ expect(compactLines).toContain("│ And 6 lines more");
219
+ expect(compactLines).not.toContain("│ echo line 1");
220
+ expect(verboseLines).toContain("│ echo line 1");
221
+ expect(compactLines.length).toBeLessThan(verboseLines.length);
222
+ });
223
+
224
+ test("MCP tool-call previews honor verbose mode", () => {
225
+ const args = {
226
+ query: "routing",
227
+ section: "guides",
228
+ limit: 10,
229
+ offset: 0,
230
+ filter1: "alpha",
231
+ filter2: "beta",
232
+ filter3: "gamma",
233
+ filter4: "delta",
234
+ filter5: "epsilon",
235
+ filter6: "zeta",
236
+ filter7: "eta",
237
+ filter8: "theta",
238
+ };
239
+
240
+ const compactLines = collectRenderedLines(
241
+ renderAssistantMessage(
242
+ {
243
+ content: [
244
+ {
245
+ type: "toolCall",
246
+ id: "call-mcp",
247
+ name: "docs__search",
248
+ arguments: args,
249
+ },
250
+ ],
251
+ },
252
+ {
253
+ showReasoning: true,
254
+ verbose: false,
255
+ theme: DEFAULT_THEME,
256
+ cwd: "/tmp/project",
257
+ previewWidth: 80,
258
+ },
259
+ ),
260
+ );
261
+ const verboseLines = collectRenderedLines(
262
+ renderAssistantMessage(
263
+ {
264
+ content: [
265
+ {
266
+ type: "toolCall",
267
+ id: "call-mcp",
268
+ name: "docs__search",
269
+ arguments: args,
270
+ },
271
+ ],
272
+ },
273
+ {
274
+ showReasoning: true,
275
+ verbose: true,
276
+ theme: DEFAULT_THEME,
277
+ cwd: "/tmp/project",
278
+ previewWidth: 80,
279
+ },
280
+ ),
281
+ );
282
+
283
+ expect(compactLines).toContain("│ docs__search ->");
284
+ expect(compactLines.some((line) => line.includes("And "))).toBe(true);
285
+ expect(compactLines.join("\n")).not.toContain('"query": "routing"');
286
+ expect(verboseLines.join("\n")).toContain('"query": "routing"');
287
+ expect(compactLines.length).toBeLessThan(verboseLines.length);
288
+ });
289
+
290
+ test("shell tool results render structured stdout/stderr details without stderr labels or error styling", () => {
291
+ const stdout = Array.from(
292
+ { length: 12 },
293
+ (_, index) => `line ${index + 1}`,
294
+ ).join("\n");
295
+ const node = renderToolResult(
296
+ "shell",
297
+ { command: "run-tests" },
298
+ "Exit code: 1\nlegacy text should be ignored when details exist",
299
+ true,
300
+ {
301
+ showReasoning: true,
302
+ verbose: false,
303
+ theme: DEFAULT_THEME,
304
+ cwd: "/tmp/project",
305
+ previewWidth: 48,
306
+ },
307
+ {
308
+ stdout,
309
+ stderr: "boom",
310
+ exitCode: 1,
311
+ },
312
+ );
313
+ const lines = collectRenderedLines(node);
314
+ const textNodes = collectTextNodes(node);
315
+
316
+ expect(lines).toContain("│ shell <-");
317
+ expect(lines).toContain("│ boom");
318
+ expect(lines).toContain("│ exit 1");
319
+ expect(lines).toContain("│ And 6 lines more");
320
+ expect(lines.join("\n")).not.toContain("stderr:");
321
+ expect(lines.join("\n")).not.toContain("legacy text should be ignored");
322
+ expect(
323
+ textNodes.some(
324
+ (textNode) =>
325
+ textNode.content === "boom" &&
326
+ textNode.props.fgColor === DEFAULT_THEME.toolText,
327
+ ),
328
+ ).toBe(true);
329
+ expect(
330
+ textNodes.some(
331
+ (textNode) =>
332
+ textNode.content === "exit 1" &&
333
+ textNode.props.fgColor === DEFAULT_THEME.toolText,
334
+ ),
335
+ ).toBe(true);
336
+ });
337
+
338
+ test("shell tool results still render legacy flattened results from persisted history without stderr labels", () => {
339
+ const node = renderToolResult(
340
+ "shell",
341
+ { command: "run-tests" },
342
+ "Exit code: 1\nout\n\n[stderr]\nerr",
343
+ true,
344
+ {
345
+ showReasoning: true,
346
+ verbose: true,
347
+ theme: DEFAULT_THEME,
348
+ cwd: "/tmp/project",
349
+ previewWidth: 80,
350
+ },
351
+ );
352
+ const lines = collectRenderedLines(node);
353
+ const textNodes = collectTextNodes(node);
354
+
355
+ expect(lines).toContain("│ out");
356
+ expect(lines).toContain("│ err");
357
+ expect(lines).toContain("│ exit 1");
358
+ expect(lines.join("\n")).not.toContain("stderr:");
359
+ expect(lines.join("\n")).not.toContain("[stderr]");
360
+ expect(lines.join("\n")).not.toContain("Exit code:");
361
+ expect(
362
+ textNodes.some(
363
+ (textNode) =>
364
+ textNode.content === "err" &&
365
+ textNode.props.fgColor === DEFAULT_THEME.toolText,
366
+ ),
367
+ ).toBe(true);
368
+ expect(
369
+ textNodes.some(
370
+ (textNode) =>
371
+ textNode.content === "exit 1" &&
372
+ textNode.props.fgColor === DEFAULT_THEME.toolText,
373
+ ),
374
+ ).toBe(true);
175
375
  });
176
376
 
177
377
  test("read tool results include the resolved path, hide model paging hints, and render fewer body lines when verbose is off", () => {
@@ -292,17 +492,12 @@ describe("ui/conversation", () => {
292
492
  },
293
493
  );
294
494
 
295
- expect(findTextNode(node, "42").props.fgColor).toBe(
296
- DEFAULT_THEME.secondaryAccentText,
297
- );
298
- expect(
299
- findTextNode(node, "supercalifragilisticexpialidociousIdentifier")
300
- .content,
301
- ).toBe("supercalifragilisticexpialidociousIdentifier");
302
495
  expect(
303
- findTextNode(node, "supercalifragilisticexpialidociousIdentifier").props
304
- .fgColor,
305
- ).toBeUndefined();
496
+ collectTextNodes(node).some(
497
+ (textNode) =>
498
+ textNode.content === "supercalifragilisticexpialidociousIdentifier",
499
+ ),
500
+ ).toBe(true);
306
501
  });
307
502
 
308
503
  test("grep tool results render grouped files and lines instead of raw JSON", () => {
@@ -353,8 +548,38 @@ describe("ui/conversation", () => {
353
548
  expect(lines).toContain("│ 858: spec: ToolBlockSpec,");
354
549
  expect(lines.join("\n")).not.toContain('"files"');
355
550
  expect(lines.join("\n")).not.toContain('"kind"');
356
- expect(
357
- findTextNode(node, " 857: function renderToolBlock(").props.fgColor,
358
- ).toBe(DEFAULT_THEME.toolText);
551
+ });
552
+
553
+ test("MCP tool results honor verbose mode", () => {
554
+ const output = Array.from(
555
+ { length: 14 },
556
+ (_, index) => `result ${index + 1}`,
557
+ ).join("\n");
558
+
559
+ const compactLines = collectRenderedLines(
560
+ renderToolResult("docs__search", { query: "routing" }, output, false, {
561
+ showReasoning: true,
562
+ verbose: false,
563
+ theme: DEFAULT_THEME,
564
+ cwd: "/tmp/project",
565
+ previewWidth: 80,
566
+ }),
567
+ );
568
+ const verboseLines = collectRenderedLines(
569
+ renderToolResult("docs__search", { query: "routing" }, output, false, {
570
+ showReasoning: true,
571
+ verbose: true,
572
+ theme: DEFAULT_THEME,
573
+ cwd: "/tmp/project",
574
+ previewWidth: 80,
575
+ }),
576
+ );
577
+
578
+ expect(compactLines).toContain("│ docs__search <-");
579
+ expect(compactLines).toContain("│ result 14");
580
+ expect(compactLines).toContain("│ And 6 lines more");
581
+ expect(compactLines).not.toContain("│ result 1");
582
+ expect(verboseLines).toContain("│ result 1");
583
+ expect(compactLines.length).toBeLessThan(verboseLines.length);
359
584
  });
360
585
  });