mini-coder 0.5.5 → 0.5.7
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/bun.lock +13 -13
- package/package.json +3 -3
- package/src/agent.ts +141 -29
- package/src/index.ts +27 -2
- package/src/prompt.ts +84 -78
- package/src/submit.ts +53 -6
- package/src/tools.ts +160 -42
- package/src/ui/agent.ts +16 -2
- package/src/ui/commands.test.ts +1 -0
- package/src/ui/conversation.test.ts +87 -0
- package/src/ui/conversation.ts +51 -7
- package/src/ui/input.test.ts +29 -1
- package/src/ui/input.ts +45 -51
- package/src/ui/render-performance.test.ts +444 -0
- package/src/ui/status.test.ts +1 -0
- package/src/ui.ts +50 -10
package/src/submit.ts
CHANGED
|
@@ -204,12 +204,63 @@ export function resolveRawInput(
|
|
|
204
204
|
}
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
function recordRawPromptHistory(
|
|
208
|
+
rawInput: string,
|
|
209
|
+
state: Pick<AppState, "db" | "cwd">,
|
|
210
|
+
sessionId: string,
|
|
211
|
+
): void {
|
|
212
|
+
appendPromptHistory(state.db, {
|
|
213
|
+
text: rawInput,
|
|
214
|
+
cwd: state.cwd,
|
|
215
|
+
sessionId,
|
|
216
|
+
});
|
|
217
|
+
truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Queue resolved user content for the next model-request boundary of an active run.
|
|
222
|
+
*
|
|
223
|
+
* The raw prompt is recorded immediately in prompt history, but the model-visible
|
|
224
|
+
* `UserMessage` is only appended to session history when the agent loop consumes it.
|
|
225
|
+
*
|
|
226
|
+
* @param rawInput - Exact raw submitted prompt text.
|
|
227
|
+
* @param content - Resolved model-visible user content.
|
|
228
|
+
* @param state - Mutable application state.
|
|
229
|
+
*/
|
|
230
|
+
export function queueResolvedInput(
|
|
231
|
+
rawInput: string,
|
|
232
|
+
content: UserMessage["content"],
|
|
233
|
+
state: AppState,
|
|
234
|
+
): void {
|
|
235
|
+
if (!state.running) {
|
|
236
|
+
throw new Error("Cannot queue input while no turn is running.");
|
|
237
|
+
}
|
|
238
|
+
if (isEmptyUserContent(content)) {
|
|
239
|
+
throw new Error("Cannot queue empty input.");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const session = ensureSession(state);
|
|
243
|
+
recordRawPromptHistory(rawInput, state, session.id);
|
|
244
|
+
state.queuedUserMessages.push({
|
|
245
|
+
role: "user",
|
|
246
|
+
content,
|
|
247
|
+
timestamp: Date.now(),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
207
251
|
// ---------------------------------------------------------------------------
|
|
208
252
|
// Turn submission
|
|
209
253
|
// ---------------------------------------------------------------------------
|
|
210
254
|
|
|
211
255
|
function handleAgentEvent(event: AgentEvent, state: AppState): void {
|
|
212
256
|
switch (event.type) {
|
|
257
|
+
case "user_message":
|
|
258
|
+
state.messages.push(event.message);
|
|
259
|
+
state.contextTokens = addMessageToContextTokens(
|
|
260
|
+
state.contextTokens,
|
|
261
|
+
event.message,
|
|
262
|
+
);
|
|
263
|
+
break;
|
|
213
264
|
case "assistant_message":
|
|
214
265
|
state.messages.push(event.message);
|
|
215
266
|
state.stats = addMessageToStats(state.stats, event.message);
|
|
@@ -270,12 +321,7 @@ export async function submitResolvedInput(
|
|
|
270
321
|
}
|
|
271
322
|
|
|
272
323
|
const session = ensureSession(state);
|
|
273
|
-
|
|
274
|
-
text: rawInput,
|
|
275
|
-
cwd: state.cwd,
|
|
276
|
-
sessionId: session.id,
|
|
277
|
-
});
|
|
278
|
-
truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
|
|
324
|
+
recordRawPromptHistory(rawInput, state, session.id);
|
|
279
325
|
|
|
280
326
|
const userMessage = {
|
|
281
327
|
role: "user",
|
|
@@ -318,6 +364,7 @@ export async function submitResolvedInput(
|
|
|
318
364
|
apiKey: state.providers.get(state.model.provider),
|
|
319
365
|
effort: state.effort,
|
|
320
366
|
signal: state.abortController.signal,
|
|
367
|
+
takeQueuedUserMessage: () => state.queuedUserMessages.shift() ?? null,
|
|
321
368
|
onEvent: (event) => {
|
|
322
369
|
handleAgentEvent(event, state);
|
|
323
370
|
hooks?.onEvent?.(event, state);
|
package/src/tools.ts
CHANGED
|
@@ -490,9 +490,12 @@ interface ShellOpts {
|
|
|
490
490
|
onUpdate?: ToolUpdateCallback;
|
|
491
491
|
}
|
|
492
492
|
|
|
493
|
+
type ShellProcess = ReturnType<typeof Bun.spawn>;
|
|
494
|
+
|
|
493
495
|
const DEFAULT_MAX_LINES = 1000;
|
|
494
496
|
const DEFAULT_MAX_BYTES = 50_000;
|
|
495
497
|
const SHELL_UPDATE_INTERVAL_MS = 75;
|
|
498
|
+
const SHELL_STREAM_DRAIN_TIMEOUT_MS = 25;
|
|
496
499
|
|
|
497
500
|
/** Format combined stdout/stderr for display in tool results. */
|
|
498
501
|
function formatShellOutput(stdout: string, stderr: string): string {
|
|
@@ -837,26 +840,132 @@ function normalizeShellCommand(command: string): string {
|
|
|
837
840
|
}
|
|
838
841
|
}
|
|
839
842
|
|
|
840
|
-
|
|
841
|
-
|
|
843
|
+
interface ShellStreamCapture {
|
|
844
|
+
done: Promise<void>;
|
|
845
|
+
getOutput: () => string;
|
|
846
|
+
isFinished: () => boolean;
|
|
847
|
+
close: () => Promise<void>;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function startShellStreamCapture(
|
|
842
851
|
stream: ReadableStream<Uint8Array>,
|
|
843
852
|
onChunk: (chunk: string) => void,
|
|
844
|
-
):
|
|
853
|
+
): ShellStreamCapture {
|
|
845
854
|
const reader = stream.getReader();
|
|
846
855
|
const decoder = new TextDecoder();
|
|
847
856
|
let output = "";
|
|
857
|
+
let closed = false;
|
|
858
|
+
let finished = false;
|
|
859
|
+
|
|
860
|
+
const done = (async (): Promise<void> => {
|
|
861
|
+
try {
|
|
862
|
+
while (true) {
|
|
863
|
+
const { done, value } = await reader.read();
|
|
864
|
+
if (done) {
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
869
|
+
output += chunk;
|
|
870
|
+
onChunk(chunk);
|
|
871
|
+
}
|
|
872
|
+
} catch (error) {
|
|
873
|
+
if (!closed) {
|
|
874
|
+
throw error;
|
|
875
|
+
}
|
|
876
|
+
} finally {
|
|
877
|
+
const trailing = decoder.decode();
|
|
878
|
+
output += trailing;
|
|
879
|
+
onChunk(trailing);
|
|
880
|
+
finished = true;
|
|
881
|
+
}
|
|
882
|
+
})();
|
|
848
883
|
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
884
|
+
return {
|
|
885
|
+
done,
|
|
886
|
+
getOutput: () => output,
|
|
887
|
+
isFinished: () => finished,
|
|
888
|
+
close: async (): Promise<void> => {
|
|
889
|
+
if (!finished) {
|
|
890
|
+
closed = true;
|
|
891
|
+
try {
|
|
892
|
+
await reader.cancel();
|
|
893
|
+
} catch {
|
|
894
|
+
// Ignore cancellation errors while closing the pipe after exit/abort.
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
await done;
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
async function finalizeShellStreamCaptures(
|
|
903
|
+
captures: readonly ShellStreamCapture[],
|
|
904
|
+
): Promise<void> {
|
|
905
|
+
const pending = captures
|
|
906
|
+
.filter((capture) => !capture.isFinished())
|
|
907
|
+
.map((capture) => capture.done);
|
|
908
|
+
|
|
909
|
+
if (pending.length > 0) {
|
|
910
|
+
await new Promise<void>((resolve) => {
|
|
911
|
+
const timer = setTimeout(resolve, SHELL_STREAM_DRAIN_TIMEOUT_MS);
|
|
912
|
+
void Promise.allSettled(pending).then(() => {
|
|
913
|
+
clearTimeout(timer);
|
|
914
|
+
resolve();
|
|
915
|
+
});
|
|
916
|
+
});
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
await Promise.all(captures.map((capture) => capture.close()));
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function buildShellSpawnOptions(cwd: string): Parameters<typeof Bun.spawn>[1] {
|
|
923
|
+
return {
|
|
924
|
+
cwd,
|
|
925
|
+
stdout: "pipe",
|
|
926
|
+
stderr: "pipe",
|
|
927
|
+
...(process.platform === "win32" ? {} : { detached: true }),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
function abortShellProcess(proc: ShellProcess): void {
|
|
932
|
+
if (proc.killed || proc.exitCode !== null) {
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (process.platform !== "win32") {
|
|
937
|
+
try {
|
|
938
|
+
process.kill(-proc.pid, "SIGTERM");
|
|
939
|
+
return;
|
|
940
|
+
} catch {
|
|
941
|
+
// Fall through to a direct kill when the process group is unavailable.
|
|
854
942
|
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
proc.kill("SIGTERM");
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function registerShellAbort(
|
|
949
|
+
signal: AbortSignal | undefined,
|
|
950
|
+
proc: ShellProcess,
|
|
951
|
+
): (() => void) | null {
|
|
952
|
+
if (!signal) {
|
|
953
|
+
return null;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
const abortListener = (): void => {
|
|
957
|
+
abortShellProcess(proc);
|
|
958
|
+
};
|
|
855
959
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
960
|
+
if (signal.aborted) {
|
|
961
|
+
abortShellProcess(proc);
|
|
962
|
+
return null;
|
|
859
963
|
}
|
|
964
|
+
|
|
965
|
+
signal.addEventListener("abort", abortListener, { once: true });
|
|
966
|
+
return () => {
|
|
967
|
+
signal.removeEventListener("abort", abortListener);
|
|
968
|
+
};
|
|
860
969
|
}
|
|
861
970
|
|
|
862
971
|
/**
|
|
@@ -880,22 +989,13 @@ export async function executeShell(
|
|
|
880
989
|
const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
|
|
881
990
|
const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
882
991
|
let updateTimer: ReturnType<typeof setTimeout> | null = null;
|
|
992
|
+
let cleanupAbort: (() => void) | null = null;
|
|
993
|
+
let lastReportedOutput = "";
|
|
994
|
+
let lastReportAt = 0;
|
|
995
|
+
let stdoutCapture: ShellStreamCapture | null = null;
|
|
996
|
+
let stderrCapture: ShellStreamCapture | null = null;
|
|
883
997
|
|
|
884
998
|
try {
|
|
885
|
-
const spawnOpts: Parameters<typeof Bun.spawn>[1] = {
|
|
886
|
-
cwd,
|
|
887
|
-
stdout: "pipe",
|
|
888
|
-
stderr: "pipe",
|
|
889
|
-
};
|
|
890
|
-
if (opts?.signal) spawnOpts.signal = opts.signal;
|
|
891
|
-
const command = normalizeShellCommand(args.command);
|
|
892
|
-
const proc = Bun.spawn([shell, "-c", command], spawnOpts);
|
|
893
|
-
|
|
894
|
-
let stdoutBuf = "";
|
|
895
|
-
let stderrBuf = "";
|
|
896
|
-
let lastReportedOutput = "";
|
|
897
|
-
let lastReportAt = 0;
|
|
898
|
-
|
|
899
999
|
const clearPendingUpdate = (): void => {
|
|
900
1000
|
if (updateTimer) {
|
|
901
1001
|
clearTimeout(updateTimer);
|
|
@@ -903,9 +1003,14 @@ export async function executeShell(
|
|
|
903
1003
|
}
|
|
904
1004
|
};
|
|
905
1005
|
|
|
906
|
-
const
|
|
1006
|
+
const buildOutput = (trimEnd: boolean): string => {
|
|
1007
|
+
const stdout = stdoutCapture?.getOutput() ?? "";
|
|
1008
|
+
const stderr = stderrCapture?.getOutput() ?? "";
|
|
907
1009
|
return truncateOutput(
|
|
908
|
-
formatShellOutput(
|
|
1010
|
+
formatShellOutput(
|
|
1011
|
+
trimEnd ? stdout.trimEnd() : stdout,
|
|
1012
|
+
trimEnd ? stderr.trimEnd() : stderr,
|
|
1013
|
+
),
|
|
909
1014
|
maxLines,
|
|
910
1015
|
maxBytes,
|
|
911
1016
|
);
|
|
@@ -917,7 +1022,7 @@ export async function executeShell(
|
|
|
917
1022
|
return;
|
|
918
1023
|
}
|
|
919
1024
|
|
|
920
|
-
const output =
|
|
1025
|
+
const output = buildOutput(false);
|
|
921
1026
|
if (!output || output === lastReportedOutput) {
|
|
922
1027
|
return;
|
|
923
1028
|
}
|
|
@@ -946,24 +1051,29 @@ export async function executeShell(
|
|
|
946
1051
|
}, SHELL_UPDATE_INTERVAL_MS - elapsed);
|
|
947
1052
|
};
|
|
948
1053
|
|
|
949
|
-
const
|
|
950
|
-
|
|
951
|
-
|
|
1054
|
+
const command = normalizeShellCommand(args.command);
|
|
1055
|
+
const proc = Bun.spawn([shell, "-c", command], buildShellSpawnOptions(cwd));
|
|
1056
|
+
cleanupAbort = registerShellAbort(opts?.signal, proc);
|
|
1057
|
+
stdoutCapture = startShellStreamCapture(
|
|
1058
|
+
proc.stdout as ReadableStream<Uint8Array>,
|
|
1059
|
+
() => {
|
|
952
1060
|
scheduleUpdate();
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
|
|
1061
|
+
},
|
|
1062
|
+
);
|
|
1063
|
+
stderrCapture = startShellStreamCapture(
|
|
1064
|
+
proc.stderr as ReadableStream<Uint8Array>,
|
|
1065
|
+
() => {
|
|
956
1066
|
scheduleUpdate();
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
|
|
1067
|
+
},
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
const exitCode = await proc.exited;
|
|
1071
|
+
cleanupAbort?.();
|
|
1072
|
+
cleanupAbort = null;
|
|
960
1073
|
|
|
1074
|
+
await finalizeShellStreamCaptures([stdoutCapture, stderrCapture]);
|
|
961
1075
|
clearPendingUpdate();
|
|
962
|
-
const output =
|
|
963
|
-
formatShellOutput(stdout.trimEnd(), stderr.trimEnd()),
|
|
964
|
-
maxLines,
|
|
965
|
-
maxBytes,
|
|
966
|
-
);
|
|
1076
|
+
const output = buildOutput(true);
|
|
967
1077
|
if (opts?.onUpdate && output && output !== lastReportedOutput) {
|
|
968
1078
|
lastReportedOutput = output;
|
|
969
1079
|
opts.onUpdate(textResult(output, false));
|
|
@@ -973,6 +1083,14 @@ export async function executeShell(
|
|
|
973
1083
|
const body = output || "(no output)";
|
|
974
1084
|
return textResult(`Exit code: ${exitCode}\n${body}`, isError);
|
|
975
1085
|
} catch (err) {
|
|
1086
|
+
cleanupAbort?.();
|
|
1087
|
+
cleanupAbort = null;
|
|
1088
|
+
const captures = [stdoutCapture, stderrCapture].filter(
|
|
1089
|
+
(capture): capture is ShellStreamCapture => capture !== null,
|
|
1090
|
+
);
|
|
1091
|
+
if (captures.length > 0) {
|
|
1092
|
+
await Promise.allSettled(captures.map((capture) => capture.close()));
|
|
1093
|
+
}
|
|
976
1094
|
if (updateTimer) {
|
|
977
1095
|
clearTimeout(updateTimer);
|
|
978
1096
|
updateTimer = null;
|
package/src/ui/agent.ts
CHANGED
|
@@ -13,7 +13,11 @@ import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
|
13
13
|
import type { AgentEvent } from "../agent.ts";
|
|
14
14
|
import { getErrorMessage } from "../errors.ts";
|
|
15
15
|
import type { AppState } from "../index.ts";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
queueResolvedInput,
|
|
18
|
+
resolveRawInput,
|
|
19
|
+
submitResolvedInput,
|
|
20
|
+
} from "../submit.ts";
|
|
17
21
|
import type {
|
|
18
22
|
PendingToolResult,
|
|
19
23
|
StreamingConversationState,
|
|
@@ -103,7 +107,12 @@ export function createUiAgentController(
|
|
|
103
107
|
break;
|
|
104
108
|
}
|
|
105
109
|
|
|
106
|
-
if (
|
|
110
|
+
if (state.running) {
|
|
111
|
+
queueResolvedInput(rawInput, resolved.content, state);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!state.model) {
|
|
107
116
|
return;
|
|
108
117
|
}
|
|
109
118
|
|
|
@@ -154,6 +163,11 @@ export function createUiAgentController(
|
|
|
154
163
|
runtime.render();
|
|
155
164
|
break;
|
|
156
165
|
|
|
166
|
+
case "user_message":
|
|
167
|
+
runtime.scrollConversationToBottom();
|
|
168
|
+
runtime.render();
|
|
169
|
+
break;
|
|
170
|
+
|
|
157
171
|
case "assistant_message":
|
|
158
172
|
streamingContent = [];
|
|
159
173
|
runtime.render();
|
package/src/ui/commands.test.ts
CHANGED
|
@@ -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
|
);
|
package/src/ui/input.test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { DEFAULT_THEME } from "../theme.ts";
|
|
6
6
|
import {
|
|
7
7
|
autocompleteInputPath,
|
|
8
|
+
findInputPathMatches,
|
|
8
9
|
type InputController,
|
|
9
10
|
renderInputArea,
|
|
10
11
|
} from "./input.ts";
|
|
@@ -60,7 +61,7 @@ describe("ui/input", () => {
|
|
|
60
61
|
expect(placeholder.props.italic).toBe(true);
|
|
61
62
|
});
|
|
62
63
|
|
|
63
|
-
test("autocompleteInputPath completes the last file path token", () => {
|
|
64
|
+
test("autocompleteInputPath completes the last file path token when exactly one match is available", () => {
|
|
64
65
|
const cwd = createTempDir();
|
|
65
66
|
mkdirSync(join(cwd, "src"), { recursive: true });
|
|
66
67
|
writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
|
|
@@ -70,6 +71,33 @@ describe("ui/input", () => {
|
|
|
70
71
|
);
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
test("autocompleteInputPath returns null when multiple matches are available", () => {
|
|
75
|
+
const cwd = createTempDir();
|
|
76
|
+
mkdirSync(join(cwd, "src"), { recursive: true });
|
|
77
|
+
writeFileSync(join(cwd, "src", "ui.ts"), "", "utf-8");
|
|
78
|
+
writeFileSync(join(cwd, "src", "utils.ts"), "", "utf-8");
|
|
79
|
+
|
|
80
|
+
expect(autocompleteInputPath("inspect src/u", cwd)).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("findInputPathMatches returns sorted selectable matches for the last file path token", () => {
|
|
84
|
+
const cwd = createTempDir();
|
|
85
|
+
mkdirSync(join(cwd, "src"), { recursive: true });
|
|
86
|
+
writeFileSync(join(cwd, "src", "alpine.ts"), "", "utf-8");
|
|
87
|
+
writeFileSync(join(cwd, "src", "alpha.ts"), "", "utf-8");
|
|
88
|
+
|
|
89
|
+
expect(findInputPathMatches("inspect src/al", cwd)).toEqual([
|
|
90
|
+
{
|
|
91
|
+
label: "src/alpha.ts",
|
|
92
|
+
value: "inspect src/alpha.ts",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
label: "src/alpine.ts",
|
|
96
|
+
value: "inspect src/alpine.ts",
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
});
|
|
100
|
+
|
|
73
101
|
test("autocompleteInputPath returns null when no completion is available", () => {
|
|
74
102
|
const cwd = createTempDir();
|
|
75
103
|
|