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 +1 -1
- package/src/index.ts +5 -0
- package/src/session.ts +151 -1
- 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 +87 -0
- package/src/ui/conversation.ts +51 -7
- package/src/ui/render-performance.test.ts +443 -0
- package/src/ui/status.test.ts +58 -24
- package/src/ui/status.ts +2 -139
package/package.json
CHANGED
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);
|
package/src/ui/commands.test.ts
CHANGED
|
@@ -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
|
]);
|
package/src/ui/commands.ts
CHANGED
|
@@ -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`;
|
package/src/ui/conversation.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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"
|
|
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) =>
|
|
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(
|
|
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
|
+
});
|
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. */
|