mini-coder 0.6.2 → 0.6.5
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 +108 -188
- package/package.json +5 -5
- package/src/agent.ts +11 -1
- package/src/git.ts +23 -0
- package/src/index.ts +3 -8
- package/src/oauth.ts +45 -12
- package/src/prompt.ts +3 -8
- package/src/session.ts +6 -4
- package/src/tool-bash.ts +2 -20
- package/src/tui-components.ts +1 -1
- package/src/tui-conversation.ts +123 -221
- package/src/tui-overlay.ts +64 -2
- package/src/tui.ts +84 -27
- package/src/types.ts +17 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mini-coder",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"packageManager": "bun@1.3.12",
|
|
6
6
|
"bin": {
|
|
@@ -14,17 +14,17 @@
|
|
|
14
14
|
"format": "bun run prettier --write *.md && bun run biome format --write . && bun run biome check --write ."
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
|
17
|
-
"@biomejs/biome": "^2.4.
|
|
18
|
-
"@types/bun": "^1.3.
|
|
17
|
+
"@biomejs/biome": "^2.4.15",
|
|
18
|
+
"@types/bun": "^1.3.14",
|
|
19
19
|
"prettier": "^3.8.3",
|
|
20
20
|
"typescript": "^6.0.3"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"@cel-tui/components": "^0.8.3",
|
|
24
24
|
"@cel-tui/core": "^0.8.3",
|
|
25
|
-
"@earendil-works/pi-ai": "^0.
|
|
25
|
+
"@earendil-works/pi-ai": "^0.75.5",
|
|
26
26
|
"diff": "^9.0.0",
|
|
27
27
|
"simple-git": "^3.36.0",
|
|
28
|
-
"yaml": "^2.
|
|
28
|
+
"yaml": "^2.9.0"
|
|
29
29
|
}
|
|
30
30
|
}
|
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/oauth.ts
CHANGED
|
@@ -5,11 +5,55 @@ import {
|
|
|
5
5
|
getOAuthApiKey,
|
|
6
6
|
getOAuthProvider,
|
|
7
7
|
getOAuthProviders,
|
|
8
|
+
type OAuthLoginCallbacks,
|
|
9
|
+
type OAuthPrompt,
|
|
8
10
|
type OAuthProviderId,
|
|
11
|
+
type OAuthSelectPrompt,
|
|
9
12
|
} from "@earendil-works/pi-ai/oauth";
|
|
10
13
|
import { AUTH_PATH as AUTH_FILE } from "./shared";
|
|
11
14
|
import type { CliOptions, SavedOAuthCreds } from "./types";
|
|
12
15
|
|
|
16
|
+
type ReadlineInterface = ReturnType<typeof readline.createInterface>;
|
|
17
|
+
|
|
18
|
+
function ask(rl: ReadlineInterface, question: string): Promise<string> {
|
|
19
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function formatPrompt(prompt: OAuthPrompt): string {
|
|
23
|
+
const placeholder = prompt.placeholder ? ` (${prompt.placeholder})` : "";
|
|
24
|
+
return `${prompt.message}${placeholder}: `;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function selectOption(
|
|
28
|
+
rl: ReadlineInterface,
|
|
29
|
+
prompt: OAuthSelectPrompt,
|
|
30
|
+
): Promise<string | undefined> {
|
|
31
|
+
console.log(prompt.message);
|
|
32
|
+
for (let i = 0; i < prompt.options.length; i++) {
|
|
33
|
+
console.log(`${i + 1}. ${prompt.options[i]?.label}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const choice = await ask(rl, `Enter number (1-${prompt.options.length}): `);
|
|
37
|
+
const index = Number.parseInt(choice, 10) - 1;
|
|
38
|
+
return prompt.options[index]?.id;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createLoginCallbacks(rl: ReadlineInterface): OAuthLoginCallbacks {
|
|
42
|
+
return {
|
|
43
|
+
onAuth: ({ url, instructions }) => {
|
|
44
|
+
console.log(`Open: ${url}`);
|
|
45
|
+
if (instructions) console.log(instructions);
|
|
46
|
+
},
|
|
47
|
+
onDeviceCode: ({ userCode, verificationUri }) => {
|
|
48
|
+
console.log(`Open: ${verificationUri}`);
|
|
49
|
+
console.log(`Enter code: ${userCode}`);
|
|
50
|
+
},
|
|
51
|
+
onPrompt: (prompt) => ask(rl, formatPrompt(prompt)),
|
|
52
|
+
onProgress: (message) => console.log(message),
|
|
53
|
+
onSelect: (prompt) => selectOption(rl, prompt),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
13
57
|
export function isOAuthProvider(provider: string): boolean {
|
|
14
58
|
return getOAuthProviders().some(
|
|
15
59
|
(oauthProvider) => oauthProvider.id === provider,
|
|
@@ -47,18 +91,7 @@ export async function loginOAuth(provider: OAuthProviderId) {
|
|
|
47
91
|
});
|
|
48
92
|
|
|
49
93
|
try {
|
|
50
|
-
const creds = await oauthProvider.login(
|
|
51
|
-
onAuth: ({ url, instructions }) => {
|
|
52
|
-
console.log(`Open: ${url}`);
|
|
53
|
-
if (instructions) console.log(instructions);
|
|
54
|
-
},
|
|
55
|
-
onPrompt: async (prompt) => {
|
|
56
|
-
let answer: string = "";
|
|
57
|
-
await rl.question(prompt.message, (a) => (answer = a));
|
|
58
|
-
return answer;
|
|
59
|
-
},
|
|
60
|
-
onProgress: (message) => console.log(message),
|
|
61
|
-
});
|
|
94
|
+
const creds = await oauthProvider.login(createLoginCallbacks(rl));
|
|
62
95
|
|
|
63
96
|
await writeCreds({ [provider]: { type: "oauth", ...creds } });
|
|
64
97
|
} finally {
|
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-components.ts
CHANGED
package/src/tui-conversation.ts
CHANGED
|
@@ -1,227 +1,11 @@
|
|
|
1
1
|
import { SyntaxHighlight } from "@cel-tui/components";
|
|
2
2
|
import { HStack, type Node, Text, VStack } from "@cel-tui/core";
|
|
3
|
-
import
|
|
4
|
-
AssistantMessage,
|
|
5
|
-
Message,
|
|
6
|
-
ToolResultMessage,
|
|
7
|
-
UserMessage,
|
|
8
|
-
} from "@earendil-works/pi-ai";
|
|
9
|
-
import { estimateTokens, relativeTime } from "./shared";
|
|
3
|
+
import { estimateTokens } from "./shared";
|
|
10
4
|
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)];
|
|
5
|
+
import type { TUIMessage, TUIState, TUIToolCall } from "./types";
|
|
223
6
|
|
|
224
7
|
export function emptyState(): Node {
|
|
8
|
+
const randColor = theme.bgreen;
|
|
225
9
|
return HStack({ flex: 1, alignItems: "center" }, [
|
|
226
10
|
VStack({ flex: 1, alignItems: "center", gap: 1 }, [
|
|
227
11
|
HStack({ gap: 1 }, [
|
|
@@ -254,11 +38,129 @@ export function emptyState(): Node {
|
|
|
254
38
|
]);
|
|
255
39
|
}
|
|
256
40
|
|
|
41
|
+
function ConversationMessageToolCall(call: TUIToolCall) {
|
|
42
|
+
let outputNode: Node | null = null;
|
|
43
|
+
|
|
44
|
+
// Compress read and bash calls
|
|
45
|
+
if (call.tool === "read") {
|
|
46
|
+
outputNode = Text(`Read ~${estimateTokens(call.output)} tokens`, {
|
|
47
|
+
wrap: "word",
|
|
48
|
+
});
|
|
49
|
+
} else if (call.tool === "bash") {
|
|
50
|
+
const tail = call.output.trim().slice(-200);
|
|
51
|
+
const blocks: Node[] = [];
|
|
52
|
+
|
|
53
|
+
blocks.push(Text(tail, { fgColor: theme.white, wrap: "word" }));
|
|
54
|
+
|
|
55
|
+
if (tail.length !== call.output.trim().length) {
|
|
56
|
+
blocks.push(
|
|
57
|
+
Text("Showing the last 200 characters", {
|
|
58
|
+
fgColor: theme.bblack,
|
|
59
|
+
italic: true,
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
outputNode = VStack({ width: "100%" }, blocks);
|
|
65
|
+
} else if (call.tool === "edit") {
|
|
66
|
+
outputNode = SyntaxHighlight(call.output, "patch");
|
|
67
|
+
} else {
|
|
68
|
+
outputNode = Text(call.output, { wrap: "word" });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let argumentNodes: Node[] = [];
|
|
72
|
+
|
|
73
|
+
if (call.tool === "edit") {
|
|
74
|
+
argumentNodes = [
|
|
75
|
+
Text(`Writing... ~${estimateTokens(JSON.stringify(call.args))} tokens`),
|
|
76
|
+
];
|
|
77
|
+
} else {
|
|
78
|
+
argumentNodes = Object.entries(call.args).map(([key, value]) => {
|
|
79
|
+
let node: Node | null = Text(String(value));
|
|
80
|
+
|
|
81
|
+
// Syntax highlight bash args
|
|
82
|
+
if (call.tool === "bash") {
|
|
83
|
+
node = SyntaxHighlight(String(value), "bash");
|
|
84
|
+
return node;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return HStack({ gap: 1 }, [
|
|
88
|
+
Text(`${key}`, { italic: true, fgColor: theme.white }),
|
|
89
|
+
node,
|
|
90
|
+
]);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return VStack({}, [
|
|
95
|
+
TextPill(call.tool, theme.black, theme.bwhite),
|
|
96
|
+
...argumentNodes,
|
|
97
|
+
outputNode,
|
|
98
|
+
]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function ConversationMessage(message: TUIMessage) {
|
|
102
|
+
const blocks: Node[] = [];
|
|
103
|
+
|
|
104
|
+
if (message.thinking) {
|
|
105
|
+
// Compress thinking blocks
|
|
106
|
+
const estThinkingTok = estimateTokens(message.thinking);
|
|
107
|
+
blocks.push(
|
|
108
|
+
Text(`Thinking... ~${estThinkingTok} tokens`, {
|
|
109
|
+
wrap: "word",
|
|
110
|
+
fgColor: theme.bblack,
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (message.text.length) {
|
|
116
|
+
blocks.push(
|
|
117
|
+
VStack(
|
|
118
|
+
{
|
|
119
|
+
width: "100%",
|
|
120
|
+
bgColor: message.role === "user" ? theme.bblack : undefined,
|
|
121
|
+
padding: { y: 1, x: message.role === "user" ? 1 : 0 },
|
|
122
|
+
},
|
|
123
|
+
[SyntaxHighlight(message.text, "markdown")],
|
|
124
|
+
),
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (message.toolCalls?.length) {
|
|
129
|
+
blocks.push(
|
|
130
|
+
VStack({ gap: 1 }, message.toolCalls.map(ConversationMessageToolCall)),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!message.thinking && !message.text.length && !message.toolCalls?.length) {
|
|
135
|
+
blocks.push(
|
|
136
|
+
Text(`Loading...`, {
|
|
137
|
+
wrap: "word",
|
|
138
|
+
fgColor: theme.bblack,
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Msg footer:
|
|
144
|
+
blocks.push(
|
|
145
|
+
Text(`on ${message.timestamp} by ${message.role}`, {
|
|
146
|
+
fgColor: theme.bblack,
|
|
147
|
+
italic: true,
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
return VStack(
|
|
152
|
+
{
|
|
153
|
+
gap: 1,
|
|
154
|
+
},
|
|
155
|
+
blocks,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
257
159
|
export function Conversation(state: TUIState) {
|
|
258
160
|
return VStack(
|
|
259
161
|
{
|
|
260
162
|
flex: 1,
|
|
261
|
-
gap:
|
|
163
|
+
gap: 2,
|
|
262
164
|
overflow: "scroll",
|
|
263
165
|
scrollOffset: state.stickToBottom ? Infinity : state.scrollOffset,
|
|
264
166
|
onScroll(offset, maxOffset) {
|
|
@@ -266,6 +168,6 @@ export function Conversation(state: TUIState) {
|
|
|
266
168
|
state.stickToBottom = offset >= maxOffset;
|
|
267
169
|
},
|
|
268
170
|
},
|
|
269
|
-
state.
|
|
171
|
+
state.tuiMessages.map(ConversationMessage),
|
|
270
172
|
);
|
|
271
173
|
}
|