mini-coder 0.5.1 → 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/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. */
@@ -51,10 +64,6 @@ const THINKING_LEVELS = new Set<ThinkingLevel>([
51
64
  "xhigh",
52
65
  ]);
53
66
 
54
- function getErrorMessage(error: unknown): string {
55
- return error instanceof Error ? error.message : String(error);
56
- }
57
-
58
67
  /**
59
68
  * Load and validate user settings from disk.
60
69
  *
@@ -175,9 +184,62 @@ function sanitizeSettings(value: unknown): UserSettings {
175
184
  settings.verbose = candidate.verbose;
176
185
  }
177
186
 
187
+ const customProviders = sanitizeCustomProviders(candidate.customProviders);
188
+ if (customProviders) {
189
+ settings.customProviders = customProviders;
190
+ }
191
+
178
192
  return settings;
179
193
  }
180
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
+
181
243
  /**
182
244
  * Check whether a value is a valid thinking level.
183
245
  *
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 getGitState(state.cwd);
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 getGitState(state.cwd);
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 for exploring the codebase (rg, find, ls, cat), running tests, builds, git, etc.",
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
  *
@@ -91,6 +91,9 @@ function createTestState(): AppState {
91
91
  activeTurnPromise: null,
92
92
  showReasoning: true,
93
93
  verbose: false,
94
+ versionLabel: "dev",
95
+ customModels: [],
96
+ startupWarnings: [],
94
97
  };
95
98
  }
96
99
 
@@ -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).toContain("IMPORTANT_PREFIX");
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 () => {