mini-coder 0.5.4 → 0.5.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "A small, fast CLI coding agent",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/index.ts CHANGED
@@ -47,6 +47,7 @@ import {
47
47
  } from "./prompt.ts";
48
48
  import {
49
49
  appendMessage,
50
+ computeContextTokens,
50
51
  computeStats,
51
52
  createSession,
52
53
  filterModelMessages,
@@ -526,6 +527,8 @@ export interface AppState {
526
527
  messages: ReturnType<typeof loadMessages>;
527
528
  /** Cumulative session input/output/cost stats for the status bar. */
528
529
  stats: SessionStats;
530
+ /** Estimated model-visible context tokens for the next request. */
531
+ contextTokens: number;
529
532
  /** Discovered AGENTS.md files. */
530
533
  agentsMd: AgentsMdFile[];
531
534
  /** Discovered skills. */
@@ -610,6 +613,7 @@ export async function init(): Promise<AppState> {
610
613
  const effort = startup.effort;
611
614
  const messages: ReturnType<typeof loadMessages> = [];
612
615
  const stats = computeStats(messages);
616
+ const contextTokens = computeContextTokens(messages);
613
617
  const promptContext = await loadPromptContext(filterModelMessages(messages), {
614
618
  cwd,
615
619
  });
@@ -621,6 +625,7 @@ export async function init(): Promise<AppState> {
621
625
  effort,
622
626
  messages,
623
627
  stats,
628
+ contextTokens,
624
629
  agentsMd: promptContext.agentsMd,
625
630
  skills: promptContext.skills,
626
631
  plugins: promptContext.plugins,
package/src/session.ts CHANGED
@@ -107,7 +107,7 @@ export interface UiMessage {
107
107
  }
108
108
 
109
109
  /** Any message persisted in session history. */
110
- type PersistedMessage = Message | UiMessage;
110
+ export type PersistedMessage = Message | UiMessage;
111
111
 
112
112
  /** Options for creating a new session. */
113
113
  interface CreateSessionOpts {
@@ -1071,3 +1071,153 @@ export function computeStats(
1071
1071
 
1072
1072
  return stats;
1073
1073
  }
1074
+
1075
+ // ---------------------------------------------------------------------------
1076
+ // Context estimation
1077
+ // ---------------------------------------------------------------------------
1078
+
1079
+ /** Conservative fixed estimate for an image block's token footprint. */
1080
+ const ESTIMATED_IMAGE_TOKENS = 1_200;
1081
+
1082
+ /** Calculate context tokens from assistant usage, falling back when `totalTokens` is zero. */
1083
+ function calculateUsageTokens(usage: AssistantMessage["usage"]): number {
1084
+ return (
1085
+ usage.totalTokens ||
1086
+ usage.input + usage.output + usage.cacheRead + usage.cacheWrite
1087
+ );
1088
+ }
1089
+
1090
+ /** Estimate token usage from a character count using a conservative chars/4 heuristic. */
1091
+ function estimateCharacterTokens(charCount: number): number {
1092
+ return Math.ceil(charCount / 4);
1093
+ }
1094
+
1095
+ type UserMultipartContent = Exclude<
1096
+ Extract<Message, { role: "user" }>["content"],
1097
+ string
1098
+ >;
1099
+ type TextOrImageContentBlock =
1100
+ | UserMultipartContent[number]
1101
+ | Extract<Message, { role: "toolResult" }>["content"][number];
1102
+
1103
+ function estimateTextOrImageContentTokens(
1104
+ content: readonly TextOrImageContentBlock[],
1105
+ ): number {
1106
+ let chars = 0;
1107
+ let imageTokens = 0;
1108
+
1109
+ for (const block of content) {
1110
+ if (block.type === "text") {
1111
+ chars += block.text.length;
1112
+ continue;
1113
+ }
1114
+ if (block.type === "image") {
1115
+ imageTokens += ESTIMATED_IMAGE_TOKENS;
1116
+ }
1117
+ }
1118
+
1119
+ return estimateCharacterTokens(chars) + imageTokens;
1120
+ }
1121
+
1122
+ function estimateUserMessageTokens(
1123
+ message: Extract<Message, { role: "user" }>,
1124
+ ): number {
1125
+ if (typeof message.content === "string") {
1126
+ return estimateCharacterTokens(message.content.length);
1127
+ }
1128
+ return estimateTextOrImageContentTokens(message.content);
1129
+ }
1130
+
1131
+ function estimateAssistantBlockCharacters(
1132
+ block: Extract<Message, { role: "assistant" }>["content"][number],
1133
+ ): number {
1134
+ if (block.type === "text") {
1135
+ return block.text.length;
1136
+ }
1137
+ if (block.type === "thinking") {
1138
+ return block.thinking.length;
1139
+ }
1140
+ return block.name.length + JSON.stringify(block.arguments).length;
1141
+ }
1142
+
1143
+ function estimateAssistantMessageTokens(
1144
+ message: Extract<Message, { role: "assistant" }>,
1145
+ ): number {
1146
+ const chars = message.content.reduce((total, block) => {
1147
+ return total + estimateAssistantBlockCharacters(block);
1148
+ }, 0);
1149
+ return estimateCharacterTokens(chars);
1150
+ }
1151
+
1152
+ function estimateToolResultMessageTokens(
1153
+ message: Extract<Message, { role: "toolResult" }>,
1154
+ ): number {
1155
+ return estimateTextOrImageContentTokens(message.content);
1156
+ }
1157
+
1158
+ /** Estimate token usage for a model-visible message. */
1159
+ function estimateMessageTokens(message: Message): number {
1160
+ switch (message.role) {
1161
+ case "user":
1162
+ return estimateUserMessageTokens(message);
1163
+ case "assistant":
1164
+ return estimateAssistantMessageTokens(message);
1165
+ case "toolResult":
1166
+ return estimateToolResultMessageTokens(message);
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * Fold one persisted message into the running context estimate for the next request.
1172
+ *
1173
+ * Assistant messages with valid usage anchor the full model-visible context for
1174
+ * that point in the transcript, so they replace the running estimate. All other
1175
+ * model-visible messages are added incrementally using the same conservative
1176
+ * estimation logic used before the first valid assistant usage appears.
1177
+ *
1178
+ * @param contextTokens - Running estimate before this message.
1179
+ * @param message - Persisted message to fold into the estimate.
1180
+ * @returns Updated context-token estimate.
1181
+ */
1182
+ export function addMessageToContextTokens(
1183
+ contextTokens: number,
1184
+ message: PersistedMessage,
1185
+ ): number {
1186
+ if (message.role === "ui") {
1187
+ return contextTokens;
1188
+ }
1189
+
1190
+ const usage = getAssistantUsage(message);
1191
+ if (
1192
+ message.role === "assistant" &&
1193
+ usage &&
1194
+ message.stopReason !== "aborted" &&
1195
+ message.stopReason !== "error"
1196
+ ) {
1197
+ return calculateUsageTokens(usage);
1198
+ }
1199
+
1200
+ return contextTokens + estimateMessageTokens(message);
1201
+ }
1202
+
1203
+ /**
1204
+ * Estimate the current model-visible context size for the next request.
1205
+ *
1206
+ * Recomputed on session-load boundaries and maintained incrementally during an
1207
+ * active turn so render-time status-bar updates do not need to rescan the full
1208
+ * message history.
1209
+ *
1210
+ * @param messages - Full persisted session history.
1211
+ * @returns Estimated context tokens visible to the next model request.
1212
+ */
1213
+ export function computeContextTokens(
1214
+ messages: readonly PersistedMessage[],
1215
+ ): number {
1216
+ let contextTokens = 0;
1217
+
1218
+ for (const message of messages) {
1219
+ contextTokens = addMessageToContextTokens(contextTokens, message);
1220
+ }
1221
+
1222
+ return contextTokens;
1223
+ }
package/src/submit.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from "./index.ts";
20
20
  import { parseInput } from "./input.ts";
21
21
  import {
22
+ addMessageToContextTokens,
22
23
  addMessageToStats,
23
24
  appendMessage,
24
25
  appendPromptHistory,
@@ -212,9 +213,17 @@ function handleAgentEvent(event: AgentEvent, state: AppState): void {
212
213
  case "assistant_message":
213
214
  state.messages.push(event.message);
214
215
  state.stats = addMessageToStats(state.stats, event.message);
216
+ state.contextTokens = addMessageToContextTokens(
217
+ state.contextTokens,
218
+ event.message,
219
+ );
215
220
  break;
216
221
  case "tool_result":
217
222
  state.messages.push(event.message);
223
+ state.contextTokens = addMessageToContextTokens(
224
+ state.contextTokens,
225
+ event.message,
226
+ );
218
227
  break;
219
228
  case "text_delta":
220
229
  case "thinking_delta":
@@ -277,6 +286,10 @@ export async function submitResolvedInput(
277
286
 
278
287
  const turn = appendMessage(state.db, session.id, userMessage);
279
288
  state.messages.push(userMessage);
289
+ state.contextTokens = addMessageToContextTokens(
290
+ state.contextTokens,
291
+ userMessage,
292
+ );
280
293
  hooks?.onUserMessage?.(state);
281
294
 
282
295
  state.git = await loadGitState(state.cwd);
@@ -8,6 +8,7 @@ import type { AppState } from "../index.ts";
8
8
  import { COMMANDS } from "../input.ts";
9
9
  import {
10
10
  appendPromptHistory,
11
+ computeContextTokens,
11
12
  createSession,
12
13
  openDatabase,
13
14
  } from "../session.ts";
@@ -75,6 +76,7 @@ function createTestState(): AppState {
75
76
  effort: "medium",
76
77
  messages: [],
77
78
  stats: { totalInput: 0, totalOutput: 0, totalCost: 0 },
79
+ contextTokens: 0,
78
80
  agentsMd: [],
79
81
  skills: [],
80
82
  plugins: [],
@@ -377,6 +379,7 @@ describe("ui/commands", () => {
377
379
  totalOutput: 0,
378
380
  totalCost: 0,
379
381
  });
382
+ expect(state.contextTokens).toBe(computeContextTokens(state.messages));
380
383
  } finally {
381
384
  state.db.close();
382
385
  }
@@ -436,6 +439,7 @@ describe("ui/commands", () => {
436
439
  { role: "ui", kind: "info", content: "old", timestamp: 1 },
437
440
  ];
438
441
  state.stats = { totalInput: 10, totalOutput: 20, totalCost: 0.5 };
442
+ state.contextTokens = 123;
439
443
 
440
444
  try {
441
445
  expect(controller.handleCommand("new", state)).toBe(true);
@@ -449,6 +453,7 @@ describe("ui/commands", () => {
449
453
  totalOutput: 0,
450
454
  totalCost: 0,
451
455
  });
456
+ expect(state.contextTokens).toBe(0);
452
457
  expect(state.agentsMd).toEqual([
453
458
  { path: "/tmp/reloaded/AGENTS.md", content: "Reloaded context" },
454
459
  ]);
@@ -19,6 +19,7 @@ import type { AppState } from "../index.ts";
19
19
  import { getAvailableModels, saveOAuthCredentials } from "../index.ts";
20
20
  import { COMMANDS } from "../input.ts";
21
21
  import {
22
+ computeContextTokens,
22
23
  computeStats,
23
24
  forkSession,
24
25
  listPromptHistory,
@@ -387,6 +388,7 @@ export function createCommandController(
387
388
  state.session = picked;
388
389
  state.messages = loadMessages(state.db, picked.id);
389
390
  state.stats = computeStats(state.messages);
391
+ state.contextTokens = computeContextTokens(state.messages);
390
392
  runtime.scrollConversationToBottom();
391
393
  }
392
394
  }
@@ -402,6 +404,7 @@ export function createCommandController(
402
404
  state.session = null;
403
405
  state.messages = [];
404
406
  state.stats = { totalInput: 0, totalOutput: 0, totalCost: 0 };
407
+ state.contextTokens = 0;
405
408
  await runtime.reloadPromptContext(state);
406
409
  runtime.scrollConversationToBottom();
407
410
  runtime.render();
@@ -415,6 +418,7 @@ export function createCommandController(
415
418
  state.session = forked;
416
419
  state.messages = loadMessages(state.db, forked.id);
417
420
  state.stats = computeStats(state.messages);
421
+ state.contextTokens = computeContextTokens(state.messages);
418
422
  runtime.appendInfoMessage("Forked session.", state);
419
423
  };
420
424
 
@@ -434,6 +438,7 @@ export function createCommandController(
434
438
  if (removed) {
435
439
  state.messages = loadMessages(state.db, state.session.id);
436
440
  state.stats = computeStats(state.messages);
441
+ state.contextTokens = computeContextTokens(state.messages);
437
442
  runtime.scrollConversationToBottom();
438
443
  runtime.render();
439
444
  }
@@ -838,6 +838,93 @@ describe("ui/conversation", () => {
838
838
  expect(text.some((line) => line.includes("TAIL"))).toBe(true);
839
839
  });
840
840
 
841
+ test("renderAssistantMessage for a long single-token shell argument wraps through the tail in a narrow viewport", async () => {
842
+ // Arrange
843
+ const command = `printf ${"x".repeat(40)}TAIL`;
844
+ const assistant = {
845
+ content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
846
+ };
847
+
848
+ // Act
849
+ const text = await renderVisibleText(
850
+ renderAssistantMessage(assistant, {
851
+ ...RENDER_OPTS,
852
+ verbose: true,
853
+ previewWidth: 12,
854
+ }),
855
+ 12,
856
+ 20,
857
+ );
858
+
859
+ // Assert
860
+ expect(text.some((line) => line.includes("TAIL"))).toBe(true);
861
+ });
862
+
863
+ test("renderAssistantMessage for a long quoted shell string preserves string color across wrapped rows", async () => {
864
+ // Arrange
865
+ const command = `printf "${"x".repeat(80)}TAIL"`;
866
+ const assistant = {
867
+ content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
868
+ };
869
+
870
+ // Act
871
+ const rows = await renderBufferRows(
872
+ renderAssistantMessage(assistant, {
873
+ ...RENDER_OPTS,
874
+ verbose: true,
875
+ previewWidth: 24,
876
+ }),
877
+ 24,
878
+ 20,
879
+ );
880
+ const headRowIndex = rows.findIndex((row) => row.text.includes('"x'));
881
+ const headRow = headRowIndex >= 0 ? rows[headRowIndex] : undefined;
882
+ const wrappedRow =
883
+ headRowIndex >= 0
884
+ ? rows
885
+ .slice(headRowIndex + 1)
886
+ .find((row) => row.text.includes("xxxxxxxx"))
887
+ : undefined;
888
+
889
+ // Assert
890
+ expect(headRow).toBeDefined();
891
+ expect(wrappedRow).toBeDefined();
892
+ expect(headRow?.fgColors[headRow.text.indexOf('"')]).toBe(
893
+ DEFAULT_THEME.diffAdded ?? null,
894
+ );
895
+ expect(wrappedRow?.fgColors[wrappedRow.text.indexOf("x")]).toBe(
896
+ DEFAULT_THEME.diffAdded ?? null,
897
+ );
898
+ });
899
+
900
+ test("renderAssistantMessage for long inline markdown code keeps the tail visible", async () => {
901
+ // Arrange
902
+ const message = fauxAssistantMessage(`Use \`${"x".repeat(80)}TAIL\``);
903
+
904
+ // Act
905
+ const rows = await renderBufferRows(
906
+ renderAssistantMessage(message, {
907
+ ...RENDER_OPTS,
908
+ previewWidth: 24,
909
+ }),
910
+ 24,
911
+ 20,
912
+ );
913
+ const codeRows = rows.filter(
914
+ (row) => row.text.includes("x") || row.text.includes("TAIL`"),
915
+ );
916
+
917
+ // Assert
918
+ expect(codeRows.length).toBeGreaterThan(1);
919
+ expect(rows.some((row) => row.text.includes("TAIL`"))).toBe(true);
920
+ expect(codeRows[0]?.fgColors[codeRows[0].text.indexOf("x")]).toBe(
921
+ DEFAULT_THEME.diffAdded ?? null,
922
+ );
923
+ expect(codeRows.at(-1)?.fgColors[codeRows.at(-1)!.text.indexOf("T")]).toBe(
924
+ DEFAULT_THEME.diffAdded ?? null,
925
+ );
926
+ });
927
+
841
928
  test("renderAssistantMessage for a long single-token shell command uses wrapped preview height when verbose is off", () => {
842
929
  // Arrange
843
930
  const command = `printf ${"x".repeat(220)}TAIL`;
@@ -34,6 +34,9 @@ const UI_TOOL_PREVIEW_ROWS = 8;
34
34
  /** Default width used when preview measurements do not receive one explicitly. */
35
35
  const DEFAULT_TOOL_PREVIEW_WIDTH = 80;
36
36
 
37
+ /** Horizontal columns consumed by assistant-markdown padding. */
38
+ const MARKDOWN_BLOCK_CHROME_WIDTH = 2;
39
+
37
40
  /** Horizontal columns consumed by tool-block padding and the left border. */
38
41
  const TOOL_BLOCK_CHROME_WIDTH = 4;
39
42
 
@@ -226,7 +229,11 @@ function renderUserMessage(msg: UserMessage, theme: Theme): Node {
226
229
  }
227
230
 
228
231
  /** Render a syntax-highlighted raw markdown block. */
229
- function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
232
+ function renderMarkdownTextBlock(
233
+ content: string,
234
+ theme: Theme,
235
+ previewWidth?: number,
236
+ ): Node | null {
230
237
  if (content === "") {
231
238
  return null;
232
239
  }
@@ -238,6 +245,7 @@ function renderMarkdownTextBlock(content: string, theme: Theme): Node | null {
238
245
  themeVariant: "markdown",
239
246
  },
240
247
  theme,
248
+ getMarkdownBodyWidth(previewWidth),
241
249
  );
242
250
  if (children.length === 0) {
243
251
  return null;
@@ -313,7 +321,7 @@ function renderAssistantContentBlock(
313
321
  opts: ConversationRenderOpts,
314
322
  ): Node | null {
315
323
  if (block.type === "text" && block.text) {
316
- return renderMarkdownTextBlock(block.text, opts.theme);
324
+ return renderMarkdownTextBlock(block.text, opts.theme, opts.previewWidth);
317
325
  }
318
326
  if (block.type === "thinking" && block.thinking) {
319
327
  return renderThinkingBlock(block.thinking, opts);
@@ -366,10 +374,21 @@ function getPreviewWidth(previewWidth?: number): number {
366
374
  return Math.max(1, Math.floor(previewWidth!));
367
375
  }
368
376
 
377
+ function getMarkdownBodyWidth(previewWidth?: number): number {
378
+ return Math.max(
379
+ 1,
380
+ getPreviewWidth(previewWidth) - MARKDOWN_BLOCK_CHROME_WIDTH,
381
+ );
382
+ }
383
+
369
384
  function getToolBodyWidth(previewWidth?: number): number {
370
385
  return Math.max(1, getPreviewWidth(previewWidth) - TOOL_BLOCK_CHROME_WIDTH);
371
386
  }
372
387
 
388
+ function getHighlightWrapChunkSize(bodyWidth: number): number {
389
+ return Math.max(1, Math.min(HIGHLIGHT_WRAP_MAX_CHUNK_GRAPHEMES, bodyWidth));
390
+ }
391
+
373
392
  /** Split multi-line tool text into logical render lines. */
374
393
  function splitToolTextLines(
375
394
  text: string,
@@ -426,6 +445,7 @@ type SyntaxThemeTokenColor = NonNullable<
426
445
  const graphemeSegmenter = new Intl.Segmenter(undefined, {
427
446
  granularity: "grapheme",
428
447
  });
448
+ const HIGHLIGHT_WRAP_MAX_CHUNK_GRAPHEMES = 32;
429
449
  const syntaxThemeCache: Record<
430
450
  SyntaxThemeVariant,
431
451
  WeakMap<Theme, SyntaxThemeRegistration>
@@ -559,6 +579,7 @@ function getSyntaxTheme(
559
579
 
560
580
  function splitHighlightedTextNode(
561
581
  node: Extract<Node, { type: "text" }>,
582
+ chunkSize: number,
562
583
  ): Node[] {
563
584
  if (node.content === "") {
564
585
  return [Text("", node.props)];
@@ -576,21 +597,37 @@ function splitHighlightedTextNode(
576
597
  continue;
577
598
  }
578
599
 
600
+ let chunk = "";
601
+ let chunkGraphemes = 0;
579
602
  for (const { segment } of graphemeSegmenter.segment(part)) {
580
- children.push(Text(segment, node.props));
603
+ chunk += segment;
604
+ chunkGraphemes += 1;
605
+
606
+ if (chunkGraphemes === chunkSize) {
607
+ children.push(Text(chunk, node.props));
608
+ chunk = "";
609
+ chunkGraphemes = 0;
610
+ }
611
+ }
612
+
613
+ if (chunk !== "") {
614
+ children.push(Text(chunk, node.props));
581
615
  }
582
616
  }
583
617
 
584
618
  return children;
585
619
  }
586
620
 
587
- function normalizeHighlightedLine(line: Node): Node {
621
+ function normalizeHighlightedLine(line: Node, bodyWidth: number): Node {
588
622
  if (line.type !== "hstack") {
589
623
  return line;
590
624
  }
591
625
 
626
+ const chunkSize = getHighlightWrapChunkSize(bodyWidth);
592
627
  const children = line.children.flatMap((child) => {
593
- return child.type === "text" ? splitHighlightedTextNode(child) : [child];
628
+ return child.type === "text"
629
+ ? splitHighlightedTextNode(child, chunkSize)
630
+ : [child];
594
631
  });
595
632
  return HStack(line.props, children);
596
633
  }
@@ -598,6 +635,7 @@ function normalizeHighlightedLine(line: Node): Node {
598
635
  function getHighlightedBodyLines(
599
636
  spec: HighlightedBodySpec,
600
637
  theme: Theme,
638
+ bodyWidth: number,
601
639
  ): Node[] {
602
640
  if (spec.text === "") {
603
641
  return [];
@@ -606,7 +644,9 @@ function getHighlightedBodyLines(
606
644
  const highlighted = SyntaxHighlight(spec.text, spec.language, {
607
645
  theme: getSyntaxTheme(theme, spec.themeVariant),
608
646
  });
609
- return highlighted.children.map((line) => normalizeHighlightedLine(line));
647
+ return highlighted.children.map((line) =>
648
+ normalizeHighlightedLine(line, bodyWidth),
649
+ );
610
650
  }
611
651
 
612
652
  /** Render a single styled text node for a tool line. */
@@ -740,7 +780,11 @@ function renderToolBody(
740
780
  ): { body: Node | null; summary?: ToolRenderLine } {
741
781
  if (spec.highlightedBody) {
742
782
  return renderToolBodyFromNodes(
743
- getHighlightedBodyLines(spec.highlightedBody, opts.theme),
783
+ getHighlightedBodyLines(
784
+ spec.highlightedBody,
785
+ opts.theme,
786
+ getToolBodyWidth(opts.previewWidth),
787
+ ),
744
788
  spec.previewBody,
745
789
  opts,
746
790
  );
@@ -0,0 +1,443 @@
1
+ import { afterEach, describe, test } from "bun:test";
2
+ import { cel, MockTerminal } from "@cel-tui/core";
3
+ import type { Node } from "@cel-tui/types";
4
+ import {
5
+ fauxAssistantMessage,
6
+ fauxThinking,
7
+ fauxToolCall,
8
+ } from "@mariozechner/pi-ai";
9
+ import type { AppState } from "../index.ts";
10
+ import {
11
+ computeContextTokens,
12
+ computeStats,
13
+ createUiMessage,
14
+ openDatabase,
15
+ } from "../session.ts";
16
+ import { DEFAULT_SHOW_REASONING, DEFAULT_VERBOSE } from "../settings.ts";
17
+ import { DEFAULT_THEME } from "../theme.ts";
18
+ import {
19
+ buildConversationLogNodes,
20
+ resetConversationRenderCache,
21
+ } from "../ui/conversation.ts";
22
+ import {
23
+ createInputController,
24
+ renderActiveOverlay,
25
+ renderBaseLayout,
26
+ resetUiState,
27
+ } from "../ui.ts";
28
+
29
+ const VIEWPORT_COLS = 120;
30
+ const VIEWPORT_ROWS = 40;
31
+ const TYPING_SAMPLE_COUNT = 9;
32
+ const LARGE_MARKDOWN_SECTION_COUNT = 8;
33
+ const NODE_BUDGET = 6_000;
34
+ const TYPING_MEDIAN_BUDGET_MS = 40;
35
+
36
+ function flushCelRender(): Promise<void> {
37
+ return new Promise((resolve) => {
38
+ process.nextTick(resolve);
39
+ });
40
+ }
41
+
42
+ function startUiViewport(
43
+ state: AppState,
44
+ terminal: MockTerminal,
45
+ controller: ReturnType<typeof createInputController>,
46
+ ): void {
47
+ cel.init(terminal);
48
+ cel.viewport(() => {
49
+ const base = renderBaseLayout(state, terminal.columns, controller);
50
+ const overlay = renderActiveOverlay(state);
51
+ return overlay ? [base, overlay] : base;
52
+ });
53
+ }
54
+
55
+ function createTestState(): AppState {
56
+ const cwd = "/tmp/mini-coder-ui-render-perf-test";
57
+ const model: NonNullable<AppState["model"]> = {
58
+ id: "gpt-5.4",
59
+ name: "gpt-5.4",
60
+ provider: "openai-codex",
61
+ api: "responses",
62
+ baseUrl: "http://localhost:0",
63
+ reasoning: true,
64
+ input: ["text"],
65
+ cost: {
66
+ input: 0,
67
+ output: 0,
68
+ cacheRead: 0,
69
+ cacheWrite: 0,
70
+ },
71
+ contextWindow: 272_000,
72
+ maxTokens: 8_192,
73
+ };
74
+
75
+ return {
76
+ db: openDatabase(":memory:"),
77
+ session: null,
78
+ model,
79
+ effort: "medium",
80
+ messages: [],
81
+ stats: { totalInput: 0, totalOutput: 0, totalCost: 0 },
82
+ contextTokens: 0,
83
+ agentsMd: [],
84
+ skills: [],
85
+ plugins: [],
86
+ theme: DEFAULT_THEME,
87
+ git: null,
88
+ providers: new Map(),
89
+ oauthCredentials: {},
90
+ settings: {},
91
+ settingsPath: `${cwd}/settings.json`,
92
+ cwd,
93
+ canonicalCwd: cwd,
94
+ running: false,
95
+ abortController: null,
96
+ activeTurnPromise: null,
97
+ showReasoning: DEFAULT_SHOW_REASONING,
98
+ verbose: DEFAULT_VERBOSE,
99
+ versionLabel: "dev",
100
+ customModels: [],
101
+ startupWarnings: [],
102
+ };
103
+ }
104
+
105
+ function setMessages(state: AppState, messages: AppState["messages"]): void {
106
+ state.messages = messages;
107
+ state.stats = computeStats(messages);
108
+ state.contextTokens = computeContextTokens(messages);
109
+ }
110
+
111
+ function buildLargeMarkdown(seed: number): string {
112
+ return Array.from({ length: LARGE_MARKDOWN_SECTION_COUNT }, (_, index) => {
113
+ const section = seed * 100 + index;
114
+ return [
115
+ `# Section ${section}`,
116
+ "",
117
+ `Paragraph ${section}: ${"lorem ipsum dolor sit amet ".repeat(10)}`,
118
+ "",
119
+ `- item ${section}a with [link](https://example.com/${section})`,
120
+ `- item ${section}b with **bold** text and \`inline_code_${section}\``,
121
+ "",
122
+ `> Quote ${section}: ${"wrapped quoted text ".repeat(10)}`,
123
+ "",
124
+ "```ts",
125
+ `const value${section} = ${section};`,
126
+ `console.log(value${section});`,
127
+ "```",
128
+ ].join("\n");
129
+ }).join("\n\n");
130
+ }
131
+
132
+ function createLargeMarkdownMessages(): AppState["messages"] {
133
+ return [
134
+ {
135
+ role: "user",
136
+ content: "Investigate the rendering lag in this session.",
137
+ timestamp: 1,
138
+ },
139
+ fauxAssistantMessage(buildLargeMarkdown(1), { timestamp: 2 }),
140
+ {
141
+ role: "user",
142
+ content: "Keep going with the detailed write-up.",
143
+ timestamp: 3,
144
+ },
145
+ fauxAssistantMessage(buildLargeMarkdown(2), { timestamp: 4 }),
146
+ {
147
+ role: "user",
148
+ content: "Add one more large markdown response for history.",
149
+ timestamp: 5,
150
+ },
151
+ fauxAssistantMessage(buildLargeMarkdown(3), { timestamp: 6 }),
152
+ ];
153
+ }
154
+
155
+ function countNodes(node: Node): number {
156
+ if (node.type === "text" || node.type === "textinput") {
157
+ return 1;
158
+ }
159
+
160
+ return (
161
+ 1 + node.children.reduce((total, child) => total + countNodes(child), 0)
162
+ );
163
+ }
164
+
165
+ function getConversationNode(
166
+ messages: AppState["messages"],
167
+ index: number,
168
+ ): Node {
169
+ resetConversationRenderCache();
170
+
171
+ const nodes = buildConversationLogNodes(
172
+ {
173
+ messages,
174
+ showReasoning: true,
175
+ verbose: false,
176
+ theme: DEFAULT_THEME,
177
+ versionLabel: "dev",
178
+ },
179
+ {
180
+ isStreaming: false,
181
+ content: [],
182
+ pendingToolResults: [],
183
+ },
184
+ 0,
185
+ 80,
186
+ );
187
+ const node = nodes[index];
188
+
189
+ if (!node) {
190
+ throw new Error(`Expected a rendered node at index ${index}`);
191
+ }
192
+
193
+ return node;
194
+ }
195
+
196
+ function expectNodeCountAtMost(
197
+ label: string,
198
+ node: Node,
199
+ maxNodes: number,
200
+ ): void {
201
+ const nodeCount = countNodes(node);
202
+
203
+ if (nodeCount > maxNodes) {
204
+ throw new Error(
205
+ `Expected ${label} node count <= ${maxNodes}, got ${nodeCount}`,
206
+ );
207
+ }
208
+ }
209
+
210
+ function median(values: readonly number[]): number {
211
+ const sorted = [...values].sort((a, b) => a - b);
212
+ const middle = Math.floor(sorted.length / 2);
213
+
214
+ if (sorted.length % 2 === 0) {
215
+ return (sorted[middle - 1]! + sorted[middle]!) / 2;
216
+ }
217
+
218
+ return sorted[middle]!;
219
+ }
220
+
221
+ async function measureTypingMedianRerenderMs(state: AppState): Promise<number> {
222
+ const terminal = new MockTerminal(VIEWPORT_COLS, VIEWPORT_ROWS);
223
+ const controller = createInputController(state);
224
+ const samples: number[] = [];
225
+
226
+ startUiViewport(state, terminal, controller);
227
+ await flushCelRender();
228
+
229
+ try {
230
+ for (let index = 0; index < TYPING_SAMPLE_COUNT; index++) {
231
+ const nextValue = index % 2 === 0 ? "a" : "ab";
232
+ const start = performance.now();
233
+ controller.onChange(nextValue);
234
+ await flushCelRender();
235
+ samples.push(performance.now() - start);
236
+ }
237
+ } finally {
238
+ cel.stop();
239
+ }
240
+
241
+ return median(samples);
242
+ }
243
+
244
+ afterEach(() => {
245
+ resetUiState();
246
+ cel.stop();
247
+ });
248
+
249
+ describe("ui render performance", () => {
250
+ test("renderBaseLayout with large historical assistant markdown stays within the node budget", () => {
251
+ // Arrange
252
+ const state = createTestState();
253
+ setMessages(state, createLargeMarkdownMessages());
254
+ const controller = createInputController(state);
255
+
256
+ try {
257
+ // Act
258
+ const base = renderBaseLayout(state, VIEWPORT_COLS, controller);
259
+ const nodeCount = countNodes(base);
260
+
261
+ // Assert
262
+ if (nodeCount > NODE_BUDGET) {
263
+ throw new Error(
264
+ `Expected large-markdown layout node count <= ${NODE_BUDGET}, got ${nodeCount}`,
265
+ );
266
+ }
267
+ } finally {
268
+ state.db.close();
269
+ }
270
+ });
271
+
272
+ test("non-markdown conversation log items stay within bounded node budgets", () => {
273
+ // Arrange
274
+ const userNode = getConversationNode(
275
+ [
276
+ {
277
+ role: "user",
278
+ content: "lorem ipsum dolor sit amet ".repeat(400),
279
+ timestamp: 1,
280
+ },
281
+ ],
282
+ 0,
283
+ );
284
+ const uiNode = getConversationNode(
285
+ [createUiMessage("status update ".repeat(400))],
286
+ 0,
287
+ );
288
+ const thinkingNode = getConversationNode(
289
+ [fauxAssistantMessage([fauxThinking("plan\n".repeat(400))])],
290
+ 0,
291
+ );
292
+ const assistantToolCallsNode = getConversationNode(
293
+ [
294
+ fauxAssistantMessage([
295
+ fauxThinking("brief plan"),
296
+ fauxToolCall(
297
+ "shell",
298
+ { command: "printf 'foo\\nbar'" },
299
+ { id: "tool-1" },
300
+ ),
301
+ fauxToolCall(
302
+ "edit",
303
+ {
304
+ path: "src/app.ts",
305
+ oldText: "before",
306
+ newText: "after",
307
+ },
308
+ { id: "tool-2" },
309
+ ),
310
+ fauxToolCall("readImage", { path: "diagram.png" }, { id: "tool-3" }),
311
+ ]),
312
+ ],
313
+ 0,
314
+ );
315
+ const longShellToolCallNode = getConversationNode(
316
+ [
317
+ fauxAssistantMessage([
318
+ fauxToolCall(
319
+ "shell",
320
+ { command: `printf ${"x".repeat(300)}TAIL` },
321
+ { id: "tool-4" },
322
+ ),
323
+ ]),
324
+ ],
325
+ 0,
326
+ );
327
+ const shellResultNode = getConversationNode(
328
+ [
329
+ fauxAssistantMessage([
330
+ fauxToolCall("shell", { command: "seq 1 200" }, { id: "tool-1" }),
331
+ ]),
332
+ {
333
+ role: "toolResult",
334
+ toolCallId: "tool-1",
335
+ toolName: "shell",
336
+ content: [
337
+ {
338
+ type: "text",
339
+ text: Array.from(
340
+ { length: 200 },
341
+ (_, index) => `line ${index + 1}`,
342
+ ).join("\n"),
343
+ },
344
+ ],
345
+ isError: false,
346
+ timestamp: 2,
347
+ },
348
+ ],
349
+ 1,
350
+ );
351
+ const editErrorNode = getConversationNode(
352
+ [
353
+ fauxAssistantMessage([
354
+ fauxToolCall(
355
+ "edit",
356
+ {
357
+ path: "src/app.ts",
358
+ oldText: "before",
359
+ newText: "after",
360
+ },
361
+ { id: "tool-2" },
362
+ ),
363
+ ]),
364
+ {
365
+ role: "toolResult",
366
+ toolCallId: "tool-2",
367
+ toolName: "edit",
368
+ content: [
369
+ {
370
+ type: "text",
371
+ text: Array.from(
372
+ { length: 120 },
373
+ (_, index) => `line ${index + 1}`,
374
+ ).join("\n"),
375
+ },
376
+ ],
377
+ isError: true,
378
+ timestamp: 3,
379
+ },
380
+ ],
381
+ 1,
382
+ );
383
+ const genericResultNode = getConversationNode(
384
+ [
385
+ {
386
+ role: "toolResult",
387
+ toolCallId: "tool-3",
388
+ toolName: "pluginSearch",
389
+ content: [
390
+ {
391
+ type: "text",
392
+ text: Array.from(
393
+ { length: 200 },
394
+ (_, index) => `row ${index + 1}`,
395
+ ).join("\n"),
396
+ },
397
+ ],
398
+ isError: false,
399
+ timestamp: 4,
400
+ },
401
+ ],
402
+ 0,
403
+ );
404
+
405
+ // Assert
406
+ expectNodeCountAtMost("long user message", userNode, 4);
407
+ expectNodeCountAtMost("long UI message", uiNode, 4);
408
+ expectNodeCountAtMost("thinking-only assistant message", thinkingNode, 4);
409
+ expectNodeCountAtMost(
410
+ "assistant tool-call bundle",
411
+ assistantToolCallsNode,
412
+ 64,
413
+ );
414
+ expectNodeCountAtMost(
415
+ "long single-token shell tool call",
416
+ longShellToolCallNode,
417
+ 64,
418
+ );
419
+ expectNodeCountAtMost("shell tool-result preview", shellResultNode, 24);
420
+ expectNodeCountAtMost("edit error preview", editErrorNode, 24);
421
+ expectNodeCountAtMost("generic tool result", genericResultNode, 240);
422
+ });
423
+
424
+ test("typing into the input with large historical assistant markdown stays within the rerender budget", async () => {
425
+ // Arrange
426
+ const state = createTestState();
427
+ setMessages(state, createLargeMarkdownMessages());
428
+
429
+ try {
430
+ // Act
431
+ const medianRerenderMs = await measureTypingMedianRerenderMs(state);
432
+
433
+ // Assert
434
+ if (medianRerenderMs > TYPING_MEDIAN_BUDGET_MS) {
435
+ throw new Error(
436
+ `Expected typing median rerender <= ${TYPING_MEDIAN_BUDGET_MS}ms, got ${medianRerenderMs.toFixed(1)}ms`,
437
+ );
438
+ }
439
+ } finally {
440
+ state.db.close();
441
+ }
442
+ });
443
+ });
@@ -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. */