mini-coder 0.6.1 → 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.
@@ -5,223 +5,13 @@ import type {
5
5
  Message,
6
6
  ToolResultMessage,
7
7
  UserMessage,
8
- } from "@mariozechner/pi-ai";
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
  }
@@ -1,6 +1,6 @@
1
1
  import { HStack, Text, TextInput, VStack } from "@cel-tui/core";
2
- import { getModels, type ThinkingLevel } from "@mariozechner/pi-ai";
3
- import { getOAuthProviders } from "@mariozechner/pi-ai/oauth";
2
+ import { getModels, type ThinkingLevel } from "@earendil-works/pi-ai";
3
+ import { getOAuthProviders } from "@earendil-works/pi-ai/oauth";
4
4
  import { saveSettings } from "./args";
5
5
  import { getAvailableProviders } from "./oauth";
6
6
  import { listSessionsForCwd } from "./session";
package/src/tui.ts CHANGED
@@ -1,9 +1,18 @@
1
1
  import { cel, HStack, ProcessTerminal, VStack } from "@cel-tui/core";
2
- import simpleGit from "simple-git";
3
- import { compactContext, streamAgent } from "./agent";
4
- import { buildSystemPrompt, injectEnvReminder, MAIN_PROMPT } from "./prompt";
2
+ import type {
3
+ AssistantMessage,
4
+ ToolResultMessage,
5
+ } from "@earendil-works/pi-ai";
6
+ import { streamAgent } from "./agent";
7
+ import { getBranchLabel } from "./git";
8
+ import {
9
+ buildSystemPrompt,
10
+ injectEnvReminder,
11
+ insertToolUsageReminder,
12
+ MAIN_PROMPT,
13
+ } from "./prompt";
5
14
  import { updateSession } from "./session";
6
- import { estimateTokens, secureRandomString } from "./shared";
15
+ import { estimateTokens, formatTimestamp, secureRandomString } from "./shared";
7
16
  import { bash, runBashTool } from "./tool-bash";
8
17
  import { edit, runEditTool } from "./tool-edit";
9
18
  import { read, runReadTool } from "./tool-read";
@@ -19,10 +28,7 @@ import {
19
28
  import { Conversation, emptyState } from "./tui-conversation";
20
29
  import { Editor } from "./tui-editor";
21
30
  import { mainMenu } from "./tui-overlay";
22
- import type { AgentContex, ToolAndRunner, TUIState } from "./types";
23
-
24
- // TODO: move all git things to `git.ts`
25
- const git = simpleGit();
31
+ import type { AgentContex, ToolAndRunner, TUIMessage, TUIState } from "./types";
26
32
 
27
33
  function clearOrAbort(state: TUIState) {
28
34
  // Are we mid stream? Abort it.
@@ -154,6 +160,11 @@ async function streamAgentTUI(state: TUIState) {
154
160
  content: userContent,
155
161
  timestamp: Date.now(),
156
162
  });
163
+ state.tuiMessages.push({
164
+ timestamp: formatTimestamp(Date.now()),
165
+ role: "user",
166
+ text: state.prompt,
167
+ });
157
168
  state.prompt = "";
158
169
 
159
170
  const systemPrompt = await buildSystemPrompt(MAIN_PROMPT);
@@ -165,41 +176,102 @@ async function streamAgentTUI(state: TUIState) {
165
176
  signal: state.abortController?.signal,
166
177
  };
167
178
 
168
- // We send a reference to state.messages, so things just render.
169
- // 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
+
170
226
  const agent = streamAgent(ctx);
171
227
  try {
172
228
  for await (const ev of agent) {
173
229
  switch (ev.type) {
174
230
  case "message_start":
231
+ state.tuiMessages.push(toTUIMessage(ev.partial));
232
+ break;
175
233
  case "message_update":
234
+ state.tuiMessages[state.tuiMessages.length - 1] = toTUIMessage(
235
+ ev.partial,
236
+ );
176
237
  break;
177
-
178
- case "message_end":
179
- 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
+ );
180
246
  break;
247
+ }
181
248
 
182
249
  case "tool_message_start":
250
+ updateToolCall(ev.partial, state.tuiMessages);
251
+ break;
183
252
  case "tool_message_update":
253
+ updateToolCall(ev.partial, state.tuiMessages);
184
254
  break;
185
-
186
255
  case "tool_message_end": {
187
- // TODO: reminder needs to be refactored
188
- // const withReminder = insertToolUsageReminder(
189
- // state.messages,
190
- // ev.message,
191
- // );
192
-
193
- // const idx = state.messages.findIndex(
194
- // (m) =>
195
- // m.role === "toolResult" &&
196
- // m.toolCallId === withReminder.toolCallId,
197
- // );
198
- // if (idx >= 0) {
199
- // state.messages[idx] = withReminder;
200
- // }
201
-
202
- state.contextSize = estimateTokens(JSON.stringify(ctx));
256
+ updateToolCall(ev.message, state.tuiMessages);
257
+ const withReminder = insertToolUsageReminder(
258
+ state.messages,
259
+ ev.message,
260
+ );
261
+
262
+ const idx = state.messages.findIndex(
263
+ (m) =>
264
+ m.role === "toolResult" &&
265
+ m.toolCallId === withReminder.toolCallId,
266
+ );
267
+ if (idx >= 0) {
268
+ state.messages[idx] = withReminder;
269
+ }
270
+
271
+ const { systemPrompt, tools, messages } = ctx;
272
+ state.contextSize = estimateTokens(
273
+ JSON.stringify({ systemPrompt, tools, messages }),
274
+ );
203
275
  }
204
276
  }
205
277
  }
@@ -209,20 +281,8 @@ async function streamAgentTUI(state: TUIState) {
209
281
  const id = secureRandomString(10);
210
282
  state.sessionId = id;
211
283
  }
212
- // TODO: Should we make this delta only so compaction doesn;t affect saves?
213
- // I'm not sure since it we do, there is no trace in logs about compaction
214
- // and that would mean the logs don't repesent the truth. Confusing decision.
215
284
  await updateSession(state.sessionId, state.messages);
216
-
217
- // Compact after saving, if the next turn fails because of compaction, the session is recoverable.
218
- // Compact at 80k tokens, the dumb zone threshold.
219
- if (estimateTokens(JSON.stringify(state.messages)) > 80000)
220
- compactContext(state.messages);
221
285
  }
222
286
 
223
- try {
224
- const gitStatus = (await git.status()).isClean() ? "" : "*";
225
- const gitBranch = (await git.branch()).current;
226
- state.gitBranch = `${gitBranch}${gitStatus}`;
227
- } catch (_) {}
287
+ state.gitBranch = await getBranchLabel();
228
288
  }
package/src/types.ts CHANGED
@@ -8,8 +8,8 @@ import {
8
8
  type Tool,
9
9
  type ToolResultMessage,
10
10
  Type,
11
- } from "@mariozechner/pi-ai";
12
- import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
11
+ } from "@earendil-works/pi-ai";
12
+ import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
13
13
 
14
14
  const ThinkingLevelSchema = Type.Unsafe<ThinkingLevel>(
15
15
  Type.Union([
@@ -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;