mini-coder 0.5.3 → 0.5.5
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 +31 -27
- package/bun.lock +7 -7
- package/package.json +4 -4
- package/src/index.ts +5 -0
- package/src/prompt.ts +4 -1
- package/src/session.ts +198 -18
- package/src/submit.ts +13 -0
- package/src/ui/commands.test.ts +5 -0
- package/src/ui/commands.ts +5 -0
- package/src/ui/conversation.test.ts +181 -15
- package/src/ui/conversation.ts +109 -31
- package/src/ui/help.test.ts +9 -4
- package/src/ui/help.ts +5 -3
- package/src/ui/status.test.ts +58 -24
- package/src/ui/status.ts +2 -139
- package/src/ui.ts +7 -3
package/src/ui/status.test.ts
CHANGED
|
@@ -4,7 +4,11 @@ import {
|
|
|
4
4
|
registerFauxProvider,
|
|
5
5
|
} from "@mariozechner/pi-ai";
|
|
6
6
|
import type { AppState } from "../index.ts";
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
computeContextTokens,
|
|
9
|
+
createUiMessage,
|
|
10
|
+
openDatabase,
|
|
11
|
+
} from "../session.ts";
|
|
8
12
|
import { DEFAULT_SHOW_REASONING, DEFAULT_VERBOSE } from "../settings.ts";
|
|
9
13
|
import { DEFAULT_THEME } from "../theme.ts";
|
|
10
14
|
import { renderStatusBar } from "./status.ts";
|
|
@@ -87,6 +91,7 @@ function createTestState(): AppState {
|
|
|
87
91
|
effort: "medium",
|
|
88
92
|
messages: [],
|
|
89
93
|
stats: { totalInput: 0, totalOutput: 0, totalCost: 0 },
|
|
94
|
+
contextTokens: 0,
|
|
90
95
|
agentsMd: [],
|
|
91
96
|
skills: [],
|
|
92
97
|
plugins: [],
|
|
@@ -109,6 +114,11 @@ function createTestState(): AppState {
|
|
|
109
114
|
};
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
function setMessages(state: AppState, messages: AppState["messages"]): void {
|
|
118
|
+
state.messages = messages;
|
|
119
|
+
state.contextTokens = computeContextTokens(messages);
|
|
120
|
+
}
|
|
121
|
+
|
|
112
122
|
function makeAssistantWithUsage(
|
|
113
123
|
text: string,
|
|
114
124
|
usage: Partial<ReturnType<typeof fauxAssistantMessage>["usage"]>,
|
|
@@ -217,11 +227,11 @@ describe("ui/status", () => {
|
|
|
217
227
|
] as const;
|
|
218
228
|
|
|
219
229
|
for (const testCase of cases) {
|
|
220
|
-
state
|
|
230
|
+
setMessages(state, [
|
|
221
231
|
makeAssistantWithUsage("context anchor", {
|
|
222
232
|
totalTokens: testCase.totalTokens,
|
|
223
233
|
}),
|
|
224
|
-
];
|
|
234
|
+
]);
|
|
225
235
|
expect(getUsagePill(state)).toMatchObject({
|
|
226
236
|
text: `in:0 out:0 · ${testCase.totalTokens.toFixed(1)}%/100 · $0.00`,
|
|
227
237
|
bgColor: testCase.bgColor,
|
|
@@ -234,6 +244,24 @@ describe("ui/status", () => {
|
|
|
234
244
|
}
|
|
235
245
|
});
|
|
236
246
|
|
|
247
|
+
test("renderStatusBar uses cached contextTokens instead of rescanning messages", () => {
|
|
248
|
+
const faux = registerFauxProvider();
|
|
249
|
+
const state = createTestState();
|
|
250
|
+
state.model = {
|
|
251
|
+
...faux.getModel(),
|
|
252
|
+
contextWindow: 100,
|
|
253
|
+
};
|
|
254
|
+
state.messages = [{ role: "user", content: "x".repeat(400), timestamp: 1 }];
|
|
255
|
+
state.contextTokens = 25;
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
expect(getUsageSummary(state)).toBe("in:0 out:0 · 25.0%/100 · $0.00");
|
|
259
|
+
} finally {
|
|
260
|
+
faux.unregister();
|
|
261
|
+
state.db.close();
|
|
262
|
+
}
|
|
263
|
+
});
|
|
264
|
+
|
|
237
265
|
test("renderStatusBar colors the model and usage pills independently", () => {
|
|
238
266
|
const faux = registerFauxProvider();
|
|
239
267
|
const state = createTestState();
|
|
@@ -244,9 +272,9 @@ describe("ui/status", () => {
|
|
|
244
272
|
|
|
245
273
|
try {
|
|
246
274
|
state.effort = "xhigh";
|
|
247
|
-
state
|
|
275
|
+
setMessages(state, [
|
|
248
276
|
makeAssistantWithUsage("cold context", { totalTokens: 10 }),
|
|
249
|
-
];
|
|
277
|
+
]);
|
|
250
278
|
expect(getModelPill(state)).toMatchObject({
|
|
251
279
|
bgColor: "color09",
|
|
252
280
|
fgColor: "color00",
|
|
@@ -257,9 +285,9 @@ describe("ui/status", () => {
|
|
|
257
285
|
});
|
|
258
286
|
|
|
259
287
|
state.effort = "low";
|
|
260
|
-
state
|
|
288
|
+
setMessages(state, [
|
|
261
289
|
makeAssistantWithUsage("hot context", { totalTokens: 95 }),
|
|
262
|
-
];
|
|
290
|
+
]);
|
|
263
291
|
expect(getModelPill(state)).toMatchObject({
|
|
264
292
|
bgColor: "color02",
|
|
265
293
|
fgColor: "color00",
|
|
@@ -288,26 +316,29 @@ describe("ui/status", () => {
|
|
|
288
316
|
};
|
|
289
317
|
|
|
290
318
|
try {
|
|
291
|
-
state
|
|
319
|
+
setMessages(state, [
|
|
292
320
|
{ role: "user", content: "first", timestamp: 1 },
|
|
293
321
|
makeAssistantWithUsage("Second.", {
|
|
294
322
|
input: 200,
|
|
295
323
|
output: 50,
|
|
296
324
|
totalTokens: 250,
|
|
297
325
|
}),
|
|
298
|
-
];
|
|
326
|
+
]);
|
|
299
327
|
const anchoredPercent = getUsagePercent(state);
|
|
300
328
|
|
|
301
|
-
state
|
|
329
|
+
setMessages(state, [
|
|
330
|
+
...state.messages,
|
|
331
|
+
createUiMessage("x".repeat(5_000)),
|
|
332
|
+
]);
|
|
302
333
|
const withUiOnlyPercent = getUsagePercent(state);
|
|
303
334
|
|
|
304
|
-
state
|
|
335
|
+
setMessages(state, [
|
|
305
336
|
...state.messages,
|
|
306
337
|
{ role: "user", content: "12345678", timestamp: 2 },
|
|
307
|
-
];
|
|
338
|
+
]);
|
|
308
339
|
const withTrailingUserPercent = getUsagePercent(state);
|
|
309
340
|
|
|
310
|
-
state
|
|
341
|
+
setMessages(state, [
|
|
311
342
|
...state.messages,
|
|
312
343
|
{
|
|
313
344
|
role: "toolResult",
|
|
@@ -317,7 +348,7 @@ describe("ui/status", () => {
|
|
|
317
348
|
isError: false,
|
|
318
349
|
timestamp: 3,
|
|
319
350
|
},
|
|
320
|
-
];
|
|
351
|
+
]);
|
|
321
352
|
const withToolResultPercent = getUsagePercent(state);
|
|
322
353
|
|
|
323
354
|
expect(getUsageSummary(state)).toBe(
|
|
@@ -341,16 +372,19 @@ describe("ui/status", () => {
|
|
|
341
372
|
};
|
|
342
373
|
|
|
343
374
|
try {
|
|
344
|
-
state
|
|
375
|
+
setMessages(state, [{ role: "user", content: "12345678", timestamp: 1 }]);
|
|
345
376
|
const initialPercent = getUsagePercent(state);
|
|
346
377
|
|
|
347
|
-
state
|
|
378
|
+
setMessages(state, [
|
|
379
|
+
createUiMessage("x".repeat(5_000)),
|
|
380
|
+
...state.messages,
|
|
381
|
+
]);
|
|
348
382
|
const withUiOnlyPercent = getUsagePercent(state);
|
|
349
383
|
|
|
350
|
-
state
|
|
384
|
+
setMessages(state, [
|
|
351
385
|
...state.messages,
|
|
352
386
|
{ role: "user", content: "more visible text", timestamp: 2 },
|
|
353
|
-
];
|
|
387
|
+
]);
|
|
354
388
|
const withSecondUserPercent = getUsagePercent(state);
|
|
355
389
|
|
|
356
390
|
expect(initialPercent).toBeGreaterThan(0);
|
|
@@ -383,7 +417,7 @@ describe("ui/status", () => {
|
|
|
383
417
|
...faux.getModel(),
|
|
384
418
|
contextWindow: 1_000,
|
|
385
419
|
};
|
|
386
|
-
state
|
|
420
|
+
setMessages(state, [
|
|
387
421
|
{ role: "user", content: "first", timestamp: 1 },
|
|
388
422
|
makeAssistantWithUsage("valid", {
|
|
389
423
|
input: 150,
|
|
@@ -392,7 +426,7 @@ describe("ui/status", () => {
|
|
|
392
426
|
}),
|
|
393
427
|
aborted,
|
|
394
428
|
{ role: "user", content: "1234", timestamp: 2 },
|
|
395
|
-
];
|
|
429
|
+
]);
|
|
396
430
|
return state;
|
|
397
431
|
};
|
|
398
432
|
|
|
@@ -422,7 +456,7 @@ describe("ui/status", () => {
|
|
|
422
456
|
...faux.getModel(),
|
|
423
457
|
contextWindow: 1_000,
|
|
424
458
|
};
|
|
425
|
-
fromComponents
|
|
459
|
+
setMessages(fromComponents, [
|
|
426
460
|
makeAssistantWithUsage("fallback", {
|
|
427
461
|
input: 120,
|
|
428
462
|
output: 30,
|
|
@@ -430,8 +464,8 @@ describe("ui/status", () => {
|
|
|
430
464
|
cacheWrite: 25,
|
|
431
465
|
totalTokens: 0,
|
|
432
466
|
}),
|
|
433
|
-
];
|
|
434
|
-
fromTotalTokens
|
|
467
|
+
]);
|
|
468
|
+
setMessages(fromTotalTokens, [
|
|
435
469
|
makeAssistantWithUsage("fallback", {
|
|
436
470
|
input: 120,
|
|
437
471
|
output: 30,
|
|
@@ -439,7 +473,7 @@ describe("ui/status", () => {
|
|
|
439
473
|
cacheWrite: 25,
|
|
440
474
|
totalTokens: 200,
|
|
441
475
|
}),
|
|
442
|
-
];
|
|
476
|
+
]);
|
|
443
477
|
|
|
444
478
|
try {
|
|
445
479
|
expect(getUsagePercent(fromComponents)).toBe(
|
package/src/ui/status.ts
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
* Status-bar formatting and rendering for the terminal UI.
|
|
3
3
|
*
|
|
4
4
|
* Computes the cumulative usage pill, abbreviates the working directory, and
|
|
5
|
-
*
|
|
6
|
-
* history.
|
|
5
|
+
* formats the cached current-context estimate maintained on application state.
|
|
7
6
|
*
|
|
8
7
|
* @module
|
|
9
8
|
*/
|
|
@@ -12,14 +11,9 @@ import { homedir } from "node:os";
|
|
|
12
11
|
import { Spacer } from "@cel-tui/components";
|
|
13
12
|
import { HStack, Text, visibleWidth } from "@cel-tui/core";
|
|
14
13
|
import type { Node } from "@cel-tui/types";
|
|
15
|
-
import type { AssistantMessage, Message } from "@mariozechner/pi-ai";
|
|
16
14
|
import type { AppState } from "../index.ts";
|
|
17
|
-
import { filterModelMessages, getAssistantUsage } from "../session.ts";
|
|
18
15
|
import type { StatusTone, Theme } from "../theme.ts";
|
|
19
16
|
|
|
20
|
-
/** Conservative fixed estimate for an image block's token footprint. */
|
|
21
|
-
const ESTIMATED_IMAGE_TOKENS = 1_200;
|
|
22
|
-
|
|
23
17
|
/**
|
|
24
18
|
* Abbreviate a path with `~` for the home directory.
|
|
25
19
|
*
|
|
@@ -84,143 +78,12 @@ function formatModelInfo(state: AppState): string {
|
|
|
84
78
|
return `${state.model.provider}/${state.model.id} · ${formatEffort(state.effort)}`;
|
|
85
79
|
}
|
|
86
80
|
|
|
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
81
|
/** Estimate current context usage as a percentage of the active model window. */
|
|
218
82
|
function getContextPercentage(state: AppState): number {
|
|
219
83
|
if (!state.model || state.model.contextWindow <= 0) {
|
|
220
84
|
return 0;
|
|
221
85
|
}
|
|
222
|
-
|
|
223
|
-
return (contextTokens / state.model.contextWindow) * 100;
|
|
86
|
+
return (state.contextTokens / state.model.contextWindow) * 100;
|
|
224
87
|
}
|
|
225
88
|
|
|
226
89
|
/** Format cumulative session totals plus estimated current context usage for the status bar. */
|
package/src/ui.ts
CHANGED
|
@@ -572,7 +572,6 @@ function renderConversationLog(state: AppState, width: number): Node {
|
|
|
572
572
|
flex: 1,
|
|
573
573
|
gap: CONVERSATION_GAP,
|
|
574
574
|
overflow: "scroll",
|
|
575
|
-
scrollbar: true,
|
|
576
575
|
justifyContent: state.messages.length === 0 ? "center" : undefined,
|
|
577
576
|
scrollOffset: stickToBottom ? Infinity : scrollOffset,
|
|
578
577
|
onScroll: (offset, maxOffset) => {
|
|
@@ -631,8 +630,13 @@ export function renderBaseLayout(
|
|
|
631
630
|
onSuspend?.();
|
|
632
631
|
return;
|
|
633
632
|
}
|
|
634
|
-
if (key === "escape"
|
|
635
|
-
if (state.
|
|
633
|
+
if (key === "escape") {
|
|
634
|
+
if (state.running) {
|
|
635
|
+
inputFocused = true;
|
|
636
|
+
if (state.abortController) {
|
|
637
|
+
state.abortController.abort();
|
|
638
|
+
}
|
|
639
|
+
}
|
|
636
640
|
return;
|
|
637
641
|
}
|
|
638
642
|
return false;
|