mini-coder 0.7.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mini-coder",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
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/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
@@ -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";
@@ -45,52 +43,6 @@ function clearOrAbort(state: TUIState) {
45
43
  }
46
44
  }
47
45
 
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
46
  export function initTUI(state: TUIState, leave: (s: string) => void) {
95
47
  // TODO: Cleanup accumulated sessions for this cwd.
96
48
  const { spinnerEvery, currentSpinner } = Spinner();
@@ -136,16 +88,6 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
136
88
  const submit = async () => {
137
89
  await streamAgentTUI(state);
138
90
  };
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
91
  // onKeyPress
150
92
  if (key === "enter") {
151
93
  if (state.prompt === ":q") {
@@ -164,17 +106,15 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
164
106
  state.stickToBottom = true;
165
107
  return false;
166
108
  }
167
- if (state.prompt && state.prompt[0] === "/" && !state.streaming) {
168
- skillSubmit();
169
- return false;
170
- }
171
109
  if (state.prompt && !state.streaming) submit();
172
110
  return false;
173
111
  }
174
112
  };
175
113
 
176
- cel.init(new ProcessTerminal());
114
+ applyTUITheme(state.options.theme);
115
+ cel.init(new ProcessTerminal(), { theme: activeTuiTheme });
177
116
  cel.viewport(() => {
117
+ const activeTheme = getTUITheme(state.options.theme);
178
118
  const layers = [
179
119
  VStack(
180
120
  {
@@ -182,9 +122,12 @@ export function initTUI(state: TUIState, leave: (s: string) => void) {
182
122
  gap: 1,
183
123
  padding: { x: 1, y: 1 },
184
124
  onKeyPress: onWindowKeyPress,
125
+ fgColor: activeTheme.rootFgColor,
126
+ bgColor: activeTheme.rootBgColor,
127
+ italic: state.forceThemeRefresh,
185
128
  },
186
129
  [
187
- state.messages.length ? Conversation(state) : emptyState(),
130
+ state.messages.length ? Conversation(state) : emptyState(state),
188
131
  HStack({ gap: 1 }, [
189
132
  ModelPill(state),
190
133
  TextPill(`../${state.cwd}`, theme.bwhite, theme.bblack),
@@ -221,12 +164,6 @@ async function streamAgentTUI(state: TUIState) {
221
164
  ];
222
165
 
223
166
  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
167
  if (state.messages.length === 0) {
231
168
  const envReminder = await injectEnvReminder();
232
169
  userContent = `${envReminder}\n\n${userContent}`;
@@ -351,6 +288,13 @@ async function streamAgentTUI(state: TUIState) {
351
288
  }
352
289
  }
353
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
+ });
354
298
  } finally {
355
299
  state.streaming = false;
356
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
 
@@ -94,7 +101,6 @@ export type TUIMessage = {
94
101
  export type TUIState = {
95
102
  options: CliOptions;
96
103
  prompt: string;
97
- promptSkill?: string;
98
104
  messages: Message[]; // Context messages
99
105
  tuiMessages: TUIMessage[];
100
106
  contextSize?: number;
@@ -105,6 +111,7 @@ export type TUIState = {
105
111
  cwd: string;
106
112
  gitBranch?: string;
107
113
  overlay?: boolean | undefined;
114
+ forceThemeRefresh?: boolean | undefined;
108
115
  sessionId?: string | undefined;
109
116
  };
110
117