mini-coder 0.5.0 → 0.5.2
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/README.md +2 -0
- package/bun.lock +39 -40
- package/package.json +10 -11
- package/src/agent.ts +49 -1
- package/src/errors.ts +15 -0
- package/src/git.ts +50 -17
- package/src/index.ts +151 -11
- package/src/prompt.ts +19 -9
- package/src/session.ts +211 -6
- package/src/settings.ts +72 -3
- package/src/skills.ts +11 -7
- package/src/submit.ts +4 -6
- package/src/tools.ts +3 -2
- package/src/ui/agent.ts +1 -4
- package/src/ui/commands.test.ts +3 -0
- package/src/ui/commands.ts +1 -4
- package/src/ui/conversation.test.ts +165 -1
- package/src/ui/conversation.ts +268 -36
- package/src/ui/help.test.ts +18 -0
- package/src/ui/help.ts +6 -0
- package/src/ui/input.test.ts +5 -1
- package/src/ui/input.ts +7 -1
- package/src/ui/status.test.ts +4 -0
- package/src/ui.ts +96 -11
- package/src/version.ts +48 -0
package/src/settings.ts
CHANGED
|
@@ -10,6 +10,17 @@
|
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { dirname } from "node:path";
|
|
12
12
|
import type { ThinkingLevel } from "@mariozechner/pi-ai";
|
|
13
|
+
import { getErrorMessage } from "./errors.ts";
|
|
14
|
+
|
|
15
|
+
/** A user-configured OpenAI-compatible provider endpoint. */
|
|
16
|
+
export interface CustomProvider {
|
|
17
|
+
/** Provider identifier, e.g. "ollama". Shown as the provider prefix in model names. */
|
|
18
|
+
name: string;
|
|
19
|
+
/** OpenAI-compatible API base URL, e.g. "http://localhost:11434/v1". */
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
/** Optional API key. Defaults to "no-key" at discovery time. */
|
|
22
|
+
apiKey?: string;
|
|
23
|
+
}
|
|
13
24
|
|
|
14
25
|
/** Default reasoning effort when no saved setting exists. */
|
|
15
26
|
const DEFAULT_EFFORT: ThinkingLevel = "medium";
|
|
@@ -30,6 +41,8 @@ export interface UserSettings {
|
|
|
30
41
|
showReasoning?: boolean;
|
|
31
42
|
/** Whether full tool output is shown in the UI. */
|
|
32
43
|
verbose?: boolean;
|
|
44
|
+
/** Custom OpenAI-compatible provider endpoints. */
|
|
45
|
+
customProviders?: CustomProvider[];
|
|
33
46
|
}
|
|
34
47
|
|
|
35
48
|
/** Resolved startup settings after applying defaults and availability checks. */
|
|
@@ -54,7 +67,8 @@ const THINKING_LEVELS = new Set<ThinkingLevel>([
|
|
|
54
67
|
/**
|
|
55
68
|
* Load and validate user settings from disk.
|
|
56
69
|
*
|
|
57
|
-
*
|
|
70
|
+
* Missing files are treated as empty settings. Invalid JSON or unreadable files
|
|
71
|
+
* fail with a descriptive error instead of silently discarding saved state.
|
|
58
72
|
*
|
|
59
73
|
* @param path - Absolute path to `settings.json`.
|
|
60
74
|
* @returns The validated settings object.
|
|
@@ -67,8 +81,10 @@ export function loadSettings(path: string): UserSettings {
|
|
|
67
81
|
try {
|
|
68
82
|
const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
|
|
69
83
|
return sanitizeSettings(raw);
|
|
70
|
-
} catch {
|
|
71
|
-
|
|
84
|
+
} catch (error) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`Failed to read settings ${path}: ${getErrorMessage(error)}`,
|
|
87
|
+
);
|
|
72
88
|
}
|
|
73
89
|
}
|
|
74
90
|
|
|
@@ -168,9 +184,62 @@ function sanitizeSettings(value: unknown): UserSettings {
|
|
|
168
184
|
settings.verbose = candidate.verbose;
|
|
169
185
|
}
|
|
170
186
|
|
|
187
|
+
const customProviders = sanitizeCustomProviders(candidate.customProviders);
|
|
188
|
+
if (customProviders) {
|
|
189
|
+
settings.customProviders = customProviders;
|
|
190
|
+
}
|
|
191
|
+
|
|
171
192
|
return settings;
|
|
172
193
|
}
|
|
173
194
|
|
|
195
|
+
/** Try to parse a single custom provider entry, returning null on failure. */
|
|
196
|
+
function parseCustomProvider(item: unknown): CustomProvider | null {
|
|
197
|
+
if (item == null || typeof item !== "object" || Array.isArray(item)) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const candidate = item as Record<string, unknown>;
|
|
202
|
+
const name = typeof candidate.name === "string" ? candidate.name.trim() : "";
|
|
203
|
+
const baseUrl =
|
|
204
|
+
typeof candidate.baseUrl === "string" ? candidate.baseUrl.trim() : "";
|
|
205
|
+
|
|
206
|
+
if (!name || !baseUrl) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const entry: CustomProvider = { name, baseUrl };
|
|
211
|
+
if (typeof candidate.apiKey === "string") {
|
|
212
|
+
entry.apiKey = candidate.apiKey;
|
|
213
|
+
}
|
|
214
|
+
return entry;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Validate and normalize custom provider entries.
|
|
219
|
+
*
|
|
220
|
+
* Drops entries with missing/empty name or baseUrl, and deduplicates by name
|
|
221
|
+
* (first entry wins).
|
|
222
|
+
*/
|
|
223
|
+
function sanitizeCustomProviders(value: unknown): CustomProvider[] | undefined {
|
|
224
|
+
if (!Array.isArray(value)) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const result: CustomProvider[] = [];
|
|
229
|
+
const seen = new Set<string>();
|
|
230
|
+
|
|
231
|
+
for (const item of value) {
|
|
232
|
+
const entry = parseCustomProvider(item);
|
|
233
|
+
if (!entry || seen.has(entry.name)) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
seen.add(entry.name);
|
|
237
|
+
result.push(entry);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return result.length > 0 ? result : undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
174
243
|
/**
|
|
175
244
|
* Check whether a value is a valid thinking level.
|
|
176
245
|
*
|
package/src/skills.ts
CHANGED
|
@@ -183,13 +183,17 @@ function readSkill(basePath: string, entry: string): Skill | null {
|
|
|
183
183
|
return null;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
186
|
+
try {
|
|
187
|
+
const content = readFileSync(skillPath, "utf-8");
|
|
188
|
+
const frontmatter = parseFrontmatter(content);
|
|
189
|
+
return {
|
|
190
|
+
name: frontmatter.name ?? entry,
|
|
191
|
+
description: frontmatter.description ?? "",
|
|
192
|
+
path: skillPath,
|
|
193
|
+
};
|
|
194
|
+
} catch {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
/**
|
package/src/submit.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import type { UserMessage } from "@mariozechner/pi-ai";
|
|
9
9
|
import type { AgentEvent } from "./agent.ts";
|
|
10
10
|
import { runAgentLoop } from "./agent.ts";
|
|
11
|
+
import { getErrorMessage } from "./errors.ts";
|
|
11
12
|
import { getGitState } from "./git.ts";
|
|
12
13
|
import {
|
|
13
14
|
type AppState,
|
|
@@ -103,10 +104,6 @@ export function isEmptyUserContent(content: UserMessage["content"]): boolean {
|
|
|
103
104
|
);
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
function getErrorMessage(error: unknown): string {
|
|
107
|
-
return error instanceof Error ? error.message : String(error);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
107
|
function buildSkillMessageContent(
|
|
111
108
|
skillName: string,
|
|
112
109
|
userText: string,
|
|
@@ -276,12 +273,13 @@ export async function submitResolvedInput(
|
|
|
276
273
|
content,
|
|
277
274
|
timestamp: Date.now(),
|
|
278
275
|
} satisfies UserMessage;
|
|
276
|
+
const loadGitState = state.loadGitState ?? getGitState;
|
|
279
277
|
|
|
280
278
|
const turn = appendMessage(state.db, session.id, userMessage);
|
|
281
279
|
state.messages.push(userMessage);
|
|
282
280
|
hooks?.onUserMessage?.(state);
|
|
283
281
|
|
|
284
|
-
state.git = await
|
|
282
|
+
state.git = await loadGitState(state.cwd);
|
|
285
283
|
|
|
286
284
|
const systemPrompt = buildPrompt(state);
|
|
287
285
|
const { tools, toolHandlers } = buildToolList(state);
|
|
@@ -313,7 +311,7 @@ export async function submitResolvedInput(
|
|
|
313
311
|
},
|
|
314
312
|
});
|
|
315
313
|
stopReason = result.stopReason;
|
|
316
|
-
state.git = await
|
|
314
|
+
state.git = await loadGitState(state.cwd);
|
|
317
315
|
return result.stopReason;
|
|
318
316
|
} finally {
|
|
319
317
|
state.running = false;
|
package/src/tools.ts
CHANGED
|
@@ -532,7 +532,8 @@ export const editTool: Tool = {
|
|
|
532
532
|
"Make an exact-text replacement in a single file. " +
|
|
533
533
|
"Provide the file path, the exact text to find, and the replacement text. " +
|
|
534
534
|
"The old text must match exactly one location in the file. " +
|
|
535
|
-
"To create a new file, use an empty old text and the full file content as new text."
|
|
535
|
+
"To create a new file, use an empty old text and the full file content as new text. " +
|
|
536
|
+
"Use this to write the exact final file content the task requires.",
|
|
536
537
|
parameters: Type.Object({
|
|
537
538
|
path: Type.String({
|
|
538
539
|
description: "File path (absolute or relative to cwd)",
|
|
@@ -552,7 +553,7 @@ export const shellTool: Tool = {
|
|
|
552
553
|
name: "shell",
|
|
553
554
|
description:
|
|
554
555
|
"Run a command in the user's shell. Returns stdout, stderr, and exit code. " +
|
|
555
|
-
"Use
|
|
556
|
+
"Use this to explore the codebase, read tests/verifiers/examples, inspect required outputs, and run targeted checks, builds, or git commands.",
|
|
556
557
|
parameters: Type.Object({
|
|
557
558
|
command: Type.String({ description: "The shell command to execute" }),
|
|
558
559
|
}),
|
package/src/ui/agent.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
|
13
13
|
import type { AgentEvent } from "../agent.ts";
|
|
14
|
+
import { getErrorMessage } from "../errors.ts";
|
|
14
15
|
import type { AppState } from "../index.ts";
|
|
15
16
|
import { resolveRawInput, submitResolvedInput } from "../submit.ts";
|
|
16
17
|
import type {
|
|
@@ -75,10 +76,6 @@ export function getStreamingConversationState(): StreamingConversationState {
|
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
function getErrorMessage(error: unknown): string {
|
|
79
|
-
return error instanceof Error ? error.message : String(error);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
79
|
/**
|
|
83
80
|
* Create the UI agent controller bound to runtime hooks supplied by `ui.ts`.
|
|
84
81
|
*
|
package/src/ui/commands.test.ts
CHANGED
package/src/ui/commands.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { Select } from "@cel-tui/components";
|
|
|
14
14
|
import type { Model, ThinkingLevel } from "@mariozechner/pi-ai";
|
|
15
15
|
import type { OAuthProviderInterface } from "@mariozechner/pi-ai/oauth";
|
|
16
16
|
import { getOAuthProviders } from "@mariozechner/pi-ai/oauth";
|
|
17
|
+
import { getErrorMessage } from "../errors.ts";
|
|
17
18
|
import type { AppState } from "../index.ts";
|
|
18
19
|
import { getAvailableModels, saveOAuthCredentials } from "../index.ts";
|
|
19
20
|
import { COMMANDS } from "../input.ts";
|
|
@@ -39,10 +40,6 @@ const EFFORT_LEVELS: { label: string; value: ThinkingLevel }[] = [
|
|
|
39
40
|
{ label: "xhigh", value: "xhigh" },
|
|
40
41
|
];
|
|
41
42
|
|
|
42
|
-
function getErrorMessage(error: unknown): string {
|
|
43
|
-
return error instanceof Error ? error.message : String(error);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
43
|
/** Runtime hooks injected from the stateful UI module. */
|
|
47
44
|
interface UiCommandRuntime {
|
|
48
45
|
/** Open an overlay and trigger a re-render. */
|
|
@@ -40,6 +40,12 @@ function collectText(node: Node | null): string[] {
|
|
|
40
40
|
if (node.type === "textinput") {
|
|
41
41
|
return [];
|
|
42
42
|
}
|
|
43
|
+
if (
|
|
44
|
+
node.type === "hstack" &&
|
|
45
|
+
node.children.every((child) => child.type === "text")
|
|
46
|
+
) {
|
|
47
|
+
return [node.children.map((child) => child.content).join("")];
|
|
48
|
+
}
|
|
43
49
|
return node.children.flatMap((child) => collectText(child));
|
|
44
50
|
}
|
|
45
51
|
|
|
@@ -524,6 +530,164 @@ describe("ui/conversation", () => {
|
|
|
524
530
|
expect(text).not.toContain('"command": "echo hi"');
|
|
525
531
|
});
|
|
526
532
|
|
|
533
|
+
test("renderAssistantMessage for a shell tool call syntax-highlights bash tokens", async () => {
|
|
534
|
+
// Arrange
|
|
535
|
+
const assistant = {
|
|
536
|
+
content: [
|
|
537
|
+
fauxToolCall(
|
|
538
|
+
"shell",
|
|
539
|
+
{ command: 'if true; then echo "$HOME"; fi' },
|
|
540
|
+
{ id: "tool-1" },
|
|
541
|
+
),
|
|
542
|
+
],
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// Act
|
|
546
|
+
const rows = await renderBufferRows(
|
|
547
|
+
renderAssistantMessage(assistant, RENDER_OPTS),
|
|
548
|
+
48,
|
|
549
|
+
12,
|
|
550
|
+
);
|
|
551
|
+
const commandRow = rows.find((row) =>
|
|
552
|
+
row.text.includes('if true; then echo "$HOME"; fi'),
|
|
553
|
+
);
|
|
554
|
+
|
|
555
|
+
// Assert
|
|
556
|
+
expect(commandRow).toBeDefined();
|
|
557
|
+
expect(commandRow?.fgColors[commandRow.text.indexOf("if")]).toBe(
|
|
558
|
+
DEFAULT_THEME.secondaryAccentText ?? null,
|
|
559
|
+
);
|
|
560
|
+
expect(commandRow?.fgColors[commandRow.text.indexOf("echo")]).toBe(
|
|
561
|
+
DEFAULT_THEME.accentText ?? null,
|
|
562
|
+
);
|
|
563
|
+
expect(commandRow?.fgColors[commandRow.text.indexOf('"$HOME"')]).toBe(
|
|
564
|
+
DEFAULT_THEME.diffAdded ?? null,
|
|
565
|
+
);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("renderAssistantMessage for a multiline shell tool call preserves syntax state across lines", async () => {
|
|
569
|
+
// Arrange
|
|
570
|
+
const assistant = {
|
|
571
|
+
content: [
|
|
572
|
+
fauxToolCall(
|
|
573
|
+
"shell",
|
|
574
|
+
{ command: "printf 'foo\nbar'" },
|
|
575
|
+
{ id: "tool-1" },
|
|
576
|
+
),
|
|
577
|
+
],
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
// Act
|
|
581
|
+
const rows = await renderBufferRows(
|
|
582
|
+
renderAssistantMessage(assistant, {
|
|
583
|
+
...RENDER_OPTS,
|
|
584
|
+
verbose: true,
|
|
585
|
+
}),
|
|
586
|
+
32,
|
|
587
|
+
12,
|
|
588
|
+
);
|
|
589
|
+
const firstRow = rows.find((row) => row.text.includes("printf 'foo"));
|
|
590
|
+
const secondRow = rows.find((row) => row.text.includes("bar'"));
|
|
591
|
+
|
|
592
|
+
// Assert
|
|
593
|
+
expect(firstRow).toBeDefined();
|
|
594
|
+
expect(secondRow).toBeDefined();
|
|
595
|
+
expect(firstRow?.fgColors[firstRow.text.indexOf("foo")]).toBe(
|
|
596
|
+
DEFAULT_THEME.diffAdded ?? null,
|
|
597
|
+
);
|
|
598
|
+
expect(secondRow?.fgColors[secondRow.text.indexOf("bar")]).toBe(
|
|
599
|
+
DEFAULT_THEME.diffAdded ?? null,
|
|
600
|
+
);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("renderAssistantMessage for a shell tool call uses theme-derived syntax colors", async () => {
|
|
604
|
+
// Arrange
|
|
605
|
+
const theme = {
|
|
606
|
+
...DEFAULT_THEME,
|
|
607
|
+
accentText: "color14",
|
|
608
|
+
secondaryAccentText: "color09",
|
|
609
|
+
diffAdded: "color10",
|
|
610
|
+
mutedText: "color13",
|
|
611
|
+
toolText: "color15",
|
|
612
|
+
} satisfies typeof DEFAULT_THEME;
|
|
613
|
+
const assistant = {
|
|
614
|
+
content: [
|
|
615
|
+
fauxToolCall(
|
|
616
|
+
"shell",
|
|
617
|
+
{ command: 'if true; then echo "$HOME"; fi' },
|
|
618
|
+
{ id: "tool-1" },
|
|
619
|
+
),
|
|
620
|
+
],
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
// Act
|
|
624
|
+
const rows = await renderBufferRows(
|
|
625
|
+
renderAssistantMessage(assistant, {
|
|
626
|
+
...RENDER_OPTS,
|
|
627
|
+
theme,
|
|
628
|
+
}),
|
|
629
|
+
48,
|
|
630
|
+
12,
|
|
631
|
+
);
|
|
632
|
+
const commandRow = rows.find((row) =>
|
|
633
|
+
row.text.includes('if true; then echo "$HOME"; fi'),
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
// Assert
|
|
637
|
+
expect(commandRow).toBeDefined();
|
|
638
|
+
expect(commandRow?.fgColors[commandRow.text.indexOf("if")]).toBe(
|
|
639
|
+
theme.secondaryAccentText ?? null,
|
|
640
|
+
);
|
|
641
|
+
expect(commandRow?.fgColors[commandRow.text.indexOf("echo")]).toBe(
|
|
642
|
+
theme.accentText ?? null,
|
|
643
|
+
);
|
|
644
|
+
expect(commandRow?.fgColors[commandRow.text.indexOf('"$HOME"')]).toBe(
|
|
645
|
+
theme.diffAdded ?? null,
|
|
646
|
+
);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
test("renderAssistantMessage for a long single-token shell argument wraps through the tail in verbose mode", async () => {
|
|
650
|
+
// Arrange
|
|
651
|
+
const command = `printf ${"x".repeat(60)}TAIL`;
|
|
652
|
+
const assistant = {
|
|
653
|
+
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// Act
|
|
657
|
+
const text = await renderVisibleText(
|
|
658
|
+
renderAssistantMessage(assistant, {
|
|
659
|
+
...RENDER_OPTS,
|
|
660
|
+
verbose: true,
|
|
661
|
+
previewWidth: 24,
|
|
662
|
+
}),
|
|
663
|
+
24,
|
|
664
|
+
20,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
// Assert
|
|
668
|
+
expect(text.some((line) => line.includes("TAIL"))).toBe(true);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
test("renderAssistantMessage for a long single-token shell command uses wrapped preview height when verbose is off", () => {
|
|
672
|
+
// Arrange
|
|
673
|
+
const command = `printf ${"x".repeat(220)}TAIL`;
|
|
674
|
+
const assistant = {
|
|
675
|
+
content: [fauxToolCall("shell", { command }, { id: "tool-1" })],
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
// Act
|
|
679
|
+
const height = measureRenderedHeight(
|
|
680
|
+
renderAssistantMessage(assistant, {
|
|
681
|
+
...RENDER_OPTS,
|
|
682
|
+
previewWidth: 24,
|
|
683
|
+
}),
|
|
684
|
+
24,
|
|
685
|
+
);
|
|
686
|
+
|
|
687
|
+
// Assert
|
|
688
|
+
expect(height).toBe(9);
|
|
689
|
+
});
|
|
690
|
+
|
|
527
691
|
test("renderAssistantMessage for a wrapped shell command keeps a fixed preview height when verbose is off", () => {
|
|
528
692
|
// Arrange
|
|
529
693
|
const command = Array.from(
|
|
@@ -564,7 +728,7 @@ describe("ui/conversation", () => {
|
|
|
564
728
|
);
|
|
565
729
|
|
|
566
730
|
// Assert
|
|
567
|
-
expect(text).
|
|
731
|
+
expect(text.some((line) => line.includes("IMPORTANT_PREFIX"))).toBe(true);
|
|
568
732
|
});
|
|
569
733
|
|
|
570
734
|
test("renderToolResult for a shell preview allows the outer conversation scroll to handle mouse wheel events", async () => {
|