mini-coder 0.4.1 → 0.5.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.
- package/README.md +89 -48
- package/assets/icon-1-minimal.svg +31 -0
- package/assets/icon-2-dark-terminal.svg +48 -0
- package/assets/icon-3-gradient-modern.svg +45 -0
- package/assets/icon-4-filled-bold.svg +54 -0
- package/assets/icon-5-community-badge.svg +63 -0
- package/assets/preview-0-5-0.png +0 -0
- package/assets/preview.gif +0 -0
- package/bin/mc.ts +14 -0
- package/bun.lock +438 -0
- package/package.json +12 -29
- package/src/agent.ts +640 -0
- package/src/cli.ts +124 -0
- package/src/git.ts +171 -0
- package/src/headless.ts +140 -0
- package/src/index.ts +666 -0
- package/src/input.ts +155 -0
- package/src/paths.ts +37 -0
- package/src/plugins.ts +183 -0
- package/src/prompt.ts +301 -0
- package/src/session.ts +1043 -0
- package/src/settings.ts +191 -0
- package/src/skills.ts +262 -0
- package/src/submit.ts +323 -0
- package/src/theme.ts +147 -0
- package/src/tools.ts +636 -0
- package/src/ui/agent.test.ts +49 -0
- package/src/ui/agent.ts +210 -0
- package/src/ui/commands.test.ts +610 -0
- package/src/ui/commands.ts +638 -0
- package/src/ui/conversation.test.ts +892 -0
- package/src/ui/conversation.ts +926 -0
- package/src/ui/help.test.ts +44 -0
- package/src/ui/help.ts +125 -0
- package/src/ui/input.test.ts +74 -0
- package/src/ui/input.ts +138 -0
- package/src/ui/overlay.test.ts +42 -0
- package/src/ui/overlay.ts +59 -0
- package/src/ui/status.test.ts +451 -0
- package/src/ui/status.ts +357 -0
- package/src/ui.ts +694 -0
- package/.claude/settings.local.json +0 -54
- package/.prettierignore +0 -7
- package/dist/mc-edit.js +0 -275
- package/dist/mc.js +0 -7355
- package/docs/KNOWN_ISSUES.md +0 -13
- package/docs/design-decisions.md +0 -31
- package/docs/mini-coder.1.md +0 -227
- package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
- package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
- package/lefthook.yml +0 -4
package/src/ui/status.ts
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Status-bar formatting and rendering for the terminal UI.
|
|
3
|
+
*
|
|
4
|
+
* Computes the cumulative usage pill, abbreviates the working directory, and
|
|
5
|
+
* estimates current model-visible context usage from persisted conversation
|
|
6
|
+
* history.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { Spacer } from "@cel-tui/components";
|
|
13
|
+
import { HStack, Text, visibleWidth } from "@cel-tui/core";
|
|
14
|
+
import type { Node } from "@cel-tui/types";
|
|
15
|
+
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
|
|
16
|
+
import type { AppState } from "../index.ts";
|
|
17
|
+
import { filterModelMessages, getAssistantUsage } from "../session.ts";
|
|
18
|
+
import type { StatusTone, Theme } from "../theme.ts";
|
|
19
|
+
|
|
20
|
+
/** Conservative fixed estimate for an image block's token footprint. */
|
|
21
|
+
const ESTIMATED_IMAGE_TOKENS = 1_200;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Abbreviate a path with `~` for the home directory.
|
|
25
|
+
*
|
|
26
|
+
* @param path - Absolute path to abbreviate.
|
|
27
|
+
* @returns The abbreviated display path.
|
|
28
|
+
*/
|
|
29
|
+
export function abbreviatePath(path: string): string {
|
|
30
|
+
const home = homedir();
|
|
31
|
+
if (path === home) {
|
|
32
|
+
return "~";
|
|
33
|
+
}
|
|
34
|
+
if (path.startsWith(`${home}/`)) {
|
|
35
|
+
return `~${path.slice(home.length)}`;
|
|
36
|
+
}
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Format a token count with human-friendly units (1.2k, 45k, 1.2M). */
|
|
41
|
+
function formatTokens(n: number): string {
|
|
42
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
43
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
44
|
+
return String(n);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Format a token capacity, trimming unnecessary trailing `.0`. */
|
|
48
|
+
function formatTokenCapacity(n: number): string {
|
|
49
|
+
return formatTokens(n).replace(/\.0([kM])$/, "$1");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Format a dollar cost. */
|
|
53
|
+
function formatCost(cost: number): string {
|
|
54
|
+
return `$${cost.toFixed(2)}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Format the effort level for display. */
|
|
58
|
+
function formatEffort(effort: string): string {
|
|
59
|
+
const map: Record<string, string> = {
|
|
60
|
+
minimal: "min",
|
|
61
|
+
low: "low",
|
|
62
|
+
medium: "med",
|
|
63
|
+
high: "high",
|
|
64
|
+
xhigh: "xhigh",
|
|
65
|
+
};
|
|
66
|
+
return map[effort] ?? effort;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Format git status for the status bar right side. */
|
|
70
|
+
function formatGitStatus(state: AppState): string {
|
|
71
|
+
if (!state.git) return "";
|
|
72
|
+
const parts: string[] = [state.git.branch];
|
|
73
|
+
if (state.git.staged > 0) parts.push(`+${state.git.staged}`);
|
|
74
|
+
if (state.git.modified > 0) parts.push(`~${state.git.modified}`);
|
|
75
|
+
if (state.git.untracked > 0) parts.push(`?${state.git.untracked}`);
|
|
76
|
+
if (state.git.ahead > 0) parts.push(`▲ ${state.git.ahead}`);
|
|
77
|
+
if (state.git.behind > 0) parts.push(`▼ ${state.git.behind}`);
|
|
78
|
+
return parts.join(" ");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Format model info for the status bar left side. */
|
|
82
|
+
function formatModelInfo(state: AppState): string {
|
|
83
|
+
if (!state.model) return "no model";
|
|
84
|
+
return `${state.model.provider}/${state.model.id} · ${formatEffort(state.effort)}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Calculate context tokens from assistant usage, falling back when `totalTokens` is zero. */
|
|
88
|
+
function calculateUsageTokens(usage: AssistantMessage["usage"]): number {
|
|
89
|
+
return (
|
|
90
|
+
usage.totalTokens ||
|
|
91
|
+
usage.input + usage.output + usage.cacheRead + usage.cacheWrite
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Estimate token usage from a character count using a conservative chars/4 heuristic. */
|
|
96
|
+
function estimateCharacterTokens(charCount: number): number {
|
|
97
|
+
return Math.ceil(charCount / 4);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
type UserMultipartContent = Exclude<
|
|
101
|
+
Extract<Message, { role: "user" }>["content"],
|
|
102
|
+
string
|
|
103
|
+
>;
|
|
104
|
+
type TextOrImageContentBlock =
|
|
105
|
+
| UserMultipartContent[number]
|
|
106
|
+
| Extract<Message, { role: "toolResult" }>["content"][number];
|
|
107
|
+
|
|
108
|
+
function estimateTextOrImageContentTokens(
|
|
109
|
+
content: readonly TextOrImageContentBlock[],
|
|
110
|
+
): number {
|
|
111
|
+
let chars = 0;
|
|
112
|
+
let imageTokens = 0;
|
|
113
|
+
|
|
114
|
+
for (const block of content) {
|
|
115
|
+
if (block.type === "text") {
|
|
116
|
+
chars += block.text.length;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (block.type === "image") {
|
|
120
|
+
imageTokens += ESTIMATED_IMAGE_TOKENS;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return estimateCharacterTokens(chars) + imageTokens;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function estimateUserMessageTokens(
|
|
128
|
+
message: Extract<Message, { role: "user" }>,
|
|
129
|
+
): number {
|
|
130
|
+
if (typeof message.content === "string") {
|
|
131
|
+
return estimateCharacterTokens(message.content.length);
|
|
132
|
+
}
|
|
133
|
+
return estimateTextOrImageContentTokens(message.content);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function estimateAssistantBlockCharacters(
|
|
137
|
+
block: Extract<Message, { role: "assistant" }>["content"][number],
|
|
138
|
+
): number {
|
|
139
|
+
if (block.type === "text") {
|
|
140
|
+
return block.text.length;
|
|
141
|
+
}
|
|
142
|
+
if (block.type === "thinking") {
|
|
143
|
+
return block.thinking.length;
|
|
144
|
+
}
|
|
145
|
+
return block.name.length + JSON.stringify(block.arguments).length;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function estimateAssistantMessageTokens(
|
|
149
|
+
message: Extract<Message, { role: "assistant" }>,
|
|
150
|
+
): number {
|
|
151
|
+
const chars = message.content.reduce((total, block) => {
|
|
152
|
+
return total + estimateAssistantBlockCharacters(block);
|
|
153
|
+
}, 0);
|
|
154
|
+
return estimateCharacterTokens(chars);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function estimateToolResultMessageTokens(
|
|
158
|
+
message: Extract<Message, { role: "toolResult" }>,
|
|
159
|
+
): number {
|
|
160
|
+
return estimateTextOrImageContentTokens(message.content);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Estimate token usage for a model-visible message. */
|
|
164
|
+
function estimateMessageTokens(message: Message): number {
|
|
165
|
+
switch (message.role) {
|
|
166
|
+
case "user":
|
|
167
|
+
return estimateUserMessageTokens(message);
|
|
168
|
+
case "assistant":
|
|
169
|
+
return estimateAssistantMessageTokens(message);
|
|
170
|
+
case "toolResult":
|
|
171
|
+
return estimateToolResultMessageTokens(message);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Find the latest assistant usage that can anchor context estimation. */
|
|
176
|
+
function getLatestValidAssistantUsage(
|
|
177
|
+
messages: readonly Message[],
|
|
178
|
+
): { index: number; tokens: number } | null {
|
|
179
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
180
|
+
const message = messages[i];
|
|
181
|
+
if (
|
|
182
|
+
message?.role === "assistant" &&
|
|
183
|
+
message.stopReason !== "aborted" &&
|
|
184
|
+
message.stopReason !== "error"
|
|
185
|
+
) {
|
|
186
|
+
const usage = getAssistantUsage(message);
|
|
187
|
+
if (!usage) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
index: i,
|
|
192
|
+
tokens: calculateUsageTokens(usage),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Estimate the current model-visible context size for the next request. */
|
|
200
|
+
function estimateCurrentContextTokens(state: AppState): number {
|
|
201
|
+
const messages = filterModelMessages(state.messages);
|
|
202
|
+
const latestUsage = getLatestValidAssistantUsage(messages);
|
|
203
|
+
|
|
204
|
+
if (!latestUsage) {
|
|
205
|
+
return messages.reduce((total, message) => {
|
|
206
|
+
return total + estimateMessageTokens(message);
|
|
207
|
+
}, 0);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
let total = latestUsage.tokens;
|
|
211
|
+
for (let i = latestUsage.index + 1; i < messages.length; i++) {
|
|
212
|
+
total += estimateMessageTokens(messages[i]!);
|
|
213
|
+
}
|
|
214
|
+
return total;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Estimate current context usage as a percentage of the active model window. */
|
|
218
|
+
function getContextPercentage(state: AppState): number {
|
|
219
|
+
if (!state.model || state.model.contextWindow <= 0) {
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
const contextTokens = estimateCurrentContextTokens(state);
|
|
223
|
+
return (contextTokens / state.model.contextWindow) * 100;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Format cumulative session totals plus estimated current context usage for the status bar. */
|
|
227
|
+
function formatUsage(state: AppState, contextPct: number): string {
|
|
228
|
+
if (!state.model) return "";
|
|
229
|
+
const input = formatTokens(state.stats.totalInput);
|
|
230
|
+
const output = formatTokens(state.stats.totalOutput);
|
|
231
|
+
const contextWindow = formatTokenCapacity(state.model.contextWindow);
|
|
232
|
+
return `in:${input} out:${output} · ${contextPct.toFixed(1)}%/${contextWindow} · ${formatCost(state.stats.totalCost)}`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Select the model/effort pill tone for the current reasoning effort. */
|
|
236
|
+
function getEffortTone(theme: Theme, effort: string): StatusTone {
|
|
237
|
+
switch (effort) {
|
|
238
|
+
case "xhigh":
|
|
239
|
+
return theme.statusEffortScale[3];
|
|
240
|
+
case "high":
|
|
241
|
+
return theme.statusEffortScale[2];
|
|
242
|
+
case "medium":
|
|
243
|
+
return theme.statusEffortScale[1];
|
|
244
|
+
default:
|
|
245
|
+
return theme.statusEffortScale[0];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Select the usage/context pill tone for the current context pressure. */
|
|
250
|
+
function getContextTone(theme: Theme, contextPct: number): StatusTone {
|
|
251
|
+
if (contextPct >= 90) {
|
|
252
|
+
return theme.statusContextScale[4];
|
|
253
|
+
}
|
|
254
|
+
if (contextPct >= 75) {
|
|
255
|
+
return theme.statusContextScale[3];
|
|
256
|
+
}
|
|
257
|
+
if (contextPct >= 50) {
|
|
258
|
+
return theme.statusContextScale[2];
|
|
259
|
+
}
|
|
260
|
+
if (contextPct >= 25) {
|
|
261
|
+
return theme.statusContextScale[1];
|
|
262
|
+
}
|
|
263
|
+
return theme.statusContextScale[0];
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Render a single compact status pill. */
|
|
267
|
+
function renderStatusPill(text: string, tone: StatusTone): Node {
|
|
268
|
+
return HStack({ bgColor: tone.bg, padding: { x: 1 } }, [
|
|
269
|
+
Text(text, { fgColor: tone.fg }),
|
|
270
|
+
]);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function measureStatusPill(text: string): number {
|
|
274
|
+
return visibleWidth(text) + 2;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function leftTruncate(text: string, maxWidth: number): string {
|
|
278
|
+
if (maxWidth <= 0) {
|
|
279
|
+
return "";
|
|
280
|
+
}
|
|
281
|
+
if (visibleWidth(text) <= maxWidth) {
|
|
282
|
+
return text;
|
|
283
|
+
}
|
|
284
|
+
if (maxWidth === 1) {
|
|
285
|
+
return "…";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (let i = text.lastIndexOf("/"); i > 0; i = text.lastIndexOf("/", i - 1)) {
|
|
289
|
+
const candidate = text.slice(i);
|
|
290
|
+
if (visibleWidth(candidate) <= maxWidth - 1) {
|
|
291
|
+
return `…${candidate}`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
let suffix = "";
|
|
296
|
+
for (const char of Array.from(text).reverse()) {
|
|
297
|
+
const next = `${char}${suffix}`;
|
|
298
|
+
if (visibleWidth(next) > maxWidth - 1) {
|
|
299
|
+
break;
|
|
300
|
+
}
|
|
301
|
+
suffix = next;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return `…${suffix}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Render the one-line status bar as compact padded pills.
|
|
309
|
+
*
|
|
310
|
+
* The inner pills use the neutral secondary tone. The model pill uses a
|
|
311
|
+
* reasoning-effort tone scale, and the usage pill uses an independent
|
|
312
|
+
* context-pressure tone scale. The git pill is omitted outside repositories.
|
|
313
|
+
*
|
|
314
|
+
* @param state - Application state.
|
|
315
|
+
* @param cols - Current terminal width in columns.
|
|
316
|
+
* @returns The rendered status bar node.
|
|
317
|
+
*/
|
|
318
|
+
export function renderStatusBar(
|
|
319
|
+
state: AppState,
|
|
320
|
+
cols = Number.POSITIVE_INFINITY,
|
|
321
|
+
): Node {
|
|
322
|
+
const fullCwd = abbreviatePath(state.cwd);
|
|
323
|
+
const gitStatus = formatGitStatus(state);
|
|
324
|
+
const modelInfo = formatModelInfo(state);
|
|
325
|
+
const contextPct = getContextPercentage(state);
|
|
326
|
+
const usage = formatUsage(state, contextPct);
|
|
327
|
+
const reservedWidth =
|
|
328
|
+
2 +
|
|
329
|
+
measureStatusPill(modelInfo) +
|
|
330
|
+
measureStatusPill(usage) +
|
|
331
|
+
(gitStatus ? measureStatusPill(gitStatus) : 0) +
|
|
332
|
+
2;
|
|
333
|
+
const cwd = Number.isFinite(cols)
|
|
334
|
+
? leftTruncate(fullCwd, Math.max(Math.floor(cols) - reservedWidth, 1))
|
|
335
|
+
: fullCwd;
|
|
336
|
+
|
|
337
|
+
const secondaryTone = state.theme.statusSecondary;
|
|
338
|
+
const children: Node[] = [
|
|
339
|
+
renderStatusPill(modelInfo, getEffortTone(state.theme, state.effort)),
|
|
340
|
+
renderStatusPill(cwd, secondaryTone),
|
|
341
|
+
Spacer(),
|
|
342
|
+
];
|
|
343
|
+
if (gitStatus) {
|
|
344
|
+
children.push(renderStatusPill(gitStatus, secondaryTone));
|
|
345
|
+
}
|
|
346
|
+
children.push(
|
|
347
|
+
renderStatusPill(usage, getContextTone(state.theme, contextPct)),
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
return HStack(
|
|
351
|
+
{
|
|
352
|
+
height: 1,
|
|
353
|
+
padding: { x: 1 },
|
|
354
|
+
},
|
|
355
|
+
children,
|
|
356
|
+
);
|
|
357
|
+
}
|