mini-coder 0.5.12 → 0.5.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/settings.ts CHANGED
@@ -2,7 +2,8 @@
2
2
  * User settings persistence and startup resolution.
3
3
  *
4
4
  * Stores global defaults such as model, effort, reasoning visibility,
5
- * and verbose tool output in a JSON file under the app data directory.
5
+ * verbose tool output, custom providers, and MCP server settings in a JSON
6
+ * file under the app data directory.
6
7
  *
7
8
  * @module
8
9
  */
@@ -23,6 +24,22 @@ export interface CustomProvider {
23
24
  apiKey?: string;
24
25
  }
25
26
 
27
+ /** A single configured MCP server endpoint. */
28
+ export interface McpServerConfig {
29
+ /** Stable server identifier. Used as the imported tool-name prefix. */
30
+ name: string;
31
+ /** Absolute Streamable HTTP MCP endpoint URL. */
32
+ url: string;
33
+ /** Whether the server should start enabled. */
34
+ enabled: boolean;
35
+ }
36
+
37
+ /** MCP-related user settings. */
38
+ export interface McpSettings {
39
+ /** MCP servers to connect to at startup. */
40
+ servers?: McpServerConfig[];
41
+ }
42
+
26
43
  /** Default reasoning effort when no saved setting exists. */
27
44
  const DEFAULT_EFFORT: ThinkingLevel = "medium";
28
45
 
@@ -44,6 +61,8 @@ export interface UserSettings {
44
61
  verbose?: boolean;
45
62
  /** Custom OpenAI-compatible provider endpoints. */
46
63
  customProviders?: CustomProvider[];
64
+ /** MCP server definitions to manage and connect when enabled. */
65
+ mcp?: McpSettings;
47
66
  }
48
67
 
49
68
  /** Resolved startup settings after applying defaults and availability checks. */
@@ -65,6 +84,8 @@ const THINKING_LEVELS = new Set<ThinkingLevel>([
65
84
  "xhigh",
66
85
  ]);
67
86
 
87
+ const MCP_SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]+$/;
88
+
68
89
  /**
69
90
  * Load and validate user settings from disk.
70
91
  *
@@ -80,13 +101,81 @@ export function loadSettings(path: string): UserSettings {
80
101
  }
81
102
 
82
103
  try {
83
- const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
84
- return sanitizeSettings(raw);
104
+ return parseSettingsFile(path);
85
105
  } catch (error) {
86
- throw new Error(
87
- `Failed to read settings ${path}: ${getErrorMessage(error)}`,
88
- );
106
+ throw createSettingsReadError(path, error);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Load settings for startup without aborting on invalid JSON.
112
+ *
113
+ * Missing files and invalid JSON content are treated as empty settings so
114
+ * startup behaves like there are no saved settings. Other filesystem errors
115
+ * still fail with the same descriptive read error as {@link loadSettings}.
116
+ *
117
+ * @param path - Absolute path to `settings.json`.
118
+ * @returns The validated settings object, or `{}` when startup should ignore invalid JSON.
119
+ */
120
+ export function loadStartupSettings(path: string): UserSettings {
121
+ if (!existsSync(path)) {
122
+ return {};
123
+ }
124
+
125
+ try {
126
+ return parseSettingsFile(path);
127
+ } catch (error) {
128
+ if (error instanceof SyntaxError) {
129
+ return {};
130
+ }
131
+ throw createSettingsReadError(path, error);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Merge two settings objects using startup overlay semantics.
137
+ *
138
+ * Scalar fields use override-wins. `customProviders` merges by provider name,
139
+ * and `mcp.servers` merges by server name. Same-name override entries replace
140
+ * base entries while keeping the base ordering stable; new override entries are
141
+ * appended in override order.
142
+ *
143
+ * @param base - Base settings, usually the global settings file.
144
+ * @param override - Higher-priority settings, usually a repo-local overlay.
145
+ * @returns The merged effective settings.
146
+ */
147
+ export function mergeUserSettings(
148
+ base: UserSettings,
149
+ override: UserSettings,
150
+ ): UserSettings {
151
+ const sanitizedBase = sanitizeSettings(base);
152
+ const sanitizedOverride = sanitizeSettings(override);
153
+ const merged: UserSettings = {
154
+ ...sanitizedBase,
155
+ ...sanitizedOverride,
156
+ };
157
+
158
+ const customProviders = mergeNamedEntries(
159
+ sanitizedBase.customProviders,
160
+ sanitizedOverride.customProviders,
161
+ );
162
+ if (customProviders) {
163
+ merged.customProviders = customProviders;
164
+ } else {
165
+ delete merged.customProviders;
166
+ }
167
+
168
+ const servers = mergeNamedEntries(
169
+ sanitizedBase.mcp?.servers,
170
+ sanitizedOverride.mcp?.servers,
171
+ );
172
+ if (servers) {
173
+ merged.mcp = { servers };
174
+ } else {
175
+ delete merged.mcp;
89
176
  }
177
+
178
+ return merged;
90
179
  }
91
180
 
92
181
  /**
@@ -123,7 +212,7 @@ export function updateSettings(
123
212
  update: Partial<UserSettings>,
124
213
  ): UserSettings {
125
214
  const current = loadSettings(path);
126
- const merged = { ...current, ...sanitizeSettings(update) };
215
+ const merged = mergeUserSettings(current, sanitizeSettings(update));
127
216
  return saveSettings(path, merged);
128
217
  }
129
218
 
@@ -156,6 +245,46 @@ export function resolveStartupSettings(
156
245
  };
157
246
  }
158
247
 
248
+ function mergeNamedEntries<T extends { name: string }>(
249
+ base: readonly T[] | undefined,
250
+ override: readonly T[] | undefined,
251
+ ): T[] | undefined {
252
+ if (!base?.length && !override?.length) {
253
+ return undefined;
254
+ }
255
+
256
+ const merged = [...(base ?? [])];
257
+ const indexes = new Map<string, number>();
258
+
259
+ for (const [index, entry] of merged.entries()) {
260
+ indexes.set(entry.name, index);
261
+ }
262
+
263
+ for (const entry of override ?? []) {
264
+ const existingIndex = indexes.get(entry.name);
265
+ if (existingIndex === undefined) {
266
+ indexes.set(entry.name, merged.length);
267
+ merged.push(entry);
268
+ continue;
269
+ }
270
+
271
+ merged[existingIndex] = entry;
272
+ }
273
+
274
+ return merged.length > 0 ? merged : undefined;
275
+ }
276
+
277
+ function parseSettingsFile(path: string): UserSettings {
278
+ const raw = JSON.parse(readFileSync(path, "utf-8")) as unknown;
279
+ return sanitizeSettings(raw);
280
+ }
281
+
282
+ function createSettingsReadError(path: string, error: unknown): Error {
283
+ return new Error(
284
+ `Failed to read settings ${path}: ${getErrorMessage(error)}`,
285
+ );
286
+ }
287
+
159
288
  /**
160
289
  * Validate and normalize a parsed settings object.
161
290
  *
@@ -193,6 +322,11 @@ function sanitizeSettings(value: unknown): UserSettings {
193
322
  settings.customProviders = customProviders;
194
323
  }
195
324
 
325
+ const mcp = sanitizeMcpSettings(candidate.mcp);
326
+ if (mcp) {
327
+ settings.mcp = mcp;
328
+ }
329
+
196
330
  return settings;
197
331
  }
198
332
 
@@ -244,6 +378,64 @@ function sanitizeCustomProviders(value: unknown): CustomProvider[] | undefined {
244
378
  return result.length > 0 ? result : undefined;
245
379
  }
246
380
 
381
+ function sanitizeMcpSettings(value: unknown): McpSettings | undefined {
382
+ const candidate = toRecord(value);
383
+ if (!candidate) {
384
+ return undefined;
385
+ }
386
+
387
+ const servers = sanitizeMcpServers(candidate.servers);
388
+ if (!servers) {
389
+ return undefined;
390
+ }
391
+
392
+ return { servers };
393
+ }
394
+
395
+ /** Try to parse a single MCP server entry, returning null on failure. */
396
+ function parseMcpServer(item: unknown): McpServerConfig | null {
397
+ const candidate = toRecord(item);
398
+ if (!candidate) {
399
+ return null;
400
+ }
401
+
402
+ const name = readString(candidate, "name")?.trim() ?? "";
403
+ const url = readString(candidate, "url")?.trim() ?? "";
404
+ const enabled = readBoolean(candidate, "enabled") ?? true;
405
+
406
+ if (!name || !url || !MCP_SERVER_NAME_PATTERN.test(name)) {
407
+ return null;
408
+ }
409
+
410
+ return { name, url, enabled };
411
+ }
412
+
413
+ /**
414
+ * Validate and normalize configured MCP servers.
415
+ *
416
+ * Drops entries with missing/invalid names or URLs, and deduplicates by name
417
+ * (first entry wins).
418
+ */
419
+ function sanitizeMcpServers(value: unknown): McpServerConfig[] | undefined {
420
+ if (!Array.isArray(value)) {
421
+ return undefined;
422
+ }
423
+
424
+ const result: McpServerConfig[] = [];
425
+ const seen = new Set<string>();
426
+
427
+ for (const item of value) {
428
+ const entry = parseMcpServer(item);
429
+ if (!entry || seen.has(entry.name)) {
430
+ continue;
431
+ }
432
+ seen.add(entry.name);
433
+ result.push(entry);
434
+ }
435
+
436
+ return result.length > 0 ? result : undefined;
437
+ }
438
+
247
439
  /**
248
440
  * Check whether a value is a valid thinking level.
249
441
  *
package/src/skills.ts CHANGED
@@ -226,6 +226,15 @@ export function discoverSkills(scanPaths: string[]): Skill[] {
226
226
  // Catalog generation
227
227
  // ---------------------------------------------------------------------------
228
228
 
229
+ function escapeXml(value: string): string {
230
+ return value
231
+ .replaceAll("&", "&amp;")
232
+ .replaceAll("<", "&lt;")
233
+ .replaceAll(">", "&gt;")
234
+ .replaceAll('"', "&quot;")
235
+ .replaceAll("'", "&apos;");
236
+ }
237
+
229
238
  /**
230
239
  * Build the XML skill catalog for the system prompt.
231
240
  *
@@ -243,9 +252,9 @@ export function buildSkillCatalog(skills: Skill[]): string {
243
252
  .map(
244
253
  (s) =>
245
254
  ` <skill>\n` +
246
- ` <name>${s.name}</name>\n` +
247
- ` <description>${s.description}</description>\n` +
248
- ` <location>${s.path}</location>\n` +
255
+ ` <name>${escapeXml(s.name)}</name>\n` +
256
+ ` <description>${escapeXml(s.description)}</description>\n` +
257
+ ` <location>${escapeXml(s.path)}</location>\n` +
249
258
  ` </skill>`,
250
259
  )
251
260
  .join("\n");
package/src/submit.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  appendConversationMessage,
22
22
  appendMessage,
23
23
  appendPromptHistory,
24
- filterModelMessages,
24
+ loadCompactedModelMessages,
25
25
  truncatePromptHistory,
26
26
  } from "./session.ts";
27
27
  import { executeReadImage } from "./tools.ts";
@@ -215,6 +215,20 @@ function recordRawPromptHistory(
215
215
  truncatePromptHistory(state.db, MAX_PROMPT_HISTORY);
216
216
  }
217
217
 
218
+ /**
219
+ * Drop any resolved steering messages still queued on the active app state.
220
+ *
221
+ * Raw prompt-history rows are intentionally left untouched because queue resets
222
+ * must not rewrite global input-history behavior.
223
+ *
224
+ * @param state - Mutable application state containing the queued steering list.
225
+ */
226
+ export function clearQueuedUserMessages(
227
+ state: Pick<AppState, "queuedUserMessages">,
228
+ ): void {
229
+ state.queuedUserMessages.length = 0;
230
+ }
231
+
218
232
  /**
219
233
  * Queue resolved user content for the next model-request boundary of an active run.
220
234
  *
@@ -257,6 +271,10 @@ function handleAgentEvent(event: AgentEvent, state: AppState): void {
257
271
  case "tool_result":
258
272
  appendConversationMessage(state, event.message);
259
273
  break;
274
+ case "context_compacted":
275
+ state.contextTokens = event.contextTokens;
276
+ state.stats = event.stats;
277
+ break;
260
278
  case "text_delta":
261
279
  case "thinking_delta":
262
280
  case "toolcall_start":
@@ -301,6 +319,9 @@ export async function submitResolvedInput(
301
319
  throw new Error("Cannot submit empty input.");
302
320
  }
303
321
 
322
+ clearQueuedUserMessages(state);
323
+ state.delegationBudgetRemaining = state.delegationBudgetLimit;
324
+
304
325
  const session = ensureSession(state);
305
326
  recordRawPromptHistory(rawInput, state, session.id);
306
327
 
@@ -316,7 +337,7 @@ export async function submitResolvedInput(
316
337
 
317
338
  const systemPrompt = buildPrompt(state);
318
339
  const { tools, toolHandlers } = buildToolList(state);
319
- const modelMessages = filterModelMessages(state.messages);
340
+ const modelMessages = loadCompactedModelMessages(state.db, session.id);
320
341
 
321
342
  state.running = true;
322
343
  state.abortController = new AbortController();
@@ -347,6 +368,7 @@ export async function submitResolvedInput(
347
368
  stopReason = result.stopReason;
348
369
  return result.stopReason;
349
370
  } finally {
371
+ clearQueuedUserMessages(state);
350
372
  state.running = false;
351
373
  state.abortController = null;
352
374
  hooks?.onTurnEnd?.(state, stopReason);
package/src/theme.ts CHANGED
@@ -2,8 +2,7 @@
2
2
  * UI theme definition and default colors.
3
3
  *
4
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.
5
+ * never hardcodes colors. Theme overrides are merged left-to-right.
7
6
  *
8
7
  * Theme values are cel-tui {@link Color} palette references. The default
9
8
  * theme prefers ANSI 16-color palette entries so it adapts cleanly to the
@@ -12,11 +11,47 @@
12
11
  * @module
13
12
  */
14
13
 
14
+ import type { SyntaxHighlightTheme } from "@cel-tui/components";
15
15
  import type { Color } from "@cel-tui/types";
16
16
 
17
17
  /** A cel-tui palette color or the terminal default when undefined. */
18
18
  type ThemeColor = Color | undefined;
19
19
 
20
+ type SyntaxThemeRegistration = Exclude<SyntaxHighlightTheme, string>;
21
+ type SyntaxThemeTokenColor = NonNullable<
22
+ SyntaxThemeRegistration["tokenColors"]
23
+ >[number];
24
+ type SyntaxThemeVariant = "markdown" | "code" | "shell";
25
+
26
+ /** ANSI16 fallback hex values for syntax-highlighter theme overrides. */
27
+ const ANSI_COLOR_HEX: Readonly<Record<Color, string>> = {
28
+ color00: "#000000",
29
+ color01: "#cd3131",
30
+ color02: "#0dbc79",
31
+ color03: "#e5e510",
32
+ color04: "#2472c8",
33
+ color05: "#bc3fbc",
34
+ color06: "#11a8cd",
35
+ color07: "#e5e5e5",
36
+ color08: "#666666",
37
+ color09: "#f14c4c",
38
+ color10: "#23d18b",
39
+ color11: "#f5f543",
40
+ color12: "#3b8eea",
41
+ color13: "#d670d6",
42
+ color14: "#29b8db",
43
+ color15: "#ffffff",
44
+ };
45
+
46
+ const syntaxThemeCache: Record<
47
+ SyntaxThemeVariant,
48
+ WeakMap<Theme, SyntaxThemeRegistration>
49
+ > = {
50
+ markdown: new WeakMap(),
51
+ code: new WeakMap(),
52
+ shell: new WeakMap(),
53
+ };
54
+
20
55
  // ---------------------------------------------------------------------------
21
56
  // Types
22
57
  // ---------------------------------------------------------------------------
@@ -132,7 +167,7 @@ export const DEFAULT_THEME: Theme = {
132
167
  * mutated.
133
168
  *
134
169
  * @param base - The base theme to start from.
135
- * @param overrides - Partial theme objects to merge (from plugins).
170
+ * @param overrides - Partial theme objects to merge.
136
171
  * @returns A complete {@link Theme} with all overrides applied.
137
172
  */
138
173
  export function mergeThemes(
@@ -145,3 +180,151 @@ export function mergeThemes(
145
180
  }
146
181
  return merged;
147
182
  }
183
+
184
+ function colorToHex(color: ThemeColor): string | undefined {
185
+ return color ? ANSI_COLOR_HEX[color] : undefined;
186
+ }
187
+
188
+ function pushSyntaxTokenColor(
189
+ tokenColors: SyntaxThemeTokenColor[],
190
+ scope: string | readonly string[],
191
+ foreground: ThemeColor,
192
+ fontStyle?: string,
193
+ ): void {
194
+ const foregroundHex = colorToHex(foreground);
195
+ if (!foregroundHex && !fontStyle) {
196
+ return;
197
+ }
198
+
199
+ tokenColors.push({
200
+ scope,
201
+ settings: {
202
+ ...(foregroundHex ? { foreground: foregroundHex } : {}),
203
+ ...(fontStyle ? { fontStyle } : {}),
204
+ },
205
+ });
206
+ }
207
+
208
+ function pushCodeSyntaxTokenColors(
209
+ tokenColors: SyntaxThemeTokenColor[],
210
+ theme: Theme,
211
+ ): void {
212
+ pushSyntaxTokenColor(
213
+ tokenColors,
214
+ ["comment", "quote", "doctag", "markup.quote"],
215
+ theme.mutedText,
216
+ "italic",
217
+ );
218
+ pushSyntaxTokenColor(
219
+ tokenColors,
220
+ ["keyword", "operator"],
221
+ theme.secondaryAccentText,
222
+ );
223
+ pushSyntaxTokenColor(
224
+ tokenColors,
225
+ ["command", "function_", "function", "title"],
226
+ theme.accentText,
227
+ );
228
+ pushSyntaxTokenColor(
229
+ tokenColors,
230
+ ["builtin", "built_in", "class_", "class", "inherited__", "type"],
231
+ theme.accentText,
232
+ );
233
+ pushSyntaxTokenColor(
234
+ tokenColors,
235
+ ["escape", "literal", "number", "symbol"],
236
+ theme.secondaryAccentText ?? theme.accentText,
237
+ );
238
+ pushSyntaxTokenColor(
239
+ tokenColors,
240
+ ["code", "string", "markup.code"],
241
+ theme.diffAdded,
242
+ );
243
+ pushSyntaxTokenColor(tokenColors, "regexp", theme.diffRemoved);
244
+ pushSyntaxTokenColor(
245
+ tokenColors,
246
+ ["attr", "attribute", "params", "property", "selector-attr"],
247
+ theme.accentText,
248
+ );
249
+ pushSyntaxTokenColor(
250
+ tokenColors,
251
+ [
252
+ "name",
253
+ "tag",
254
+ "selector-class",
255
+ "selector-id",
256
+ "selector-pseudo",
257
+ "selector-tag",
258
+ ],
259
+ theme.accentText,
260
+ );
261
+ }
262
+
263
+ function pushMarkdownSyntaxTokenColors(
264
+ tokenColors: SyntaxThemeTokenColor[],
265
+ theme: Theme,
266
+ ): void {
267
+ pushSyntaxTokenColor(
268
+ tokenColors,
269
+ ["quote", "markup.quote"],
270
+ theme.mutedText,
271
+ "italic",
272
+ );
273
+ pushSyntaxTokenColor(
274
+ tokenColors,
275
+ ["section", "markup.heading"],
276
+ theme.accentText,
277
+ "bold",
278
+ );
279
+ pushSyntaxTokenColor(
280
+ tokenColors,
281
+ ["bullet", "markup.list"],
282
+ theme.secondaryAccentText,
283
+ "bold",
284
+ );
285
+ pushSyntaxTokenColor(
286
+ tokenColors,
287
+ ["code", "string", "markup.code"],
288
+ theme.diffAdded,
289
+ );
290
+ pushSyntaxTokenColor(tokenColors, "link", theme.accentText, "underline");
291
+ pushSyntaxTokenColor(tokenColors, "strong", undefined, "bold");
292
+ pushSyntaxTokenColor(tokenColors, "emphasis", undefined, "italic");
293
+ }
294
+
295
+ /**
296
+ * Build the shared syntax-highlight theme registration for a UI theme variant.
297
+ *
298
+ * Markdown, read/code blocks, and shell/tool previews all flow through this
299
+ * helper so active-theme overrides stay consistent in one place. Variant names
300
+ * are included in the registration so cel-tui's internal SyntaxHighlight cache
301
+ * does not collapse code and shell renders onto the same custom-theme key.
302
+ *
303
+ * @param theme - Active UI theme.
304
+ * @param variant - Semantic highlighting variant for the rendered content.
305
+ * @returns A cached cel-tui syntax-highlight theme registration.
306
+ */
307
+ export function getSyntaxHighlightTheme(
308
+ theme: Theme,
309
+ variant: SyntaxThemeVariant,
310
+ ): SyntaxThemeRegistration {
311
+ const cache = syntaxThemeCache[variant];
312
+ const cached = cache.get(theme);
313
+ if (cached) {
314
+ return cached;
315
+ }
316
+
317
+ const tokenColors: SyntaxThemeTokenColor[] = [];
318
+ if (variant === "markdown") {
319
+ pushMarkdownSyntaxTokenColors(tokenColors, theme);
320
+ } else {
321
+ pushCodeSyntaxTokenColors(tokenColors, theme);
322
+ }
323
+
324
+ const syntaxTheme: SyntaxThemeRegistration = {
325
+ name: `mini-coder-${variant}`,
326
+ tokenColors,
327
+ };
328
+ cache.set(theme, syntaxTheme);
329
+ return syntaxTheme;
330
+ }
@@ -23,6 +23,8 @@ import { validateToolArguments } from "@mariozechner/pi-ai";
23
23
  export interface ToolExecResult {
24
24
  /** Content blocks for the tool result. */
25
25
  content: (TextContent | ImageContent)[];
26
+ /** Optional structured metadata preserved on the tool-result message. */
27
+ details?: unknown;
26
28
  /** Whether the execution encountered an error. */
27
29
  isError: boolean;
28
30
  }