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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "packageManager": "bun@1.3.12",
6
6
  "bin": {
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 simpleGit, { type StatusResult } from "simple-git";
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
- let gitStatus: StatusResult | { nogit: string };
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
- existing.messages = messages;
75
- await saveSession(existing);
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 = `# EXIT CODE: ${exitCode}`;
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 {
@@ -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: 3,
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.messages.map(conversationMessageNode),
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 simpleGit from "simple-git";
3
- import { compactContext, streamAgent } from "./agent";
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
- // We send a reference to state.messages, so things just render.
174
- // We just need to react to some updates.
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
- case "message_end":
184
- state.contextSize = estimateTokens(JSON.stringify(ctx));
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
- state.contextSize = estimateTokens(JSON.stringify(ctx));
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
- try {
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;