mini-coder 0.7.4 → 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.
Files changed (47) hide show
  1. package/AGENTS.md +114 -0
  2. package/README.md +53 -66
  3. package/bin/mini-coder.ts +2 -0
  4. package/demo.gif +0 -0
  5. package/package.json +17 -20
  6. package/src/agent.ts +181 -274
  7. package/src/cli.ts +101 -0
  8. package/src/config.ts +150 -0
  9. package/src/prompt.ts +54 -207
  10. package/src/session.ts +124 -69
  11. package/src/tools/bash.ts +89 -0
  12. package/src/tools/common.ts +32 -0
  13. package/src/tools/edit.ts +41 -0
  14. package/src/tools/index.ts +47 -0
  15. package/src/tools/read.ts +64 -0
  16. package/src/tui/commands.ts +63 -0
  17. package/src/tui/editor.ts +291 -0
  18. package/src/tui/highlight.ts +189 -0
  19. package/src/tui/stream.ts +142 -0
  20. package/src/tui/styles.ts +42 -0
  21. package/src/tui/term.ts +436 -0
  22. package/src/tui/theme.ts +121 -0
  23. package/src/tui/tui.ts +595 -0
  24. package/src/tui/usage.ts +67 -0
  25. package/tsconfig.json +8 -8
  26. package/bin/mc.ts +0 -11
  27. package/bun.lock +0 -350
  28. package/nono-mini-coder.json +0 -42
  29. package/src/args.ts +0 -252
  30. package/src/error-handling.test.ts +0 -163
  31. package/src/git.ts +0 -23
  32. package/src/headless.ts +0 -66
  33. package/src/index.ts +0 -43
  34. package/src/models.ts +0 -191
  35. package/src/oauth.ts +0 -147
  36. package/src/shared.ts +0 -119
  37. package/src/themes.ts +0 -234
  38. package/src/tool-bash.ts +0 -77
  39. package/src/tool-edit.ts +0 -121
  40. package/src/tool-read.ts +0 -100
  41. package/src/tui-components.ts +0 -127
  42. package/src/tui-conversation.ts +0 -218
  43. package/src/tui-editor.ts +0 -29
  44. package/src/tui-overlay.ts +0 -604
  45. package/src/tui.ts +0 -314
  46. package/src/types.ts +0 -194
  47. package/src/update.ts +0 -171
package/src/tool-bash.ts DELETED
@@ -1,77 +0,0 @@
1
- import { type Tool, Type } from "@earendil-works/pi-ai";
2
- import type { ToolRunnerEvent } from "./types";
3
-
4
- const description = `Bash CLI tool
5
-
6
- Execute shell commands on the user's environment.
7
-
8
- - Chain commands **only** when failure should stop the flow. Avoid long chains, **2 to 3 maximum**.
9
- - Avoid overly complex one-liners; readability matters.
10
- - Quote filenames: use \`"$file"\` not \`$file\`.
11
- - Be careful with spaces in filenames.
12
-
13
- Commands run in: ${process.cwd()}
14
- `;
15
-
16
- export const bash: Tool = {
17
- name: "bash",
18
- description,
19
- parameters: Type.Object({
20
- command: Type.String({
21
- description:
22
- "Shell command to execute. Prefer simple, focused commands over complex one-liners.",
23
- }),
24
- }),
25
- };
26
-
27
- export async function* runBashTool(
28
- args: Record<string, any>,
29
- signal?: AbortSignal,
30
- ): AsyncGenerator<ToolRunnerEvent> {
31
- // Redirect stderr into stdout for the whole shell session.
32
- const proc = Bun.spawn(["bash", "-c", `exec 2>&1; ${args.command}`], {
33
- stdout: "pipe",
34
- stderr: "pipe",
35
- env: {
36
- ...Bun.env,
37
- NO_COLOR: "1",
38
- },
39
- signal,
40
- });
41
-
42
- const decoder = new TextDecoder();
43
- const reader = proc.stdout.getReader();
44
-
45
- let output = "";
46
- while (true) {
47
- const { done, value } = await reader.read();
48
-
49
- if (done) {
50
- const remaining = Bun.stripANSI(decoder.decode());
51
-
52
- if (remaining.length) {
53
- output += remaining;
54
- yield { type: "output", text: remaining };
55
- }
56
- break;
57
- }
58
-
59
- const text = Bun.stripANSI(decoder.decode(value, { stream: true }));
60
-
61
- if (text.length) {
62
- output += text;
63
- yield { type: "output", text: text };
64
- }
65
- }
66
-
67
- const exitCode = await proc.exited;
68
-
69
- const result = `${output.length ? output : "(no output)"}\n\nExit code: ${exitCode}`;
70
-
71
- yield {
72
- type: "result",
73
- text: result,
74
- };
75
-
76
- return result;
77
- }
package/src/tool-edit.ts DELETED
@@ -1,121 +0,0 @@
1
- import { isAbsolute, join } from "node:path";
2
- import { type Tool, Type } from "@earendil-works/pi-ai";
3
- import { createPatch } from "diff";
4
- import type { ToolRunnerEvent } from "./types";
5
-
6
- const description = `Edit tool
7
-
8
- A find-and-replace file editor. Use it to create new files or modify existing ones safely. Always prefer this tool over bash editing methods (sed, awk, etc).
9
-
10
- Rules
11
-
12
- - The tool refuses to edit on multiple matches of \`oldText\`. Be specific with your matching text.
13
- - Prefer patch-based edits (small targeted replacements) for multi-line or semantic changes.
14
- - Do NOT reproduce entire files. Use shell file operations (\`cp\`, \`mv\`, etc) for wholesale file replacement instead.
15
-
16
- Failure modes
17
-
18
- - If \`oldText\` is not found, the edit fails. Verify the exact text first.
19
- - If \`oldText\` matches multiple locations, the edit fails. Narrow your match and retry.
20
- - If the file does not exist and \`oldText\` is non-empty, the edit fails.
21
- `;
22
-
23
- export const edit: Tool = {
24
- name: `edit`,
25
- description,
26
- parameters: Type.Object({
27
- path: Type.String({
28
- description:
29
- "File path. Absolute or relative to the current working directory.",
30
- }),
31
- oldText: Type.String({
32
- description:
33
- 'Exact text to find and replace. Empty string means "create new file".',
34
- }),
35
- newText: Type.String({
36
- description: "Replacement text (or full content for new files)",
37
- }),
38
- }),
39
- };
40
-
41
- function findAllIndexes(text: string, sub: string): number[] {
42
- if (sub.length === 0) return [];
43
-
44
- const indexes: number[] = [];
45
-
46
- let pos = text.indexOf(sub, 0);
47
- while (pos !== -1) {
48
- indexes.push(pos);
49
- pos += sub.length; // use pos += 1 if you want overlapping matches
50
- pos = text.indexOf(sub, pos);
51
- }
52
-
53
- return indexes;
54
- }
55
-
56
- export async function* runEditTool(
57
- args: Record<string, any>,
58
- signal?: AbortSignal,
59
- ): AsyncGenerator<ToolRunnerEvent> {
60
- const filePath = isAbsolute(args.path)
61
- ? args.path
62
- : join(process.cwd(), args.path);
63
- const file = Bun.file(filePath);
64
- const exists = await file.exists();
65
-
66
- if (args.oldText === "") {
67
- if (exists) {
68
- yield { type: "result", text: `File already exists: ${filePath}` };
69
- return;
70
- }
71
-
72
- await Bun.write(file, args.newText);
73
- yield {
74
- type: "result",
75
- text: `File written: ${filePath}\n\n${args.newText}`,
76
- };
77
- return;
78
- }
79
-
80
- if (!exists) {
81
- yield { type: "result", text: `File not found: ${filePath}` };
82
- return;
83
- }
84
-
85
- const content = await file.text();
86
- const matches = findAllIndexes(content, args.oldText);
87
-
88
- if (matches.length === 0) {
89
- yield { type: "result", text: `Old text not found in: ${filePath}` };
90
- return;
91
- }
92
-
93
- if (matches.length > 1) {
94
- yield {
95
- type: "result",
96
- text: `Multiple matches found in ${filePath}: ${matches.length} matches, be more specific and try again`,
97
- };
98
- return;
99
- }
100
-
101
- const idx = matches[0];
102
- const updated =
103
- content.slice(0, idx) +
104
- args.newText +
105
- content.slice(idx + args.oldText.length);
106
-
107
- if (signal?.aborted) {
108
- yield { type: "result", text: "Aborted before write." };
109
- return;
110
- }
111
-
112
- await file.write(updated);
113
- const patch = createPatch(filePath, content, updated);
114
-
115
- yield {
116
- type: "result",
117
- text: patch,
118
- };
119
-
120
- return;
121
- }
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
- }