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.
@@ -3,14 +3,15 @@ import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import type { ContainerNode } from "@cel-tui/types";
6
- import { registerFauxProvider } from "@mariozechner/pi-ai";
7
- import type { AppState } from "../index.ts";
6
+ import { registerFauxProvider, Type } from "@mariozechner/pi-ai";
7
+ import { type AppState, buildToolList } from "../index.ts";
8
8
  import {
9
9
  computeContextTokens,
10
10
  createSession,
11
+ loadMessages,
11
12
  openDatabase,
12
13
  } from "../session.ts";
13
- import { loadSettings } from "../settings.ts";
14
+ import { loadSettings, saveSettings } from "../settings.ts";
14
15
  import { DEFAULT_THEME } from "../theme.ts";
15
16
  import { createCommandController } from "./commands.ts";
16
17
  import type { ActiveOverlay } from "./overlay.ts";
@@ -58,12 +59,12 @@ function createTestState(): AppState {
58
59
  contextTokens: 0,
59
60
  agentsMd: [],
60
61
  skills: [],
61
- plugins: [],
62
62
  theme: DEFAULT_THEME,
63
63
  git: null,
64
64
  providers: new Map(),
65
65
  oauthCredentials: {},
66
66
  settings: {},
67
+ repoSettings: {},
67
68
  settingsPath,
68
69
  cwd,
69
70
  canonicalCwd: cwd,
@@ -74,6 +75,7 @@ function createTestState(): AppState {
74
75
  showReasoning: true,
75
76
  verbose: false,
76
77
  versionLabel: "dev",
78
+ mcpServers: [],
77
79
  customModels: [],
78
80
  startupWarnings: [],
79
81
  };
@@ -90,6 +92,7 @@ describe("ui/commands", () => {
90
92
  const state = createTestState();
91
93
  const runtimeState = { overlay: null as ActiveOverlay | null };
92
94
  let scrollCalls = 0;
95
+ let renderCalls = 0;
93
96
  const controller = createCommandController({
94
97
  openOverlay: (nextOverlay) => {
95
98
  runtimeState.overlay = nextOverlay;
@@ -103,7 +106,9 @@ describe("ui/commands", () => {
103
106
  scrollConversationToBottom: () => {
104
107
  scrollCalls += 1;
105
108
  },
106
- render: () => {},
109
+ requestRender: () => {
110
+ renderCalls += 1;
111
+ },
107
112
  reloadPromptContext: async () => {},
108
113
  openInBrowser: () => {},
109
114
  });
@@ -113,6 +118,9 @@ describe("ui/commands", () => {
113
118
  model: "test/beta",
114
119
  effort: "high",
115
120
  });
121
+ state.queuedUserMessages = [
122
+ { role: "user", content: "stale steering", timestamp: now - 1 },
123
+ ];
116
124
 
117
125
  state.db.run(
118
126
  "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
@@ -153,13 +161,12 @@ describe("ui/commands", () => {
153
161
  expect(controller.handleCommand("session", state)).toBe(true);
154
162
 
155
163
  const overlay = expectOverlay(runtimeState.overlay);
156
- expect(overlay.title).toBe("Resume a session");
157
-
158
164
  const selectNode = renderSelect(overlay);
159
165
  selectNode.props.onKeyPress?.("enter");
160
166
 
161
167
  expect(runtimeState.overlay).toBeNull();
162
168
  expect(scrollCalls).toBe(1);
169
+ expect(renderCalls).toBe(1);
163
170
  expect(state.session?.id).toBe(session.id);
164
171
  expect(state.messages.map((message) => message.role)).toEqual([
165
172
  "user",
@@ -171,6 +178,7 @@ describe("ui/commands", () => {
171
178
  totalCost: 0,
172
179
  });
173
180
  expect(state.contextTokens).toBe(computeContextTokens(state.messages));
181
+ expect(state.queuedUserMessages).toEqual([]);
174
182
  } finally {
175
183
  state.db.close();
176
184
  }
@@ -187,7 +195,7 @@ describe("ui/commands", () => {
187
195
  appendInfoMessage: () => {},
188
196
  appendTodoMessage: () => {},
189
197
  scrollConversationToBottom: () => {},
190
- render: () => {},
198
+ requestRender: () => {},
191
199
  reloadPromptContext: async (nextState) => {
192
200
  reloadCount++;
193
201
  nextState.agentsMd = [
@@ -212,6 +220,9 @@ describe("ui/commands", () => {
212
220
  ];
213
221
  state.stats = { totalInput: 10, totalOutput: 20, totalCost: 0.5 };
214
222
  state.contextTokens = 123;
223
+ state.queuedUserMessages = [
224
+ { role: "user", content: "stale steering", timestamp: 2 },
225
+ ];
215
226
 
216
227
  try {
217
228
  expect(controller.handleCommand("new", state)).toBe(true);
@@ -229,6 +240,74 @@ describe("ui/commands", () => {
229
240
  expect(state.agentsMd).toEqual([
230
241
  { path: "/tmp/reloaded/AGENTS.md", content: "Reloaded context" },
231
242
  ]);
243
+ expect(state.queuedUserMessages).toEqual([]);
244
+ } finally {
245
+ state.db.close();
246
+ }
247
+ });
248
+
249
+ test("/undo clears queued steering before removing the latest turn", async () => {
250
+ const state = createTestState();
251
+ const controller = createCommandController({
252
+ openOverlay: () => {},
253
+ dismissOverlay: () => {},
254
+ setInputValue: () => {},
255
+ appendInfoMessage: () => {},
256
+ appendTodoMessage: () => {},
257
+ scrollConversationToBottom: () => {},
258
+ requestRender: () => {},
259
+ reloadPromptContext: async () => {},
260
+ openInBrowser: () => {},
261
+ });
262
+ const now = new Date("2026-04-07T12:00:00Z").getTime();
263
+ const session = createSession(state.db, {
264
+ cwd: state.canonicalCwd,
265
+ model: "test/beta",
266
+ effort: "high",
267
+ });
268
+
269
+ state.session = session;
270
+ state.queuedUserMessages = [
271
+ { role: "user", content: "stale steering", timestamp: now + 2 },
272
+ ];
273
+ state.db.run(
274
+ "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
275
+ [
276
+ session.id,
277
+ 1,
278
+ JSON.stringify({
279
+ role: "user",
280
+ content: "historical prompt",
281
+ timestamp: now,
282
+ }),
283
+ now,
284
+ ],
285
+ );
286
+ state.db.run(
287
+ "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
288
+ [
289
+ session.id,
290
+ 1,
291
+ JSON.stringify({
292
+ role: "assistant",
293
+ content: [{ type: "text", text: "historical reply" }],
294
+ api: "anthropic-messages",
295
+ provider: "anthropic",
296
+ model: "test/beta",
297
+ stopReason: "stop",
298
+ timestamp: now + 1,
299
+ }),
300
+ now + 1,
301
+ ],
302
+ );
303
+ state.messages = loadMessages(state.db, session.id);
304
+
305
+ try {
306
+ expect(controller.handleCommand("undo", state)).toBe(true);
307
+ await Promise.resolve();
308
+
309
+ expect(state.queuedUserMessages).toEqual([]);
310
+ expect(state.messages).toEqual([]);
232
311
  } finally {
233
312
  state.db.close();
234
313
  }
@@ -243,7 +322,7 @@ describe("ui/commands", () => {
243
322
  appendInfoMessage: () => {},
244
323
  appendTodoMessage: () => {},
245
324
  scrollConversationToBottom: () => {},
246
- render: () => {},
325
+ requestRender: () => {},
247
326
  reloadPromptContext: async () => {},
248
327
  openInBrowser: () => {},
249
328
  });
@@ -269,7 +348,7 @@ describe("ui/commands", () => {
269
348
  appendInfoMessage: () => {},
270
349
  appendTodoMessage: () => {},
271
350
  scrollConversationToBottom: () => {},
272
- render: () => {},
351
+ requestRender: () => {},
273
352
  reloadPromptContext: async () => {},
274
353
  openInBrowser: () => {},
275
354
  });
@@ -284,8 +363,396 @@ describe("ui/commands", () => {
284
363
  }
285
364
  });
286
365
 
287
- test("/help appends markdown help without creating a session", () => {
366
+ test("/mcp toggles the selected server, persists it, and reconnects when re-enabled", async () => {
288
367
  const state = createTestState();
368
+ state.model = {
369
+ id: "test-model",
370
+ name: "test-model",
371
+ api: "openai-completions",
372
+ provider: "test-provider",
373
+ baseUrl: "http://localhost:1234/v1",
374
+ reasoning: false,
375
+ input: ["text"],
376
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
377
+ contextWindow: 8192,
378
+ maxTokens: 4096,
379
+ };
380
+
381
+ const docsTools = [
382
+ {
383
+ name: "docs__search",
384
+ description: "[MCP docs] Search the docs",
385
+ parameters: Type.Object({}),
386
+ },
387
+ {
388
+ name: "docs__fetch",
389
+ description: "[MCP docs] Fetch a page",
390
+ parameters: Type.Object({}),
391
+ },
392
+ ];
393
+ const docsHandlers = new Map();
394
+ let disconnectCalls = 0;
395
+ let connectCalls = 0;
396
+
397
+ state.mcpServers = [
398
+ {
399
+ name: "docs",
400
+ url: "http://docs.test/mcp",
401
+ enabled: true,
402
+ connected: true,
403
+ tools: docsTools,
404
+ toolHandlers: docsHandlers,
405
+ close: async () => {
406
+ disconnectCalls += 1;
407
+ },
408
+ },
409
+ ];
410
+ state.settings = saveSettings(state.settingsPath, {
411
+ mcp: {
412
+ servers: [
413
+ {
414
+ name: "docs",
415
+ url: "http://docs.test/mcp",
416
+ enabled: true,
417
+ },
418
+ {
419
+ name: "down",
420
+ url: "http://down.test/mcp",
421
+ enabled: false,
422
+ },
423
+ ],
424
+ },
425
+ });
426
+ const runtimeState = { overlay: null as ActiveOverlay | null };
427
+ const appended: Array<{
428
+ text: string;
429
+ sessionId: string | null;
430
+ }> = [];
431
+ const controller = createCommandController(
432
+ {
433
+ openOverlay: (nextOverlay) => {
434
+ runtimeState.overlay = nextOverlay;
435
+ },
436
+ dismissOverlay: () => {
437
+ runtimeState.overlay = null;
438
+ },
439
+ setInputValue: () => {},
440
+ appendInfoMessage: (text, nextState) => {
441
+ appended.push({ text, sessionId: nextState.session?.id ?? null });
442
+ },
443
+ appendTodoMessage: () => {},
444
+ scrollConversationToBottom: () => {},
445
+ requestRender: () => {},
446
+ reloadPromptContext: async () => {},
447
+ openInBrowser: () => {},
448
+ },
449
+ {
450
+ connectMcpServer: async (server) => {
451
+ connectCalls += 1;
452
+ server.connected = true;
453
+ server.tools = docsTools;
454
+ server.toolHandlers = docsHandlers;
455
+ server.close = async () => {
456
+ disconnectCalls += 1;
457
+ };
458
+ return [];
459
+ },
460
+ disconnectMcpServer: async (server) => {
461
+ await server.close();
462
+ server.connected = false;
463
+ server.tools = [];
464
+ server.toolHandlers = new Map();
465
+ server.close = async () => {};
466
+ },
467
+ },
468
+ );
469
+
470
+ try {
471
+ expect(controller.handleCommand("mcp", state)).toBe(true);
472
+ const firstOverlay = expectOverlay(runtimeState.overlay);
473
+ expect(firstOverlay.title).toBe("Toggle MCP servers");
474
+ expect(buildToolList(state).tools.map((tool) => tool.name)).toContain(
475
+ "docs__search",
476
+ );
477
+ renderSelect(firstOverlay).props.onKeyPress?.("enter");
478
+ await Promise.resolve();
479
+ await Promise.resolve();
480
+
481
+ expect(runtimeState.overlay).toBeNull();
482
+ expect(state.mcpServers[0]?.enabled).toBe(false);
483
+ expect(state.mcpServers[0]?.connected).toBe(false);
484
+ expect(state.settings.mcp).toEqual({
485
+ servers: [
486
+ {
487
+ name: "docs",
488
+ url: "http://docs.test/mcp",
489
+ enabled: false,
490
+ },
491
+ {
492
+ name: "down",
493
+ url: "http://down.test/mcp",
494
+ enabled: false,
495
+ },
496
+ ],
497
+ });
498
+ expect(loadSettings(state.settingsPath)).toEqual({
499
+ mcp: {
500
+ servers: [
501
+ {
502
+ name: "docs",
503
+ url: "http://docs.test/mcp",
504
+ enabled: false,
505
+ },
506
+ {
507
+ name: "down",
508
+ url: "http://down.test/mcp",
509
+ enabled: false,
510
+ },
511
+ ],
512
+ },
513
+ });
514
+ expect(disconnectCalls).toBe(1);
515
+ expect(appended).toEqual([
516
+ {
517
+ text: 'Disabled MCP server "docs" (-2 tools).',
518
+ sessionId: null,
519
+ },
520
+ ]);
521
+ expect(buildToolList(state).tools.map((tool) => tool.name)).not.toContain(
522
+ "docs__search",
523
+ );
524
+
525
+ expect(controller.handleCommand("mcp", state)).toBe(true);
526
+ const secondOverlay = expectOverlay(runtimeState.overlay);
527
+ renderSelect(secondOverlay).props.onKeyPress?.("enter");
528
+ await Promise.resolve();
529
+ await Promise.resolve();
530
+
531
+ expect(state.mcpServers[0]?.enabled).toBe(true);
532
+ expect(state.mcpServers[0]?.connected).toBe(true);
533
+ expect(connectCalls).toBe(1);
534
+ expect(loadSettings(state.settingsPath)).toEqual({
535
+ mcp: {
536
+ servers: [
537
+ {
538
+ name: "docs",
539
+ url: "http://docs.test/mcp",
540
+ enabled: true,
541
+ },
542
+ {
543
+ name: "down",
544
+ url: "http://down.test/mcp",
545
+ enabled: false,
546
+ },
547
+ ],
548
+ },
549
+ });
550
+ expect(appended[1]).toEqual({
551
+ text: 'Enabled MCP server "docs" (+2 tools).',
552
+ sessionId: null,
553
+ });
554
+ expect(buildToolList(state).tools.map((tool) => tool.name)).toContain(
555
+ "docs__search",
556
+ );
557
+ } finally {
558
+ state.db.close();
559
+ }
560
+ });
561
+
562
+ test("/mcp toggles repo-local servers for the current run without persisting a global shadow copy", async () => {
563
+ const state = createTestState();
564
+ state.model = {
565
+ id: "test-model",
566
+ name: "test-model",
567
+ api: "openai-completions",
568
+ provider: "test-provider",
569
+ baseUrl: "http://localhost:1234/v1",
570
+ reasoning: false,
571
+ input: ["text"],
572
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
573
+ contextWindow: 8192,
574
+ maxTokens: 4096,
575
+ };
576
+
577
+ const docsTools = [
578
+ {
579
+ name: "docs__search",
580
+ description: "[MCP docs] Search the docs",
581
+ parameters: Type.Object({}),
582
+ },
583
+ ];
584
+ const docsHandlers = new Map();
585
+ let disconnectCalls = 0;
586
+ let connectCalls = 0;
587
+
588
+ state.mcpServers = [
589
+ {
590
+ name: "docs",
591
+ url: "http://repo-docs.test/mcp",
592
+ enabled: true,
593
+ connected: true,
594
+ tools: docsTools,
595
+ toolHandlers: docsHandlers,
596
+ close: async () => {
597
+ disconnectCalls += 1;
598
+ },
599
+ },
600
+ ];
601
+ state.settings = saveSettings(state.settingsPath, {
602
+ mcp: {
603
+ servers: [
604
+ {
605
+ name: "docs",
606
+ url: "http://global-docs.test/mcp",
607
+ enabled: false,
608
+ },
609
+ {
610
+ name: "down",
611
+ url: "http://down.test/mcp",
612
+ enabled: false,
613
+ },
614
+ ],
615
+ },
616
+ });
617
+ state.repoSettings = {
618
+ mcp: {
619
+ servers: [
620
+ {
621
+ name: "docs",
622
+ url: "http://repo-docs.test/mcp",
623
+ enabled: true,
624
+ },
625
+ ],
626
+ },
627
+ };
628
+ const runtimeState = { overlay: null as ActiveOverlay | null };
629
+ const appended: Array<{
630
+ text: string;
631
+ sessionId: string | null;
632
+ }> = [];
633
+ const controller = createCommandController(
634
+ {
635
+ openOverlay: (nextOverlay) => {
636
+ runtimeState.overlay = nextOverlay;
637
+ },
638
+ dismissOverlay: () => {
639
+ runtimeState.overlay = null;
640
+ },
641
+ setInputValue: () => {},
642
+ appendInfoMessage: (text, nextState) => {
643
+ appended.push({ text, sessionId: nextState.session?.id ?? null });
644
+ },
645
+ appendTodoMessage: () => {},
646
+ scrollConversationToBottom: () => {},
647
+ requestRender: () => {},
648
+ reloadPromptContext: async () => {},
649
+ openInBrowser: () => {},
650
+ },
651
+ {
652
+ connectMcpServer: async (server) => {
653
+ connectCalls += 1;
654
+ server.connected = true;
655
+ server.tools = docsTools;
656
+ server.toolHandlers = docsHandlers;
657
+ server.close = async () => {
658
+ disconnectCalls += 1;
659
+ };
660
+ return [];
661
+ },
662
+ disconnectMcpServer: async (server) => {
663
+ await server.close();
664
+ server.connected = false;
665
+ server.tools = [];
666
+ server.toolHandlers = new Map();
667
+ server.close = async () => {};
668
+ },
669
+ },
670
+ );
671
+
672
+ try {
673
+ expect(controller.handleCommand("mcp", state)).toBe(true);
674
+ renderSelect(expectOverlay(runtimeState.overlay)).props.onKeyPress?.(
675
+ "enter",
676
+ );
677
+ await Promise.resolve();
678
+ await Promise.resolve();
679
+
680
+ expect(state.mcpServers[0]?.enabled).toBe(false);
681
+ expect(state.mcpServers[0]?.connected).toBe(false);
682
+ expect(state.settings).toEqual({
683
+ mcp: {
684
+ servers: [
685
+ {
686
+ name: "docs",
687
+ url: "http://global-docs.test/mcp",
688
+ enabled: false,
689
+ },
690
+ {
691
+ name: "down",
692
+ url: "http://down.test/mcp",
693
+ enabled: false,
694
+ },
695
+ ],
696
+ },
697
+ });
698
+ expect(loadSettings(state.settingsPath)).toEqual(state.settings);
699
+ expect(disconnectCalls).toBe(1);
700
+
701
+ expect(controller.handleCommand("mcp", state)).toBe(true);
702
+ renderSelect(expectOverlay(runtimeState.overlay)).props.onKeyPress?.(
703
+ "enter",
704
+ );
705
+ await Promise.resolve();
706
+ await Promise.resolve();
707
+
708
+ expect(state.mcpServers[0]?.enabled).toBe(true);
709
+ expect(state.mcpServers[0]?.connected).toBe(true);
710
+ expect(connectCalls).toBe(1);
711
+ expect(state.settings).toEqual({
712
+ mcp: {
713
+ servers: [
714
+ {
715
+ name: "docs",
716
+ url: "http://global-docs.test/mcp",
717
+ enabled: false,
718
+ },
719
+ {
720
+ name: "down",
721
+ url: "http://down.test/mcp",
722
+ enabled: false,
723
+ },
724
+ ],
725
+ },
726
+ });
727
+ expect(loadSettings(state.settingsPath)).toEqual(state.settings);
728
+ expect(appended).toEqual([
729
+ {
730
+ text: 'Disabled MCP server "docs" (-1 tool).',
731
+ sessionId: null,
732
+ },
733
+ {
734
+ text: 'Enabled MCP server "docs" (+1 tool).',
735
+ sessionId: null,
736
+ },
737
+ ]);
738
+ } finally {
739
+ state.db.close();
740
+ }
741
+ });
742
+
743
+ test("/help appends a markdown info message without creating a session", () => {
744
+ const state = createTestState();
745
+ state.mcpServers = [
746
+ {
747
+ name: "docs",
748
+ url: "http://docs.test/mcp",
749
+ enabled: false,
750
+ connected: false,
751
+ tools: [],
752
+ toolHandlers: new Map(),
753
+ close: async () => {},
754
+ },
755
+ ];
289
756
  const appended: Array<{
290
757
  text: string;
291
758
  format: string | undefined;
@@ -304,7 +771,7 @@ describe("ui/commands", () => {
304
771
  },
305
772
  appendTodoMessage: () => {},
306
773
  scrollConversationToBottom: () => {},
307
- render: () => {},
774
+ requestRender: () => {},
308
775
  reloadPromptContext: async () => {},
309
776
  openInBrowser: () => {},
310
777
  });
@@ -312,8 +779,13 @@ describe("ui/commands", () => {
312
779
  try {
313
780
  expect(controller.handleCommand("help", state)).toBe(true);
314
781
  expect(appended).toHaveLength(1);
315
- expect(appended[0]?.text).toContain("# Help");
316
- expect(appended[0]?.text).toContain("## Commands");
782
+ expect(appended[0]?.text.length).toBeGreaterThan(0);
783
+ expect(appended[0]?.text).toContain("`/mcp`");
784
+ expect(appended[0]?.text).toContain("`/skill:name`");
785
+ expect(appended[0]?.text).toContain(
786
+ "submit `/skill` to open the skill picker",
787
+ );
788
+ expect(appended[0]?.text).toContain("`docs` (off)");
317
789
  expect(appended[0]?.format).toBe("markdown");
318
790
  expect(appended[0]?.sessionId).toBeNull();
319
791
  expect(state.session).toBeNull();
@@ -360,7 +832,7 @@ describe("ui/commands", () => {
360
832
  });
361
833
  },
362
834
  scrollConversationToBottom: () => {},
363
- render: () => {},
835
+ requestRender: () => {},
364
836
  reloadPromptContext: async () => {},
365
837
  openInBrowser: () => {},
366
838
  });
@@ -393,7 +865,7 @@ describe("ui/commands", () => {
393
865
  appendInfoMessage: () => {},
394
866
  appendTodoMessage: () => {},
395
867
  scrollConversationToBottom: () => {},
396
- render: () => {},
868
+ requestRender: () => {},
397
869
  reloadPromptContext: async () => {},
398
870
  openInBrowser: () => {},
399
871
  });
@@ -421,7 +893,7 @@ describe("ui/commands", () => {
421
893
  appendInfoMessage: () => {},
422
894
  appendTodoMessage: () => {},
423
895
  scrollConversationToBottom: () => {},
424
- render: () => {},
896
+ requestRender: () => {},
425
897
  reloadPromptContext: async () => {},
426
898
  openInBrowser: () => {},
427
899
  });