mini-coder 0.5.6 → 0.5.8

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/src/tools.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /**
2
- * Built-in tool implementations: `edit`, `shell`, and `readImage`.
2
+ * Built-in tool implementations: `edit`, `shell`, `todoWrite`, `todoRead`,
3
+ * and `readImage`.
3
4
  *
4
5
  * Each tool is exposed as a pure-ish execute function that takes typed
5
6
  * arguments and a working directory, returning a result object. The pi-ai
@@ -11,7 +12,13 @@
11
12
 
12
13
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
14
  import { dirname, extname, isAbsolute, join } from "node:path";
14
- import type { ImageContent, TextContent, Tool } from "@mariozechner/pi-ai";
15
+ import type {
16
+ ImageContent,
17
+ Message,
18
+ TextContent,
19
+ Tool,
20
+ ToolResultMessage,
21
+ } from "@mariozechner/pi-ai";
15
22
  import { Type } from "@mariozechner/pi-ai";
16
23
  import type { ToolUpdateCallback } from "./agent.ts";
17
24
 
@@ -37,6 +44,191 @@ function textResult(text: string, isError: boolean): ToolExecResult {
37
44
  return { content: [{ type: "text", text }], isError };
38
45
  }
39
46
 
47
+ /** Persisted todo status values shown to the user and stored in snapshots. */
48
+ export type TodoStatus = "pending" | "in_progress" | "completed";
49
+
50
+ /** Todo status values accepted by `todoWrite`. */
51
+ export type TodoWriteStatus = TodoStatus | "cancelled";
52
+
53
+ /** A single persisted todo item. */
54
+ export interface TodoItem {
55
+ /** Task description shown in the checklist. */
56
+ content: string;
57
+ /** Current persisted task status. */
58
+ status: TodoStatus;
59
+ }
60
+
61
+ interface TodoWriteInputItem {
62
+ /** Task description used as the matching key. */
63
+ content: string;
64
+ /** Requested next status for the task. */
65
+ status: TodoWriteStatus;
66
+ }
67
+
68
+ /** Arguments for the `todoWrite` tool. */
69
+ export interface TodoWriteArgs {
70
+ /** Todo items to create, update, or remove. */
71
+ todos: TodoWriteInputItem[];
72
+ }
73
+
74
+ const MAX_TODO_CONTENT_LENGTH = 1_000;
75
+
76
+ type TodoHistoryMessage = Message | { role: "ui" };
77
+
78
+ function isTodoStatus(value: unknown): value is TodoStatus {
79
+ return (
80
+ value === "pending" || value === "in_progress" || value === "completed"
81
+ );
82
+ }
83
+
84
+ function isTodoWriteStatus(value: unknown): value is TodoWriteStatus {
85
+ return value === "cancelled" || isTodoStatus(value);
86
+ }
87
+
88
+ function cloneTodoItems(todos: readonly TodoItem[]): TodoItem[] {
89
+ return todos.map((todo) => ({ ...todo }));
90
+ }
91
+
92
+ function getToolResultText(content: ToolResultMessage["content"]): string {
93
+ return content
94
+ .filter((entry): entry is TextContent => entry.type === "text")
95
+ .map((entry) => entry.text)
96
+ .join("\n");
97
+ }
98
+
99
+ /** Serialize a full todo snapshot for storage in a tool result. */
100
+ export function formatTodoSnapshot(todos: readonly TodoItem[]): string {
101
+ return JSON.stringify({ todos: cloneTodoItems(todos) }, null, 2);
102
+ }
103
+
104
+ /** Parse a serialized todo snapshot from tool-result text. */
105
+ export function parseTodoSnapshot(text: string): TodoItem[] | null {
106
+ let parsed: unknown;
107
+ try {
108
+ parsed = JSON.parse(text) as unknown;
109
+ } catch {
110
+ return null;
111
+ }
112
+
113
+ if (
114
+ typeof parsed !== "object" ||
115
+ parsed === null ||
116
+ !Array.isArray((parsed as { todos?: unknown }).todos)
117
+ ) {
118
+ return null;
119
+ }
120
+
121
+ const todos = (parsed as { todos: unknown[] }).todos;
122
+ if (
123
+ !todos.every((todo) => {
124
+ return (
125
+ typeof todo === "object" &&
126
+ todo !== null &&
127
+ typeof (todo as { content?: unknown }).content === "string" &&
128
+ isTodoStatus((todo as { status?: unknown }).status)
129
+ );
130
+ })
131
+ ) {
132
+ return null;
133
+ }
134
+
135
+ return todos.map((todo) => ({
136
+ content: (todo as { content: string }).content,
137
+ status: (todo as { status: TodoStatus }).status,
138
+ }));
139
+ }
140
+
141
+ function getTodoSnapshotFromToolResult(
142
+ message: ToolResultMessage,
143
+ ): TodoItem[] | null {
144
+ if (message.isError) {
145
+ return null;
146
+ }
147
+ if (message.toolName !== "todoWrite" && message.toolName !== "todoRead") {
148
+ return null;
149
+ }
150
+ return parseTodoSnapshot(getToolResultText(message.content));
151
+ }
152
+
153
+ /** Return the current todo list derived from persisted message history. */
154
+ export function getTodoItems(
155
+ messages: readonly TodoHistoryMessage[],
156
+ ): TodoItem[] {
157
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
158
+ const message = messages[index];
159
+ if (!message || message.role !== "toolResult") {
160
+ continue;
161
+ }
162
+
163
+ const snapshot = getTodoSnapshotFromToolResult(message);
164
+ if (snapshot) {
165
+ return snapshot;
166
+ }
167
+ }
168
+
169
+ return [];
170
+ }
171
+
172
+ function validateTodoContent(content: string): string | null {
173
+ if (content.trim().length === 0) {
174
+ return "Todo content cannot be empty";
175
+ }
176
+ if (content.length > MAX_TODO_CONTENT_LENGTH) {
177
+ return `Todo content exceeds maximum length of ${MAX_TODO_CONTENT_LENGTH} characters`;
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /** Apply incremental todo changes and return the new full snapshot. */
183
+ export function executeTodoWrite(
184
+ args: TodoWriteArgs,
185
+ messages: readonly TodoHistoryMessage[],
186
+ ): ToolExecResult {
187
+ const nextTodos = cloneTodoItems(getTodoItems(messages));
188
+
189
+ for (const todo of args.todos) {
190
+ const validationError = validateTodoContent(todo.content);
191
+ if (validationError) {
192
+ return textResult(validationError, true);
193
+ }
194
+ if (!isTodoWriteStatus(todo.status)) {
195
+ return textResult(`Invalid todo status: ${String(todo.status)}`, true);
196
+ }
197
+
198
+ if (todo.status === "cancelled") {
199
+ const index = nextTodos.findIndex(
200
+ (existingTodo) => existingTodo.content === todo.content,
201
+ );
202
+ if (index !== -1) {
203
+ nextTodos.splice(index, 1);
204
+ }
205
+ continue;
206
+ }
207
+
208
+ const existingTodo = nextTodos.find(
209
+ (candidate) => candidate.content === todo.content,
210
+ );
211
+ if (existingTodo) {
212
+ existingTodo.status = todo.status;
213
+ continue;
214
+ }
215
+
216
+ nextTodos.push({
217
+ content: todo.content,
218
+ status: todo.status,
219
+ });
220
+ }
221
+
222
+ return textResult(formatTodoSnapshot(nextTodos), false);
223
+ }
224
+
225
+ /** Return the current full todo snapshot without mutating it. */
226
+ export function executeTodoRead(
227
+ messages: readonly TodoHistoryMessage[],
228
+ ): ToolExecResult {
229
+ return textResult(formatTodoSnapshot(getTodoItems(messages)), false);
230
+ }
231
+
40
232
  function detectLineEnding(content: string): "\n" | "\r\n" | null {
41
233
  if (content.includes("\r\n")) {
42
234
  return "\r\n";
@@ -490,9 +682,12 @@ interface ShellOpts {
490
682
  onUpdate?: ToolUpdateCallback;
491
683
  }
492
684
 
685
+ type ShellProcess = ReturnType<typeof Bun.spawn>;
686
+
493
687
  const DEFAULT_MAX_LINES = 1000;
494
688
  const DEFAULT_MAX_BYTES = 50_000;
495
689
  const SHELL_UPDATE_INTERVAL_MS = 75;
690
+ const SHELL_STREAM_DRAIN_TIMEOUT_MS = 25;
496
691
 
497
692
  /** Format combined stdout/stderr for display in tool results. */
498
693
  function formatShellOutput(stdout: string, stderr: string): string {
@@ -837,26 +1032,132 @@ function normalizeShellCommand(command: string): string {
837
1032
  }
838
1033
  }
839
1034
 
840
- /** Read a spawned shell stream into a string, reporting progressive updates. */
841
- async function consumeShellStream(
1035
+ interface ShellStreamCapture {
1036
+ done: Promise<void>;
1037
+ getOutput: () => string;
1038
+ isFinished: () => boolean;
1039
+ close: () => Promise<void>;
1040
+ }
1041
+
1042
+ function startShellStreamCapture(
842
1043
  stream: ReadableStream<Uint8Array>,
843
1044
  onChunk: (chunk: string) => void,
844
- ): Promise<string> {
1045
+ ): ShellStreamCapture {
845
1046
  const reader = stream.getReader();
846
1047
  const decoder = new TextDecoder();
847
1048
  let output = "";
1049
+ let closed = false;
1050
+ let finished = false;
1051
+
1052
+ const done = (async (): Promise<void> => {
1053
+ try {
1054
+ while (true) {
1055
+ const { done, value } = await reader.read();
1056
+ if (done) {
1057
+ break;
1058
+ }
1059
+
1060
+ const chunk = decoder.decode(value, { stream: true });
1061
+ output += chunk;
1062
+ onChunk(chunk);
1063
+ }
1064
+ } catch (error) {
1065
+ if (!closed) {
1066
+ throw error;
1067
+ }
1068
+ } finally {
1069
+ const trailing = decoder.decode();
1070
+ output += trailing;
1071
+ onChunk(trailing);
1072
+ finished = true;
1073
+ }
1074
+ })();
848
1075
 
849
- while (true) {
850
- const { done, value } = await reader.read();
851
- if (done) {
852
- output += decoder.decode();
853
- return output;
1076
+ return {
1077
+ done,
1078
+ getOutput: () => output,
1079
+ isFinished: () => finished,
1080
+ close: async (): Promise<void> => {
1081
+ if (!finished) {
1082
+ closed = true;
1083
+ try {
1084
+ await reader.cancel();
1085
+ } catch {
1086
+ // Ignore cancellation errors while closing the pipe after exit/abort.
1087
+ }
1088
+ }
1089
+ await done;
1090
+ },
1091
+ };
1092
+ }
1093
+
1094
+ async function finalizeShellStreamCaptures(
1095
+ captures: readonly ShellStreamCapture[],
1096
+ ): Promise<void> {
1097
+ const pending = captures
1098
+ .filter((capture) => !capture.isFinished())
1099
+ .map((capture) => capture.done);
1100
+
1101
+ if (pending.length > 0) {
1102
+ await new Promise<void>((resolve) => {
1103
+ const timer = setTimeout(resolve, SHELL_STREAM_DRAIN_TIMEOUT_MS);
1104
+ void Promise.allSettled(pending).then(() => {
1105
+ clearTimeout(timer);
1106
+ resolve();
1107
+ });
1108
+ });
1109
+ }
1110
+
1111
+ await Promise.all(captures.map((capture) => capture.close()));
1112
+ }
1113
+
1114
+ function buildShellSpawnOptions(cwd: string): Parameters<typeof Bun.spawn>[1] {
1115
+ return {
1116
+ cwd,
1117
+ stdout: "pipe",
1118
+ stderr: "pipe",
1119
+ ...(process.platform === "win32" ? {} : { detached: true }),
1120
+ };
1121
+ }
1122
+
1123
+ function abortShellProcess(proc: ShellProcess): void {
1124
+ if (proc.killed || proc.exitCode !== null) {
1125
+ return;
1126
+ }
1127
+
1128
+ if (process.platform !== "win32") {
1129
+ try {
1130
+ process.kill(-proc.pid, "SIGTERM");
1131
+ return;
1132
+ } catch {
1133
+ // Fall through to a direct kill when the process group is unavailable.
854
1134
  }
1135
+ }
1136
+
1137
+ proc.kill("SIGTERM");
1138
+ }
1139
+
1140
+ function registerShellAbort(
1141
+ signal: AbortSignal | undefined,
1142
+ proc: ShellProcess,
1143
+ ): (() => void) | null {
1144
+ if (!signal) {
1145
+ return null;
1146
+ }
1147
+
1148
+ const abortListener = (): void => {
1149
+ abortShellProcess(proc);
1150
+ };
855
1151
 
856
- const chunk = decoder.decode(value, { stream: true });
857
- output += chunk;
858
- onChunk(chunk);
1152
+ if (signal.aborted) {
1153
+ abortShellProcess(proc);
1154
+ return null;
859
1155
  }
1156
+
1157
+ signal.addEventListener("abort", abortListener, { once: true });
1158
+ return () => {
1159
+ signal.removeEventListener("abort", abortListener);
1160
+ };
860
1161
  }
861
1162
 
862
1163
  /**
@@ -880,22 +1181,13 @@ export async function executeShell(
880
1181
  const maxLines = opts?.maxLines ?? DEFAULT_MAX_LINES;
881
1182
  const maxBytes = opts?.maxBytes ?? DEFAULT_MAX_BYTES;
882
1183
  let updateTimer: ReturnType<typeof setTimeout> | null = null;
1184
+ let cleanupAbort: (() => void) | null = null;
1185
+ let lastReportedOutput = "";
1186
+ let lastReportAt = 0;
1187
+ let stdoutCapture: ShellStreamCapture | null = null;
1188
+ let stderrCapture: ShellStreamCapture | null = null;
883
1189
 
884
1190
  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
1191
  const clearPendingUpdate = (): void => {
900
1192
  if (updateTimer) {
901
1193
  clearTimeout(updateTimer);
@@ -903,9 +1195,14 @@ export async function executeShell(
903
1195
  }
904
1196
  };
905
1197
 
906
- const buildProgressOutput = (): string => {
1198
+ const buildOutput = (trimEnd: boolean): string => {
1199
+ const stdout = stdoutCapture?.getOutput() ?? "";
1200
+ const stderr = stderrCapture?.getOutput() ?? "";
907
1201
  return truncateOutput(
908
- formatShellOutput(stdoutBuf, stderrBuf),
1202
+ formatShellOutput(
1203
+ trimEnd ? stdout.trimEnd() : stdout,
1204
+ trimEnd ? stderr.trimEnd() : stderr,
1205
+ ),
909
1206
  maxLines,
910
1207
  maxBytes,
911
1208
  );
@@ -917,7 +1214,7 @@ export async function executeShell(
917
1214
  return;
918
1215
  }
919
1216
 
920
- const output = buildProgressOutput();
1217
+ const output = buildOutput(false);
921
1218
  if (!output || output === lastReportedOutput) {
922
1219
  return;
923
1220
  }
@@ -946,24 +1243,29 @@ export async function executeShell(
946
1243
  }, SHELL_UPDATE_INTERVAL_MS - elapsed);
947
1244
  };
948
1245
 
949
- const [stdout, stderr, exitCode] = await Promise.all([
950
- consumeShellStream(proc.stdout as ReadableStream<Uint8Array>, (chunk) => {
951
- stdoutBuf += chunk;
1246
+ const command = normalizeShellCommand(args.command);
1247
+ const proc = Bun.spawn([shell, "-c", command], buildShellSpawnOptions(cwd));
1248
+ cleanupAbort = registerShellAbort(opts?.signal, proc);
1249
+ stdoutCapture = startShellStreamCapture(
1250
+ proc.stdout as ReadableStream<Uint8Array>,
1251
+ () => {
952
1252
  scheduleUpdate();
953
- }),
954
- consumeShellStream(proc.stderr as ReadableStream<Uint8Array>, (chunk) => {
955
- stderrBuf += chunk;
1253
+ },
1254
+ );
1255
+ stderrCapture = startShellStreamCapture(
1256
+ proc.stderr as ReadableStream<Uint8Array>,
1257
+ () => {
956
1258
  scheduleUpdate();
957
- }),
958
- proc.exited,
959
- ]);
1259
+ },
1260
+ );
960
1261
 
1262
+ const exitCode = await proc.exited;
1263
+ cleanupAbort?.();
1264
+ cleanupAbort = null;
1265
+
1266
+ await finalizeShellStreamCaptures([stdoutCapture, stderrCapture]);
961
1267
  clearPendingUpdate();
962
- const output = truncateOutput(
963
- formatShellOutput(stdout.trimEnd(), stderr.trimEnd()),
964
- maxLines,
965
- maxBytes,
966
- );
1268
+ const output = buildOutput(true);
967
1269
  if (opts?.onUpdate && output && output !== lastReportedOutput) {
968
1270
  lastReportedOutput = output;
969
1271
  opts.onUpdate(textResult(output, false));
@@ -973,6 +1275,14 @@ export async function executeShell(
973
1275
  const body = output || "(no output)";
974
1276
  return textResult(`Exit code: ${exitCode}\n${body}`, isError);
975
1277
  } catch (err) {
1278
+ cleanupAbort?.();
1279
+ cleanupAbort = null;
1280
+ const captures = [stdoutCapture, stderrCapture].filter(
1281
+ (capture): capture is ShellStreamCapture => capture !== null,
1282
+ );
1283
+ if (captures.length > 0) {
1284
+ await Promise.allSettled(captures.map((capture) => capture.close()));
1285
+ }
976
1286
  if (updateTimer) {
977
1287
  clearTimeout(updateTimer);
978
1288
  updateTimer = null;
@@ -1212,6 +1522,53 @@ export const editTool: Tool = {
1212
1522
  }),
1213
1523
  };
1214
1524
 
1525
+ /** pi-ai tool definition for `todoWrite`. */
1526
+ export const todoWriteTool: Tool = {
1527
+ name: "todoWrite",
1528
+ description:
1529
+ "Use this tool to create and manage a structured task list for your current coding session. " +
1530
+ "This helps you track progress, organize complex tasks, and keep the user informed. " +
1531
+ "Only send the items that changed; unchanged items stay as they are. " +
1532
+ "Each item must include `content` and `status`, where `status` is one of `pending`, `in_progress`, `completed`, or `cancelled`. " +
1533
+ "Use `cancelled` to remove an item from the list. " +
1534
+ "Mark tasks `in_progress` before starting them and `completed` immediately after verification succeeds.",
1535
+ parameters: Type.Object({
1536
+ todos: Type.Array(
1537
+ Type.Object({
1538
+ content: Type.String({
1539
+ description: "Task description used as the matching key",
1540
+ }),
1541
+ status: Type.Union(
1542
+ [
1543
+ Type.Literal("pending"),
1544
+ Type.Literal("in_progress"),
1545
+ Type.Literal("completed"),
1546
+ Type.Literal("cancelled"),
1547
+ ],
1548
+ {
1549
+ description:
1550
+ "Task status. Use `cancelled` to remove the item entirely.",
1551
+ },
1552
+ ),
1553
+ }),
1554
+ {
1555
+ description:
1556
+ "List of todo items to create, update, or remove. Only send the items that changed.",
1557
+ },
1558
+ ),
1559
+ }),
1560
+ };
1561
+
1562
+ /** pi-ai tool definition for `todoRead`. */
1563
+ export const todoReadTool: Tool = {
1564
+ name: "todoRead",
1565
+ description:
1566
+ "Retrieves the current todo list for this coding session. " +
1567
+ "Use this tool before updating todos when you need to inspect the current list, or when the user asks for the current plan or progress. " +
1568
+ "If no todos exist yet, it returns an empty list.",
1569
+ parameters: Type.Object({}),
1570
+ };
1571
+
1215
1572
  /** pi-ai tool definition for `shell`. */
1216
1573
  export const shellTool: Tool = {
1217
1574
  name: "shell",
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 { resolveRawInput, submitResolvedInput } from "../submit.ts";
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 (!state.model || state.running) {
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();