mini-coder 0.7.1 → 0.7.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "packageManager": "bun@1.3.13",
6
6
  "bin": {
package/src/agent.ts CHANGED
@@ -78,10 +78,6 @@ export async function* streamAgent(
78
78
 
79
79
  // Main agent loop, continues until llm sends a response other than toolCall or has no tool calls.
80
80
  while (true) {
81
- let estimate = estimateTokens(JSON.stringify(llmCtx));
82
- // 80k is the agreed uppon threshold to the DUMB ZONE
83
- if (estimate > 80000) compactContext(llmCtx.messages);
84
-
85
81
  const s = streamSimple(agentCtx.options.model, llmCtx, {
86
82
  reasoning: agentCtx.options.effort,
87
83
  signal: agentCtx.signal,
@@ -115,9 +111,6 @@ export async function* streamAgent(
115
111
  yield { type: "message_update", partial };
116
112
  }
117
113
 
118
- estimate = estimateTokens(JSON.stringify(llmCtx));
119
- // 80k is the agreed uppon threshold to the DUMB ZONE
120
- if (estimate > 80000) compactContext(llmCtx.messages);
121
114
  break;
122
115
  }
123
116
 
@@ -134,6 +127,12 @@ export async function* streamAgent(
134
127
  return;
135
128
  }
136
129
  }
130
+
131
+ // Experiment: use this inside the inner loop for "running" compaction
132
+ // after we are in the dumb zone.
133
+ const estimate = estimateTokens(JSON.stringify(llmCtx));
134
+ // 80k is the agreed uppon threshold to the DUMB ZONE
135
+ if (estimate > 80000) compactContext(llmCtx.messages);
137
136
  }
138
137
 
139
138
  const message = await s.result();
package/src/args.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  import { Value } from "typebox/value";
10
10
  import { getAvailableProviders, isOAuthProvider, loginOAuth } from "./oauth";
11
11
  import { DATA_DIR, SETTINGS_PATH } from "./shared.ts";
12
+ import { DEFAULT_TUI_THEME_ID } from "./themes.ts";
12
13
  import {
13
14
  type CliOptions,
14
15
  CliOptionsSchema,
@@ -63,7 +64,14 @@ async function getSettings(): Promise<Settings | undefined> {
63
64
  }
64
65
 
65
66
  const jsonText = await file.text();
66
- const settings = JSON.parse(jsonText) as unknown;
67
+ let settings: unknown;
68
+
69
+ try {
70
+ settings = JSON.parse(jsonText) as unknown;
71
+ } catch (err) {
72
+ const message = err instanceof Error ? err.message : String(err);
73
+ throw new Error(`Invalid settings JSON: ${message}`);
74
+ }
67
75
 
68
76
  return parseSettings(settings, "settings");
69
77
  }
@@ -149,6 +157,7 @@ export async function handleArgv(argv: string[]): Promise<CliOptions> {
149
157
  ? DEFAULT_MODEL_ID
150
158
  : getFirstModelConfig(settingsProvider, customProviders).id);
151
159
  const settingsEffort = settings?.effort ?? DEFAULT_EFFORT;
160
+ const settingsTheme = settings?.theme ?? DEFAULT_TUI_THEME_ID;
152
161
  let provider: string = settingsProvider;
153
162
  let modelId = settingsModelId;
154
163
  let effort: string = settingsEffort;
@@ -273,6 +282,7 @@ export async function handleArgv(argv: string[]): Promise<CliOptions> {
273
282
  effort: cliSettings.effort,
274
283
  prompt,
275
284
  customProviders,
285
+ theme: settingsTheme,
276
286
  });
277
287
  const nextSettings = parseSettings(
278
288
  {
@@ -280,6 +290,7 @@ export async function handleArgv(argv: string[]): Promise<CliOptions> {
280
290
  model: options.model.id,
281
291
  effort: options.effort,
282
292
  customProviders,
293
+ theme: options.theme,
283
294
  },
284
295
  "settings",
285
296
  );
@@ -0,0 +1,163 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { runBashTool } from "./tool-bash.ts";
6
+ import { runEditTool } from "./tool-edit.ts";
7
+ import { runReadTool } from "./tool-read.ts";
8
+ import type { ToolRunnerEvent } from "./types.ts";
9
+
10
+ const originalDataDir = Bun.env.MINI_CODER_DATA_DIR;
11
+ const testHome = await mkdtemp(join(tmpdir(), "mini-coder-errors-"));
12
+ const dataDir = join(testHome, "data");
13
+ Bun.env.MINI_CODER_DATA_DIR = dataDir;
14
+
15
+ const sessionsDir = join(dataDir, "sessions");
16
+ const { handleArgv } = await import("./args.ts");
17
+ const { getAvailableProviders } = await import("./oauth.ts");
18
+ const { getSession, listSessionsForCwd } = await import("./session.ts");
19
+
20
+ type ResultEvent = Extract<ToolRunnerEvent, { type: "result" }>;
21
+
22
+ async function collectToolEvents(
23
+ events: AsyncGenerator<ToolRunnerEvent>,
24
+ ): Promise<ToolRunnerEvent[]> {
25
+ const collected: ToolRunnerEvent[] = [];
26
+
27
+ for await (const event of events) {
28
+ collected.push(event);
29
+ }
30
+
31
+ return collected;
32
+ }
33
+
34
+ function resultEvent(events: ToolRunnerEvent[]): ResultEvent {
35
+ const result = events.findLast(
36
+ (event): event is ResultEvent => event.type === "result",
37
+ );
38
+
39
+ if (!result) {
40
+ throw new Error("Tool did not yield a result");
41
+ }
42
+
43
+ return result;
44
+ }
45
+
46
+ async function toolResult(
47
+ events: AsyncGenerator<ToolRunnerEvent>,
48
+ ): Promise<ResultEvent> {
49
+ return resultEvent(await collectToolEvents(events));
50
+ }
51
+
52
+ beforeEach(async () => {
53
+ await rm(dataDir, { recursive: true, force: true });
54
+ });
55
+
56
+ afterAll(async () => {
57
+ if (originalDataDir === undefined) {
58
+ delete Bun.env.MINI_CODER_DATA_DIR;
59
+ } else {
60
+ Bun.env.MINI_CODER_DATA_DIR = originalDataDir;
61
+ }
62
+
63
+ await rm(testHome, { recursive: true, force: true });
64
+ });
65
+
66
+ describe("tool runner error handling", () => {
67
+ test("read and edit report missing files as tool results", async () => {
68
+ const missingPath = join(testHome, "missing.txt");
69
+
70
+ const readResult = await toolResult(runReadTool({ path: missingPath }));
71
+ expect(readResult.text).toBe(`File not found: ${missingPath}`);
72
+
73
+ const editResult = await toolResult(
74
+ runEditTool({ path: missingPath, oldText: "old", newText: "new" }),
75
+ );
76
+ expect(editResult.text).toBe(`File not found: ${missingPath}`);
77
+ });
78
+
79
+ test("edit refuses unsafe writes and preserves the file", async () => {
80
+ const path = join(testHome, "edit.txt");
81
+ await writeFile(path, "one\none\n");
82
+
83
+ const ambiguous = await toolResult(
84
+ runEditTool({ path, oldText: "one", newText: "two" }),
85
+ );
86
+ expect(ambiguous.text).toContain("Multiple matches found");
87
+ expect(await Bun.file(path).text()).toBe("one\none\n");
88
+
89
+ const missing = await toolResult(
90
+ runEditTool({ path, oldText: "missing", newText: "two" }),
91
+ );
92
+ expect(missing.text).toBe(`Old text not found in: ${path}`);
93
+ expect(await Bun.file(path).text()).toBe("one\none\n");
94
+
95
+ const abortController = new AbortController();
96
+ abortController.abort();
97
+ await writeFile(path, "hello");
98
+
99
+ const aborted = await toolResult(
100
+ runEditTool(
101
+ { path, oldText: "hello", newText: "bye" },
102
+ abortController.signal,
103
+ ),
104
+ );
105
+ expect(aborted.text).toBe("Aborted before write.");
106
+ expect(await Bun.file(path).text()).toBe("hello");
107
+ });
108
+
109
+ test("bash reports non-zero exits instead of throwing", async () => {
110
+ const result = await toolResult(
111
+ runBashTool({ command: 'printf "bad"; exit 7' }),
112
+ );
113
+
114
+ expect(result.text).toBe("bad\n\nExit code: 7");
115
+ });
116
+ });
117
+
118
+ describe("startup file error handling", () => {
119
+ test("settings JSON parse failures are labelled", async () => {
120
+ await mkdir(dataDir, { recursive: true });
121
+ await writeFile(join(dataDir, "settings.json"), "{");
122
+
123
+ await expect(handleArgv([])).rejects.toThrow("Invalid settings JSON:");
124
+ });
125
+
126
+ test("auth JSON parse failures are labelled", async () => {
127
+ await mkdir(dataDir, { recursive: true });
128
+ await writeFile(join(dataDir, "auth.json"), "{");
129
+
130
+ await expect(getAvailableProviders()).rejects.toThrow("Invalid auth JSON:");
131
+ });
132
+ });
133
+
134
+ describe("session file error handling", () => {
135
+ test("listSessionsForCwd ignores missing dirs and bad session files", async () => {
136
+ expect(await listSessionsForCwd()).toEqual([]);
137
+
138
+ await mkdir(sessionsDir, { recursive: true });
139
+ await writeFile(join(sessionsDir, "broken.json"), "{");
140
+ await writeFile(
141
+ join(sessionsDir, "elsewhere.json"),
142
+ JSON.stringify({ id: "elsewhere", cwd: "/elsewhere", messages: [] }),
143
+ );
144
+ await writeFile(
145
+ join(sessionsDir, "ok.json"),
146
+ JSON.stringify({
147
+ id: "ok",
148
+ cwd: process.cwd(),
149
+ messages: [{ role: "user", content: "hi", timestamp: 2 }],
150
+ }),
151
+ );
152
+
153
+ const sessions = await listSessionsForCwd();
154
+ expect(sessions.map((session) => session.id)).toEqual(["ok"]);
155
+ });
156
+
157
+ test("getSession returns undefined for bad session files", async () => {
158
+ await mkdir(sessionsDir, { recursive: true });
159
+ await writeFile(join(sessionsDir, "broken.json"), "{");
160
+
161
+ expect(await getSession("broken")).toBeUndefined();
162
+ });
163
+ });
package/src/headless.ts CHANGED
@@ -39,6 +39,7 @@ export async function streamHeadless(
39
39
  };
40
40
 
41
41
  const agent = streamAgent(ctx);
42
+
42
43
  for await (const ev of agent) {
43
44
  switch (ev.type) {
44
45
  case "message_end":
package/src/index.ts CHANGED
@@ -4,10 +4,18 @@ import { getBranchLabel } from "./git.ts";
4
4
  import { streamHeadless } from "./headless.ts";
5
5
  import { initTUI } from "./tui.ts";
6
6
  import type { TUIState } from "./types.ts";
7
+ import { updateMiniCoder } from "./update.ts";
7
8
 
8
9
  export async function main(): Promise<void> {
10
+ const argv = process.argv.slice(2);
11
+
12
+ if (argv.includes("--update")) {
13
+ await updateMiniCoder();
14
+ return;
15
+ }
16
+
9
17
  const cwd = basename(process.cwd());
10
- const options = await handleArgv(process.argv.slice(2));
18
+ const options = await handleArgv(argv);
11
19
 
12
20
  function leave(msg?: string) {
13
21
  if (msg) console.log(msg);
package/src/oauth.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { mkdir } from "node:fs/promises";
1
2
  import readline from "node:readline";
2
3
 
3
4
  import { getEnvApiKey, getProviders } from "@earendil-works/pi-ai";
@@ -10,7 +11,7 @@ import {
10
11
  type OAuthProviderId,
11
12
  type OAuthSelectPrompt,
12
13
  } from "@earendil-works/pi-ai/oauth";
13
- import { AUTH_PATH as AUTH_FILE } from "./shared";
14
+ import { AUTH_PATH as AUTH_FILE, DATA_DIR } from "./shared";
14
15
  import type { CliOptions, SavedOAuthCreds } from "./types";
15
16
 
16
17
  type ReadlineInterface = ReturnType<typeof readline.createInterface>;
@@ -134,13 +135,19 @@ export async function getApiKey(options: CliOptions) {
134
135
  export async function readCreds(): Promise<SavedOAuthCreds> {
135
136
  const file = Bun.file(AUTH_FILE);
136
137
  if (await file.exists()) {
137
- return JSON.parse(await file.text());
138
+ try {
139
+ return JSON.parse(await file.text());
140
+ } catch (err) {
141
+ const message = err instanceof Error ? err.message : String(err);
142
+ throw new Error(`Invalid auth JSON: ${message}`);
143
+ }
138
144
  }
139
145
 
140
146
  return {};
141
147
  }
142
148
 
143
149
  async function writeCreds(creds: SavedOAuthCreds) {
150
+ await mkdir(DATA_DIR, { recursive: true });
144
151
  await Bun.write(AUTH_FILE, JSON.stringify(await mergeCreds(creds)));
145
152
  }
146
153
 
package/src/prompt.ts CHANGED
@@ -11,12 +11,11 @@ export const MAIN_PROMPT = `You are a coding agent interacting with users via th
11
11
  - Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without unnecessary superlatives, praise, or emotional validation.
12
12
  - User messages and Tool results may include <system-reminder> tags. These contain system-generated reminders and bear no direct relation to the specific tool result in which they appear.
13
13
  - You have access to bash, read and edit tools. Prefer using read and edit for file operations, use bash for finding read candidates or to run development commands.
14
- - If a tool call fails or is denied, do NOT re-attempt the exact same call. Analyze why it failed and adjust your approach.
15
14
  - Use recent online information, the current environment, and your training data combined for a complete answer.
16
15
  - Ensure that you fulfill the user's expectation, requirements and contract **exactly**.
16
+ - Do not overstate what changed or what was verified. Summaries must match the diff.
17
17
  - Use temp directory for temp files, scripts, plan files, or anything that doesn't match the requested output.
18
18
  - Be concise. Use a professional colleague tone: direct, never condescending, and never rude.
19
- - Do not overstate what changed or what was verified. Summaries must match the diff.
20
19
  `;
21
20
 
22
21
  async function getDir() {
package/src/session.ts CHANGED
@@ -9,7 +9,6 @@ export async function ensureSessionsDir(): Promise<void> {
9
9
  await mkdir(SESSIONS_DIR, { recursive: true });
10
10
  }
11
11
 
12
- // readSession: writes the session
13
12
  export async function getSession(id: string): Promise<Session | undefined> {
14
13
  const file = Bun.file(join(SESSIONS_DIR, `${id}.json`));
15
14
 
@@ -17,11 +16,16 @@ export async function getSession(id: string): Promise<Session | undefined> {
17
16
  return;
18
17
  }
19
18
 
20
- const sessionJson = await file.text();
21
- const parsed = JSON.parse(sessionJson) as unknown;
22
- const valid = Value.Check(SessionSchema, parsed);
19
+ try {
20
+ const sessionJson = await file.text();
21
+ const parsed = JSON.parse(sessionJson) as unknown;
22
+ const valid = Value.Check(SessionSchema, parsed);
23
+
24
+ if (valid) return parsed;
25
+ } catch {
26
+ return;
27
+ }
23
28
 
24
- if (valid) return parsed;
25
29
  return;
26
30
  }
27
31
 
@@ -65,7 +69,6 @@ export async function saveSession(s: Session) {
65
69
  await Bun.write(file, JSON.stringify(s));
66
70
  }
67
71
 
68
- // updateSession: finds and appends to the existing setting file
69
72
  export async function updateSession(id: string, messages: Message[]) {
70
73
  const existing = await getSession(id);
71
74
  if (existing) {
package/src/shared.ts CHANGED
@@ -4,7 +4,8 @@ import { parseDocument } from "yaml";
4
4
 
5
5
  // Mixed bag of helpers that can be shared across the codebase
6
6
 
7
- export const DATA_DIR = join(homedir(), ".config", "mini-coder");
7
+ export const DATA_DIR =
8
+ Bun.env.MINI_CODER_DATA_DIR ?? join(homedir(), ".config", "mini-coder");
8
9
  export const SESSIONS_DIR = join(DATA_DIR, "sessions");
9
10
  export const AUTH_PATH = join(DATA_DIR, "auth.json");
10
11
  export const SETTINGS_PATH = join(DATA_DIR, "settings.json");
package/src/themes.ts ADDED
@@ -0,0 +1,234 @@
1
+ import type { SyntaxHighlightTheme } from "@cel-tui/components";
2
+ import type { Color, Theme } from "@cel-tui/core";
3
+
4
+ export const TUI_THEME_IDS = [
5
+ "ansi16",
6
+ "molokai-dark",
7
+ "molokai-light",
8
+ ] as const;
9
+
10
+ export type TUIThemeId = (typeof TUI_THEME_IDS)[number];
11
+
12
+ export interface TUIThemeDefinition {
13
+ id: TUIThemeId;
14
+ label: string;
15
+ palette: Theme;
16
+ syntax: SyntaxHighlightTheme;
17
+ rootFgColor?: Color;
18
+ rootBgColor?: Color;
19
+ userMessageBgColor?: Color;
20
+ }
21
+
22
+ const ANSI_SLOT_HEX: Record<Color, string> = {
23
+ color00: "#000000",
24
+ color01: "#cd3131",
25
+ color02: "#0dbc79",
26
+ color03: "#e5e510",
27
+ color04: "#2472c8",
28
+ color05: "#bc3fbc",
29
+ color06: "#11a8cd",
30
+ color07: "#e5e5e5",
31
+ color08: "#666666",
32
+ color09: "#f14c4c",
33
+ color10: "#23d18b",
34
+ color11: "#f5f543",
35
+ color12: "#3b8eea",
36
+ color13: "#d670d6",
37
+ color14: "#29b8db",
38
+ color15: "#ffffff",
39
+ };
40
+
41
+ export const theme = {
42
+ black: "color00",
43
+ red: "color01",
44
+ green: "color02",
45
+ yellow: "color03",
46
+ blue: "color04",
47
+ magenta: "color05",
48
+ cyan: "color06",
49
+ white: "color07",
50
+ bblack: "color08",
51
+ bred: "color09",
52
+ bgreen: "color10",
53
+ byellow: "color11",
54
+ bblue: "color12",
55
+ bmagenta: "color13",
56
+ bcyan: "color14",
57
+ bwhite: "color15",
58
+ } as const satisfies Record<string, Color>;
59
+
60
+ const ansi16Palette: Theme = {
61
+ color00: 0,
62
+ color01: 1,
63
+ color02: 2,
64
+ color03: 3,
65
+ color04: 4,
66
+ color05: 5,
67
+ color06: 6,
68
+ color07: 7,
69
+ color08: 8,
70
+ color09: 9,
71
+ color10: 10,
72
+ color11: 11,
73
+ color12: 12,
74
+ color13: 13,
75
+ color14: 14,
76
+ color15: 15,
77
+ };
78
+
79
+ const molokaiDarkPalette: Theme = {
80
+ color00: "#272822",
81
+ color01: "#f92672",
82
+ color02: "#a6e22e",
83
+ color03: "#e6db74",
84
+ color04: "#66d9ef",
85
+ color05: "#f92672",
86
+ color06: "#66d9ef",
87
+ color07: "#f8f8f2",
88
+ color08: "#6f705f",
89
+ color09: "#ff6188",
90
+ color10: "#a6e22e",
91
+ color11: "#ffd866",
92
+ color12: "#78dce8",
93
+ color13: "#ae81ff",
94
+ color14: "#66d9ef",
95
+ color15: "#ffffff",
96
+ };
97
+
98
+ const molokaiLightPalette: Theme = {
99
+ color00: "#272822",
100
+ color01: "#ff5f87",
101
+ color02: "#8bcf26",
102
+ color03: "#c7a100",
103
+ color04: "#61aeee",
104
+ color05: "#d16dff",
105
+ color06: "#00a8b5",
106
+ color07: "#f2efe4",
107
+ color08: "#5f6060",
108
+ color09: "#b0003a",
109
+ color10: "#3f7d00",
110
+ color11: "#725f00",
111
+ color12: "#005f9f",
112
+ color13: "#7f2caf",
113
+ color14: "#007885",
114
+ color15: "#fffdf5",
115
+ };
116
+
117
+ const slotColor = (slot: Color) => ANSI_SLOT_HEX[slot];
118
+
119
+ function syntaxScope(
120
+ scope: string | readonly string[],
121
+ foreground: Color,
122
+ fontStyle?: string,
123
+ ) {
124
+ return {
125
+ scope,
126
+ settings: {
127
+ foreground: slotColor(foreground),
128
+ ...(fontStyle ? { fontStyle } : {}),
129
+ },
130
+ };
131
+ }
132
+
133
+ const molokaiDarkSyntax = {
134
+ name: "mini-coder-molokai-dark",
135
+ type: "dark",
136
+ fg: slotColor("color07"),
137
+ tokenColors: [
138
+ syntaxScope(["comment", "markup.quote"], "color08", "italic"),
139
+ syntaxScope(["keyword", "operator"], "color05"),
140
+ syntaxScope(["string", "escape", "markup.list"], "color03"),
141
+ syntaxScope(["number", "regexp"], "color13"),
142
+ syntaxScope(["function", "command", "markup.code"], "color10"),
143
+ syntaxScope(["builtin", "property", "type", "meta"], "color06"),
144
+ syntaxScope("markup.heading", "color04", "bold"),
145
+ syntaxScope(["diff.deleted", "diff.file.old"], "color09"),
146
+ syntaxScope(["diff.inserted", "diff.file.new"], "color10"),
147
+ syntaxScope("diff.hunk", "color13"),
148
+ syntaxScope(["diff.header", "diff.no-newline"], "color08"),
149
+ ],
150
+ } as const satisfies SyntaxHighlightTheme;
151
+
152
+ const molokaiLightSyntax = {
153
+ name: "mini-coder-molokai-light",
154
+ type: "light",
155
+ fg: slotColor("color00"),
156
+ tokenColors: [
157
+ syntaxScope(["comment", "markup.quote"], "color08", "italic"),
158
+ syntaxScope(["keyword", "operator"], "color13"),
159
+ syntaxScope(["string", "escape", "markup.list"], "color11"),
160
+ syntaxScope(["number", "regexp"], "color09"),
161
+ syntaxScope(["function", "command", "markup.code"], "color10"),
162
+ syntaxScope(["builtin", "property", "type", "meta"], "color14"),
163
+ syntaxScope("markup.heading", "color12", "bold"),
164
+ syntaxScope(["diff.deleted", "diff.file.old"], "color09"),
165
+ syntaxScope(["diff.inserted", "diff.file.new"], "color10"),
166
+ syntaxScope("diff.hunk", "color13"),
167
+ syntaxScope(["diff.header", "diff.no-newline"], "color08"),
168
+ ],
169
+ } as const satisfies SyntaxHighlightTheme;
170
+
171
+ export const DEFAULT_TUI_THEME_ID: TUIThemeId = "ansi16";
172
+
173
+ export const TUI_THEMES = {
174
+ ansi16: {
175
+ id: "ansi16",
176
+ label: "ansi16",
177
+ palette: ansi16Palette,
178
+ syntax: "default",
179
+ userMessageBgColor: theme.bblack,
180
+ },
181
+ "molokai-dark": {
182
+ id: "molokai-dark",
183
+ label: "molokai dark",
184
+ palette: molokaiDarkPalette,
185
+ syntax: molokaiDarkSyntax,
186
+ rootFgColor: theme.bwhite,
187
+ rootBgColor: theme.black,
188
+ userMessageBgColor: theme.bblack,
189
+ },
190
+ "molokai-light": {
191
+ id: "molokai-light",
192
+ label: "molokai light",
193
+ palette: molokaiLightPalette,
194
+ syntax: molokaiLightSyntax,
195
+ rootFgColor: theme.black,
196
+ rootBgColor: theme.bwhite,
197
+ userMessageBgColor: theme.white,
198
+ },
199
+ } as const satisfies Record<TUIThemeId, TUIThemeDefinition>;
200
+
201
+ export const activeTuiTheme: Theme = { ...ansi16Palette };
202
+
203
+ export function applyTUITheme(id: TUIThemeId): void {
204
+ Object.assign(activeTuiTheme, TUI_THEMES[id].palette);
205
+ }
206
+
207
+ export function getTUITheme(id: TUIThemeId): TUIThemeDefinition {
208
+ return TUI_THEMES[id];
209
+ }
210
+
211
+ export function textColorForBackground(
212
+ bgColor: Color,
213
+ themeId: TUIThemeId,
214
+ ): Color {
215
+ if (bgColor === theme.bblack) return theme.bwhite;
216
+
217
+ if (
218
+ themeId === "molokai-light" &&
219
+ (
220
+ [
221
+ theme.bred,
222
+ theme.bgreen,
223
+ theme.byellow,
224
+ theme.bblue,
225
+ theme.bmagenta,
226
+ theme.bcyan,
227
+ ] as readonly Color[]
228
+ ).includes(bgColor)
229
+ ) {
230
+ return theme.bwhite;
231
+ }
232
+
233
+ return theme.black;
234
+ }
package/src/tool-bash.ts CHANGED
@@ -66,10 +66,7 @@ export async function* runBashTool(
66
66
 
67
67
  const exitCode = await proc.exited;
68
68
 
69
- let result = `${output.length ? output : "(no ouput)"}\n\nExit code: ${exitCode}`;
70
- if (output.length) {
71
- result += `${output}`;
72
- }
69
+ const result = `${output.length ? output : "(no output)"}\n\nExit code: ${exitCode}`;
73
70
 
74
71
  yield {
75
72
  type: "result",
@@ -1,32 +1,9 @@
1
1
  import { type Color, HStack, Text } from "@cel-tui/core";
2
2
  import { onceEvery } from "./shared";
3
+ import { textColorForBackground, theme } from "./themes";
3
4
  import type { TUIState } from "./types";
4
5
 
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
- };
6
+ export { theme };
30
7
 
31
8
  export function TextPill(
32
9
  content: string,
@@ -100,7 +77,7 @@ export function ActivityPill(state: TUIState, spinnerFrame: string) {
100
77
 
101
78
  export function ContextPill(state: TUIState) {
102
79
  let text = "";
103
- let bg = theme.bgreen;
80
+ let bg: Color = theme.bgreen;
104
81
  if (!state.contextSize) {
105
82
  text = "0%";
106
83
  bg = theme.bwhite;
@@ -126,7 +103,7 @@ export function ContextPill(state: TUIState) {
126
103
 
127
104
  text += ` (${state.options.model.contextWindow / 1000}k)`;
128
105
 
129
- return TextPill(text, theme.black, bg);
106
+ return TextPill(text, textColorForBackground(bg, state.options.theme), bg);
130
107
  }
131
108
 
132
109
  export function GitPill(state: TUIState) {
@@ -1,44 +1,71 @@
1
- import { SyntaxHighlight } from "@cel-tui/components";
2
- import { HStack, type Node, Text, VStack } from "@cel-tui/core";
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";
3
6
  import { estimateTokens } from "./shared";
7
+ import { getTUITheme, textColorForBackground } from "./themes";
4
8
  import { TextPill, theme } from "./tui-components";
5
9
  import type { TUIMessage, TUIState, TUIToolCall } from "./types";
6
10
 
7
- export function emptyState(): Node {
11
+ export function emptyState(state: TUIState): Node {
8
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
+ : [];
9
28
  return HStack({ flex: 1, alignItems: "center" }, [
10
29
  VStack({ flex: 1, alignItems: "center", gap: 1 }, [
11
30
  HStack({ gap: 1 }, [
12
31
  Text("mini"),
13
- TextPill("coder", theme.black, randColor),
32
+ TextPill(
33
+ "coder",
34
+ textColorForBackground(randColor, state.options.theme),
35
+ randColor,
36
+ ),
14
37
  ]),
15
38
  VStack({ gap: 1 }, [
16
39
  HStack({ gap: 1 }, [
17
- TextPill("/new", randColor, theme.bblack, 13),
40
+ TextPill("/new", shortcutFgColor, theme.bblack, 13),
18
41
  Text("Start a new session from the input box.", {
19
42
  fgColor: theme.bblack,
20
43
  }),
21
44
  ]),
22
45
  HStack({ gap: 1 }, [
23
- TextPill("ctrl+p", randColor, theme.bblack, 13),
46
+ TextPill("ctrl+p", shortcutFgColor, theme.bblack, 13),
24
47
  Text("Menu for session history, and settings.", {
25
48
  fgColor: theme.bblack,
26
49
  }),
27
50
  ]),
28
51
  HStack({ gap: 1 }, [
29
- TextPill("ESC", randColor, theme.bblack, 13),
52
+ TextPill("ESC", shortcutFgColor, theme.bblack, 13),
30
53
  Text("Abort agent response.", { fgColor: theme.bblack }),
31
54
  ]),
32
55
  HStack({ gap: 1 }, [
33
- TextPill("ctrl+c|d|q", randColor, theme.bblack, 13),
56
+ TextPill("ctrl+c|d|q", shortcutFgColor, theme.bblack, 13),
34
57
  Text("Quit.", { fgColor: theme.bblack }),
35
58
  ]),
59
+ ...updateNotice,
36
60
  ]),
37
61
  ]),
38
62
  ]);
39
63
  }
40
64
 
41
- function ConversationMessageToolCall(call: TUIToolCall) {
65
+ function ConversationMessageToolCall(
66
+ call: TUIToolCall,
67
+ syntaxTheme: SyntaxHighlightTheme,
68
+ ) {
42
69
  let outputNode: Node | null = null;
43
70
 
44
71
  // Compress read and bash calls
@@ -64,7 +91,7 @@ function ConversationMessageToolCall(call: TUIToolCall) {
64
91
 
65
92
  outputNode = VStack({ width: "100%" }, blocks);
66
93
  } else if (call.tool === "edit") {
67
- outputNode = SyntaxHighlight(call.output, "patch");
94
+ outputNode = SyntaxHighlight(call.output, "patch", { theme: syntaxTheme });
68
95
  } else {
69
96
  outputNode = Text(call.output, { wrap: "word" });
70
97
  }
@@ -81,7 +108,7 @@ function ConversationMessageToolCall(call: TUIToolCall) {
81
108
 
82
109
  // Syntax highlight bash args
83
110
  if (call.tool === "bash") {
84
- node = SyntaxHighlight(String(value), "bash");
111
+ node = SyntaxHighlight(String(value), "bash", { theme: syntaxTheme });
85
112
  return node;
86
113
  }
87
114
 
@@ -99,7 +126,11 @@ function ConversationMessageToolCall(call: TUIToolCall) {
99
126
  ]);
100
127
  }
101
128
 
102
- function ConversationMessage(message: TUIMessage) {
129
+ function ConversationMessage(
130
+ message: TUIMessage,
131
+ syntaxTheme: SyntaxHighlightTheme,
132
+ userMessageBgColor: Color | undefined,
133
+ ) {
103
134
  const blocks: Node[] = [];
104
135
 
105
136
  if (message.thinking) {
@@ -118,17 +149,22 @@ function ConversationMessage(message: TUIMessage) {
118
149
  VStack(
119
150
  {
120
151
  width: "100%",
121
- bgColor: message.role === "user" ? theme.bblack : undefined,
152
+ bgColor: message.role === "user" ? userMessageBgColor : undefined,
122
153
  padding: { y: 1, x: message.role === "user" ? 1 : 0 },
123
154
  },
124
- [SyntaxHighlight(message.text, "markdown")],
155
+ [SyntaxHighlight(message.text, "markdown", { theme: syntaxTheme })],
125
156
  ),
126
157
  );
127
158
  }
128
159
 
129
160
  if (message.toolCalls?.length) {
130
161
  blocks.push(
131
- VStack({ gap: 1 }, message.toolCalls.map(ConversationMessageToolCall)),
162
+ VStack(
163
+ { gap: 1 },
164
+ message.toolCalls.map((call) =>
165
+ ConversationMessageToolCall(call, syntaxTheme),
166
+ ),
167
+ ),
132
168
  );
133
169
  }
134
170
 
@@ -158,6 +194,8 @@ function ConversationMessage(message: TUIMessage) {
158
194
  }
159
195
 
160
196
  export function Conversation(state: TUIState) {
197
+ const activeTheme = getTUITheme(state.options.theme);
198
+
161
199
  return VStack(
162
200
  {
163
201
  flex: 1,
@@ -169,6 +207,12 @@ export function Conversation(state: TUIState) {
169
207
  state.stickToBottom = offset >= maxOffset;
170
208
  },
171
209
  },
172
- state.tuiMessages.map(ConversationMessage),
210
+ state.tuiMessages.map((message) =>
211
+ ConversationMessage(
212
+ message,
213
+ activeTheme.syntax,
214
+ activeTheme.userMessageBgColor,
215
+ ),
216
+ ),
173
217
  );
174
218
  }
@@ -1,4 +1,4 @@
1
- import { HStack, Text, TextInput, VStack } from "@cel-tui/core";
1
+ import { cel, HStack, Text, TextInput, VStack } from "@cel-tui/core";
2
2
  import {
3
3
  getModels,
4
4
  type Message,
@@ -9,6 +9,7 @@ import { saveSettings } from "./args";
9
9
  import { getAvailableProviders } from "./oauth";
10
10
  import { listSessionsForCwd } from "./session";
11
11
  import { estimateTokens, formatTimestamp } from "./shared";
12
+ import { applyTUITheme, getTUITheme, TUI_THEME_IDS } from "./themes";
12
13
  import { TextPill, theme } from "./tui-components";
13
14
  import type { SelectOptions, SelectState, Session, TUIState } from "./types";
14
15
 
@@ -344,12 +345,28 @@ export function mainMenu(state: TUIState, initialPane = "main") {
344
345
  })),
345
346
  });
346
347
 
348
+ const themesPane = (): MenuPane => ({
349
+ label: "themes",
350
+ filter: "",
351
+ list: TUI_THEME_IDS.map((id) => {
352
+ const tuiTheme = getTUITheme(id);
353
+ return {
354
+ label:
355
+ id === state.options.theme
356
+ ? `${tuiTheme.label} (current)`
357
+ : tuiTheme.label,
358
+ value: id,
359
+ };
360
+ }),
361
+ });
362
+
347
363
  const mainPane: MenuPane = {
348
364
  label: "main",
349
365
  filter: state.prompt.length ? state.prompt : "",
350
366
  list: [
351
367
  { label: "models and providers", value: "providers" },
352
368
  { label: "reasoning effort", value: "effort" },
369
+ { label: "themes", value: "themes" },
353
370
  { label: "sessions", value: "sessions" },
354
371
  { label: "fork", value: "fork" },
355
372
  ],
@@ -359,7 +376,7 @@ export function mainMenu(state: TUIState, initialPane = "main") {
359
376
  filter: "",
360
377
  list: efforts,
361
378
  };
362
- const panes: MenuPane[] = [effortPane];
379
+ const panes: MenuPane[] = [effortPane, themesPane()];
363
380
  let currentPane = initialPane === "fork" ? forkPane() : mainPane;
364
381
  let selectedProvider: string | undefined;
365
382
 
@@ -387,6 +404,14 @@ export function mainMenu(state: TUIState, initialPane = "main") {
387
404
  state.overlay = false;
388
405
  };
389
406
 
407
+ const requestThemeRefresh = () => {
408
+ state.forceThemeRefresh = true;
409
+ setTimeout(() => {
410
+ state.forceThemeRefresh = false;
411
+ cel.render();
412
+ }, 0);
413
+ };
414
+
390
415
  const toTUIMessage = (msg: Message) => {
391
416
  const textFromContent = (
392
417
  content: string | { type: string; text?: string }[],
@@ -509,6 +534,24 @@ export function mainMenu(state: TUIState, initialPane = "main") {
509
534
  return;
510
535
  }
511
536
 
537
+ if (currentPane.label === "themes") {
538
+ const nextTheme = TUI_THEME_IDS.find((id) => id === s.selected);
539
+ if (!nextTheme) return;
540
+
541
+ state.options.theme = nextTheme;
542
+ applyTUITheme(nextTheme);
543
+ saveSettings({
544
+ provider: state.options.provider,
545
+ model: state.options.model.id,
546
+ effort: state.options.effort,
547
+ customProviders: state.options.customProviders,
548
+ theme: nextTheme,
549
+ });
550
+ requestThemeRefresh();
551
+ closeMenu(s);
552
+ return;
553
+ }
554
+
512
555
  if (currentPane.label === "providers") {
513
556
  const provider = currentProviders.find((v) => v === s.selected);
514
557
  if (!provider) return;
@@ -541,6 +584,7 @@ export function mainMenu(state: TUIState, initialPane = "main") {
541
584
  model: model.id,
542
585
  effort: state.options.effort,
543
586
  customProviders: state.options.customProviders,
587
+ theme: state.options.theme,
544
588
  });
545
589
  closeMenu(s);
546
590
  return;
@@ -556,6 +600,7 @@ export function mainMenu(state: TUIState, initialPane = "main") {
556
600
  model: state.options.model.id,
557
601
  effort: effort,
558
602
  customProviders: state.options.customProviders,
603
+ theme: state.options.theme,
559
604
  });
560
605
  closeMenu(s);
561
606
  }
package/src/tui.ts CHANGED
@@ -1,6 +1,3 @@
1
- import { stat } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
1
  import { cel, HStack, ProcessTerminal, VStack } from "@cel-tui/core";
5
2
  import type {
6
3
  AssistantMessage,
@@ -16,6 +13,7 @@ import {
16
13
  } from "./prompt";
17
14
  import { updateSession } from "./session";
18
15
  import { estimateTokens, formatTimestamp, secureRandomString } from "./shared";
16
+ import { activeTuiTheme, applyTUITheme, getTUITheme } from "./themes";
19
17
  import { bash, runBashTool } from "./tool-bash";
20
18
  import { edit, runEditTool } from "./tool-edit";
21
19
  import { read, runReadTool } from "./tool-read";
@@ -32,6 +30,11 @@ import { Conversation, emptyState } from "./tui-conversation";
32
30
  import { Editor } from "./tui-editor";
33
31
  import { mainMenu } from "./tui-overlay";
34
32
  import type { AgentContex, ToolAndRunner, TUIMessage, TUIState } from "./types";
33
+ import { getAvailableUpdate } from "./update";
34
+
35
+ async function refreshAvailableUpdate(state: TUIState): Promise<void> {
36
+ state.availableUpdate = await getAvailableUpdate();
37
+ }
35
38
 
36
39
  function clearOrAbort(state: TUIState) {
37
40
  // Are we mid stream? Abort it.
@@ -45,55 +48,10 @@ function clearOrAbort(state: TUIState) {
45
48
  }
46
49
  }
47
50
 
48
- async function directoryExists(path: string): Promise<boolean> {
49
- try {
50
- return (await stat(path)).isDirectory();
51
- } catch {
52
- return false;
53
- }
54
- }
55
-
56
- async function handleSkillSlashCommand(prompt: string) {
57
- const match = prompt.trim().match(/^\/([a-zA-Z0-9_-]+)(?:\s|$)/);
58
-
59
- if (!match) return;
60
-
61
- const skillName = match[1];
62
- const skillRoots = [
63
- join(homedir(), ".agents", "skills"),
64
- join(process.cwd(), ".agents", "skills"),
65
- ];
66
-
67
- for (const root of skillRoots) {
68
- if (!(await directoryExists(root))) {
69
- continue;
70
- }
71
-
72
- const glob = new Bun.Glob("*/SKILL.md");
73
-
74
- for await (const path of glob.scan({ cwd: root, absolute: true })) {
75
- const file = Bun.file(path);
76
-
77
- if (!(await file.exists())) {
78
- continue;
79
- }
80
-
81
- const body = await file.text();
82
- const frontmatter = body.match(/^---\s*\n([\s\S]*?)\n---/);
83
- const name = frontmatter?.[1]
84
- .match(/^name:\s*["']?([^"'\n]+)["']?\s*$/m)?.[1]
85
- ?.trim();
86
-
87
- if (name === skillName) {
88
- return body;
89
- }
90
- }
91
- }
92
- }
93
-
94
51
  export function initTUI(state: TUIState, leave: (s: string) => void) {
95
52
  // TODO: Cleanup accumulated sessions for this cwd.
96
53
  const { spinnerEvery, currentSpinner } = Spinner();
54
+ void refreshAvailableUpdate(state);
97
55
 
98
56
  // Stable 60fps rendering.
99
57
  // This ensure Xfps, and excessive calls get coalesced in cel-tui.
@@ -136,16 +94,6 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
136
94
  const submit = async () => {
137
95
  await streamAgentTUI(state);
138
96
  };
139
- const skillSubmit = async () => {
140
- const skill = await handleSkillSlashCommand(state.prompt);
141
- if (skill) {
142
- state.streaming = true;
143
- // This cause the prompt to flash the body before vanishing on submit.
144
- // To avoid this I added a new state property
145
- state.promptSkill = skill;
146
- await streamAgentTUI(state);
147
- }
148
- };
149
97
  // onKeyPress
150
98
  if (key === "enter") {
151
99
  if (state.prompt === ":q") {
@@ -164,17 +112,15 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
164
112
  state.stickToBottom = true;
165
113
  return false;
166
114
  }
167
- if (state.prompt && state.prompt[0] === "/" && !state.streaming) {
168
- skillSubmit();
169
- return false;
170
- }
171
115
  if (state.prompt && !state.streaming) submit();
172
116
  return false;
173
117
  }
174
118
  };
175
119
 
176
- cel.init(new ProcessTerminal());
120
+ applyTUITheme(state.options.theme);
121
+ cel.init(new ProcessTerminal(), { theme: activeTuiTheme });
177
122
  cel.viewport(() => {
123
+ const activeTheme = getTUITheme(state.options.theme);
178
124
  const layers = [
179
125
  VStack(
180
126
  {
@@ -182,9 +128,12 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
182
128
  gap: 1,
183
129
  padding: { x: 1, y: 1 },
184
130
  onKeyPress: onWindowKeyPress,
131
+ fgColor: activeTheme.rootFgColor,
132
+ bgColor: activeTheme.rootBgColor,
133
+ italic: state.forceThemeRefresh,
185
134
  },
186
135
  [
187
- state.messages.length ? Conversation(state) : emptyState(),
136
+ state.messages.length ? Conversation(state) : emptyState(state),
188
137
  HStack({ gap: 1 }, [
189
138
  ModelPill(state),
190
139
  TextPill(`../${state.cwd}`, theme.bwhite, theme.bblack),
@@ -221,12 +170,6 @@ async function streamAgentTUI(state: TUIState) {
221
170
  ];
222
171
 
223
172
  let userContent = state.prompt;
224
- if (state.promptSkill) {
225
- // TODO: Skill arguments? replace template marks in skill body for arguments?
226
- // Right now we just appead the user prompt to the skill body
227
- userContent = `${state.promptSkill}\n${state.prompt}`;
228
- state.promptSkill = undefined;
229
- }
230
173
  if (state.messages.length === 0) {
231
174
  const envReminder = await injectEnvReminder();
232
175
  userContent = `${envReminder}\n\n${userContent}`;
@@ -351,6 +294,13 @@ async function streamAgentTUI(state: TUIState) {
351
294
  }
352
295
  }
353
296
  }
297
+ } catch (error) {
298
+ const text = error instanceof Error ? error.message : String(error);
299
+ state.tuiMessages.push({
300
+ timestamp: formatTimestamp(Date.now()),
301
+ role: "assistant",
302
+ text,
303
+ });
354
304
  } finally {
355
305
  state.streaming = false;
356
306
  if (!state.sessionId) {
package/src/types.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  Type,
11
11
  } from "@earendil-works/pi-ai";
12
12
  import type { OAuthCredentials } from "@earendil-works/pi-ai/oauth";
13
+ import { TUI_THEME_IDS, type TUIThemeId } from "./themes";
13
14
 
14
15
  const ThinkingLevelSchema = Type.Unsafe<ThinkingLevel>(
15
16
  Type.Union([
@@ -45,11 +46,16 @@ const ModelSchema = Type.Unsafe<Model<Api>>(
45
46
  }),
46
47
  );
47
48
 
49
+ const TUIThemeIdSchema = Type.Unsafe<TUIThemeId>(
50
+ Type.Union(TUI_THEME_IDS.map((id) => Type.Literal(id))),
51
+ );
52
+
48
53
  export const SettingsSchema = Type.Object({
49
54
  provider: Type.String(),
50
55
  model: Type.String(),
51
56
  effort: ThinkingLevelSchema,
52
57
  customProviders: Type.Optional(Type.Array(ModelSchema)),
58
+ theme: Type.Optional(TUIThemeIdSchema),
53
59
  });
54
60
  export type Settings = Static<typeof SettingsSchema>;
55
61
 
@@ -59,6 +65,7 @@ export const CliOptionsSchema = Type.Object({
59
65
  effort: ThinkingLevelSchema,
60
66
  prompt: Type.Optional(Type.String()),
61
67
  customProviders: Type.Optional(Type.Array(ModelSchema)),
68
+ theme: TUIThemeIdSchema,
62
69
  });
63
70
  export type CliOptions = Static<typeof CliOptionsSchema>;
64
71
 
@@ -91,10 +98,14 @@ export type TUIMessage = {
91
98
  toolCalls?: TUIToolCall[];
92
99
  };
93
100
 
101
+ export type AvailableUpdate = {
102
+ currentVersion: string;
103
+ latestVersion: string;
104
+ };
105
+
94
106
  export type TUIState = {
95
107
  options: CliOptions;
96
108
  prompt: string;
97
- promptSkill?: string;
98
109
  messages: Message[]; // Context messages
99
110
  tuiMessages: TUIMessage[];
100
111
  contextSize?: number;
@@ -104,7 +115,9 @@ export type TUIState = {
104
115
  abortController?: AbortController;
105
116
  cwd: string;
106
117
  gitBranch?: string;
118
+ availableUpdate?: AvailableUpdate | undefined;
107
119
  overlay?: boolean | undefined;
120
+ forceThemeRefresh?: boolean | undefined;
108
121
  sessionId?: string | undefined;
109
122
  };
110
123
 
package/src/update.ts ADDED
@@ -0,0 +1,171 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import { type Static, Type } from "@earendil-works/pi-ai";
5
+ import { Value } from "typebox/value";
6
+
7
+ import { DATA_DIR } from "./shared";
8
+ import type { AvailableUpdate } from "./types";
9
+
10
+ const PACKAGE_NAME = "mini-coder";
11
+ const PACKAGE_MANIFEST_URL = new URL("../package.json", import.meta.url);
12
+ const UPDATE_CHECK_CACHE_PATH = join(DATA_DIR, "update-check.json");
13
+ const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
14
+ const UPDATE_CHECK_TIMEOUT_MS = 5_000;
15
+
16
+ const UpdateCheckCacheSchema = Type.Object({
17
+ checkedAt: Type.Number(),
18
+ currentVersion: Type.String(),
19
+ latestVersion: Type.Optional(Type.String()),
20
+ });
21
+ type UpdateCheckCache = Static<typeof UpdateCheckCacheSchema>;
22
+
23
+ function parseLatestVersion(output: string): string | undefined {
24
+ const version = output.trim().split(/\s+/)[0];
25
+
26
+ if (!version || !isValidVersion(version)) {
27
+ return;
28
+ }
29
+
30
+ return version;
31
+ }
32
+
33
+ function isValidVersion(version: string): boolean {
34
+ try {
35
+ Bun.semver.order(version, version);
36
+ return true;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function isNewerVersion(
43
+ currentVersion: string,
44
+ latestVersion: string,
45
+ ): boolean {
46
+ try {
47
+ return Bun.semver.order(latestVersion, currentVersion) === 1;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ function isFreshUpdateCheck(cache: UpdateCheckCache, now: number): boolean {
54
+ return now - cache.checkedAt < UPDATE_CHECK_INTERVAL_MS;
55
+ }
56
+
57
+ function getAvailableUpdateFromLatest(
58
+ currentVersion: string,
59
+ latestVersion: string | undefined,
60
+ ): AvailableUpdate | undefined {
61
+ if (!latestVersion || !isNewerVersion(currentVersion, latestVersion)) {
62
+ return;
63
+ }
64
+
65
+ return { currentVersion, latestVersion };
66
+ }
67
+
68
+ async function getCurrentVersion(): Promise<string | undefined> {
69
+ const manifest = (await Bun.file(PACKAGE_MANIFEST_URL).json()) as {
70
+ version?: unknown;
71
+ };
72
+
73
+ if (typeof manifest.version !== "string") {
74
+ return;
75
+ }
76
+
77
+ return manifest.version;
78
+ }
79
+
80
+ async function getLatestVersion(): Promise<string | undefined> {
81
+ try {
82
+ const proc = Bun.spawn(["bun", "pm", "view", PACKAGE_NAME, "version"], {
83
+ stdout: "pipe",
84
+ stderr: "ignore",
85
+ timeout: UPDATE_CHECK_TIMEOUT_MS,
86
+ });
87
+ const [stdout, exitCode] = await Promise.all([
88
+ proc.stdout.text(),
89
+ proc.exited,
90
+ ]);
91
+
92
+ if (exitCode !== 0) {
93
+ return;
94
+ }
95
+
96
+ return parseLatestVersion(stdout);
97
+ } catch {
98
+ return;
99
+ }
100
+ }
101
+
102
+ async function readUpdateCheckCache(): Promise<UpdateCheckCache | undefined> {
103
+ try {
104
+ const file = Bun.file(UPDATE_CHECK_CACHE_PATH);
105
+
106
+ if (!(await file.exists())) {
107
+ return;
108
+ }
109
+
110
+ const value = (await file.json()) as unknown;
111
+
112
+ if (!Value.Check(UpdateCheckCacheSchema, value)) {
113
+ return;
114
+ }
115
+
116
+ return value;
117
+ } catch {
118
+ return;
119
+ }
120
+ }
121
+
122
+ async function writeUpdateCheckCache(cache: UpdateCheckCache): Promise<void> {
123
+ try {
124
+ await mkdir(DATA_DIR, { recursive: true });
125
+ await Bun.write(UPDATE_CHECK_CACHE_PATH, JSON.stringify(cache, null, 2));
126
+ } catch {
127
+ // Update checks are best-effort and must never interrupt startup.
128
+ }
129
+ }
130
+
131
+ export async function getAvailableUpdate(): Promise<
132
+ AvailableUpdate | undefined
133
+ > {
134
+ const currentVersion = await getCurrentVersion().catch(() => undefined);
135
+
136
+ if (!currentVersion) {
137
+ return;
138
+ }
139
+
140
+ const now = Date.now();
141
+ const cache = await readUpdateCheckCache();
142
+
143
+ if (cache && isFreshUpdateCheck(cache, now)) {
144
+ return getAvailableUpdateFromLatest(currentVersion, cache.latestVersion);
145
+ }
146
+
147
+ const latestVersion = await getLatestVersion();
148
+ await writeUpdateCheckCache({
149
+ checkedAt: now,
150
+ currentVersion,
151
+ latestVersion,
152
+ });
153
+
154
+ return getAvailableUpdateFromLatest(currentVersion, latestVersion);
155
+ }
156
+
157
+ export async function updateMiniCoder(): Promise<void> {
158
+ console.log("Updating mini-coder...");
159
+
160
+ const proc = Bun.spawn(["bun", "add", "-g", "mini-coder@latest"], {
161
+ stdout: "inherit",
162
+ stderr: "inherit",
163
+ });
164
+ const exitCode = await proc.exited;
165
+
166
+ if (exitCode !== 0) {
167
+ throw new Error(`Update failed with exit code ${exitCode}`);
168
+ }
169
+
170
+ console.log("mini-coder updated.");
171
+ }