mini-coder 0.4.1 → 0.5.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 (51) hide show
  1. package/README.md +87 -48
  2. package/assets/icon-1-minimal.svg +31 -0
  3. package/assets/icon-2-dark-terminal.svg +48 -0
  4. package/assets/icon-3-gradient-modern.svg +45 -0
  5. package/assets/icon-4-filled-bold.svg +54 -0
  6. package/assets/icon-5-community-badge.svg +63 -0
  7. package/assets/preview-0-5-0.png +0 -0
  8. package/assets/preview.gif +0 -0
  9. package/bin/mc.ts +14 -0
  10. package/bun.lock +438 -0
  11. package/package.json +12 -29
  12. package/src/agent.ts +592 -0
  13. package/src/cli.ts +124 -0
  14. package/src/git.ts +164 -0
  15. package/src/headless.ts +140 -0
  16. package/src/index.ts +645 -0
  17. package/src/input.ts +155 -0
  18. package/src/paths.ts +37 -0
  19. package/src/plugins.ts +183 -0
  20. package/src/prompt.ts +294 -0
  21. package/src/session.ts +838 -0
  22. package/src/settings.ts +184 -0
  23. package/src/skills.ts +258 -0
  24. package/src/submit.ts +323 -0
  25. package/src/theme.ts +147 -0
  26. package/src/tools.ts +636 -0
  27. package/src/ui/agent.test.ts +49 -0
  28. package/src/ui/agent.ts +210 -0
  29. package/src/ui/commands.test.ts +610 -0
  30. package/src/ui/commands.ts +638 -0
  31. package/src/ui/conversation.test.ts +892 -0
  32. package/src/ui/conversation.ts +926 -0
  33. package/src/ui/help.test.ts +26 -0
  34. package/src/ui/help.ts +119 -0
  35. package/src/ui/input.test.ts +74 -0
  36. package/src/ui/input.ts +138 -0
  37. package/src/ui/overlay.test.ts +42 -0
  38. package/src/ui/overlay.ts +59 -0
  39. package/src/ui/status.test.ts +450 -0
  40. package/src/ui/status.ts +357 -0
  41. package/src/ui.ts +615 -0
  42. package/.claude/settings.local.json +0 -54
  43. package/.prettierignore +0 -7
  44. package/dist/mc-edit.js +0 -275
  45. package/dist/mc.js +0 -7355
  46. package/docs/KNOWN_ISSUES.md +0 -13
  47. package/docs/design-decisions.md +0 -31
  48. package/docs/mini-coder.1.md +0 -227
  49. package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
  50. package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
  51. package/lefthook.yml +0 -4
@@ -0,0 +1,450 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ fauxAssistantMessage,
4
+ registerFauxProvider,
5
+ } from "@mariozechner/pi-ai";
6
+ import type { AppState } from "../index.ts";
7
+ import { createUiMessage, openDatabase } from "../session.ts";
8
+ import { DEFAULT_SHOW_REASONING, DEFAULT_VERBOSE } from "../settings.ts";
9
+ import { DEFAULT_THEME } from "../theme.ts";
10
+ import { renderStatusBar } from "./status.ts";
11
+
12
+ interface RenderedStatusPill {
13
+ text: string;
14
+ bgColor: string | undefined;
15
+ fgColor: string | undefined;
16
+ }
17
+
18
+ function renderStatusPills(
19
+ state: AppState,
20
+ cols?: number,
21
+ ): RenderedStatusPill[] {
22
+ const bar = renderStatusBar(state, cols);
23
+ if (bar.type !== "hstack") {
24
+ throw new Error("Expected the status bar root to be an hstack");
25
+ }
26
+
27
+ return bar.children.flatMap((child) => {
28
+ if (child.type !== "hstack") {
29
+ return [];
30
+ }
31
+ const [textNode] = child.children;
32
+ if (!textNode || textNode.type !== "text") {
33
+ return [];
34
+ }
35
+ return [
36
+ {
37
+ text: textNode.content,
38
+ bgColor: child.props.bgColor,
39
+ fgColor: textNode.props.fgColor,
40
+ },
41
+ ];
42
+ });
43
+ }
44
+
45
+ function renderStatusText(state: AppState, cols?: number): string[] {
46
+ return renderStatusPills(state, cols)
47
+ .map((pill) => pill.text)
48
+ .filter(Boolean);
49
+ }
50
+
51
+ function getModelPill(state: AppState): RenderedStatusPill {
52
+ const [modelPill] = renderStatusPills(state);
53
+ if (!modelPill) {
54
+ throw new Error("Expected a model pill");
55
+ }
56
+ return modelPill;
57
+ }
58
+
59
+ function getUsagePill(state: AppState): RenderedStatusPill {
60
+ const pills = renderStatusPills(state);
61
+ const usage = pills.at(-1);
62
+ if (!usage) {
63
+ throw new Error("Expected a usage pill");
64
+ }
65
+ return usage;
66
+ }
67
+
68
+ function getUsageSummary(state: AppState): string {
69
+ return getUsagePill(state).text;
70
+ }
71
+
72
+ function getUsagePercent(state: AppState): number {
73
+ const match = /· ([\d.]+)%\//.exec(getUsageSummary(state));
74
+ if (!match) {
75
+ throw new Error("Expected a context percentage in the usage summary");
76
+ }
77
+ return Number(match[1]);
78
+ }
79
+
80
+ function createTestState(): AppState {
81
+ const db = openDatabase(":memory:");
82
+ const cwd = "/tmp/mini-coder-ui-status-test";
83
+ return {
84
+ db,
85
+ session: null,
86
+ model: null,
87
+ effort: "medium",
88
+ messages: [],
89
+ stats: { totalInput: 0, totalOutput: 0, totalCost: 0 },
90
+ agentsMd: [],
91
+ skills: [],
92
+ plugins: [],
93
+ theme: DEFAULT_THEME,
94
+ git: null,
95
+ providers: new Map(),
96
+ oauthCredentials: {},
97
+ settings: {},
98
+ settingsPath: `${cwd}/settings.json`,
99
+ cwd,
100
+ canonicalCwd: cwd,
101
+ running: false,
102
+ abortController: null,
103
+ activeTurnPromise: null,
104
+ showReasoning: DEFAULT_SHOW_REASONING,
105
+ verbose: DEFAULT_VERBOSE,
106
+ };
107
+ }
108
+
109
+ function makeAssistantWithUsage(
110
+ text: string,
111
+ usage: Partial<ReturnType<typeof fauxAssistantMessage>["usage"]>,
112
+ ) {
113
+ const message = fauxAssistantMessage(text);
114
+ message.usage = {
115
+ input: 100,
116
+ output: 50,
117
+ cacheRead: 0,
118
+ cacheWrite: 0,
119
+ totalTokens: 150,
120
+ cost: {
121
+ input: 0.001,
122
+ output: 0.002,
123
+ cacheRead: 0,
124
+ cacheWrite: 0,
125
+ total: 0.003,
126
+ },
127
+ ...usage,
128
+ };
129
+ return message;
130
+ }
131
+
132
+ describe("ui/status", () => {
133
+ test("renderStatusBar shows model cwd git and usage summaries in reading order", () => {
134
+ const faux = registerFauxProvider();
135
+ const state = createTestState();
136
+ state.model = faux.getModel();
137
+ state.git = {
138
+ root: state.cwd,
139
+ branch: "main",
140
+ staged: 1,
141
+ modified: 2,
142
+ untracked: 3,
143
+ ahead: 4,
144
+ behind: 0,
145
+ };
146
+
147
+ try {
148
+ expect(renderStatusText(state)).toEqual([
149
+ `${state.model.provider}/${state.model.id} · med`,
150
+ state.cwd,
151
+ "main +1 ~2 ?3 ▲ 4",
152
+ "in:0 out:0 · 0.0%/128k · $0.00",
153
+ ]);
154
+ } finally {
155
+ faux.unregister();
156
+ state.db.close();
157
+ }
158
+ });
159
+
160
+ test("renderStatusBar left-truncates the cwd on narrow terminals", () => {
161
+ const state = createTestState();
162
+ state.cwd = "/tmp/very/long/path/to/mini-coder";
163
+
164
+ try {
165
+ expect(renderStatusText(state, 30)).toEqual(["no model", "…/mini-coder"]);
166
+ } finally {
167
+ state.db.close();
168
+ }
169
+ });
170
+
171
+ test("renderStatusBar maps reasoning effort levels to the model pill tone scale", () => {
172
+ const faux = registerFauxProvider();
173
+ const state = createTestState();
174
+ state.model = faux.getModel();
175
+
176
+ try {
177
+ const cases = [
178
+ { effort: "low", bgColor: "color02", fgColor: "color00" },
179
+ { effort: "medium", bgColor: "color06", fgColor: "color00" },
180
+ { effort: "high", bgColor: "color05", fgColor: "color15" },
181
+ { effort: "xhigh", bgColor: "color09", fgColor: "color00" },
182
+ ] as const;
183
+
184
+ for (const testCase of cases) {
185
+ state.effort = testCase.effort;
186
+ expect(getModelPill(state)).toMatchObject({
187
+ text: `${state.model.provider}/${state.model.id} · ${testCase.effort === "medium" ? "med" : testCase.effort}`,
188
+ bgColor: testCase.bgColor,
189
+ fgColor: testCase.fgColor,
190
+ });
191
+ }
192
+ } finally {
193
+ faux.unregister();
194
+ state.db.close();
195
+ }
196
+ });
197
+
198
+ test("renderStatusBar maps context usage bands to the usage pill tone scale", () => {
199
+ const faux = registerFauxProvider();
200
+ const state = createTestState();
201
+ state.model = {
202
+ ...faux.getModel(),
203
+ contextWindow: 100,
204
+ };
205
+
206
+ try {
207
+ const cases = [
208
+ { totalTokens: 10, bgColor: "color02", fgColor: "color00" },
209
+ { totalTokens: 30, bgColor: "color06", fgColor: "color00" },
210
+ { totalTokens: 60, bgColor: "color05", fgColor: "color15" },
211
+ { totalTokens: 80, bgColor: "color01", fgColor: "color15" },
212
+ { totalTokens: 95, bgColor: "color09", fgColor: "color00" },
213
+ ] as const;
214
+
215
+ for (const testCase of cases) {
216
+ state.messages = [
217
+ makeAssistantWithUsage("context anchor", {
218
+ totalTokens: testCase.totalTokens,
219
+ }),
220
+ ];
221
+ expect(getUsagePill(state)).toMatchObject({
222
+ text: `in:0 out:0 · ${testCase.totalTokens.toFixed(1)}%/100 · $0.00`,
223
+ bgColor: testCase.bgColor,
224
+ fgColor: testCase.fgColor,
225
+ });
226
+ }
227
+ } finally {
228
+ faux.unregister();
229
+ state.db.close();
230
+ }
231
+ });
232
+
233
+ test("renderStatusBar colors the model and usage pills independently", () => {
234
+ const faux = registerFauxProvider();
235
+ const state = createTestState();
236
+ state.model = {
237
+ ...faux.getModel(),
238
+ contextWindow: 100,
239
+ };
240
+
241
+ try {
242
+ state.effort = "xhigh";
243
+ state.messages = [
244
+ makeAssistantWithUsage("cold context", { totalTokens: 10 }),
245
+ ];
246
+ expect(getModelPill(state)).toMatchObject({
247
+ bgColor: "color09",
248
+ fgColor: "color00",
249
+ });
250
+ expect(getUsagePill(state)).toMatchObject({
251
+ bgColor: "color02",
252
+ fgColor: "color00",
253
+ });
254
+
255
+ state.effort = "low";
256
+ state.messages = [
257
+ makeAssistantWithUsage("hot context", { totalTokens: 95 }),
258
+ ];
259
+ expect(getModelPill(state)).toMatchObject({
260
+ bgColor: "color02",
261
+ fgColor: "color00",
262
+ });
263
+ expect(getUsagePill(state)).toMatchObject({
264
+ bgColor: "color09",
265
+ fgColor: "color00",
266
+ });
267
+ } finally {
268
+ faux.unregister();
269
+ state.db.close();
270
+ }
271
+ });
272
+
273
+ test("renderStatusBar uses the latest valid assistant usage as an anchor and ignores trailing UI-only messages", () => {
274
+ const faux = registerFauxProvider();
275
+ const state = createTestState();
276
+ state.model = {
277
+ ...faux.getModel(),
278
+ contextWindow: 1_000,
279
+ };
280
+ state.stats = {
281
+ totalInput: 4_000,
282
+ totalOutput: 2_000,
283
+ totalCost: 1.23,
284
+ };
285
+
286
+ try {
287
+ state.messages = [
288
+ { role: "user", content: "first", timestamp: 1 },
289
+ makeAssistantWithUsage("Second.", {
290
+ input: 200,
291
+ output: 50,
292
+ totalTokens: 250,
293
+ }),
294
+ ];
295
+ const anchoredPercent = getUsagePercent(state);
296
+
297
+ state.messages = [...state.messages, createUiMessage("x".repeat(5_000))];
298
+ const withUiOnlyPercent = getUsagePercent(state);
299
+
300
+ state.messages = [
301
+ ...state.messages,
302
+ { role: "user", content: "12345678", timestamp: 2 },
303
+ ];
304
+ const withTrailingUserPercent = getUsagePercent(state);
305
+
306
+ state.messages = [
307
+ ...state.messages,
308
+ {
309
+ role: "toolResult",
310
+ toolCallId: "tool-1",
311
+ toolName: "shell",
312
+ content: [{ type: "text", text: "done" }],
313
+ isError: false,
314
+ timestamp: 3,
315
+ },
316
+ ];
317
+ const withToolResultPercent = getUsagePercent(state);
318
+
319
+ expect(getUsageSummary(state)).toBe(
320
+ `in:4.0k out:2.0k · ${withToolResultPercent.toFixed(1)}%/1k · $1.23`,
321
+ );
322
+ expect(withUiOnlyPercent).toBe(anchoredPercent);
323
+ expect(withTrailingUserPercent).toBeGreaterThan(anchoredPercent);
324
+ expect(withToolResultPercent).toBeGreaterThan(withTrailingUserPercent);
325
+ } finally {
326
+ faux.unregister();
327
+ state.db.close();
328
+ }
329
+ });
330
+
331
+ test("renderStatusBar estimates context usage from model-visible messages before the first assistant response", () => {
332
+ const faux = registerFauxProvider();
333
+ const state = createTestState();
334
+ state.model = {
335
+ ...faux.getModel(),
336
+ contextWindow: 100,
337
+ };
338
+
339
+ try {
340
+ state.messages = [{ role: "user", content: "12345678", timestamp: 1 }];
341
+ const initialPercent = getUsagePercent(state);
342
+
343
+ state.messages = [createUiMessage("x".repeat(5_000)), ...state.messages];
344
+ const withUiOnlyPercent = getUsagePercent(state);
345
+
346
+ state.messages = [
347
+ ...state.messages,
348
+ { role: "user", content: "more visible text", timestamp: 2 },
349
+ ];
350
+ const withSecondUserPercent = getUsagePercent(state);
351
+
352
+ expect(initialPercent).toBeGreaterThan(0);
353
+ expect(withUiOnlyPercent).toBe(initialPercent);
354
+ expect(withSecondUserPercent).toBeGreaterThan(initialPercent);
355
+ } finally {
356
+ faux.unregister();
357
+ state.db.close();
358
+ }
359
+ });
360
+
361
+ test("renderStatusBar skips aborted assistant usage as a context anchor", () => {
362
+ const faux = registerFauxProvider();
363
+ const abortedLowUsage = makeAssistantWithUsage("abcdefghi", {
364
+ input: 10,
365
+ output: 10,
366
+ totalTokens: 20,
367
+ });
368
+ abortedLowUsage.stopReason = "aborted";
369
+ const abortedHighUsage = makeAssistantWithUsage("abcdefghi", {
370
+ input: 700,
371
+ output: 200,
372
+ totalTokens: 900,
373
+ });
374
+ abortedHighUsage.stopReason = "aborted";
375
+
376
+ const buildState = (aborted: ReturnType<typeof makeAssistantWithUsage>) => {
377
+ const state = createTestState();
378
+ state.model = {
379
+ ...faux.getModel(),
380
+ contextWindow: 1_000,
381
+ };
382
+ state.messages = [
383
+ { role: "user", content: "first", timestamp: 1 },
384
+ makeAssistantWithUsage("valid", {
385
+ input: 150,
386
+ output: 50,
387
+ totalTokens: 200,
388
+ }),
389
+ aborted,
390
+ { role: "user", content: "1234", timestamp: 2 },
391
+ ];
392
+ return state;
393
+ };
394
+
395
+ const lowUsageState = buildState(abortedLowUsage);
396
+ const highUsageState = buildState(abortedHighUsage);
397
+
398
+ try {
399
+ expect(getUsagePercent(lowUsageState)).toBe(
400
+ getUsagePercent(highUsageState),
401
+ );
402
+ } finally {
403
+ lowUsageState.db.close();
404
+ highUsageState.db.close();
405
+ faux.unregister();
406
+ }
407
+ });
408
+
409
+ test("renderStatusBar falls back to usage components when totalTokens is zero", () => {
410
+ const faux = registerFauxProvider();
411
+ const fromComponents = createTestState();
412
+ const fromTotalTokens = createTestState();
413
+ fromComponents.model = {
414
+ ...faux.getModel(),
415
+ contextWindow: 1_000,
416
+ };
417
+ fromTotalTokens.model = {
418
+ ...faux.getModel(),
419
+ contextWindow: 1_000,
420
+ };
421
+ fromComponents.messages = [
422
+ makeAssistantWithUsage("fallback", {
423
+ input: 120,
424
+ output: 30,
425
+ cacheRead: 25,
426
+ cacheWrite: 25,
427
+ totalTokens: 0,
428
+ }),
429
+ ];
430
+ fromTotalTokens.messages = [
431
+ makeAssistantWithUsage("fallback", {
432
+ input: 120,
433
+ output: 30,
434
+ cacheRead: 25,
435
+ cacheWrite: 25,
436
+ totalTokens: 200,
437
+ }),
438
+ ];
439
+
440
+ try {
441
+ expect(getUsagePercent(fromComponents)).toBe(
442
+ getUsagePercent(fromTotalTokens),
443
+ );
444
+ } finally {
445
+ fromComponents.db.close();
446
+ fromTotalTokens.db.close();
447
+ faux.unregister();
448
+ }
449
+ });
450
+ });