mini-coder 0.5.14 → 0.6.0

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.
Files changed (70) hide show
  1. package/README.md +25 -109
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/package.json +17 -22
  5. package/src/agent.ts +237 -1403
  6. package/src/args.ts +289 -0
  7. package/src/headless.ts +43 -358
  8. package/src/index.ts +29 -1016
  9. package/src/oauth.ts +117 -0
  10. package/src/prompt.ts +227 -284
  11. package/src/session.ts +55 -1306
  12. package/src/shared.ts +117 -38
  13. package/src/tool-bash.ts +110 -0
  14. package/src/tool-edit.ts +133 -0
  15. package/src/tool-task.ts +114 -0
  16. package/src/tui-components.ts +150 -0
  17. package/src/tui-conversation.ts +262 -0
  18. package/src/tui-editor.ts +29 -0
  19. package/src/tui-overlay.ts +403 -0
  20. package/src/tui.ts +236 -0
  21. package/src/types.ts +160 -0
  22. package/tsconfig.json +17 -0
  23. package/BENCHMARK.md +0 -107
  24. package/LICENSE +0 -9
  25. package/PROGRESS.md +0 -5
  26. package/assets/icon-1-minimal.svg +0 -31
  27. package/assets/icon-2-dark-terminal.svg +0 -48
  28. package/assets/icon-3-gradient-modern.svg +0 -45
  29. package/assets/icon-4-filled-bold.svg +0 -54
  30. package/assets/icon-5-community-badge.svg +0 -63
  31. package/assets/mc-claude-smart.png +0 -0
  32. package/assets/mc-gpt-smart.png +0 -0
  33. package/assets/preview-0-5-0.png +0 -0
  34. package/assets/preview.gif +0 -0
  35. package/benchmark-baseline.sh +0 -15
  36. package/benchmark-loop.sh +0 -19
  37. package/skills-lock.json +0 -15
  38. package/src/assistant-output.ts +0 -73
  39. package/src/cli.ts +0 -134
  40. package/src/delegation.ts +0 -238
  41. package/src/errors.ts +0 -15
  42. package/src/git.ts +0 -247
  43. package/src/input.ts +0 -168
  44. package/src/mcp.ts +0 -609
  45. package/src/paths.ts +0 -37
  46. package/src/session-message.ts +0 -385
  47. package/src/settings.ts +0 -449
  48. package/src/skills.ts +0 -271
  49. package/src/submit.ts +0 -376
  50. package/src/text.ts +0 -71
  51. package/src/theme.ts +0 -330
  52. package/src/tool-common.ts +0 -93
  53. package/src/tool-delegate.ts +0 -125
  54. package/src/tool-grep.ts +0 -606
  55. package/src/tool-read.ts +0 -313
  56. package/src/tool-shell.ts +0 -1051
  57. package/src/tools.ts +0 -1179
  58. package/src/ui/agent.ts +0 -320
  59. package/src/ui/commands.test.ts +0 -957
  60. package/src/ui/commands.ts +0 -848
  61. package/src/ui/conversation.test.ts +0 -585
  62. package/src/ui/conversation.ts +0 -1836
  63. package/src/ui/help.ts +0 -158
  64. package/src/ui/input.test.ts +0 -64
  65. package/src/ui/input.ts +0 -138
  66. package/src/ui/overlay.ts +0 -59
  67. package/src/ui/runtime.ts +0 -69
  68. package/src/ui/status.ts +0 -220
  69. package/src/ui.ts +0 -1190
  70. package/src/version.ts +0 -48
@@ -1,957 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync } from "node:fs";
3
- import { tmpdir } from "node:os";
4
- import { join } from "node:path";
5
- import type { ContainerNode } from "@cel-tui/types";
6
- import { registerFauxProvider, Type } from "@mariozechner/pi-ai";
7
- import { type AppState, buildToolList } from "../index.ts";
8
- import {
9
- computeSessionContextTokens,
10
- createSession,
11
- loadMessages,
12
- openDatabase,
13
- } from "../session.ts";
14
- import { loadSettings, saveSettings } from "../settings.ts";
15
- import { DEFAULT_THEME } from "../theme.ts";
16
- import { createCommandController } from "./commands.ts";
17
- import type { ActiveOverlay } from "./overlay.ts";
18
-
19
- function expectOverlay(overlay: ActiveOverlay | null): ActiveOverlay {
20
- if (!overlay) {
21
- throw new Error("Expected an active overlay");
22
- }
23
- return overlay;
24
- }
25
-
26
- function renderSelect(overlay: ActiveOverlay): ContainerNode {
27
- return overlay.select();
28
- }
29
-
30
- function createDeferred<T = void>() {
31
- let resolve!: (value: T | PromiseLike<T>) => void;
32
- let reject!: (reason?: unknown) => void;
33
- const promise = new Promise<T>((resolvePromise, rejectPromise) => {
34
- resolve = resolvePromise;
35
- reject = rejectPromise;
36
- });
37
- return { promise, resolve, reject };
38
- }
39
-
40
- const tempDirs: string[] = [];
41
-
42
- function createTempDir(): string {
43
- const dir = mkdtempSync(join(tmpdir(), "mini-coder-ui-commands-test-"));
44
- tempDirs.push(dir);
45
- return dir;
46
- }
47
-
48
- function createTestState(): AppState {
49
- const db = openDatabase(":memory:");
50
- const cwd = "/tmp/mini-coder-ui-commands-test";
51
- const settingsPath = join(createTempDir(), "settings.json");
52
- return {
53
- db,
54
- session: null,
55
- model: null,
56
- effort: "medium",
57
- messages: [],
58
- stats: { totalInput: 0, totalOutput: 0, totalCost: 0 },
59
- contextTokens: 0,
60
- agentsMd: [],
61
- skills: [],
62
- theme: DEFAULT_THEME,
63
- git: null,
64
- delegationDepth: 0,
65
- delegationBudgetLimit: 4,
66
- delegationBudgetRemaining: 4,
67
- providers: new Map(),
68
- oauthCredentials: {},
69
- settings: {},
70
- repoSettings: {},
71
- settingsPath,
72
- cwd,
73
- canonicalCwd: cwd,
74
- running: false,
75
- abortController: null,
76
- activeTurnPromise: null,
77
- queuedUserMessages: [],
78
- showReasoning: true,
79
- verbose: false,
80
- versionLabel: "dev",
81
- mcpServers: [],
82
- customModels: [],
83
- startupWarnings: [],
84
- };
85
- }
86
-
87
- afterEach(() => {
88
- for (const dir of tempDirs.splice(0)) {
89
- rmSync(dir, { recursive: true, force: true });
90
- }
91
- });
92
-
93
- describe("ui/commands", () => {
94
- test("handleCommand('session') restores the selected session and recomputes stats", () => {
95
- const state = createTestState();
96
- const runtimeState = { overlay: null as ActiveOverlay | null };
97
- let scrollCalls = 0;
98
- let renderCalls = 0;
99
- const controller = createCommandController({
100
- openOverlay: (nextOverlay) => {
101
- runtimeState.overlay = nextOverlay;
102
- },
103
- dismissOverlay: () => {
104
- runtimeState.overlay = null;
105
- },
106
- setInputValue: () => {},
107
- appendInfoMessage: () => {},
108
- appendTodoMessage: () => {},
109
- scrollConversationToBottom: () => {
110
- scrollCalls += 1;
111
- },
112
- requestRender: () => {
113
- renderCalls += 1;
114
- },
115
- reloadPromptContext: async () => {},
116
- openInBrowser: () => {},
117
- });
118
- const now = new Date("2026-04-07T12:00:00Z").getTime();
119
- const session = createSession(state.db, {
120
- cwd: state.canonicalCwd,
121
- model: "test/beta",
122
- effort: "high",
123
- });
124
- state.queuedUserMessages = [
125
- { role: "user", content: "stale steering", timestamp: now - 1 },
126
- ];
127
-
128
- state.db.run(
129
- "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
130
- [
131
- session.id,
132
- 1,
133
- JSON.stringify({
134
- role: "user",
135
- content: "historical prompt",
136
- timestamp: now,
137
- }),
138
- now,
139
- ],
140
- );
141
- state.db.run(
142
- "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
143
- [
144
- session.id,
145
- 1,
146
- JSON.stringify({
147
- role: "assistant",
148
- content: [{ type: "text", text: "historical reply" }],
149
- api: "anthropic-messages",
150
- provider: "anthropic",
151
- model: "test/beta",
152
- stopReason: "stop",
153
- timestamp: now + 1,
154
- }),
155
- now + 1,
156
- ],
157
- );
158
- const messageRows = state.db
159
- .query<
160
- { id: number },
161
- [string]
162
- >("SELECT id FROM messages WHERE session_id = ? ORDER BY id")
163
- .all(session.id);
164
- state.db.run(
165
- "INSERT INTO session_compactions (session_id, message_end_id, summary_data, usage_data, created_at) VALUES (?, ?, ?, ?, ?)",
166
- [
167
- session.id,
168
- messageRows[1]!.id,
169
- JSON.stringify({
170
- role: "user",
171
- content: [
172
- "summary of the earlier turn",
173
- "",
174
- "<system-message>",
175
- "Read the originals from the SQLite DB if needed.",
176
- "</system-message>",
177
- ].join("\n"),
178
- timestamp: now + 2,
179
- }),
180
- JSON.stringify({
181
- input: 12,
182
- output: 3,
183
- cacheRead: 0,
184
- cacheWrite: 0,
185
- totalTokens: 15,
186
- cost: {
187
- input: 0.01,
188
- output: 0.02,
189
- cacheRead: 0,
190
- cacheWrite: 0,
191
- total: 0.03,
192
- },
193
- }),
194
- now + 2,
195
- ],
196
- );
197
- state.db.run("UPDATE sessions SET updated_at = ? WHERE id = ?", [
198
- now + 2,
199
- session.id,
200
- ]);
201
-
202
- try {
203
- expect(controller.handleCommand("session", state)).toBe(true);
204
-
205
- const overlay = expectOverlay(runtimeState.overlay);
206
- const selectNode = renderSelect(overlay);
207
- selectNode.props.onKeyPress?.("enter");
208
-
209
- expect(runtimeState.overlay).toBeNull();
210
- expect(scrollCalls).toBe(1);
211
- expect(renderCalls).toBe(1);
212
- expect(state.session?.id).toBe(session.id);
213
- expect(state.messages.map((message) => message.role)).toEqual([
214
- "user",
215
- "assistant",
216
- ]);
217
- expect(state.stats).toEqual({
218
- totalInput: 12,
219
- totalOutput: 3,
220
- totalCost: 0.03,
221
- });
222
- expect(state.contextTokens).toBe(
223
- computeSessionContextTokens(state.db, session.id),
224
- );
225
- expect(state.queuedUserMessages).toEqual([]);
226
- } finally {
227
- state.db.close();
228
- }
229
- });
230
-
231
- test("/new clears the active session state and reloads prompt context", async () => {
232
- const state = createTestState();
233
- let reloadCount = 0;
234
- const reloadFinished = createDeferred<void>();
235
- const controller = createCommandController({
236
- openOverlay: () => {},
237
- dismissOverlay: () => {},
238
- setInputValue: () => {},
239
- appendInfoMessage: () => {},
240
- appendTodoMessage: () => {},
241
- scrollConversationToBottom: () => {},
242
- requestRender: () => {},
243
- reloadPromptContext: async (nextState) => {
244
- reloadCount++;
245
- nextState.agentsMd = [
246
- { path: "/tmp/reloaded/AGENTS.md", content: "Reloaded context" },
247
- ];
248
- reloadFinished.resolve();
249
- },
250
- openInBrowser: () => {},
251
- });
252
-
253
- state.session = {
254
- id: "session-1",
255
- cwd: state.canonicalCwd,
256
- model: null,
257
- effort: state.effort,
258
- forkedFrom: null,
259
- createdAt: 1,
260
- updatedAt: 1,
261
- };
262
- state.messages = [
263
- { role: "ui", kind: "info", content: "old", timestamp: 1 },
264
- ];
265
- state.stats = { totalInput: 10, totalOutput: 20, totalCost: 0.5 };
266
- state.contextTokens = 123;
267
- state.queuedUserMessages = [
268
- { role: "user", content: "stale steering", timestamp: 2 },
269
- ];
270
-
271
- try {
272
- expect(controller.handleCommand("new", state)).toBe(true);
273
- await reloadFinished.promise;
274
-
275
- expect(reloadCount).toBe(1);
276
- expect(state.session).toBeNull();
277
- expect(state.messages).toEqual([]);
278
- expect(state.stats).toEqual({
279
- totalInput: 0,
280
- totalOutput: 0,
281
- totalCost: 0,
282
- });
283
- expect(state.contextTokens).toBe(0);
284
- expect(state.agentsMd).toEqual([
285
- { path: "/tmp/reloaded/AGENTS.md", content: "Reloaded context" },
286
- ]);
287
- expect(state.queuedUserMessages).toEqual([]);
288
- } finally {
289
- state.db.close();
290
- }
291
- });
292
-
293
- test("/undo clears queued steering before removing the latest turn", async () => {
294
- const state = createTestState();
295
- const controller = createCommandController({
296
- openOverlay: () => {},
297
- dismissOverlay: () => {},
298
- setInputValue: () => {},
299
- appendInfoMessage: () => {},
300
- appendTodoMessage: () => {},
301
- scrollConversationToBottom: () => {},
302
- requestRender: () => {},
303
- reloadPromptContext: async () => {},
304
- openInBrowser: () => {},
305
- });
306
- const now = new Date("2026-04-07T12:00:00Z").getTime();
307
- const session = createSession(state.db, {
308
- cwd: state.canonicalCwd,
309
- model: "test/beta",
310
- effort: "high",
311
- });
312
-
313
- state.session = session;
314
- state.queuedUserMessages = [
315
- { role: "user", content: "stale steering", timestamp: now + 2 },
316
- ];
317
- state.db.run(
318
- "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
319
- [
320
- session.id,
321
- 1,
322
- JSON.stringify({
323
- role: "user",
324
- content: "historical prompt",
325
- timestamp: now,
326
- }),
327
- now,
328
- ],
329
- );
330
- state.db.run(
331
- "INSERT INTO messages (session_id, turn, data, created_at) VALUES (?, ?, ?, ?)",
332
- [
333
- session.id,
334
- 1,
335
- JSON.stringify({
336
- role: "assistant",
337
- content: [{ type: "text", text: "historical reply" }],
338
- api: "anthropic-messages",
339
- provider: "anthropic",
340
- model: "test/beta",
341
- stopReason: "stop",
342
- timestamp: now + 1,
343
- }),
344
- now + 1,
345
- ],
346
- );
347
- state.messages = loadMessages(state.db, session.id);
348
-
349
- try {
350
- expect(controller.handleCommand("undo", state)).toBe(true);
351
- await Promise.resolve();
352
-
353
- expect(state.queuedUserMessages).toEqual([]);
354
- expect(state.messages).toEqual([]);
355
- } finally {
356
- state.db.close();
357
- }
358
- });
359
-
360
- test("/reasoning toggles the UI state and persists it", () => {
361
- const state = createTestState();
362
- const controller = createCommandController({
363
- openOverlay: () => {},
364
- dismissOverlay: () => {},
365
- setInputValue: () => {},
366
- appendInfoMessage: () => {},
367
- appendTodoMessage: () => {},
368
- scrollConversationToBottom: () => {},
369
- requestRender: () => {},
370
- reloadPromptContext: async () => {},
371
- openInBrowser: () => {},
372
- });
373
-
374
- try {
375
- expect(controller.handleCommand("reasoning", state)).toBe(true);
376
- expect(state.showReasoning).toBe(false);
377
- expect(state.settings.showReasoning).toBe(false);
378
- expect(loadSettings(state.settingsPath)).toEqual({
379
- showReasoning: false,
380
- });
381
- } finally {
382
- state.db.close();
383
- }
384
- });
385
-
386
- test("/verbose toggles the UI state and persists it", () => {
387
- const state = createTestState();
388
- const controller = createCommandController({
389
- openOverlay: () => {},
390
- dismissOverlay: () => {},
391
- setInputValue: () => {},
392
- appendInfoMessage: () => {},
393
- appendTodoMessage: () => {},
394
- scrollConversationToBottom: () => {},
395
- requestRender: () => {},
396
- reloadPromptContext: async () => {},
397
- openInBrowser: () => {},
398
- });
399
-
400
- try {
401
- expect(controller.handleCommand("verbose", state)).toBe(true);
402
- expect(state.verbose).toBe(true);
403
- expect(state.settings.verbose).toBe(true);
404
- expect(loadSettings(state.settingsPath)).toEqual({ verbose: true });
405
- } finally {
406
- state.db.close();
407
- }
408
- });
409
-
410
- test("/mcp toggles the selected server, persists it, and reconnects when re-enabled", async () => {
411
- const state = createTestState();
412
- state.model = {
413
- id: "test-model",
414
- name: "test-model",
415
- api: "openai-completions",
416
- provider: "test-provider",
417
- baseUrl: "http://localhost:1234/v1",
418
- reasoning: false,
419
- input: ["text"],
420
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
421
- contextWindow: 8192,
422
- maxTokens: 4096,
423
- };
424
-
425
- const docsTools = [
426
- {
427
- name: "docs__search",
428
- description: "[MCP docs] Search the docs",
429
- parameters: Type.Object({}),
430
- },
431
- {
432
- name: "docs__fetch",
433
- description: "[MCP docs] Fetch a page",
434
- parameters: Type.Object({}),
435
- },
436
- ];
437
- const docsHandlers = new Map();
438
- let disconnectCalls = 0;
439
- let connectCalls = 0;
440
-
441
- state.mcpServers = [
442
- {
443
- name: "docs",
444
- url: "http://docs.test/mcp",
445
- enabled: true,
446
- connected: true,
447
- tools: docsTools,
448
- toolHandlers: docsHandlers,
449
- close: async () => {
450
- disconnectCalls += 1;
451
- },
452
- },
453
- ];
454
- state.settings = saveSettings(state.settingsPath, {
455
- mcp: {
456
- servers: [
457
- {
458
- name: "docs",
459
- url: "http://docs.test/mcp",
460
- enabled: true,
461
- },
462
- {
463
- name: "down",
464
- url: "http://down.test/mcp",
465
- enabled: false,
466
- },
467
- ],
468
- },
469
- });
470
- const runtimeState = { overlay: null as ActiveOverlay | null };
471
- const appended: Array<{
472
- text: string;
473
- sessionId: string | null;
474
- }> = [];
475
- const controller = createCommandController(
476
- {
477
- openOverlay: (nextOverlay) => {
478
- runtimeState.overlay = nextOverlay;
479
- },
480
- dismissOverlay: () => {
481
- runtimeState.overlay = null;
482
- },
483
- setInputValue: () => {},
484
- appendInfoMessage: (text, nextState) => {
485
- appended.push({ text, sessionId: nextState.session?.id ?? null });
486
- },
487
- appendTodoMessage: () => {},
488
- scrollConversationToBottom: () => {},
489
- requestRender: () => {},
490
- reloadPromptContext: async () => {},
491
- openInBrowser: () => {},
492
- },
493
- {
494
- connectMcpServer: async (server) => {
495
- connectCalls += 1;
496
- server.connected = true;
497
- server.tools = docsTools;
498
- server.toolHandlers = docsHandlers;
499
- server.close = async () => {
500
- disconnectCalls += 1;
501
- };
502
- return [];
503
- },
504
- disconnectMcpServer: async (server) => {
505
- await server.close();
506
- server.connected = false;
507
- server.tools = [];
508
- server.toolHandlers = new Map();
509
- server.close = async () => {};
510
- },
511
- },
512
- );
513
-
514
- try {
515
- expect(controller.handleCommand("mcp", state)).toBe(true);
516
- const firstOverlay = expectOverlay(runtimeState.overlay);
517
- expect(firstOverlay.title).toBe("Toggle MCP servers");
518
- expect(buildToolList(state).tools.map((tool) => tool.name)).toContain(
519
- "docs__search",
520
- );
521
- renderSelect(firstOverlay).props.onKeyPress?.("enter");
522
- await Promise.resolve();
523
- await Promise.resolve();
524
-
525
- expect(runtimeState.overlay).toBeNull();
526
- expect(state.mcpServers[0]?.enabled).toBe(false);
527
- expect(state.mcpServers[0]?.connected).toBe(false);
528
- expect(state.settings.mcp).toEqual({
529
- servers: [
530
- {
531
- name: "docs",
532
- url: "http://docs.test/mcp",
533
- enabled: false,
534
- },
535
- {
536
- name: "down",
537
- url: "http://down.test/mcp",
538
- enabled: false,
539
- },
540
- ],
541
- });
542
- expect(loadSettings(state.settingsPath)).toEqual({
543
- mcp: {
544
- servers: [
545
- {
546
- name: "docs",
547
- url: "http://docs.test/mcp",
548
- enabled: false,
549
- },
550
- {
551
- name: "down",
552
- url: "http://down.test/mcp",
553
- enabled: false,
554
- },
555
- ],
556
- },
557
- });
558
- expect(disconnectCalls).toBe(1);
559
- expect(appended).toEqual([
560
- {
561
- text: 'Disabled MCP server "docs" (-2 tools).',
562
- sessionId: null,
563
- },
564
- ]);
565
- expect(buildToolList(state).tools.map((tool) => tool.name)).not.toContain(
566
- "docs__search",
567
- );
568
-
569
- expect(controller.handleCommand("mcp", state)).toBe(true);
570
- const secondOverlay = expectOverlay(runtimeState.overlay);
571
- renderSelect(secondOverlay).props.onKeyPress?.("enter");
572
- await Promise.resolve();
573
- await Promise.resolve();
574
-
575
- expect(state.mcpServers[0]?.enabled).toBe(true);
576
- expect(state.mcpServers[0]?.connected).toBe(true);
577
- expect(connectCalls).toBe(1);
578
- expect(loadSettings(state.settingsPath)).toEqual({
579
- mcp: {
580
- servers: [
581
- {
582
- name: "docs",
583
- url: "http://docs.test/mcp",
584
- enabled: true,
585
- },
586
- {
587
- name: "down",
588
- url: "http://down.test/mcp",
589
- enabled: false,
590
- },
591
- ],
592
- },
593
- });
594
- expect(appended[1]).toEqual({
595
- text: 'Enabled MCP server "docs" (+2 tools).',
596
- sessionId: null,
597
- });
598
- expect(buildToolList(state).tools.map((tool) => tool.name)).toContain(
599
- "docs__search",
600
- );
601
- } finally {
602
- state.db.close();
603
- }
604
- });
605
-
606
- test("/mcp toggles repo-local servers for the current run without persisting a global shadow copy", async () => {
607
- const state = createTestState();
608
- state.model = {
609
- id: "test-model",
610
- name: "test-model",
611
- api: "openai-completions",
612
- provider: "test-provider",
613
- baseUrl: "http://localhost:1234/v1",
614
- reasoning: false,
615
- input: ["text"],
616
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
617
- contextWindow: 8192,
618
- maxTokens: 4096,
619
- };
620
-
621
- const docsTools = [
622
- {
623
- name: "docs__search",
624
- description: "[MCP docs] Search the docs",
625
- parameters: Type.Object({}),
626
- },
627
- ];
628
- const docsHandlers = new Map();
629
- let disconnectCalls = 0;
630
- let connectCalls = 0;
631
-
632
- state.mcpServers = [
633
- {
634
- name: "docs",
635
- url: "http://repo-docs.test/mcp",
636
- enabled: true,
637
- connected: true,
638
- tools: docsTools,
639
- toolHandlers: docsHandlers,
640
- close: async () => {
641
- disconnectCalls += 1;
642
- },
643
- },
644
- ];
645
- state.settings = saveSettings(state.settingsPath, {
646
- mcp: {
647
- servers: [
648
- {
649
- name: "docs",
650
- url: "http://global-docs.test/mcp",
651
- enabled: false,
652
- },
653
- {
654
- name: "down",
655
- url: "http://down.test/mcp",
656
- enabled: false,
657
- },
658
- ],
659
- },
660
- });
661
- state.repoSettings = {
662
- mcp: {
663
- servers: [
664
- {
665
- name: "docs",
666
- url: "http://repo-docs.test/mcp",
667
- enabled: true,
668
- },
669
- ],
670
- },
671
- };
672
- const runtimeState = { overlay: null as ActiveOverlay | null };
673
- const appended: Array<{
674
- text: string;
675
- sessionId: string | null;
676
- }> = [];
677
- const controller = createCommandController(
678
- {
679
- openOverlay: (nextOverlay) => {
680
- runtimeState.overlay = nextOverlay;
681
- },
682
- dismissOverlay: () => {
683
- runtimeState.overlay = null;
684
- },
685
- setInputValue: () => {},
686
- appendInfoMessage: (text, nextState) => {
687
- appended.push({ text, sessionId: nextState.session?.id ?? null });
688
- },
689
- appendTodoMessage: () => {},
690
- scrollConversationToBottom: () => {},
691
- requestRender: () => {},
692
- reloadPromptContext: async () => {},
693
- openInBrowser: () => {},
694
- },
695
- {
696
- connectMcpServer: async (server) => {
697
- connectCalls += 1;
698
- server.connected = true;
699
- server.tools = docsTools;
700
- server.toolHandlers = docsHandlers;
701
- server.close = async () => {
702
- disconnectCalls += 1;
703
- };
704
- return [];
705
- },
706
- disconnectMcpServer: async (server) => {
707
- await server.close();
708
- server.connected = false;
709
- server.tools = [];
710
- server.toolHandlers = new Map();
711
- server.close = async () => {};
712
- },
713
- },
714
- );
715
-
716
- try {
717
- expect(controller.handleCommand("mcp", state)).toBe(true);
718
- renderSelect(expectOverlay(runtimeState.overlay)).props.onKeyPress?.(
719
- "enter",
720
- );
721
- await Promise.resolve();
722
- await Promise.resolve();
723
-
724
- expect(state.mcpServers[0]?.enabled).toBe(false);
725
- expect(state.mcpServers[0]?.connected).toBe(false);
726
- expect(state.settings).toEqual({
727
- mcp: {
728
- servers: [
729
- {
730
- name: "docs",
731
- url: "http://global-docs.test/mcp",
732
- enabled: false,
733
- },
734
- {
735
- name: "down",
736
- url: "http://down.test/mcp",
737
- enabled: false,
738
- },
739
- ],
740
- },
741
- });
742
- expect(loadSettings(state.settingsPath)).toEqual(state.settings);
743
- expect(disconnectCalls).toBe(1);
744
-
745
- expect(controller.handleCommand("mcp", state)).toBe(true);
746
- renderSelect(expectOverlay(runtimeState.overlay)).props.onKeyPress?.(
747
- "enter",
748
- );
749
- await Promise.resolve();
750
- await Promise.resolve();
751
-
752
- expect(state.mcpServers[0]?.enabled).toBe(true);
753
- expect(state.mcpServers[0]?.connected).toBe(true);
754
- expect(connectCalls).toBe(1);
755
- expect(state.settings).toEqual({
756
- mcp: {
757
- servers: [
758
- {
759
- name: "docs",
760
- url: "http://global-docs.test/mcp",
761
- enabled: false,
762
- },
763
- {
764
- name: "down",
765
- url: "http://down.test/mcp",
766
- enabled: false,
767
- },
768
- ],
769
- },
770
- });
771
- expect(loadSettings(state.settingsPath)).toEqual(state.settings);
772
- expect(appended).toEqual([
773
- {
774
- text: 'Disabled MCP server "docs" (-1 tool).',
775
- sessionId: null,
776
- },
777
- {
778
- text: 'Enabled MCP server "docs" (+1 tool).',
779
- sessionId: null,
780
- },
781
- ]);
782
- } finally {
783
- state.db.close();
784
- }
785
- });
786
-
787
- test("/help appends a markdown info message without creating a session", () => {
788
- const state = createTestState();
789
- state.mcpServers = [
790
- {
791
- name: "docs",
792
- url: "http://docs.test/mcp",
793
- enabled: false,
794
- connected: false,
795
- tools: [],
796
- toolHandlers: new Map(),
797
- close: async () => {},
798
- },
799
- ];
800
- const appended: Array<{
801
- text: string;
802
- format: string | undefined;
803
- sessionId: string | null;
804
- }> = [];
805
- const controller = createCommandController({
806
- openOverlay: () => {},
807
- dismissOverlay: () => {},
808
- setInputValue: () => {},
809
- appendInfoMessage: (text, nextState, format) => {
810
- appended.push({
811
- text,
812
- format,
813
- sessionId: nextState.session?.id ?? null,
814
- });
815
- },
816
- appendTodoMessage: () => {},
817
- scrollConversationToBottom: () => {},
818
- requestRender: () => {},
819
- reloadPromptContext: async () => {},
820
- openInBrowser: () => {},
821
- });
822
-
823
- try {
824
- expect(controller.handleCommand("help", state)).toBe(true);
825
- expect(appended).toHaveLength(1);
826
- expect(appended[0]?.text.length).toBeGreaterThan(0);
827
- expect(appended[0]?.text).toContain("`/mcp`");
828
- expect(appended[0]?.text).toContain("`/skill:name`");
829
- expect(appended[0]?.text).toContain(
830
- "submit `/skill` to open the skill picker",
831
- );
832
- expect(appended[0]?.text).toContain("`docs` (off)");
833
- expect(appended[0]?.format).toBe("markdown");
834
- expect(appended[0]?.sessionId).toBeNull();
835
- expect(state.session).toBeNull();
836
- } finally {
837
- state.db.close();
838
- }
839
- });
840
-
841
- test("/todo appends the current todo list without creating a session", () => {
842
- const state = createTestState();
843
- state.messages = [
844
- {
845
- role: "toolResult",
846
- toolCallId: "todo-1",
847
- toolName: "todoWrite",
848
- content: [
849
- {
850
- type: "text",
851
- text: JSON.stringify({
852
- todos: [
853
- { content: "Review prompt wording", status: "completed" },
854
- { content: "Implement todo tools", status: "in_progress" },
855
- ],
856
- }),
857
- },
858
- ],
859
- isError: false,
860
- timestamp: 1,
861
- },
862
- ];
863
- const appended: Array<{
864
- todos: Array<{ content: string; status: string }>;
865
- sessionId: string | null;
866
- }> = [];
867
- const controller = createCommandController({
868
- openOverlay: () => {},
869
- dismissOverlay: () => {},
870
- setInputValue: () => {},
871
- appendInfoMessage: () => {},
872
- appendTodoMessage: (todos, nextState) => {
873
- appended.push({
874
- todos: todos.map((todo) => ({ ...todo })),
875
- sessionId: nextState.session?.id ?? null,
876
- });
877
- },
878
- scrollConversationToBottom: () => {},
879
- requestRender: () => {},
880
- reloadPromptContext: async () => {},
881
- openInBrowser: () => {},
882
- });
883
-
884
- try {
885
- expect(controller.handleCommand("todo", state)).toBe(true);
886
- expect(appended).toEqual([
887
- {
888
- todos: [
889
- { content: "Review prompt wording", status: "completed" },
890
- { content: "Implement todo tools", status: "in_progress" },
891
- ],
892
- sessionId: null,
893
- },
894
- ]);
895
- expect(state.session).toBeNull();
896
- } finally {
897
- state.db.close();
898
- }
899
- });
900
-
901
- test("applyModelSelection updates the state and persists the default model", () => {
902
- const faux = registerFauxProvider();
903
- const state = createTestState();
904
- const model = faux.getModel();
905
- const controller = createCommandController({
906
- openOverlay: () => {},
907
- dismissOverlay: () => {},
908
- setInputValue: () => {},
909
- appendInfoMessage: () => {},
910
- appendTodoMessage: () => {},
911
- scrollConversationToBottom: () => {},
912
- requestRender: () => {},
913
- reloadPromptContext: async () => {},
914
- openInBrowser: () => {},
915
- });
916
-
917
- try {
918
- controller.applyModelSelection(state, model);
919
-
920
- expect(state.model).toBe(model);
921
- expect(state.settings.defaultModel).toBe(`${model.provider}/${model.id}`);
922
- expect(loadSettings(state.settingsPath)).toEqual({
923
- defaultModel: `${model.provider}/${model.id}`,
924
- });
925
- } finally {
926
- faux.unregister();
927
- state.db.close();
928
- }
929
- });
930
-
931
- test("applyEffortSelection updates the active effort and persists the default effort", () => {
932
- const state = createTestState();
933
- const controller = createCommandController({
934
- openOverlay: () => {},
935
- dismissOverlay: () => {},
936
- setInputValue: () => {},
937
- appendInfoMessage: () => {},
938
- appendTodoMessage: () => {},
939
- scrollConversationToBottom: () => {},
940
- requestRender: () => {},
941
- reloadPromptContext: async () => {},
942
- openInBrowser: () => {},
943
- });
944
-
945
- try {
946
- controller.applyEffortSelection(state, "xhigh");
947
-
948
- expect(state.effort).toBe("xhigh");
949
- expect(state.settings.defaultEffort).toBe("xhigh");
950
- expect(loadSettings(state.settingsPath)).toEqual({
951
- defaultEffort: "xhigh",
952
- });
953
- } finally {
954
- state.db.close();
955
- }
956
- });
957
- });