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/README.md +5 -2
- package/bun.lock +13 -13
- package/package.json +3 -3
- package/src/agent.ts +299 -41
- package/src/cli.ts +11 -2
- package/src/headless.ts +185 -47
- package/src/index.ts +105 -12
- package/src/input.ts +1 -0
- package/src/prompt.ts +86 -78
- package/src/session.ts +56 -8
- package/src/submit.ts +53 -11
- package/src/tools.ts +401 -44
- package/src/ui/agent.ts +16 -2
- package/src/ui/commands.test.ts +72 -0
- package/src/ui/commands.ts +13 -0
- package/src/ui/conversation.test.ts +91 -0
- package/src/ui/conversation.ts +122 -3
- package/src/ui/help.test.ts +1 -0
- package/src/ui/help.ts +1 -0
- package/src/ui/input.test.ts +29 -1
- package/src/ui/input.ts +45 -51
- package/src/ui/render-performance.test.ts +1 -0
- package/src/ui/status.test.ts +1 -0
- package/src/ui.ts +85 -18
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Built-in tool implementations: `edit`, `shell`,
|
|
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 {
|
|
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
|
-
|
|
841
|
-
|
|
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
|
-
):
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
|
1198
|
+
const buildOutput = (trimEnd: boolean): string => {
|
|
1199
|
+
const stdout = stdoutCapture?.getOutput() ?? "";
|
|
1200
|
+
const stderr = stderrCapture?.getOutput() ?? "";
|
|
907
1201
|
return truncateOutput(
|
|
908
|
-
formatShellOutput(
|
|
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 =
|
|
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
|
|
950
|
-
|
|
951
|
-
|
|
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
|
-
|
|
955
|
-
|
|
1253
|
+
},
|
|
1254
|
+
);
|
|
1255
|
+
stderrCapture = startShellStreamCapture(
|
|
1256
|
+
proc.stderr as ReadableStream<Uint8Array>,
|
|
1257
|
+
() => {
|
|
956
1258
|
scheduleUpdate();
|
|
957
|
-
}
|
|
958
|
-
|
|
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 =
|
|
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 {
|
|
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();
|