mini-coder 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -48
- package/assets/icon-1-minimal.svg +31 -0
- package/assets/icon-2-dark-terminal.svg +48 -0
- package/assets/icon-3-gradient-modern.svg +45 -0
- package/assets/icon-4-filled-bold.svg +54 -0
- package/assets/icon-5-community-badge.svg +63 -0
- package/assets/preview-0-5-0.png +0 -0
- package/assets/preview.gif +0 -0
- package/bin/mc.ts +14 -0
- package/bun.lock +438 -0
- package/package.json +12 -29
- package/src/agent.ts +592 -0
- package/src/cli.ts +124 -0
- package/src/git.ts +164 -0
- package/src/headless.ts +140 -0
- package/src/index.ts +645 -0
- package/src/input.ts +155 -0
- package/src/paths.ts +37 -0
- package/src/plugins.ts +183 -0
- package/src/prompt.ts +294 -0
- package/src/session.ts +838 -0
- package/src/settings.ts +184 -0
- package/src/skills.ts +258 -0
- package/src/submit.ts +323 -0
- package/src/theme.ts +147 -0
- package/src/tools.ts +636 -0
- package/src/ui/agent.test.ts +49 -0
- package/src/ui/agent.ts +210 -0
- package/src/ui/commands.test.ts +610 -0
- package/src/ui/commands.ts +638 -0
- package/src/ui/conversation.test.ts +892 -0
- package/src/ui/conversation.ts +926 -0
- package/src/ui/help.test.ts +26 -0
- package/src/ui/help.ts +119 -0
- package/src/ui/input.test.ts +74 -0
- package/src/ui/input.ts +138 -0
- package/src/ui/overlay.test.ts +42 -0
- package/src/ui/overlay.ts +59 -0
- package/src/ui/status.test.ts +450 -0
- package/src/ui/status.ts +357 -0
- package/src/ui.ts +615 -0
- package/.claude/settings.local.json +0 -54
- package/.prettierignore +0 -7
- package/dist/mc-edit.js +0 -275
- package/dist/mc.js +0 -7355
- package/docs/KNOWN_ISSUES.md +0 -13
- package/docs/design-decisions.md +0 -31
- package/docs/mini-coder.1.md +0 -227
- package/docs/superpowers/plans/2026-03-30-anthropic-oauth-removal.md +0 -61
- package/docs/superpowers/specs/2026-03-30-anthropic-oauth-removal-design.md +0 -47
- package/lefthook.yml +0 -4
package/src/submit.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared raw-input resolution and turn submission logic.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import type { UserMessage } from "@mariozechner/pi-ai";
|
|
9
|
+
import type { AgentEvent } from "./agent.ts";
|
|
10
|
+
import { runAgentLoop } from "./agent.ts";
|
|
11
|
+
import { getGitState } from "./git.ts";
|
|
12
|
+
import {
|
|
13
|
+
type AppState,
|
|
14
|
+
buildPrompt,
|
|
15
|
+
buildToolList,
|
|
16
|
+
ensureSession,
|
|
17
|
+
MAX_PROMPT_HISTORY,
|
|
18
|
+
} from "./index.ts";
|
|
19
|
+
import { parseInput } from "./input.ts";
|
|
20
|
+
import {
|
|
21
|
+
addMessageToStats,
|
|
22
|
+
appendMessage,
|
|
23
|
+
appendPromptHistory,
|
|
24
|
+
filterModelMessages,
|
|
25
|
+
truncatePromptHistory,
|
|
26
|
+
} from "./session.ts";
|
|
27
|
+
import { executeReadImage } from "./tools.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** Result of resolving raw user input into a command, message, or error. */
|
|
34
|
+
export type ResolvedInput =
|
|
35
|
+
| {
|
|
36
|
+
/** Empty/whitespace-only input that should be ignored. */
|
|
37
|
+
type: "empty";
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
/** Parsed slash command. */
|
|
41
|
+
type: "command";
|
|
42
|
+
/** Command name without the leading slash. */
|
|
43
|
+
command: string;
|
|
44
|
+
/** Raw command arguments after trimming. */
|
|
45
|
+
args: string;
|
|
46
|
+
}
|
|
47
|
+
| {
|
|
48
|
+
/** Validation or resolution error for the raw input. */
|
|
49
|
+
type: "error";
|
|
50
|
+
/** User-facing error message. */
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
| {
|
|
54
|
+
/** Model-visible message content ready for submission. */
|
|
55
|
+
type: "message";
|
|
56
|
+
/** Fully resolved user content. */
|
|
57
|
+
content: UserMessage["content"];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Hooks used by UI and headless mode around a submitted turn. */
|
|
61
|
+
export interface SubmitTurnHooks {
|
|
62
|
+
/** Called after the user message is persisted and added to state. */
|
|
63
|
+
onUserMessage?: (state: AppState) => void;
|
|
64
|
+
/** Called after the run switches into the active streaming state. */
|
|
65
|
+
onTurnStart?: (state: AppState) => void;
|
|
66
|
+
/** Called for each agent event emitted during the turn. */
|
|
67
|
+
onEvent?: (event: AgentEvent, state: AppState) => void;
|
|
68
|
+
/** Called after the turn finishes or aborts its active state. */
|
|
69
|
+
onTurnEnd?: (
|
|
70
|
+
state: AppState,
|
|
71
|
+
stopReason: "stop" | "length" | "error" | "aborted" | null,
|
|
72
|
+
) => void;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Input resolution helpers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Strip YAML frontmatter from a skill file.
|
|
81
|
+
*
|
|
82
|
+
* @param content - Raw `SKILL.md` file content.
|
|
83
|
+
* @returns The content without a leading frontmatter block.
|
|
84
|
+
*/
|
|
85
|
+
export function stripSkillFrontmatter(content: string): string {
|
|
86
|
+
const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
|
|
87
|
+
return frontmatter ? content.slice(frontmatter[0].length) : content;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check whether user content contains any meaningful model-visible payload.
|
|
92
|
+
*
|
|
93
|
+
* @param content - User message content to inspect.
|
|
94
|
+
* @returns `true` when the content is empty or whitespace-only.
|
|
95
|
+
*/
|
|
96
|
+
export function isEmptyUserContent(content: UserMessage["content"]): boolean {
|
|
97
|
+
if (typeof content === "string") {
|
|
98
|
+
return content.trim().length === 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return content.every(
|
|
102
|
+
(block) => block.type === "text" && block.text.trim().length === 0,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getErrorMessage(error: unknown): string {
|
|
107
|
+
return error instanceof Error ? error.message : String(error);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function buildSkillMessageContent(
|
|
111
|
+
skillName: string,
|
|
112
|
+
userText: string,
|
|
113
|
+
state: Pick<AppState, "skills">,
|
|
114
|
+
): string | null {
|
|
115
|
+
const skill = state.skills.find((entry) => entry.name === skillName);
|
|
116
|
+
if (!skill) {
|
|
117
|
+
throw new Error(`Unknown skill: ${skillName}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
let skillBody: string;
|
|
121
|
+
try {
|
|
122
|
+
skillBody = stripSkillFrontmatter(readFileSync(skill.path, "utf-8")).trim();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`Failed to read skill ${skillName}: ${getErrorMessage(error)}`,
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
const parts = [skillBody, userText].filter((part) => part.length > 0);
|
|
129
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildImageMessageContent(
|
|
133
|
+
imagePath: string,
|
|
134
|
+
rawInput: string,
|
|
135
|
+
state: Pick<AppState, "cwd">,
|
|
136
|
+
): UserMessage["content"] | null {
|
|
137
|
+
const displayPath = rawInput.trim();
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const result = executeReadImage({ path: imagePath }, state.cwd);
|
|
141
|
+
if (result.isError) {
|
|
142
|
+
return displayPath || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return [
|
|
146
|
+
{ type: "text", text: displayPath },
|
|
147
|
+
...result.content.filter((block) => block.type === "image"),
|
|
148
|
+
];
|
|
149
|
+
} catch {
|
|
150
|
+
return displayPath || null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Resolve raw submitted input into a command, a model-visible message, or an error.
|
|
156
|
+
*
|
|
157
|
+
* This reuses the same parsing rules for interactive and headless input.
|
|
158
|
+
*
|
|
159
|
+
* @param raw - Raw user input.
|
|
160
|
+
* @param state - Current app state needed for skill/image resolution.
|
|
161
|
+
* @returns The resolved input result.
|
|
162
|
+
*/
|
|
163
|
+
export function resolveRawInput(
|
|
164
|
+
raw: string,
|
|
165
|
+
state: Pick<AppState, "model" | "cwd" | "skills">,
|
|
166
|
+
): ResolvedInput {
|
|
167
|
+
const parsed = parseInput(raw, {
|
|
168
|
+
supportsImages: state.model?.input.includes("image") ?? false,
|
|
169
|
+
cwd: state.cwd,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
switch (parsed.type) {
|
|
173
|
+
case "command":
|
|
174
|
+
return {
|
|
175
|
+
type: "command",
|
|
176
|
+
command: parsed.command,
|
|
177
|
+
args: parsed.args,
|
|
178
|
+
};
|
|
179
|
+
case "skill": {
|
|
180
|
+
try {
|
|
181
|
+
const content = buildSkillMessageContent(
|
|
182
|
+
parsed.skillName,
|
|
183
|
+
parsed.userText,
|
|
184
|
+
state,
|
|
185
|
+
);
|
|
186
|
+
return content && !isEmptyUserContent(content)
|
|
187
|
+
? { type: "message", content }
|
|
188
|
+
: { type: "empty" };
|
|
189
|
+
} catch (error) {
|
|
190
|
+
return {
|
|
191
|
+
type: "error",
|
|
192
|
+
message: getErrorMessage(error),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
case "image": {
|
|
197
|
+
const content = buildImageMessageContent(parsed.path, raw, state);
|
|
198
|
+
return content && !isEmptyUserContent(content)
|
|
199
|
+
? { type: "message", content }
|
|
200
|
+
: { type: "empty" };
|
|
201
|
+
}
|
|
202
|
+
case "text":
|
|
203
|
+
return parsed.text && !isEmptyUserContent(parsed.text)
|
|
204
|
+
? { type: "message", content: parsed.text }
|
|
205
|
+
: { type: "empty" };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Turn submission
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
function handleAgentEvent(event: AgentEvent, state: AppState): void {
|
|
214
|
+
switch (event.type) {
|
|
215
|
+
case "assistant_message":
|
|
216
|
+
state.messages.push(event.message);
|
|
217
|
+
state.stats = addMessageToStats(state.stats, event.message);
|
|
218
|
+
break;
|
|
219
|
+
case "tool_result":
|
|
220
|
+
state.messages.push(event.message);
|
|
221
|
+
break;
|
|
222
|
+
case "text_delta":
|
|
223
|
+
case "thinking_delta":
|
|
224
|
+
case "toolcall_start":
|
|
225
|
+
case "toolcall_delta":
|
|
226
|
+
case "toolcall_end":
|
|
227
|
+
case "tool_start":
|
|
228
|
+
case "tool_delta":
|
|
229
|
+
case "tool_end":
|
|
230
|
+
case "done":
|
|
231
|
+
case "error":
|
|
232
|
+
case "aborted":
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Submit already-resolved user content as one conversational turn.
|
|
239
|
+
*
|
|
240
|
+
* Persists the raw prompt, appends the user message, runs the full agent loop,
|
|
241
|
+
* updates in-memory state as assistant/tool messages arrive, and returns the
|
|
242
|
+
* final stop reason for the turn.
|
|
243
|
+
*
|
|
244
|
+
* @param rawInput - Exact raw submitted prompt text.
|
|
245
|
+
* @param content - Resolved model-visible user content.
|
|
246
|
+
* @param state - Mutable application state.
|
|
247
|
+
* @param hooks - Optional lifecycle hooks for UI/headless integrations.
|
|
248
|
+
* @returns The terminal stop reason for the turn.
|
|
249
|
+
*/
|
|
250
|
+
export async function submitResolvedInput(
|
|
251
|
+
rawInput: string,
|
|
252
|
+
content: UserMessage["content"],
|
|
253
|
+
state: AppState,
|
|
254
|
+
hooks?: SubmitTurnHooks,
|
|
255
|
+
): Promise<"stop" | "length" | "error" | "aborted"> {
|
|
256
|
+
if (!state.model) {
|
|
257
|
+
throw new Error("No model is available for this run.");
|
|
258
|
+
}
|
|
259
|
+
if (state.running) {
|
|
260
|
+
throw new Error("A turn is already running.");
|
|
261
|
+
}
|
|
262
|
+
if (isEmptyUserContent(content)) {
|
|
263
|
+
throw new Error("Cannot submit empty input.");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const session = ensureSession(state);
|
|
267
|
+
appendPromptHistory(state.db, {
|
|
268
|
+
text: rawInput,
|
|
269
|
+
cwd: state.cwd,
|
|
270
|
+
sessionId: session.id,
|
|
271
|
+
});
|
|
272
|
+
truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
|
|
273
|
+
|
|
274
|
+
const userMessage = {
|
|
275
|
+
role: "user",
|
|
276
|
+
content,
|
|
277
|
+
timestamp: Date.now(),
|
|
278
|
+
} satisfies UserMessage;
|
|
279
|
+
|
|
280
|
+
const turn = appendMessage(state.db, session.id, userMessage);
|
|
281
|
+
state.messages.push(userMessage);
|
|
282
|
+
hooks?.onUserMessage?.(state);
|
|
283
|
+
|
|
284
|
+
state.git = await getGitState(state.cwd);
|
|
285
|
+
|
|
286
|
+
const systemPrompt = buildPrompt(state);
|
|
287
|
+
const { tools, toolHandlers } = buildToolList(state);
|
|
288
|
+
const modelMessages = filterModelMessages(state.messages);
|
|
289
|
+
|
|
290
|
+
state.running = true;
|
|
291
|
+
state.abortController = new AbortController();
|
|
292
|
+
hooks?.onTurnStart?.(state);
|
|
293
|
+
|
|
294
|
+
let stopReason: "stop" | "length" | "error" | "aborted" | null = null;
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const result = await runAgentLoop({
|
|
298
|
+
db: state.db,
|
|
299
|
+
sessionId: session.id,
|
|
300
|
+
turn,
|
|
301
|
+
model: state.model,
|
|
302
|
+
systemPrompt,
|
|
303
|
+
tools,
|
|
304
|
+
toolHandlers,
|
|
305
|
+
messages: modelMessages,
|
|
306
|
+
cwd: state.cwd,
|
|
307
|
+
apiKey: state.providers.get(state.model.provider),
|
|
308
|
+
effort: state.effort,
|
|
309
|
+
signal: state.abortController.signal,
|
|
310
|
+
onEvent: (event) => {
|
|
311
|
+
handleAgentEvent(event, state);
|
|
312
|
+
hooks?.onEvent?.(event, state);
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
stopReason = result.stopReason;
|
|
316
|
+
state.git = await getGitState(state.cwd);
|
|
317
|
+
return result.stopReason;
|
|
318
|
+
} finally {
|
|
319
|
+
state.running = false;
|
|
320
|
+
state.abortController = null;
|
|
321
|
+
hooks?.onTurnEnd?.(state, stopReason);
|
|
322
|
+
}
|
|
323
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI theme definition and default colors.
|
|
3
|
+
*
|
|
4
|
+
* All UI colors are read from the active {@link Theme} object — the UI
|
|
5
|
+
* never hardcodes colors. Plugins can return a `Partial<Theme>` in their
|
|
6
|
+
* result to override any color. Multiple overrides are merged left-to-right.
|
|
7
|
+
*
|
|
8
|
+
* Theme values are cel-tui {@link Color} palette references. The default
|
|
9
|
+
* theme prefers ANSI 16-color palette entries so it adapts cleanly to the
|
|
10
|
+
* user's terminal theme while still allowing restrained pill styling.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Color } from "@cel-tui/types";
|
|
16
|
+
|
|
17
|
+
/** A cel-tui palette color or the terminal default when undefined. */
|
|
18
|
+
type ThemeColor = Color | undefined;
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Types
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** Foreground/background pair for a single status pill tone. */
|
|
25
|
+
export interface StatusTone {
|
|
26
|
+
/** Pill foreground color. */
|
|
27
|
+
fg: ThemeColor;
|
|
28
|
+
/** Pill background color. */
|
|
29
|
+
bg: ThemeColor;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Complete set of UI colors.
|
|
34
|
+
*
|
|
35
|
+
* The default theme uses terminal palette indices so it adapts to the
|
|
36
|
+
* user's terminal color scheme automatically.
|
|
37
|
+
*/
|
|
38
|
+
export interface Theme {
|
|
39
|
+
/** User message background. */
|
|
40
|
+
userMsgBg: ThemeColor;
|
|
41
|
+
/** Muted informational text, placeholders, and helper copy. */
|
|
42
|
+
mutedText: ThemeColor;
|
|
43
|
+
/** Primary accent for important labels and interactive highlights. */
|
|
44
|
+
accentText: ThemeColor;
|
|
45
|
+
/** Secondary accent for supplementary labels like git status. */
|
|
46
|
+
secondaryAccentText: ThemeColor;
|
|
47
|
+
/** Tool output left border and text. */
|
|
48
|
+
toolBorder: ThemeColor;
|
|
49
|
+
/** Tool output text. */
|
|
50
|
+
toolText: ThemeColor;
|
|
51
|
+
/** Added/replacement edit preview text. */
|
|
52
|
+
diffAdded: ThemeColor;
|
|
53
|
+
/** Removed/original edit preview text. */
|
|
54
|
+
diffRemoved: ThemeColor;
|
|
55
|
+
/** Divider line color (idle state). */
|
|
56
|
+
divider: ThemeColor;
|
|
57
|
+
/** Divider scanning pulse highlight color (active state). */
|
|
58
|
+
dividerPulse: ThemeColor;
|
|
59
|
+
/** Neutral status pill tone for the inner CWD/git pills. */
|
|
60
|
+
statusSecondary: StatusTone;
|
|
61
|
+
/** Model/effort pill tones from low/cold to xhigh/warm. */
|
|
62
|
+
statusEffortScale: readonly [StatusTone, StatusTone, StatusTone, StatusTone];
|
|
63
|
+
/** Usage/context pill tones from empty/cold to near-full/hot. */
|
|
64
|
+
statusContextScale: readonly [
|
|
65
|
+
StatusTone,
|
|
66
|
+
StatusTone,
|
|
67
|
+
StatusTone,
|
|
68
|
+
StatusTone,
|
|
69
|
+
StatusTone,
|
|
70
|
+
];
|
|
71
|
+
/** Error text. */
|
|
72
|
+
error: ThemeColor;
|
|
73
|
+
/** Overlay modal background. */
|
|
74
|
+
overlayBg: ThemeColor;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Default theme
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The default theme.
|
|
83
|
+
*
|
|
84
|
+
* Uses terminal palette colors so it looks reasonable across light and
|
|
85
|
+
* dark terminal themes without any configuration. The inner status pills
|
|
86
|
+
* stay neutral, while the outer pills use stepped ANSI16 tone scales so
|
|
87
|
+
* reasoning effort and context pressure move through green, cyan, purple,
|
|
88
|
+
* and red as they trend from cold/dark to warm/bright.
|
|
89
|
+
*/
|
|
90
|
+
export const DEFAULT_THEME: Theme = {
|
|
91
|
+
userMsgBg: "color08",
|
|
92
|
+
mutedText: "color08",
|
|
93
|
+
accentText: "color04",
|
|
94
|
+
secondaryAccentText: "color05",
|
|
95
|
+
toolBorder: "color08",
|
|
96
|
+
toolText: "color08",
|
|
97
|
+
diffAdded: "color02",
|
|
98
|
+
diffRemoved: "color01",
|
|
99
|
+
divider: "color08",
|
|
100
|
+
dividerPulse: "color04",
|
|
101
|
+
statusSecondary: {
|
|
102
|
+
fg: "color15",
|
|
103
|
+
bg: "color08",
|
|
104
|
+
},
|
|
105
|
+
statusEffortScale: [
|
|
106
|
+
{ fg: "color00", bg: "color02" },
|
|
107
|
+
{ fg: "color00", bg: "color06" },
|
|
108
|
+
{ fg: "color15", bg: "color05" },
|
|
109
|
+
{ fg: "color00", bg: "color09" },
|
|
110
|
+
],
|
|
111
|
+
statusContextScale: [
|
|
112
|
+
{ fg: "color00", bg: "color02" },
|
|
113
|
+
{ fg: "color00", bg: "color06" },
|
|
114
|
+
{ fg: "color15", bg: "color05" },
|
|
115
|
+
{ fg: "color15", bg: "color01" },
|
|
116
|
+
{ fg: "color00", bg: "color09" },
|
|
117
|
+
],
|
|
118
|
+
error: "color01",
|
|
119
|
+
overlayBg: "color08",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Theme merging
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Merge partial theme overrides on top of a base theme.
|
|
128
|
+
*
|
|
129
|
+
* Applies overrides left-to-right — later overrides win for the same key.
|
|
130
|
+
* This is a shallow merge, so nested status tones and tone scales are
|
|
131
|
+
* replaced as whole values. Returns a new {@link Theme}; the base is not
|
|
132
|
+
* mutated.
|
|
133
|
+
*
|
|
134
|
+
* @param base - The base theme to start from.
|
|
135
|
+
* @param overrides - Partial theme objects to merge (from plugins).
|
|
136
|
+
* @returns A complete {@link Theme} with all overrides applied.
|
|
137
|
+
*/
|
|
138
|
+
export function mergeThemes(
|
|
139
|
+
base: Theme,
|
|
140
|
+
...overrides: Partial<Theme>[]
|
|
141
|
+
): Theme {
|
|
142
|
+
let merged = { ...base };
|
|
143
|
+
for (const override of overrides) {
|
|
144
|
+
merged = { ...merged, ...override };
|
|
145
|
+
}
|
|
146
|
+
return merged;
|
|
147
|
+
}
|