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.
@@ -4,7 +4,11 @@ import {
4
4
  registerFauxProvider,
5
5
  } from "@mariozechner/pi-ai";
6
6
  import type { AppState } from "../index.ts";
7
- import { createUiMessage, openDatabase } from "../session.ts";
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.messages = [
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.messages = [
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.messages = [
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.messages = [
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.messages = [...state.messages, createUiMessage("x".repeat(5_000))];
329
+ setMessages(state, [
330
+ ...state.messages,
331
+ createUiMessage("x".repeat(5_000)),
332
+ ]);
302
333
  const withUiOnlyPercent = getUsagePercent(state);
303
334
 
304
- state.messages = [
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.messages = [
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.messages = [{ role: "user", content: "12345678", timestamp: 1 }];
375
+ setMessages(state, [{ role: "user", content: "12345678", timestamp: 1 }]);
345
376
  const initialPercent = getUsagePercent(state);
346
377
 
347
- state.messages = [createUiMessage("x".repeat(5_000)), ...state.messages];
378
+ setMessages(state, [
379
+ createUiMessage("x".repeat(5_000)),
380
+ ...state.messages,
381
+ ]);
348
382
  const withUiOnlyPercent = getUsagePercent(state);
349
383
 
350
- state.messages = [
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.messages = [
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.messages = [
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.messages = [
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
- * estimates current model-visible context usage from persisted conversation
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
- const contextTokens = estimateCurrentContextTokens(state);
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" && state.running) {
635
- if (state.abortController) state.abortController.abort();
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;