mini-coder 0.7.0 → 0.7.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/bun.lock CHANGED
@@ -7,15 +7,15 @@
7
7
  "dependencies": {
8
8
  "@cel-tui/components": "^0.8.3",
9
9
  "@cel-tui/core": "^0.8.3",
10
- "@earendil-works/pi-ai": "^0.75.5",
10
+ "@earendil-works/pi-ai": "^0.79.3",
11
11
  "diff": "^9.0.0",
12
12
  "simple-git": "^3.36.0",
13
13
  "yaml": "^2.9.0",
14
14
  },
15
15
  "devDependencies": {
16
- "@biomejs/biome": "^2.4.15",
16
+ "@biomejs/biome": "^2.5.0",
17
17
  "@types/bun": "^1.3.14",
18
- "prettier": "^3.8.3",
18
+ "prettier": "^3.8.4",
19
19
  "typescript": "^6.0.3",
20
20
  },
21
21
  },
@@ -75,23 +75,23 @@
75
75
 
76
76
  "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
77
77
 
78
- "@biomejs/biome": ["@biomejs/biome@2.4.15", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.15", "@biomejs/cli-darwin-x64": "2.4.15", "@biomejs/cli-linux-arm64": "2.4.15", "@biomejs/cli-linux-arm64-musl": "2.4.15", "@biomejs/cli-linux-x64": "2.4.15", "@biomejs/cli-linux-x64-musl": "2.4.15", "@biomejs/cli-win32-arm64": "2.4.15", "@biomejs/cli-win32-x64": "2.4.15" }, "bin": { "biome": "bin/biome" } }, "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw=="],
78
+ "@biomejs/biome": ["@biomejs/biome@2.5.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.0", "@biomejs/cli-darwin-x64": "2.5.0", "@biomejs/cli-linux-arm64": "2.5.0", "@biomejs/cli-linux-arm64-musl": "2.5.0", "@biomejs/cli-linux-x64": "2.5.0", "@biomejs/cli-linux-x64-musl": "2.5.0", "@biomejs/cli-win32-arm64": "2.5.0", "@biomejs/cli-win32-x64": "2.5.0" }, "bin": { "biome": "bin/biome" } }, "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw=="],
79
79
 
80
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.15", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg=="],
80
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw=="],
81
81
 
82
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.15", "", { "os": "darwin", "cpu": "x64" }, "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ=="],
82
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ=="],
83
83
 
84
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug=="],
84
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw=="],
85
85
 
86
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.15", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ=="],
86
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g=="],
87
87
 
88
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g=="],
88
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA=="],
89
89
 
90
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.15", "", { "os": "linux", "cpu": "x64" }, "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w=="],
90
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg=="],
91
91
 
92
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.15", "", { "os": "win32", "cpu": "arm64" }, "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w=="],
92
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw=="],
93
93
 
94
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.15", "", { "os": "win32", "cpu": "x64" }, "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ=="],
94
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="],
95
95
 
96
96
  "@cel-tui/clew": ["@cel-tui/clew@0.8.3", "", { "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-7OFUe8ECleXjq9+vv2EvDcTXsiWNSQqVALD1LC/Gi9DzVp1aw0S6SloJuAv1KUVkwjZxil+mvYlk2LoC28mETQ=="],
97
97
 
@@ -101,7 +101,7 @@
101
101
 
102
102
  "@cel-tui/types": ["@cel-tui/types@0.8.3", "", { "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-yEKXCY16U/W4A4JaVysv2/x/IBEHQAfb93CzL6HugN+N15ETB3Vn8tZrQwdyrOT/9WdlCQkeBH8Vj7BJHs4Hkw=="],
103
103
 
104
- "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.75.5", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-zf1F5kXk1pqZeFShXOqq9ibUk8QdtRoLCDPAjO+hj44e3EUs9/GFO2qnhTC5+JA2uwVCx+WCNe1PiCjlBYWm5w=="],
104
+ "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.3", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.1", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw=="],
105
105
 
106
106
  "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
107
107
 
@@ -231,7 +231,7 @@
231
231
 
232
232
  "path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
233
233
 
234
- "prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
234
+ "prettier": ["prettier@3.8.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q=="],
235
235
 
236
236
  "protobufjs": ["protobufjs@7.5.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.1", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg=="],
237
237
 
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "type": "module",
5
- "packageManager": "bun@1.3.12",
5
+ "packageManager": "bun@1.3.13",
6
6
  "bin": {
7
7
  "mc": "bin/mc.ts"
8
8
  },
@@ -14,15 +14,15 @@
14
14
  "format": "bun run prettier --write *.md && bun run biome format --write . && bun run biome check --write ."
15
15
  },
16
16
  "devDependencies": {
17
- "@biomejs/biome": "^2.4.15",
17
+ "@biomejs/biome": "^2.5.0",
18
18
  "@types/bun": "^1.3.14",
19
- "prettier": "^3.8.3",
19
+ "prettier": "^3.8.4",
20
20
  "typescript": "^6.0.3"
21
21
  },
22
22
  "dependencies": {
23
23
  "@cel-tui/components": "^0.8.3",
24
24
  "@cel-tui/core": "^0.8.3",
25
- "@earendil-works/pi-ai": "^0.75.5",
25
+ "@earendil-works/pi-ai": "^0.79.3",
26
26
  "diff": "^9.0.0",
27
27
  "simple-git": "^3.36.0",
28
28
  "yaml": "^2.9.0"
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/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,36 +1,48 @@
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);
9
17
  return HStack({ flex: 1, alignItems: "center" }, [
10
18
  VStack({ flex: 1, alignItems: "center", gap: 1 }, [
11
19
  HStack({ gap: 1 }, [
12
20
  Text("mini"),
13
- TextPill("coder", theme.black, randColor),
21
+ TextPill(
22
+ "coder",
23
+ textColorForBackground(randColor, state.options.theme),
24
+ randColor,
25
+ ),
14
26
  ]),
15
27
  VStack({ gap: 1 }, [
16
28
  HStack({ gap: 1 }, [
17
- TextPill("/new", randColor, theme.bblack, 13),
29
+ TextPill("/new", shortcutFgColor, theme.bblack, 13),
18
30
  Text("Start a new session from the input box.", {
19
31
  fgColor: theme.bblack,
20
32
  }),
21
33
  ]),
22
34
  HStack({ gap: 1 }, [
23
- TextPill("ctrl+p", randColor, theme.bblack, 13),
35
+ TextPill("ctrl+p", shortcutFgColor, theme.bblack, 13),
24
36
  Text("Menu for session history, and settings.", {
25
37
  fgColor: theme.bblack,
26
38
  }),
27
39
  ]),
28
40
  HStack({ gap: 1 }, [
29
- TextPill("ESC", randColor, theme.bblack, 13),
41
+ TextPill("ESC", shortcutFgColor, theme.bblack, 13),
30
42
  Text("Abort agent response.", { fgColor: theme.bblack }),
31
43
  ]),
32
44
  HStack({ gap: 1 }, [
33
- TextPill("ctrl+c|d|q", randColor, theme.bblack, 13),
45
+ TextPill("ctrl+c|d|q", shortcutFgColor, theme.bblack, 13),
34
46
  Text("Quit.", { fgColor: theme.bblack }),
35
47
  ]),
36
48
  ]),
@@ -38,7 +50,10 @@ export function emptyState(): Node {
38
50
  ]);
39
51
  }
40
52
 
41
- function ConversationMessageToolCall(call: TUIToolCall) {
53
+ function ConversationMessageToolCall(
54
+ call: TUIToolCall,
55
+ syntaxTheme: SyntaxHighlightTheme,
56
+ ) {
42
57
  let outputNode: Node | null = null;
43
58
 
44
59
  // Compress read and bash calls
@@ -64,7 +79,7 @@ function ConversationMessageToolCall(call: TUIToolCall) {
64
79
 
65
80
  outputNode = VStack({ width: "100%" }, blocks);
66
81
  } else if (call.tool === "edit") {
67
- outputNode = SyntaxHighlight(call.output, "patch");
82
+ outputNode = SyntaxHighlight(call.output, "patch", { theme: syntaxTheme });
68
83
  } else {
69
84
  outputNode = Text(call.output, { wrap: "word" });
70
85
  }
@@ -81,7 +96,7 @@ function ConversationMessageToolCall(call: TUIToolCall) {
81
96
 
82
97
  // Syntax highlight bash args
83
98
  if (call.tool === "bash") {
84
- node = SyntaxHighlight(String(value), "bash");
99
+ node = SyntaxHighlight(String(value), "bash", { theme: syntaxTheme });
85
100
  return node;
86
101
  }
87
102
 
@@ -99,7 +114,11 @@ function ConversationMessageToolCall(call: TUIToolCall) {
99
114
  ]);
100
115
  }
101
116
 
102
- function ConversationMessage(message: TUIMessage) {
117
+ function ConversationMessage(
118
+ message: TUIMessage,
119
+ syntaxTheme: SyntaxHighlightTheme,
120
+ userMessageBgColor: Color | undefined,
121
+ ) {
103
122
  const blocks: Node[] = [];
104
123
 
105
124
  if (message.thinking) {
@@ -118,17 +137,22 @@ function ConversationMessage(message: TUIMessage) {
118
137
  VStack(
119
138
  {
120
139
  width: "100%",
121
- bgColor: message.role === "user" ? theme.bblack : undefined,
140
+ bgColor: message.role === "user" ? userMessageBgColor : undefined,
122
141
  padding: { y: 1, x: message.role === "user" ? 1 : 0 },
123
142
  },
124
- [SyntaxHighlight(message.text, "markdown")],
143
+ [SyntaxHighlight(message.text, "markdown", { theme: syntaxTheme })],
125
144
  ),
126
145
  );
127
146
  }
128
147
 
129
148
  if (message.toolCalls?.length) {
130
149
  blocks.push(
131
- VStack({ gap: 1 }, message.toolCalls.map(ConversationMessageToolCall)),
150
+ VStack(
151
+ { gap: 1 },
152
+ message.toolCalls.map((call) =>
153
+ ConversationMessageToolCall(call, syntaxTheme),
154
+ ),
155
+ ),
132
156
  );
133
157
  }
134
158
 
@@ -158,6 +182,8 @@ function ConversationMessage(message: TUIMessage) {
158
182
  }
159
183
 
160
184
  export function Conversation(state: TUIState) {
185
+ const activeTheme = getTUITheme(state.options.theme);
186
+
161
187
  return VStack(
162
188
  {
163
189
  flex: 1,
@@ -169,6 +195,12 @@ export function Conversation(state: TUIState) {
169
195
  state.stickToBottom = offset >= maxOffset;
170
196
  },
171
197
  },
172
- state.tuiMessages.map(ConversationMessage),
198
+ state.tuiMessages.map((message) =>
199
+ ConversationMessage(
200
+ message,
201
+ activeTheme.syntax,
202
+ activeTheme.userMessageBgColor,
203
+ ),
204
+ ),
173
205
  );
174
206
  }
@@ -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
@@ -13,6 +13,7 @@ import {
13
13
  } from "./prompt";
14
14
  import { updateSession } from "./session";
15
15
  import { estimateTokens, formatTimestamp, secureRandomString } from "./shared";
16
+ import { activeTuiTheme, applyTUITheme, getTUITheme } from "./themes";
16
17
  import { bash, runBashTool } from "./tool-bash";
17
18
  import { edit, runEditTool } from "./tool-edit";
18
19
  import { read, runReadTool } from "./tool-read";
@@ -84,6 +85,9 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
84
85
  };
85
86
 
86
87
  const onEditorKeyPress = (key: string) => {
88
+ const submit = async () => {
89
+ await streamAgentTUI(state);
90
+ };
87
91
  // onKeyPress
88
92
  if (key === "enter") {
89
93
  if (state.prompt === ":q") {
@@ -102,16 +106,15 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
102
106
  state.stickToBottom = true;
103
107
  return false;
104
108
  }
105
- const submit = async () => {
106
- await streamAgentTUI(state);
107
- };
108
109
  if (state.prompt && !state.streaming) submit();
109
110
  return false;
110
111
  }
111
112
  };
112
113
 
113
- cel.init(new ProcessTerminal());
114
+ applyTUITheme(state.options.theme);
115
+ cel.init(new ProcessTerminal(), { theme: activeTuiTheme });
114
116
  cel.viewport(() => {
117
+ const activeTheme = getTUITheme(state.options.theme);
115
118
  const layers = [
116
119
  VStack(
117
120
  {
@@ -119,9 +122,12 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
119
122
  gap: 1,
120
123
  padding: { x: 1, y: 1 },
121
124
  onKeyPress: onWindowKeyPress,
125
+ fgColor: activeTheme.rootFgColor,
126
+ bgColor: activeTheme.rootBgColor,
127
+ italic: state.forceThemeRefresh,
122
128
  },
123
129
  [
124
- state.messages.length ? Conversation(state) : emptyState(),
130
+ state.messages.length ? Conversation(state) : emptyState(state),
125
131
  HStack({ gap: 1 }, [
126
132
  ModelPill(state),
127
133
  TextPill(`../${state.cwd}`, theme.bwhite, theme.bblack),
@@ -144,6 +150,8 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
144
150
  }
145
151
 
146
152
  async function streamAgentTUI(state: TUIState) {
153
+ // Just as a defensive thought, callers should set this ahead of time if doing other
154
+ // ops before starting the stream.
147
155
  state.streaming = true;
148
156
 
149
157
  const abortController = new AbortController();
@@ -280,6 +288,13 @@ async function streamAgentTUI(state: TUIState) {
280
288
  }
281
289
  }
282
290
  }
291
+ } catch (error) {
292
+ const text = error instanceof Error ? error.message : String(error);
293
+ state.tuiMessages.push({
294
+ timestamp: formatTimestamp(Date.now()),
295
+ role: "assistant",
296
+ text,
297
+ });
283
298
  } finally {
284
299
  state.streaming = false;
285
300
  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
 
@@ -104,6 +111,7 @@ export type TUIState = {
104
111
  cwd: string;
105
112
  gitBranch?: string;
106
113
  overlay?: boolean | undefined;
114
+ forceThemeRefresh?: boolean | undefined;
107
115
  sessionId?: string | undefined;
108
116
  };
109
117