mini-coder 0.5.14 → 0.6.1

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 +26 -109
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/nono-mini-coder.json +42 -0
  5. package/package.json +17 -22
  6. package/src/agent.ts +243 -1403
  7. package/src/args.ts +289 -0
  8. package/src/headless.ts +41 -359
  9. package/src/index.ts +29 -1016
  10. package/src/oauth.ts +117 -0
  11. package/src/prompt.ts +219 -284
  12. package/src/session.ts +55 -1306
  13. package/src/shared.ts +117 -38
  14. package/src/tool-bash.ts +110 -0
  15. package/src/tool-edit.ts +133 -0
  16. package/src/tool-read.ts +80 -293
  17. package/src/tui-components.ts +150 -0
  18. package/src/tui-conversation.ts +271 -0
  19. package/src/tui-editor.ts +29 -0
  20. package/src/tui-overlay.ts +403 -0
  21. package/src/tui.ts +228 -0
  22. package/src/types.ts +164 -0
  23. package/tsconfig.json +17 -0
  24. package/BENCHMARK.md +0 -107
  25. package/LICENSE +0 -9
  26. package/PROGRESS.md +0 -5
  27. package/assets/icon-1-minimal.svg +0 -31
  28. package/assets/icon-2-dark-terminal.svg +0 -48
  29. package/assets/icon-3-gradient-modern.svg +0 -45
  30. package/assets/icon-4-filled-bold.svg +0 -54
  31. package/assets/icon-5-community-badge.svg +0 -63
  32. package/assets/mc-claude-smart.png +0 -0
  33. package/assets/mc-gpt-smart.png +0 -0
  34. package/assets/preview-0-5-0.png +0 -0
  35. package/assets/preview.gif +0 -0
  36. package/benchmark-baseline.sh +0 -15
  37. package/benchmark-loop.sh +0 -19
  38. package/skills-lock.json +0 -15
  39. package/src/assistant-output.ts +0 -73
  40. package/src/cli.ts +0 -134
  41. package/src/delegation.ts +0 -238
  42. package/src/errors.ts +0 -15
  43. package/src/git.ts +0 -247
  44. package/src/input.ts +0 -168
  45. package/src/mcp.ts +0 -609
  46. package/src/paths.ts +0 -37
  47. package/src/session-message.ts +0 -385
  48. package/src/settings.ts +0 -449
  49. package/src/skills.ts +0 -271
  50. package/src/submit.ts +0 -376
  51. package/src/text.ts +0 -71
  52. package/src/theme.ts +0 -330
  53. package/src/tool-common.ts +0 -93
  54. package/src/tool-delegate.ts +0 -125
  55. package/src/tool-grep.ts +0 -606
  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
- });