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.
@@ -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
  };
@@ -116,6 +118,9 @@ describe("ui/commands", () => {
116
118
  model: "test/beta",
117
119
  effort: "high",
118
120
  });
121
+ state.queuedUserMessages = [
122
+ { role: "user", content: "stale steering", timestamp: now - 1 },
123
+ ];
119
124
 
120
125
  state.db.run(
121
126
  "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
@@ -173,6 +178,7 @@ describe("ui/commands", () => {
173
178
  totalCost: 0,
174
179
  });
175
180
  expect(state.contextTokens).toBe(computeContextTokens(state.messages));
181
+ expect(state.queuedUserMessages).toEqual([]);
176
182
  } finally {
177
183
  state.db.close();
178
184
  }
@@ -214,6 +220,9 @@ describe("ui/commands", () => {
214
220
  ];
215
221
  state.stats = { totalInput: 10, totalOutput: 20, totalCost: 0.5 };
216
222
  state.contextTokens = 123;
223
+ state.queuedUserMessages = [
224
+ { role: "user", content: "stale steering", timestamp: 2 },
225
+ ];
217
226
 
218
227
  try {
219
228
  expect(controller.handleCommand("new", state)).toBe(true);
@@ -231,6 +240,74 @@ describe("ui/commands", () => {
231
240
  expect(state.agentsMd).toEqual([
232
241
  { path: "/tmp/reloaded/AGENTS.md", content: "Reloaded context" },
233
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([]);
234
311
  } finally {
235
312
  state.db.close();
236
313
  }
@@ -286,8 +363,396 @@ describe("ui/commands", () => {
286
363
  }
287
364
  });
288
365
 
366
+ test("/mcp toggles the selected server, persists it, and reconnects when re-enabled", async () => {
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
+
289
743
  test("/help appends a markdown info message without creating a session", () => {
290
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
+ ];
291
756
  const appended: Array<{
292
757
  text: string;
293
758
  format: string | undefined;
@@ -315,6 +780,12 @@ describe("ui/commands", () => {
315
780
  expect(controller.handleCommand("help", state)).toBe(true);
316
781
  expect(appended).toHaveLength(1);
317
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)");
318
789
  expect(appended[0]?.format).toBe("markdown");
319
790
  expect(appended[0]?.sessionId).toBeNull();
320
791
  expect(state.session).toBeNull();