mini-coder 0.7.3 → 0.8.0

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/src/tool-read.ts DELETED
@@ -1,100 +0,0 @@
1
- import { Buffer } from "node:buffer";
2
- import { extname, isAbsolute, join } from "node:path";
3
- import { type Tool, Type } from "@earendil-works/pi-ai";
4
- import type { ToolRunnerEvent } from "./types";
5
-
6
- const imageMimeTypes: Record<string, string> = {
7
- ".bmp": "image/bmp",
8
- ".gif": "image/gif",
9
- ".jpeg": "image/jpeg",
10
- ".jpg": "image/jpeg",
11
- ".png": "image/png",
12
- ".svg": "image/svg+xml",
13
- ".webp": "image/webp",
14
- };
15
-
16
- const description = `Read tool
17
-
18
- Read a file by path.
19
-
20
- For text files, returns line-numbered text. Use \`offset\` and \`limit\` to read a specific range.
21
- You can also read images files.
22
- `;
23
-
24
- export const read: Tool = {
25
- name: "read",
26
- description,
27
- parameters: Type.Object({
28
- path: Type.String({
29
- description:
30
- "File path. Absolute or relative to the current working directory.",
31
- }),
32
- offset: Type.Optional(
33
- Type.Number({
34
- description:
35
- "Optional 1-based line number to start reading from. Text files only.",
36
- }),
37
- ),
38
- limit: Type.Optional(
39
- Type.Number({
40
- description:
41
- "Optional maximum number of lines to read. Text files only.",
42
- }),
43
- ),
44
- }),
45
- };
46
-
47
- export async function* runReadTool(
48
- args: Record<string, any>,
49
- signal?: AbortSignal,
50
- ): AsyncGenerator<ToolRunnerEvent> {
51
- const filePath = isAbsolute(args.path)
52
- ? args.path
53
- : join(process.cwd(), args.path);
54
- const file = Bun.file(filePath);
55
- const exists = await file.exists();
56
-
57
- if (!exists) {
58
- yield { type: "result", text: `File not found: ${filePath}` };
59
- return;
60
- }
61
-
62
- if (signal?.aborted) {
63
- yield { type: "result", text: "Aborted before read." };
64
- return;
65
- }
66
-
67
- const mimeType = imageMimeTypes[extname(filePath).toLowerCase()] ?? file.type;
68
- if (mimeType.startsWith("image/")) {
69
- const data = Buffer.from(await file.arrayBuffer()).toString("base64");
70
- const text = `Image read: ${filePath}\nMIME type: ${mimeType}\nSize: ${file.size} bytes`;
71
- yield {
72
- type: "result",
73
- text,
74
- image: { data, mimeType },
75
- };
76
- return;
77
- }
78
-
79
- const content = await file.text();
80
- const lines = content.split(/\r?\n/);
81
- if (content.endsWith("\n")) lines.pop();
82
-
83
- const offset = args.offset ?? 1;
84
- const startIndex = offset - 1;
85
- const endIndex = args.limit
86
- ? Math.min(lines.length, startIndex + args.limit)
87
- : lines.length;
88
- const width = String(endIndex).length;
89
- const body = lines
90
- .slice(startIndex, endIndex)
91
- .map((line, idx) => `${String(offset + idx).padStart(width)} | ${line}`)
92
- .join("\n");
93
-
94
- let text = `File: ${filePath}\nLines: ${offset}-${endIndex} of ${lines.length}\n\n${body}`;
95
- if (endIndex < lines.length) {
96
- text += `\n\nMore lines available. Use offset ${endIndex + 1} to continue.`;
97
- }
98
-
99
- yield { type: "result", text };
100
- }
@@ -1,127 +0,0 @@
1
- import { type Color, HStack, Text } from "@cel-tui/core";
2
- import { onceEvery } from "./shared";
3
- import { textColorForBackground, theme } from "./themes";
4
- import type { TUIState } from "./types";
5
-
6
- export { theme };
7
-
8
- export function TextPill(
9
- content: string,
10
- fgColor: Color,
11
- bgColor: Color,
12
- size?: number | undefined,
13
- ) {
14
- return HStack({ gap: 1, width: size }, [
15
- HStack({ bgColor, padding: { x: 1 } }, [
16
- Text(content, { bold: true, fgColor }),
17
- ]),
18
- ]);
19
- }
20
-
21
- export function Spinner() {
22
- const spinnerFrames = [
23
- "⠁",
24
- "⠂",
25
- "⠄",
26
- "⡀",
27
- "⡈",
28
- "⡐",
29
- "⡠",
30
- "⣀",
31
- "⣁",
32
- "⣂",
33
- "⣄",
34
- "⣌",
35
- "⣔",
36
- "⣤",
37
- "⣥",
38
- "⣦",
39
- "⣮",
40
- "⣶",
41
- "⣷",
42
- "⣿",
43
- "⡿",
44
- "⠿",
45
- "⢟",
46
- "⠟",
47
- "⡛",
48
- "⠛",
49
- "⠫",
50
- "⢋",
51
- "⠋",
52
- "⠍",
53
- "⡉",
54
- "⠉",
55
- "⠑",
56
- "⠡",
57
- "⢁",
58
- ];
59
- let spinnerTick = 0;
60
- const spinnerEvery = onceEvery(4, () => spinnerTick++);
61
- const currentSpinner = () =>
62
- spinnerFrames[spinnerTick % spinnerFrames.length];
63
-
64
- return { spinnerEvery, currentSpinner };
65
- }
66
-
67
- export function ActivityPill(state: TUIState, spinnerFrame: string) {
68
- let label = "";
69
- const frame = state.streaming ? spinnerFrame : "";
70
-
71
- if (frame) {
72
- label = frame;
73
- }
74
-
75
- return Text(label);
76
- }
77
-
78
- export function ContextPill(state: TUIState) {
79
- let text = "";
80
- let bg: Color = theme.bgreen;
81
- if (!state.contextSize) {
82
- text = "0%";
83
- bg = theme.bwhite;
84
- } else {
85
- // Colors show the progress in the "smart window" or how much
86
- // before the dumb zone. The user facing percentage is the model
87
- // amount. An elegant way to show both :)
88
- const max = state.options.model.contextWindow;
89
- const smartMax = 80000;
90
- const percent = Math.floor((state.contextSize / max) * 100);
91
- const smartPercent = Math.floor((state.contextSize / smartMax) * 100);
92
- if (smartPercent > 90) {
93
- bg = theme.bred;
94
- } else if (smartPercent > 85) {
95
- bg = theme.red;
96
- } else if (smartPercent > 80) {
97
- bg = theme.yellow;
98
- } else if (smartPercent > 60) {
99
- bg = theme.byellow;
100
- }
101
- text = `~${percent}%`;
102
- }
103
-
104
- text += ` (${state.options.model.contextWindow / 1000}k)`;
105
-
106
- return TextPill(text, textColorForBackground(bg, state.options.theme), bg);
107
- }
108
-
109
- export function GitPill(state: TUIState) {
110
- return TextPill(state.gitBranch ?? "No git.", theme.bwhite, theme.bblack);
111
- }
112
-
113
- export function ModelPill(state: TUIState) {
114
- // Like loot: Gold/purple/blue/green/white -> xhigh/high/medium/low/minimal
115
- switch (state.options.effort) {
116
- case "xhigh":
117
- return TextPill(state.options.model.name, theme.black, theme.yellow);
118
- case "high":
119
- return TextPill(state.options.model.name, theme.black, theme.magenta);
120
- case "medium":
121
- return TextPill(state.options.model.name, theme.black, theme.blue);
122
- case "low":
123
- return TextPill(state.options.model.name, theme.black, theme.green);
124
- }
125
- // Minimal
126
- return TextPill(state.options.model.name, theme.bwhite, theme.bblack);
127
- }
@@ -1,218 +0,0 @@
1
- import {
2
- SyntaxHighlight,
3
- type SyntaxHighlightTheme,
4
- } from "@cel-tui/components";
5
- import { type Color, HStack, type Node, Text, VStack } from "@cel-tui/core";
6
- import { estimateTokens } from "./shared";
7
- import { getTUITheme, textColorForBackground } from "./themes";
8
- import { TextPill, theme } from "./tui-components";
9
- import type { TUIMessage, TUIState, TUIToolCall } from "./types";
10
-
11
- export function emptyState(state: TUIState): Node {
12
- const randColor = theme.bgreen;
13
- const shortcutFgColor =
14
- state.options.theme === "ansi16"
15
- ? randColor
16
- : textColorForBackground(theme.bblack, state.options.theme);
17
- const updateNotice = state.availableUpdate
18
- ? [
19
- HStack({ gap: 1 }, [
20
- TextPill("update", shortcutFgColor, theme.bblack, 13),
21
- Text(
22
- `mini-coder ${state.availableUpdate.latestVersion} is available. Run mc --update.`,
23
- { fgColor: theme.bblack },
24
- ),
25
- ]),
26
- ]
27
- : [];
28
- return HStack({ flex: 1, alignItems: "center" }, [
29
- VStack({ flex: 1, alignItems: "center", gap: 1 }, [
30
- HStack({ gap: 1 }, [
31
- Text("mini"),
32
- TextPill(
33
- "coder",
34
- textColorForBackground(randColor, state.options.theme),
35
- randColor,
36
- ),
37
- ]),
38
- VStack({ gap: 1 }, [
39
- HStack({ gap: 1 }, [
40
- TextPill("/new", shortcutFgColor, theme.bblack, 13),
41
- Text("Start a new session from the input box.", {
42
- fgColor: theme.bblack,
43
- }),
44
- ]),
45
- HStack({ gap: 1 }, [
46
- TextPill("ctrl+p", shortcutFgColor, theme.bblack, 13),
47
- Text("Menu for session history, and settings.", {
48
- fgColor: theme.bblack,
49
- }),
50
- ]),
51
- HStack({ gap: 1 }, [
52
- TextPill("ESC", shortcutFgColor, theme.bblack, 13),
53
- Text("Abort agent response.", { fgColor: theme.bblack }),
54
- ]),
55
- HStack({ gap: 1 }, [
56
- TextPill("ctrl+c|d|q", shortcutFgColor, theme.bblack, 13),
57
- Text("Quit.", { fgColor: theme.bblack }),
58
- ]),
59
- ...updateNotice,
60
- ]),
61
- ]),
62
- ]);
63
- }
64
-
65
- function ConversationMessageToolCall(
66
- call: TUIToolCall,
67
- syntaxTheme: SyntaxHighlightTheme,
68
- ) {
69
- let outputNode: Node | null = null;
70
-
71
- // Compress read and bash calls
72
- if (call.tool === "read") {
73
- outputNode = Text(`Read ~${estimateTokens(call.output)} tokens`, {
74
- wrap: "word",
75
- });
76
- } else if (call.tool === "bash") {
77
- const tail = call.output.trim().slice(-200);
78
- const blocks: Node[] = [];
79
-
80
- if (tail.length !== call.output.trim().length) {
81
- blocks.push(
82
- Text("Showing the last 200 characters", {
83
- fgColor: theme.bblack,
84
- italic: true,
85
- }),
86
- Text(`[...] ${tail}`, { fgColor: theme.white, wrap: "word" }),
87
- );
88
- } else {
89
- blocks.push(Text(tail, { fgColor: theme.white, wrap: "word" }));
90
- }
91
-
92
- outputNode = VStack({ width: "100%" }, blocks);
93
- } else if (call.tool === "edit") {
94
- outputNode = SyntaxHighlight(call.output, "patch", { theme: syntaxTheme });
95
- } else {
96
- outputNode = Text(call.output, { wrap: "word" });
97
- }
98
-
99
- let argumentNodes: Node[] = [];
100
-
101
- if (call.tool === "edit") {
102
- argumentNodes = [
103
- Text(`Writing... ~${estimateTokens(JSON.stringify(call.args))} tokens`),
104
- ];
105
- } else {
106
- argumentNodes = Object.entries(call.args).map(([key, value]) => {
107
- let node: Node | null = Text(String(value));
108
-
109
- // Syntax highlight bash args
110
- if (call.tool === "bash") {
111
- node = SyntaxHighlight(String(value), "bash", { theme: syntaxTheme });
112
- return node;
113
- }
114
-
115
- return HStack({ gap: 1 }, [
116
- Text(`${key}`, { italic: true, fgColor: theme.white }),
117
- node,
118
- ]);
119
- });
120
- }
121
-
122
- return VStack({}, [
123
- TextPill(call.tool, theme.black, theme.bwhite),
124
- ...argumentNodes,
125
- outputNode,
126
- ]);
127
- }
128
-
129
- function ConversationMessage(
130
- message: TUIMessage,
131
- syntaxTheme: SyntaxHighlightTheme,
132
- userMessageBgColor: Color | undefined,
133
- ) {
134
- const blocks: Node[] = [];
135
-
136
- if (message.thinking) {
137
- // Compress thinking blocks
138
- const estThinkingTok = estimateTokens(message.thinking);
139
- blocks.push(
140
- Text(`Thinking... ~${estThinkingTok} tokens`, {
141
- wrap: "word",
142
- fgColor: theme.bblack,
143
- }),
144
- );
145
- }
146
-
147
- if (message.text.length) {
148
- blocks.push(
149
- VStack(
150
- {
151
- width: "100%",
152
- bgColor: message.role === "user" ? userMessageBgColor : undefined,
153
- padding: { y: 1, x: message.role === "user" ? 1 : 0 },
154
- },
155
- [SyntaxHighlight(message.text, "markdown", { theme: syntaxTheme })],
156
- ),
157
- );
158
- }
159
-
160
- if (message.toolCalls?.length) {
161
- blocks.push(
162
- VStack(
163
- { gap: 1 },
164
- message.toolCalls.map((call) =>
165
- ConversationMessageToolCall(call, syntaxTheme),
166
- ),
167
- ),
168
- );
169
- }
170
-
171
- if (!message.thinking && !message.text.length && !message.toolCalls?.length) {
172
- blocks.push(
173
- Text(`Loading...`, {
174
- wrap: "word",
175
- fgColor: theme.bblack,
176
- }),
177
- );
178
- }
179
-
180
- // Msg footer:
181
- blocks.push(
182
- Text(`on ${message.timestamp} by ${message.role}`, {
183
- fgColor: theme.bblack,
184
- italic: true,
185
- }),
186
- );
187
-
188
- return VStack(
189
- {
190
- gap: 1,
191
- },
192
- blocks,
193
- );
194
- }
195
-
196
- export function Conversation(state: TUIState) {
197
- const activeTheme = getTUITheme(state.options.theme);
198
-
199
- return VStack(
200
- {
201
- flex: 1,
202
- gap: 2,
203
- overflow: "scroll",
204
- scrollOffset: state.stickToBottom ? Infinity : state.scrollOffset,
205
- onScroll(offset, maxOffset) {
206
- state.scrollOffset = offset;
207
- state.stickToBottom = offset >= maxOffset;
208
- },
209
- },
210
- state.tuiMessages.map((message) =>
211
- ConversationMessage(
212
- message,
213
- activeTheme.syntax,
214
- activeTheme.userMessageBgColor,
215
- ),
216
- ),
217
- );
218
- }
package/src/tui-editor.ts DELETED
@@ -1,29 +0,0 @@
1
- import { cel, Text, TextInput } from "@cel-tui/core";
2
- import type { TUIState } from "./types";
3
-
4
- export function Editor(
5
- state: TUIState,
6
- onChange: (value: string) => void,
7
- onKeyPress: (key: string) => void,
8
- ) {
9
- let isEditorFocused = !state.overlay;
10
- return TextInput({
11
- value: state.prompt,
12
- minHeight: 3,
13
- maxHeight: 10,
14
- padding: { x: 1 },
15
- placeholder: Text("Message...", { italic: true }),
16
- onChange,
17
- onKeyPress,
18
- // TODO: Update `cel-tui` with an autofocus prop to fix this pattern
19
- focused: isEditorFocused,
20
- onFocus: () => {
21
- isEditorFocused = true;
22
- cel.render();
23
- },
24
- onBlur: () => {
25
- isEditorFocused = false;
26
- cel.render();
27
- },
28
- });
29
- }