mini-coder 0.6.2 → 0.6.3
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/agent.ts +11 -1
- package/src/git.ts +23 -0
- package/src/index.ts +3 -8
- package/src/prompt.ts +3 -8
- package/src/session.ts +6 -4
- package/src/tool-bash.ts +2 -20
- package/src/tui-conversation.ts +104 -214
- package/src/tui.ts +83 -27
- package/src/types.ts +17 -1
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type ToolResultMessage,
|
|
10
10
|
} from "@earendil-works/pi-ai";
|
|
11
11
|
import { getApiKey } from "./oauth";
|
|
12
|
+
import { estimateTokens } from "./shared";
|
|
12
13
|
import type {
|
|
13
14
|
AgentContex,
|
|
14
15
|
AgentEvent,
|
|
@@ -77,6 +78,10 @@ export async function* streamAgent(
|
|
|
77
78
|
|
|
78
79
|
// Main agent loop, continues until llm sends a response other than toolCall or has no tool calls.
|
|
79
80
|
while (true) {
|
|
81
|
+
let estimate = estimateTokens(JSON.stringify(llmCtx));
|
|
82
|
+
// 80k is the agreed uppon threshold to the DUMB ZONE
|
|
83
|
+
if (estimate > 80000) compactContext(llmCtx.messages);
|
|
84
|
+
|
|
80
85
|
const s = streamSimple(agentCtx.options.model, llmCtx, {
|
|
81
86
|
reasoning: agentCtx.options.effort,
|
|
82
87
|
signal: agentCtx.signal,
|
|
@@ -103,13 +108,18 @@ export async function* streamAgent(
|
|
|
103
108
|
case "thinking_end":
|
|
104
109
|
case "toolcall_start":
|
|
105
110
|
case "toolcall_delta":
|
|
106
|
-
case "toolcall_end":
|
|
111
|
+
case "toolcall_end": {
|
|
107
112
|
if (partial) {
|
|
108
113
|
partial = e.partial;
|
|
109
114
|
llmCtx.messages[llmCtx.messages.length - 1] = partial;
|
|
110
115
|
yield { type: "message_update", partial };
|
|
111
116
|
}
|
|
117
|
+
|
|
118
|
+
estimate = estimateTokens(JSON.stringify(llmCtx));
|
|
119
|
+
// 80k is the agreed uppon threshold to the DUMB ZONE
|
|
120
|
+
if (estimate > 80000) compactContext(llmCtx.messages);
|
|
112
121
|
break;
|
|
122
|
+
}
|
|
113
123
|
|
|
114
124
|
case "error": {
|
|
115
125
|
const finalMessage = await s.result();
|
package/src/git.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import simpleGit from "simple-git";
|
|
2
|
+
|
|
3
|
+
const git = simpleGit();
|
|
4
|
+
|
|
5
|
+
export async function getGitStatus() {
|
|
6
|
+
try {
|
|
7
|
+
const status = await git.status();
|
|
8
|
+
return status;
|
|
9
|
+
} catch (_) {
|
|
10
|
+
return { nogit: "No git repo in this folder." };
|
|
11
|
+
} // No git
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getBranchLabel() {
|
|
15
|
+
try {
|
|
16
|
+
const status = await git.status();
|
|
17
|
+
const isClean = status.isClean();
|
|
18
|
+
|
|
19
|
+
return `${status.current}${isClean ? "" : "*"}`;
|
|
20
|
+
} catch (_) {
|
|
21
|
+
return "No git";
|
|
22
|
+
} // No git
|
|
23
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { basename } from "node:path";
|
|
2
|
-
import simpleGit from "simple-git";
|
|
3
2
|
import { handleArgv } from "./args.ts";
|
|
3
|
+
import { getBranchLabel } from "./git.ts";
|
|
4
4
|
import { streamHeadless } from "./headless.ts";
|
|
5
5
|
import { initTUI } from "./tui.ts";
|
|
6
6
|
import type { TUIState } from "./types.ts";
|
|
@@ -23,18 +23,13 @@ export async function main(): Promise<void> {
|
|
|
23
23
|
options,
|
|
24
24
|
prompt: "",
|
|
25
25
|
messages: [],
|
|
26
|
+
tuiMessages: [],
|
|
26
27
|
streaming: false,
|
|
27
28
|
stickToBottom: true,
|
|
28
29
|
scrollOffset: 0,
|
|
29
30
|
cwd,
|
|
31
|
+
gitBranch: await getBranchLabel(),
|
|
30
32
|
};
|
|
31
33
|
|
|
32
|
-
const git = simpleGit();
|
|
33
|
-
try {
|
|
34
|
-
const gitStatus = (await git.status()).isClean() ? "" : "*";
|
|
35
|
-
const gitBranch = (await git.branch()).current;
|
|
36
|
-
state.gitBranch = `${gitBranch}${gitStatus}`;
|
|
37
|
-
} catch (_) {} // No git
|
|
38
|
-
|
|
39
34
|
initTUI(state, leave);
|
|
40
35
|
}
|
package/src/prompt.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { readdir } from "node:fs/promises";
|
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import type { Message, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
6
|
-
import
|
|
6
|
+
import { getGitStatus } from "./git";
|
|
7
7
|
import { parseSkillFrontmatter } from "./shared";
|
|
8
8
|
|
|
9
9
|
export const MAIN_PROMPT = `You are a coding agent interacting with users via the mini-coder harness. You help users by reading files, executing commands, and editting code.
|
|
@@ -36,12 +36,7 @@ async function getDir() {
|
|
|
36
36
|
|
|
37
37
|
async function getEnvPrompt() {
|
|
38
38
|
// TODO: What else do the agents always check before answering every time?
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
gitStatus = await simpleGit().status();
|
|
42
|
-
} catch (_) {
|
|
43
|
-
gitStatus = { nogit: "No git repo in this folder." };
|
|
44
|
-
}
|
|
39
|
+
const gitStatus = await getGitStatus();
|
|
45
40
|
const envKeys = ["PATH", "USER", "LANG", "HOME", "SHELL", "BUN_INSTALL"];
|
|
46
41
|
const env: Record<string, string> = {};
|
|
47
42
|
for (const key of envKeys) {
|
|
@@ -216,7 +211,7 @@ export function insertToolUsageReminder(
|
|
|
216
211
|
for (const c of calls) {
|
|
217
212
|
const args = JSON.stringify(c.arguments);
|
|
218
213
|
if (seenArgs.has(args)) sameToolCount++;
|
|
219
|
-
seenArgs.add(args)
|
|
214
|
+
seenArgs.add(args);
|
|
220
215
|
}
|
|
221
216
|
}
|
|
222
217
|
}
|
package/src/session.ts
CHANGED
|
@@ -5,8 +5,6 @@ import { Value } from "typebox/value";
|
|
|
5
5
|
import { SESSIONS_DIR } from "./shared";
|
|
6
6
|
import { type Session, SessionSchema } from "./types";
|
|
7
7
|
|
|
8
|
-
// TODO: sessions are json files in SESSIONS_DIR inside of DATA_DIR, use a 10 length `secureRandomString()` for the ids.
|
|
9
|
-
|
|
10
8
|
export async function ensureSessionsDir(): Promise<void> {
|
|
11
9
|
await mkdir(SESSIONS_DIR, { recursive: true });
|
|
12
10
|
}
|
|
@@ -71,8 +69,12 @@ export async function saveSession(s: Session) {
|
|
|
71
69
|
export async function updateSession(id: string, messages: Message[]) {
|
|
72
70
|
const existing = await getSession(id);
|
|
73
71
|
if (existing) {
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
// Only append new messages, so we don't save compacted messages.
|
|
73
|
+
if (existing.messages.length < messages.length) {
|
|
74
|
+
const newMessages = messages.slice(existing.messages.length);
|
|
75
|
+
existing.messages = [...existing.messages, ...newMessages];
|
|
76
|
+
await saveSession(existing);
|
|
77
|
+
}
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
78
80
|
|
package/src/tool-bash.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { type Tool, Type } from "@earendil-works/pi-ai";
|
|
2
|
-
import { secureRandomString } from "./shared";
|
|
3
2
|
import type { ToolRunnerEvent } from "./types";
|
|
4
3
|
|
|
5
|
-
const OUTPUT_THRESHOLD = 16000;
|
|
6
4
|
const description = `Bash CLI tool
|
|
7
5
|
|
|
8
6
|
Execute shell commands on the user's environment.
|
|
@@ -68,25 +66,9 @@ export async function* runBashTool(
|
|
|
68
66
|
|
|
69
67
|
const exitCode = await proc.exited;
|
|
70
68
|
|
|
71
|
-
let result =
|
|
69
|
+
let result = `${output.length ? output : "(no ouput)"}\n\nExit code: ${exitCode}`;
|
|
72
70
|
if (output.length) {
|
|
73
|
-
result +=
|
|
74
|
-
# OUTPUT:
|
|
75
|
-
|
|
76
|
-
${output}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// If `out` is too big, more than ~XXKB, write it to a temp file
|
|
80
|
-
// And add that to the truncation label for the agent to be able
|
|
81
|
-
// to continue the read with scans. This is to protect context,
|
|
82
|
-
// not a general read guard. The hint is for the agent, not the TUI
|
|
83
|
-
if (result.length > OUTPUT_THRESHOLD) {
|
|
84
|
-
const key = `${Date.now()}-${secureRandomString(4)}`;
|
|
85
|
-
const pathname = `/tmp/bash_result_${key}.txt`;
|
|
86
|
-
await Bun.write(pathname, result);
|
|
87
|
-
result = `${result.substring(0, OUTPUT_THRESHOLD)}
|
|
88
|
-
|
|
89
|
-
Truncated at ~${OUTPUT_THRESHOLD / 1000}KB. Full output at ${pathname}`;
|
|
71
|
+
result += `${output}`;
|
|
90
72
|
}
|
|
91
73
|
|
|
92
74
|
yield {
|
package/src/tui-conversation.ts
CHANGED
|
@@ -8,220 +8,10 @@ import type {
|
|
|
8
8
|
} from "@earendil-works/pi-ai";
|
|
9
9
|
import { estimateTokens, relativeTime } from "./shared";
|
|
10
10
|
import { TextPill, theme } from "./tui-components";
|
|
11
|
-
import type { TUIState } from "./types";
|
|
12
|
-
|
|
13
|
-
function agentMessageNode(msg: AssistantMessage): Node {
|
|
14
|
-
let thinking = "";
|
|
15
|
-
let text = "";
|
|
16
|
-
const toolCalls: Node[] = [];
|
|
17
|
-
|
|
18
|
-
for (const block of msg.content) {
|
|
19
|
-
if (block.type === "thinking" && block.thinking.length > 0) {
|
|
20
|
-
thinking += block.thinking;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
if (block.type === "text" && block.text.length > 0) {
|
|
24
|
-
text += block.text;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
if (block.type === "toolCall" && block.arguments && block.name) {
|
|
28
|
-
let text = "";
|
|
29
|
-
let node: Node | undefined;
|
|
30
|
-
if ("path" in block.arguments) {
|
|
31
|
-
text = block.arguments.path;
|
|
32
|
-
node = Text(text);
|
|
33
|
-
} else if ("command" in block.arguments) {
|
|
34
|
-
text = block.arguments.command;
|
|
35
|
-
node = SyntaxHighlight(text, "bash");
|
|
36
|
-
} else {
|
|
37
|
-
text = JSON.stringify(block.arguments);
|
|
38
|
-
node = Text(text);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
toolCalls.push(
|
|
42
|
-
VStack({ padding: { x: 4 }, gap: 1 }, [
|
|
43
|
-
TextPill(block.name, theme.white, theme.bblack),
|
|
44
|
-
node,
|
|
45
|
-
]),
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
const textBlocks: Node[] = [];
|
|
50
|
-
if (thinking.length > 0) {
|
|
51
|
-
const tokens = estimateTokens(thinking);
|
|
52
|
-
|
|
53
|
-
textBlocks.push(
|
|
54
|
-
Text(`Thinking... (~${tokens} tokens)`, {
|
|
55
|
-
fgColor: theme.bblack,
|
|
56
|
-
italic: true,
|
|
57
|
-
}),
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
if (text.length > 0) {
|
|
61
|
-
textBlocks.push(SyntaxHighlight(text, "markdown"));
|
|
62
|
-
}
|
|
63
|
-
if (toolCalls.length > 0) {
|
|
64
|
-
textBlocks.push(...toolCalls);
|
|
65
|
-
}
|
|
66
|
-
const error =
|
|
67
|
-
((msg.stopReason === "error" || msg.stopReason === "aborted") &&
|
|
68
|
-
msg.errorMessage) ??
|
|
69
|
-
"Unknown error.";
|
|
70
|
-
if (error) {
|
|
71
|
-
textBlocks.push(
|
|
72
|
-
VStack({ padding: { x: 4 }, gap: 1 }, [
|
|
73
|
-
TextPill(msg.stopReason, theme.white, theme.bblack),
|
|
74
|
-
Text(error),
|
|
75
|
-
]),
|
|
76
|
-
);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (textBlocks.length === 0) {
|
|
80
|
-
textBlocks.push(
|
|
81
|
-
Text("Loading...", {
|
|
82
|
-
fgColor: theme.bblack,
|
|
83
|
-
italic: true,
|
|
84
|
-
}),
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
return VStack({ gap: 1 }, textBlocks);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function userMessageNode(msg: UserMessage): Node {
|
|
92
|
-
let text = "";
|
|
93
|
-
if (typeof msg.content === "string") {
|
|
94
|
-
text = msg.content;
|
|
95
|
-
} else {
|
|
96
|
-
text = msg.content
|
|
97
|
-
.filter((b) => b.type === "text")
|
|
98
|
-
.map((b) => b.text)
|
|
99
|
-
.join("");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Remove any reminders we might have attached before render
|
|
103
|
-
// Keep this fast, it runs on the render cycle.
|
|
104
|
-
text = text
|
|
105
|
-
.replaceAll(/<system-reminder>[\s\S]*?<\/system-reminder>/g, "")
|
|
106
|
-
.trimStart();
|
|
107
|
-
|
|
108
|
-
return VStack(
|
|
109
|
-
{
|
|
110
|
-
padding: { x: 1, y: 1 },
|
|
111
|
-
bgColor: theme.bblack,
|
|
112
|
-
fgColor: theme.white,
|
|
113
|
-
},
|
|
114
|
-
[SyntaxHighlight(text, "markdown")],
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function toolMessageNode(msg: ToolResultMessage): Node {
|
|
119
|
-
// TODO: read tool image output
|
|
120
|
-
// Output only shows last 10 lines of scroll.
|
|
121
|
-
const text = msg.content
|
|
122
|
-
.filter((c) => c.type === "text")
|
|
123
|
-
.map((c) => c.text)
|
|
124
|
-
.join("")
|
|
125
|
-
.trim();
|
|
126
|
-
|
|
127
|
-
return VStack({ padding: { x: 4 }, gap: 1 }, [
|
|
128
|
-
Text(`~${estimateTokens(text)} tokens, ${text.split("\n").length} lines.`, {
|
|
129
|
-
fgColor: theme.bblack,
|
|
130
|
-
}),
|
|
131
|
-
VStack(
|
|
132
|
-
{
|
|
133
|
-
flex: 1,
|
|
134
|
-
maxHeight: msg.toolName === "edit" ? 20 : 10,
|
|
135
|
-
overflow: "scroll",
|
|
136
|
-
scrollOffset: Infinity,
|
|
137
|
-
onScroll: () => false,
|
|
138
|
-
},
|
|
139
|
-
[
|
|
140
|
-
msg.toolName === "edit"
|
|
141
|
-
? SyntaxHighlight(text, "diff")
|
|
142
|
-
: Text(text, { wrap: "word", fgColor: theme.white }),
|
|
143
|
-
],
|
|
144
|
-
),
|
|
145
|
-
]);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const messageBodyCache = new WeakMap<Message, { key: number; node: Node }>();
|
|
149
|
-
|
|
150
|
-
function messageCacheKey(msg: Message): number {
|
|
151
|
-
if (msg.role === "assistant") {
|
|
152
|
-
let key = 0;
|
|
153
|
-
for (const block of msg.content) {
|
|
154
|
-
if (block.type === "thinking") key += block.thinking.length;
|
|
155
|
-
if (block.type === "text") key += block.text.length;
|
|
156
|
-
if (block.type === "toolCall" && block.arguments)
|
|
157
|
-
key += JSON.stringify(block.arguments).length;
|
|
158
|
-
}
|
|
159
|
-
if (msg.stopReason) key += msg.stopReason.length;
|
|
160
|
-
if (msg.errorMessage) key += msg.errorMessage.length;
|
|
161
|
-
return key;
|
|
162
|
-
}
|
|
163
|
-
if (msg.role === "user") {
|
|
164
|
-
if (typeof msg.content === "string") return msg.content.length;
|
|
165
|
-
return msg.content.reduce(
|
|
166
|
-
(sum, b) => (b.type === "text" ? sum + b.text.length : sum),
|
|
167
|
-
0,
|
|
168
|
-
);
|
|
169
|
-
}
|
|
170
|
-
if (msg.role === "toolResult") {
|
|
171
|
-
return msg.content.reduce(
|
|
172
|
-
(sum, b) => (b.type === "text" ? sum + b.text.length : sum),
|
|
173
|
-
0,
|
|
174
|
-
);
|
|
175
|
-
}
|
|
176
|
-
return 0;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function cachedMessageBody(msg: Message): Node {
|
|
180
|
-
const key = messageCacheKey(msg);
|
|
181
|
-
const cached = messageBodyCache.get(msg);
|
|
182
|
-
if (cached && cached.key === key) {
|
|
183
|
-
return cached.node;
|
|
184
|
-
}
|
|
185
|
-
const node =
|
|
186
|
-
msg.role === "assistant"
|
|
187
|
-
? agentMessageNode(msg)
|
|
188
|
-
: msg.role === "user"
|
|
189
|
-
? userMessageNode(msg)
|
|
190
|
-
: msg.role === "toolResult"
|
|
191
|
-
? toolMessageNode(msg)
|
|
192
|
-
: Text("Unknown message?", {
|
|
193
|
-
wrap: "word",
|
|
194
|
-
fgColor: theme.bwhite,
|
|
195
|
-
});
|
|
196
|
-
messageBodyCache.set(msg, { key, node });
|
|
197
|
-
return node;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function conversationMessageNode(msg: Message): Node {
|
|
201
|
-
const label = msg.role === "toolResult" ? `${msg.toolName} result` : msg.role;
|
|
202
|
-
return VStack({ gap: 1 }, [
|
|
203
|
-
cachedMessageBody(msg),
|
|
204
|
-
HStack({ gap: 1, justifyContent: "end" }, [
|
|
205
|
-
Text(`${relativeTime(msg.timestamp)} ago.`, {
|
|
206
|
-
fgColor: theme.bblack,
|
|
207
|
-
italic: true,
|
|
208
|
-
}),
|
|
209
|
-
TextPill(label, theme.bwhite, theme.bblack),
|
|
210
|
-
]),
|
|
211
|
-
]);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const colors = [
|
|
215
|
-
theme.bgreen,
|
|
216
|
-
theme.byellow,
|
|
217
|
-
theme.bblue,
|
|
218
|
-
theme.bcyan,
|
|
219
|
-
theme.bmagenta,
|
|
220
|
-
theme.bred,
|
|
221
|
-
];
|
|
222
|
-
const randColor = colors[Math.floor(Math.random() * colors.length)];
|
|
11
|
+
import type { TUIMessage, TUIState, TUIToolCall } from "./types";
|
|
223
12
|
|
|
224
13
|
export function emptyState(): Node {
|
|
14
|
+
const randColor = theme.bgreen;
|
|
225
15
|
return HStack({ flex: 1, alignItems: "center" }, [
|
|
226
16
|
VStack({ flex: 1, alignItems: "center", gap: 1 }, [
|
|
227
17
|
HStack({ gap: 1 }, [
|
|
@@ -254,11 +44,111 @@ export function emptyState(): Node {
|
|
|
254
44
|
]);
|
|
255
45
|
}
|
|
256
46
|
|
|
47
|
+
function ConversationMessageToolCall(call: TUIToolCall) {
|
|
48
|
+
let outputNode: Node | null = null;
|
|
49
|
+
|
|
50
|
+
// Compress read and bash calls
|
|
51
|
+
if (call.tool === "read") {
|
|
52
|
+
outputNode = Text(`Read ~${estimateTokens(call.output)} tokens`, {
|
|
53
|
+
wrap: "word",
|
|
54
|
+
});
|
|
55
|
+
} else if (call.tool === "bash") {
|
|
56
|
+
const tail = call.output.trim().slice(-200);
|
|
57
|
+
const blocks: Node[] = [];
|
|
58
|
+
|
|
59
|
+
blocks.push(Text(tail, { fgColor: theme.white, wrap: "word" }));
|
|
60
|
+
|
|
61
|
+
if (tail.length !== call.output.trim().length) {
|
|
62
|
+
blocks.push(
|
|
63
|
+
Text("Showing the last 200 characters", {
|
|
64
|
+
fgColor: theme.bblack,
|
|
65
|
+
italic: true,
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
outputNode = VStack({ width: "100%" }, blocks);
|
|
71
|
+
} else if (call.tool === "edit") {
|
|
72
|
+
outputNode = SyntaxHighlight(call.output, "patch");
|
|
73
|
+
} else {
|
|
74
|
+
outputNode = Text(call.output, { wrap: "word" });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return VStack({}, [
|
|
78
|
+
TextPill(call.tool, theme.black, theme.bwhite),
|
|
79
|
+
|
|
80
|
+
...Object.entries(call.args).map(([key, value]) => {
|
|
81
|
+
let node: Node | null = Text(String(value));
|
|
82
|
+
|
|
83
|
+
// Syntax highlight bash args
|
|
84
|
+
if (call.tool === "bash") {
|
|
85
|
+
node = SyntaxHighlight(String(value), "bash");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return HStack({ gap: 1 }, [
|
|
89
|
+
Text(`${key}`, { italic: true, fgColor: theme.white }),
|
|
90
|
+
node,
|
|
91
|
+
]);
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
outputNode,
|
|
95
|
+
]);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function ConversationMessage(message: TUIMessage) {
|
|
99
|
+
const blocks: Node[] = [];
|
|
100
|
+
|
|
101
|
+
if (message.thinking) {
|
|
102
|
+
// Compress thinking blocks
|
|
103
|
+
const estThinkingTok = estimateTokens(message.thinking);
|
|
104
|
+
blocks.push(
|
|
105
|
+
Text(`Thinking... ~${estThinkingTok} tokens`, {
|
|
106
|
+
wrap: "word",
|
|
107
|
+
fgColor: theme.bblack,
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (message.text.length) {
|
|
113
|
+
blocks.push(
|
|
114
|
+
VStack(
|
|
115
|
+
{
|
|
116
|
+
width: "100%",
|
|
117
|
+
bgColor: message.role === "user" ? theme.bblack : undefined,
|
|
118
|
+
padding: { y: 1, x: 1 },
|
|
119
|
+
},
|
|
120
|
+
[SyntaxHighlight(message.text, "markdown")],
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (message.toolCalls?.length) {
|
|
126
|
+
blocks.push(
|
|
127
|
+
VStack({ gap: 1 }, message.toolCalls.map(ConversationMessageToolCall)),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Msg footer:
|
|
132
|
+
blocks.push(
|
|
133
|
+
Text(`on ${message.timestamp} by ${message.role}`, {
|
|
134
|
+
fgColor: theme.bblack,
|
|
135
|
+
italic: true,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return VStack(
|
|
140
|
+
{
|
|
141
|
+
gap: 1,
|
|
142
|
+
},
|
|
143
|
+
blocks,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
257
147
|
export function Conversation(state: TUIState) {
|
|
258
148
|
return VStack(
|
|
259
149
|
{
|
|
260
150
|
flex: 1,
|
|
261
|
-
gap:
|
|
151
|
+
gap: 2,
|
|
262
152
|
overflow: "scroll",
|
|
263
153
|
scrollOffset: state.stickToBottom ? Infinity : state.scrollOffset,
|
|
264
154
|
onScroll(offset, maxOffset) {
|
|
@@ -266,6 +156,6 @@ export function Conversation(state: TUIState) {
|
|
|
266
156
|
state.stickToBottom = offset >= maxOffset;
|
|
267
157
|
},
|
|
268
158
|
},
|
|
269
|
-
state.
|
|
159
|
+
state.tuiMessages.map(ConversationMessage),
|
|
270
160
|
);
|
|
271
161
|
}
|
package/src/tui.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { cel, HStack, ProcessTerminal, VStack } from "@cel-tui/core";
|
|
2
|
-
import
|
|
3
|
-
|
|
2
|
+
import type {
|
|
3
|
+
AssistantMessage,
|
|
4
|
+
ToolResultMessage,
|
|
5
|
+
} from "@earendil-works/pi-ai";
|
|
6
|
+
import { streamAgent } from "./agent";
|
|
7
|
+
import { getBranchLabel } from "./git";
|
|
4
8
|
import {
|
|
5
9
|
buildSystemPrompt,
|
|
6
10
|
injectEnvReminder,
|
|
@@ -8,7 +12,7 @@ import {
|
|
|
8
12
|
MAIN_PROMPT,
|
|
9
13
|
} from "./prompt";
|
|
10
14
|
import { updateSession } from "./session";
|
|
11
|
-
import { estimateTokens, secureRandomString } from "./shared";
|
|
15
|
+
import { estimateTokens, formatTimestamp, secureRandomString } from "./shared";
|
|
12
16
|
import { bash, runBashTool } from "./tool-bash";
|
|
13
17
|
import { edit, runEditTool } from "./tool-edit";
|
|
14
18
|
import { read, runReadTool } from "./tool-read";
|
|
@@ -24,10 +28,7 @@ import {
|
|
|
24
28
|
import { Conversation, emptyState } from "./tui-conversation";
|
|
25
29
|
import { Editor } from "./tui-editor";
|
|
26
30
|
import { mainMenu } from "./tui-overlay";
|
|
27
|
-
import type { AgentContex, ToolAndRunner, TUIState } from "./types";
|
|
28
|
-
|
|
29
|
-
// TODO: move all git things to `git.ts`
|
|
30
|
-
const git = simpleGit();
|
|
31
|
+
import type { AgentContex, ToolAndRunner, TUIMessage, TUIState } from "./types";
|
|
31
32
|
|
|
32
33
|
function clearOrAbort(state: TUIState) {
|
|
33
34
|
// Are we mid stream? Abort it.
|
|
@@ -159,6 +160,11 @@ async function streamAgentTUI(state: TUIState) {
|
|
|
159
160
|
content: userContent,
|
|
160
161
|
timestamp: Date.now(),
|
|
161
162
|
});
|
|
163
|
+
state.tuiMessages.push({
|
|
164
|
+
timestamp: formatTimestamp(Date.now()),
|
|
165
|
+
role: "user",
|
|
166
|
+
text: state.prompt,
|
|
167
|
+
});
|
|
162
168
|
state.prompt = "";
|
|
163
169
|
|
|
164
170
|
const systemPrompt = await buildSystemPrompt(MAIN_PROMPT);
|
|
@@ -170,25 +176,84 @@ async function streamAgentTUI(state: TUIState) {
|
|
|
170
176
|
signal: state.abortController?.signal,
|
|
171
177
|
};
|
|
172
178
|
|
|
173
|
-
|
|
174
|
-
|
|
179
|
+
const toTUIMessage = (partial: AssistantMessage) => {
|
|
180
|
+
const text = partial.content
|
|
181
|
+
.filter((c) => c.type === "text")
|
|
182
|
+
.map((c) => c.text)
|
|
183
|
+
.join("")
|
|
184
|
+
.trim();
|
|
185
|
+
const thinking = partial.content
|
|
186
|
+
.filter((c) => c.type === "thinking")
|
|
187
|
+
.map((c) => c.thinking)
|
|
188
|
+
.join("")
|
|
189
|
+
.trim();
|
|
190
|
+
const toolCalls = partial.content
|
|
191
|
+
.filter((c) => c.type === "toolCall")
|
|
192
|
+
.map((c) => {
|
|
193
|
+
return {
|
|
194
|
+
id: c.id,
|
|
195
|
+
tool: c.name,
|
|
196
|
+
args: c.arguments,
|
|
197
|
+
output: "",
|
|
198
|
+
};
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
timestamp: formatTimestamp(partial.timestamp),
|
|
203
|
+
role: "assistant" as const,
|
|
204
|
+
text,
|
|
205
|
+
thinking,
|
|
206
|
+
toolCalls,
|
|
207
|
+
};
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
const updateToolCall = (
|
|
211
|
+
partial: ToolResultMessage,
|
|
212
|
+
tuiMessages: TUIMessage[],
|
|
213
|
+
) => {
|
|
214
|
+
tuiMessages.forEach((c) => {
|
|
215
|
+
const parentCall = c.toolCalls?.find((t) => t.id === partial.toolCallId);
|
|
216
|
+
if (parentCall) {
|
|
217
|
+
parentCall.output = partial.content
|
|
218
|
+
.filter((c) => c.type === "text")
|
|
219
|
+
.map((c) => c.text)
|
|
220
|
+
.join("")
|
|
221
|
+
.trim();
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
175
226
|
const agent = streamAgent(ctx);
|
|
176
227
|
try {
|
|
177
228
|
for await (const ev of agent) {
|
|
178
229
|
switch (ev.type) {
|
|
179
230
|
case "message_start":
|
|
231
|
+
state.tuiMessages.push(toTUIMessage(ev.partial));
|
|
232
|
+
break;
|
|
180
233
|
case "message_update":
|
|
234
|
+
state.tuiMessages[state.tuiMessages.length - 1] = toTUIMessage(
|
|
235
|
+
ev.partial,
|
|
236
|
+
);
|
|
181
237
|
break;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
238
|
+
case "message_end": {
|
|
239
|
+
state.tuiMessages[state.tuiMessages.length - 1] = toTUIMessage(
|
|
240
|
+
ev.message,
|
|
241
|
+
);
|
|
242
|
+
const { systemPrompt, tools, messages } = ctx;
|
|
243
|
+
state.contextSize = estimateTokens(
|
|
244
|
+
JSON.stringify({ systemPrompt, tools, messages }),
|
|
245
|
+
);
|
|
185
246
|
break;
|
|
247
|
+
}
|
|
186
248
|
|
|
187
249
|
case "tool_message_start":
|
|
250
|
+
updateToolCall(ev.partial, state.tuiMessages);
|
|
251
|
+
break;
|
|
188
252
|
case "tool_message_update":
|
|
253
|
+
updateToolCall(ev.partial, state.tuiMessages);
|
|
189
254
|
break;
|
|
190
|
-
|
|
191
255
|
case "tool_message_end": {
|
|
256
|
+
updateToolCall(ev.message, state.tuiMessages);
|
|
192
257
|
const withReminder = insertToolUsageReminder(
|
|
193
258
|
state.messages,
|
|
194
259
|
ev.message,
|
|
@@ -203,7 +268,10 @@ async function streamAgentTUI(state: TUIState) {
|
|
|
203
268
|
state.messages[idx] = withReminder;
|
|
204
269
|
}
|
|
205
270
|
|
|
206
|
-
|
|
271
|
+
const { systemPrompt, tools, messages } = ctx;
|
|
272
|
+
state.contextSize = estimateTokens(
|
|
273
|
+
JSON.stringify({ systemPrompt, tools, messages }),
|
|
274
|
+
);
|
|
207
275
|
}
|
|
208
276
|
}
|
|
209
277
|
}
|
|
@@ -213,20 +281,8 @@ async function streamAgentTUI(state: TUIState) {
|
|
|
213
281
|
const id = secureRandomString(10);
|
|
214
282
|
state.sessionId = id;
|
|
215
283
|
}
|
|
216
|
-
// TODO: Should we make this delta only so compaction doesn;t affect saves?
|
|
217
|
-
// I'm not sure since it we do, there is no trace in logs about compaction
|
|
218
|
-
// and that would mean the logs don't repesent the truth. Confusing decision.
|
|
219
284
|
await updateSession(state.sessionId, state.messages);
|
|
220
|
-
|
|
221
|
-
// Compact after saving, if the next turn fails because of compaction, the session is recoverable.
|
|
222
|
-
// Compact at 80k tokens, the dumb zone threshold.
|
|
223
|
-
if (estimateTokens(JSON.stringify(state.messages)) > 80000)
|
|
224
|
-
compactContext(state.messages);
|
|
225
285
|
}
|
|
226
286
|
|
|
227
|
-
|
|
228
|
-
const gitStatus = (await git.status()).isClean() ? "" : "*";
|
|
229
|
-
const gitBranch = (await git.branch()).current;
|
|
230
|
-
state.gitBranch = `${gitBranch}${gitStatus}`;
|
|
231
|
-
} catch (_) {}
|
|
287
|
+
state.gitBranch = await getBranchLabel();
|
|
232
288
|
}
|
package/src/types.ts
CHANGED
|
@@ -76,10 +76,26 @@ export const SessionSchema = Type.Object({
|
|
|
76
76
|
export type Session = Static<typeof SessionSchema>;
|
|
77
77
|
export type Sessions = Session[];
|
|
78
78
|
|
|
79
|
+
export type TUIToolCall = {
|
|
80
|
+
id: string;
|
|
81
|
+
tool: string;
|
|
82
|
+
args: Record<string, any>;
|
|
83
|
+
output: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type TUIMessage = {
|
|
87
|
+
timestamp: string;
|
|
88
|
+
role: "user" | "assistant";
|
|
89
|
+
text: string;
|
|
90
|
+
thinking?: string;
|
|
91
|
+
toolCalls?: TUIToolCall[];
|
|
92
|
+
};
|
|
93
|
+
|
|
79
94
|
export type TUIState = {
|
|
80
95
|
options: CliOptions;
|
|
81
96
|
prompt: string;
|
|
82
|
-
messages: Message[];
|
|
97
|
+
messages: Message[]; // Context messages
|
|
98
|
+
tuiMessages: TUIMessage[];
|
|
83
99
|
contextSize?: number;
|
|
84
100
|
stickToBottom: boolean;
|
|
85
101
|
scrollOffset: number;
|