mini-coder 0.5.14 → 0.6.1

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.
Files changed (70) hide show
  1. package/README.md +26 -109
  2. package/bin/mc.ts +8 -11
  3. package/bun.lock +79 -269
  4. package/nono-mini-coder.json +42 -0
  5. package/package.json +17 -22
  6. package/src/agent.ts +243 -1403
  7. package/src/args.ts +289 -0
  8. package/src/headless.ts +41 -359
  9. package/src/index.ts +29 -1016
  10. package/src/oauth.ts +117 -0
  11. package/src/prompt.ts +219 -284
  12. package/src/session.ts +55 -1306
  13. package/src/shared.ts +117 -38
  14. package/src/tool-bash.ts +110 -0
  15. package/src/tool-edit.ts +133 -0
  16. package/src/tool-read.ts +80 -293
  17. package/src/tui-components.ts +150 -0
  18. package/src/tui-conversation.ts +271 -0
  19. package/src/tui-editor.ts +29 -0
  20. package/src/tui-overlay.ts +403 -0
  21. package/src/tui.ts +228 -0
  22. package/src/types.ts +164 -0
  23. package/tsconfig.json +17 -0
  24. package/BENCHMARK.md +0 -107
  25. package/LICENSE +0 -9
  26. package/PROGRESS.md +0 -5
  27. package/assets/icon-1-minimal.svg +0 -31
  28. package/assets/icon-2-dark-terminal.svg +0 -48
  29. package/assets/icon-3-gradient-modern.svg +0 -45
  30. package/assets/icon-4-filled-bold.svg +0 -54
  31. package/assets/icon-5-community-badge.svg +0 -63
  32. package/assets/mc-claude-smart.png +0 -0
  33. package/assets/mc-gpt-smart.png +0 -0
  34. package/assets/preview-0-5-0.png +0 -0
  35. package/assets/preview.gif +0 -0
  36. package/benchmark-baseline.sh +0 -15
  37. package/benchmark-loop.sh +0 -19
  38. package/skills-lock.json +0 -15
  39. package/src/assistant-output.ts +0 -73
  40. package/src/cli.ts +0 -134
  41. package/src/delegation.ts +0 -238
  42. package/src/errors.ts +0 -15
  43. package/src/git.ts +0 -247
  44. package/src/input.ts +0 -168
  45. package/src/mcp.ts +0 -609
  46. package/src/paths.ts +0 -37
  47. package/src/session-message.ts +0 -385
  48. package/src/settings.ts +0 -449
  49. package/src/skills.ts +0 -271
  50. package/src/submit.ts +0 -376
  51. package/src/text.ts +0 -71
  52. package/src/theme.ts +0 -330
  53. package/src/tool-common.ts +0 -93
  54. package/src/tool-delegate.ts +0 -125
  55. package/src/tool-grep.ts +0 -606
  56. package/src/tool-shell.ts +0 -1051
  57. package/src/tools.ts +0 -1179
  58. package/src/ui/agent.ts +0 -320
  59. package/src/ui/commands.test.ts +0 -957
  60. package/src/ui/commands.ts +0 -848
  61. package/src/ui/conversation.test.ts +0 -585
  62. package/src/ui/conversation.ts +0 -1836
  63. package/src/ui/help.ts +0 -158
  64. package/src/ui/input.test.ts +0 -64
  65. package/src/ui/input.ts +0 -138
  66. package/src/ui/overlay.ts +0 -59
  67. package/src/ui/runtime.ts +0 -69
  68. package/src/ui/status.ts +0 -220
  69. package/src/ui.ts +0 -1190
  70. package/src/version.ts +0 -48
package/src/tool-read.ts CHANGED
@@ -1,313 +1,100 @@
1
- /**
2
- * Read-tool implementation and read-specific helpers.
3
- *
4
- * @module
5
- */
6
-
7
- import { existsSync, readFileSync, statSync } from "node:fs";
8
- import { isAbsolute, join } from "node:path";
9
- import type { Static, Tool } from "@mariozechner/pi-ai";
10
- import { Type } from "@mariozechner/pi-ai";
11
- import type { ToolHandler, ToolUpdateCallback } from "./agent.ts";
12
- import {
13
- type ToolExecResult,
14
- textResult,
15
- validateBuiltinToolArgs,
16
- } from "./tool-common.ts";
17
-
18
- const readToolParameters = Type.Object({
19
- path: Type.String({
20
- description: "File path (absolute or relative to cwd)",
21
- }),
22
- offset: Type.Optional(
23
- Type.Integer({
24
- minimum: 0,
25
- description: "Zero-based line offset to start reading from",
26
- }),
27
- ),
28
- limit: Type.Optional(
29
- Type.Integer({
30
- minimum: 1,
31
- description: "Maximum number of lines to read in this call",
32
- }),
33
- ),
34
- });
35
-
36
- /** Default line window returned when `limit` is omitted. */
37
- export const DEFAULT_READ_LIMIT = 200;
38
-
39
- /** Number of logical lines to append between progressive UI updates. */
40
- const READ_STREAM_CHUNK_LINES = 40;
1
+ import { Buffer } from "node:buffer";
2
+ import { extname, isAbsolute, join } from "node:path";
3
+ import { type Tool, Type } from "@mariozechner/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
+ };
41
15
 
42
- /** Arguments for the `read` tool. */
43
- export type ReadArgs = Static<typeof readToolParameters>;
16
+ const description = `## Read tool
44
17
 
45
- /** Continuation metadata parsed from a successful read result. */
46
- export interface ReadContinuationHint {
47
- /** Next zero-based line offset to request. */
48
- offset: number;
49
- /** Suggested line limit for the follow-up read. */
50
- limit: number;
51
- }
18
+ Read a file by path.
52
19
 
53
- /** Options for read execution. */
54
- export interface ReadOpts {
55
- /** Abort signal for interruption. */
56
- signal?: AbortSignal;
57
- /** Callback for progressive content updates. */
58
- onUpdate?: ToolUpdateCallback;
59
- }
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
+ `;
60
23
 
61
- /** pi-ai tool definition for `read`. */
62
- export const readTool: Tool<typeof readToolParameters> = {
24
+ export const read: Tool = {
63
25
  name: "read",
64
- description:
65
- "Read a UTF-8 text file from disk. " +
66
- "Takes a file path plus optional line-based `offset` and `limit`. " +
67
- "Prefer this tool over shelling out to `cat`, `sed`, `head`, or `tail` when you need file contents.",
68
- parameters: readToolParameters,
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
+ }),
69
45
  };
70
46
 
71
- function resolveReadPath(path: string, cwd: string): string {
72
- return isAbsolute(path) ? path : join(cwd, path);
73
- }
74
-
75
- function splitLinesPreservingEndings(content: string): string[] {
76
- if (content === "") {
77
- return [];
78
- }
79
-
80
- const lines: string[] = [];
81
- let start = 0;
82
-
83
- for (let index = 0; index < content.length; index++) {
84
- const char = content[index];
85
- if (char === "\n") {
86
- lines.push(content.slice(start, index + 1));
87
- start = index + 1;
88
- continue;
89
- }
90
- if (char === "\r" && content[index + 1] === "\n") {
91
- lines.push(content.slice(start, index + 2));
92
- start = index + 2;
93
- index += 1;
94
- }
95
- }
96
-
97
- if (start < content.length) {
98
- lines.push(content.slice(start));
99
- }
100
-
101
- return lines;
102
- }
103
-
104
- function waitForNextTick(): Promise<void> {
105
- return new Promise((resolve) => setImmediate(resolve));
106
- }
107
-
108
- /** Format the trailing continuation hint appended to truncated read results. */
109
- export function formatReadContinuationHint(
110
- offset: number,
111
- limit: number,
112
- ): string {
113
- return `[use offset=${offset} limit=${limit} to continue]`;
114
- }
115
-
116
- /** Parse a standalone read continuation hint block. */
117
- export function parseReadContinuationHint(
118
- text: string,
119
- ): ReadContinuationHint | null {
120
- const match = /^\[use offset=(\d+) limit=(\d+) to continue\]$/.exec(text);
121
- if (!match) {
122
- return null;
123
- }
124
-
125
- const offsetText = match[1];
126
- const limitText = match[2];
127
- if (!offsetText || !limitText) {
128
- return null;
129
- }
130
-
131
- return {
132
- offset: Number.parseInt(offsetText, 10),
133
- limit: Number.parseInt(limitText, 10),
134
- };
135
- }
136
-
137
- /** Parse a legacy flattened read result into file content plus any continuation hint. */
138
- export function parseReadResult(text: string): {
139
- body: string;
140
- continuation: ReadContinuationHint | null;
141
- } {
142
- const trailerMatch =
143
- /(?:\r?\n){2}(\[use offset=\d+ limit=\d+ to continue\])$/.exec(text);
144
- if (!trailerMatch) {
145
- return { body: text, continuation: null };
146
- }
147
-
148
- const continuation = parseReadContinuationHint(trailerMatch[1] ?? "");
149
- if (!continuation) {
150
- return { body: text, continuation: null };
151
- }
152
-
153
- return {
154
- body: text.slice(0, trailerMatch.index),
155
- continuation,
156
- };
157
- }
158
-
159
- async function streamReadPreview(
160
- lines: readonly string[],
161
- opts: ReadOpts | undefined,
162
- ): Promise<void> {
163
- if (!opts?.onUpdate || lines.length === 0) {
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}` };
164
59
  return;
165
60
  }
166
61
 
167
- const chunkSize = Math.max(1, READ_STREAM_CHUNK_LINES);
168
- for (let index = chunkSize; index < lines.length; index += chunkSize) {
169
- if (opts.signal?.aborted) {
170
- return;
171
- }
172
-
173
- opts.onUpdate(textResult(lines.slice(0, index).join(""), false));
174
- await waitForNextTick();
175
- }
176
- }
177
-
178
- function loadReadContent(
179
- args: ReadArgs,
180
- cwd: string,
181
- ): ToolExecResult | { content: string } {
182
- const filePath = resolveReadPath(args.path, cwd);
183
- if (!existsSync(filePath)) {
184
- return textResult(`File not found: ${args.path}`, true);
185
- }
186
-
187
- try {
188
- if (!statSync(filePath).isFile()) {
189
- return textResult(`Not a file: ${args.path}`, true);
190
- }
191
- } catch (error) {
192
- const message = error instanceof Error ? error.message : String(error);
193
- return textResult(`Failed to stat ${args.path}: ${message}`, true);
62
+ if (signal?.aborted) {
63
+ yield { type: "result", text: "Aborted before read." };
64
+ return;
194
65
  }
195
66
 
196
- try {
197
- return {
198
- content: readFileSync(filePath, "utf-8"),
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 },
199
75
  };
200
- } catch (error) {
201
- const message = error instanceof Error ? error.message : String(error);
202
- return textResult(`Failed to read ${args.path}: ${message}`, true);
203
- }
204
- }
205
-
206
- function getReadSlice(
207
- args: ReadArgs,
208
- content: string,
209
- ):
210
- | ToolExecResult
211
- | {
212
- lines: string[];
213
- offset: number;
214
- limit: number;
215
- } {
216
- const lines = splitLinesPreservingEndings(content);
217
- const offset = args.offset ?? 0;
218
- const limit = args.limit ?? DEFAULT_READ_LIMIT;
219
-
220
- if (offset > lines.length || (lines.length > 0 && offset === lines.length)) {
221
- return textResult(
222
- `Offset ${offset} is out of range for ${args.path} (${lines.length} lines)`,
223
- true,
224
- );
225
- }
226
-
227
- return {
228
- lines: lines.slice(offset, offset + limit),
229
- offset,
230
- limit,
231
- };
232
- }
233
-
234
- function buildReadResult(
235
- body: string,
236
- continuation: ReadContinuationHint | null,
237
- ): ToolExecResult {
238
- if (!continuation) {
239
- return textResult(body, false);
240
- }
241
-
242
- return {
243
- content: [
244
- { type: "text", text: body },
245
- {
246
- type: "text",
247
- text: formatReadContinuationHint(
248
- continuation.offset,
249
- continuation.limit,
250
- ),
251
- },
252
- ],
253
- isError: false,
254
- };
255
- }
256
-
257
- /**
258
- * Read a UTF-8 text file, optionally by line window.
259
- *
260
- * When `limit` is omitted, the tool returns a bounded initial slice and adds a
261
- * continuation hint when more content remains.
262
- *
263
- * @param args - Read arguments.
264
- * @param cwd - Working directory for resolving relative paths.
265
- * @param opts - Optional abort signal and progressive update callback.
266
- * @returns A text tool result containing file content or an error.
267
- */
268
- export async function executeRead(
269
- args: ReadArgs,
270
- cwd: string,
271
- opts?: ReadOpts,
272
- ): Promise<ToolExecResult> {
273
- const loaded = loadReadContent(args, cwd);
274
- if ("isError" in loaded) {
275
- return loaded;
76
+ return;
276
77
  }
277
78
 
278
- const slice = getReadSlice(args, loaded.content);
279
- if ("isError" in slice) {
280
- return slice;
281
- }
79
+ const content = await file.text();
80
+ const lines = content.split(/\r?\n/);
81
+ if (content.endsWith("\n")) lines.pop();
282
82
 
283
- await streamReadPreview(slice.lines, opts);
284
- if (opts?.signal?.aborted) {
285
- return textResult("Read aborted", true);
286
- }
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");
287
93
 
288
- const body = slice.lines.join("");
289
- const totalLines = splitLinesPreservingEndings(loaded.content).length;
290
- if (slice.offset + slice.lines.length >= totalLines) {
291
- return buildReadResult(body, null);
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.`;
292
97
  }
293
98
 
294
- return buildReadResult(body, {
295
- offset: slice.offset + slice.lines.length,
296
- limit: slice.limit,
297
- });
99
+ yield { type: "result", text };
298
100
  }
299
-
300
- /**
301
- * Tool handler that validates read arguments before execution.
302
- *
303
- * @param args - Raw parsed tool-call arguments.
304
- * @param cwd - Working directory for path resolution.
305
- * @param signal - Optional abort signal.
306
- * @param onUpdate - Optional progressive output callback.
307
- * @returns The read tool result.
308
- */
309
- export const readToolHandler: ToolHandler = (args, cwd, signal, onUpdate) =>
310
- executeRead(validateBuiltinToolArgs(readTool, args), cwd, {
311
- ...(signal ? { signal } : {}),
312
- ...(onUpdate ? { onUpdate } : {}),
313
- });
@@ -0,0 +1,150 @@
1
+ import { type Color, HStack, Text } from "@cel-tui/core";
2
+ import { onceEvery } from "./shared";
3
+ import type { TUIState } from "./types";
4
+
5
+ export const theme = {
6
+ black: "color00" as Color,
7
+ bblack: "color08" as Color,
8
+
9
+ red: "color01" as Color,
10
+ bred: "color09" as Color,
11
+
12
+ green: "color02" as Color,
13
+ bgreen: "color10" as Color,
14
+
15
+ yellow: "color03" as Color,
16
+ byellow: "color11" as Color,
17
+
18
+ blue: "color04" as Color,
19
+ bblue: "color12" as Color,
20
+
21
+ magenta: "color05" as Color,
22
+ bmagenta: "color13" as Color,
23
+
24
+ cyan: "color06" as Color,
25
+ bcyan: "color14" as Color,
26
+
27
+ white: "color07" as Color,
28
+ bwhite: "color15" as Color,
29
+ };
30
+
31
+ export function TextPill(
32
+ content: string,
33
+ fgColor: Color,
34
+ bgColor: Color,
35
+ size?: number | undefined,
36
+ ) {
37
+ return HStack({ gap: 1, width: size }, [
38
+ HStack({ bgColor, padding: { x: 1 } }, [
39
+ Text(content, { bold: true, fgColor }),
40
+ ]),
41
+ ]);
42
+ }
43
+
44
+ export function Spinner() {
45
+ const spinnerFrames = [
46
+ "⠁",
47
+ "⠂",
48
+ "⠄",
49
+ "⡀",
50
+ "⡈",
51
+ "⡐",
52
+ "⡠",
53
+ "⣀",
54
+ "⣁",
55
+ "⣂",
56
+ "⣄",
57
+ "⣌",
58
+ "⣔",
59
+ "⣤",
60
+ "⣥",
61
+ "⣦",
62
+ "⣮",
63
+ "⣶",
64
+ "⣷",
65
+ "⣿",
66
+ "⡿",
67
+ "⠿",
68
+ "⢟",
69
+ "⠟",
70
+ "⡛",
71
+ "⠛",
72
+ "⠫",
73
+ "⢋",
74
+ "⠋",
75
+ "⠍",
76
+ "⡉",
77
+ "⠉",
78
+ "⠑",
79
+ "⠡",
80
+ "⢁",
81
+ ];
82
+ let spinnerTick = 0;
83
+ const spinnerEvery = onceEvery(4, () => spinnerTick++);
84
+ const currentSpinner = () =>
85
+ spinnerFrames[spinnerTick % spinnerFrames.length];
86
+
87
+ return { spinnerEvery, currentSpinner };
88
+ }
89
+
90
+ export function ActivityPill(state: TUIState, spinnerFrame: string) {
91
+ let label = "idle";
92
+ const frame = state.streaming ? spinnerFrame : "";
93
+
94
+ if (frame) {
95
+ label = frame;
96
+ }
97
+
98
+ return Text(label);
99
+ }
100
+
101
+ export function ContextPill(state: TUIState) {
102
+ let text = "";
103
+ let bg = theme.bgreen;
104
+ if (!state.contextSize) {
105
+ text = "0%";
106
+ bg = theme.bwhite;
107
+ } else {
108
+ // Colors show the progress in the "smart window" or how much
109
+ // before the dumb zone. The user facing percentage is the model
110
+ // amount. An elegant way to show both :)
111
+ const max = state.options.model.contextWindow;
112
+ const smartMax = 80000;
113
+ const percent = Math.floor((state.contextSize / max) * 100);
114
+ const smartPercent = Math.floor((state.contextSize / smartMax) * 100);
115
+ if (smartPercent > 90) {
116
+ bg = theme.bred;
117
+ } else if (smartPercent > 80) {
118
+ bg = theme.red;
119
+ } else if (smartPercent > 70) {
120
+ bg = theme.yellow;
121
+ } else if (smartPercent > 50) {
122
+ bg = theme.byellow;
123
+ }
124
+ text = `~${percent}%`;
125
+ }
126
+
127
+ text += ` (${state.options.model.contextWindow / 1000}k)`;
128
+
129
+ return TextPill(text, theme.bblack, bg);
130
+ }
131
+
132
+ export function GitPill(state: TUIState) {
133
+ return TextPill(state.gitBranch ?? "No git.", theme.bwhite, theme.bblack);
134
+ }
135
+
136
+ export function ModelPill(state: TUIState) {
137
+ // Like loot: Gold/purple/blue/green/white -> xhigh/high/medium/low/minimal
138
+ switch (state.options.effort) {
139
+ case "xhigh":
140
+ return TextPill(state.options.model.name, theme.black, theme.yellow);
141
+ case "high":
142
+ return TextPill(state.options.model.name, theme.black, theme.magenta);
143
+ case "medium":
144
+ return TextPill(state.options.model.name, theme.black, theme.blue);
145
+ case "low":
146
+ return TextPill(state.options.model.name, theme.black, theme.green);
147
+ }
148
+ // Minimal
149
+ return TextPill(state.options.model.name, theme.bwhite, theme.bblack);
150
+ }