@aexol/spectral 0.7.6 → 0.7.8

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.
Files changed (57) hide show
  1. package/dist/agent/index.js +16 -140
  2. package/dist/cli.js +25 -220
  3. package/dist/extensions/spectral-vision-fallback.js +188 -0
  4. package/dist/memory/commands/status.js +5 -5
  5. package/dist/memory/commands/view.js +16 -14
  6. package/dist/memory/compaction.js +31 -3
  7. package/dist/memory/prompts.js +5 -5
  8. package/dist/memory/tools/recall-observation.js +2 -2
  9. package/dist/pi/coding-agent/config.js +0 -11
  10. package/dist/pi/coding-agent/core/agent-session.js +3 -17
  11. package/dist/pi/coding-agent/core/extensions/loader.js +0 -6
  12. package/dist/pi/coding-agent/core/extensions/runner.js +7 -1
  13. package/dist/pi/coding-agent/core/keybindings.js +129 -2
  14. package/dist/pi/coding-agent/core/settings-manager.js +20 -0
  15. package/dist/pi/coding-agent/core/tools/bash.js +17 -63
  16. package/dist/pi/coding-agent/core/tools/edit.js +4 -141
  17. package/dist/pi/coding-agent/core/tools/find.js +0 -11
  18. package/dist/pi/coding-agent/core/tools/grep.js +0 -11
  19. package/dist/pi/coding-agent/core/tools/ls.js +0 -11
  20. package/dist/pi/coding-agent/core/tools/read.js +0 -12
  21. package/dist/pi/coding-agent/core/tools/render-utils.js +1 -14
  22. package/dist/pi/coding-agent/core/tools/write.js +2 -97
  23. package/dist/pi/coding-agent/modes/interactive/components/keybinding-hints.js +1 -1
  24. package/dist/pi/coding-agent/modes/interactive/components/visual-truncate.js +6 -12
  25. package/dist/pi/coding-agent/modes/interactive/theme/theme.js +1 -2
  26. package/dist/relay/models-fetch.js +13 -1
  27. package/dist/server/pi-bridge.js +57 -4
  28. package/dist/server/session-stream.js +7 -1
  29. package/package.json +1 -1
  30. package/dist/pi/coding-agent/core/export-html/ansi-to-html.js +0 -248
  31. package/dist/pi/coding-agent/core/export-html/index.js +0 -225
  32. package/dist/pi/coding-agent/core/export-html/tool-renderer.js +0 -107
  33. package/dist/pi/tui/autocomplete.js +0 -631
  34. package/dist/pi/tui/components/box.js +0 -103
  35. package/dist/pi/tui/components/cancellable-loader.js +0 -34
  36. package/dist/pi/tui/components/editor.js +0 -1915
  37. package/dist/pi/tui/components/image.js +0 -88
  38. package/dist/pi/tui/components/input.js +0 -425
  39. package/dist/pi/tui/components/loader.js +0 -68
  40. package/dist/pi/tui/components/markdown.js +0 -633
  41. package/dist/pi/tui/components/select-list.js +0 -158
  42. package/dist/pi/tui/components/settings-list.js +0 -184
  43. package/dist/pi/tui/components/spacer.js +0 -22
  44. package/dist/pi/tui/components/text.js +0 -88
  45. package/dist/pi/tui/components/truncated-text.js +0 -50
  46. package/dist/pi/tui/editor-component.js +0 -1
  47. package/dist/pi/tui/fuzzy.js +0 -109
  48. package/dist/pi/tui/index.js +0 -31
  49. package/dist/pi/tui/keybindings.js +0 -173
  50. package/dist/pi/tui/keys.js +0 -1172
  51. package/dist/pi/tui/kill-ring.js +0 -43
  52. package/dist/pi/tui/stdin-buffer.js +0 -360
  53. package/dist/pi/tui/terminal-image.js +0 -335
  54. package/dist/pi/tui/terminal.js +0 -324
  55. package/dist/pi/tui/tui.js +0 -1076
  56. package/dist/pi/tui/undo-stack.js +0 -24
  57. package/dist/pi/tui/utils.js +0 -1016
@@ -1,4 +1,3 @@
1
- import { Container, Text } from "../../../tui/index.js";
2
1
  import { mkdir as fsMkdir, writeFile as fsWriteFile } from "fs/promises";
3
2
  import { dirname } from "path";
4
3
  import { Type } from "typebox";
@@ -16,73 +15,6 @@ const defaultWriteOperations = {
16
15
  writeFile: (path, content) => fsWriteFile(path, content, "utf-8"),
17
16
  mkdir: (dir) => fsMkdir(dir, { recursive: true }).then(() => { }),
18
17
  };
19
- class WriteCallRenderComponent extends Text {
20
- cache;
21
- constructor() {
22
- super("", 0, 0);
23
- }
24
- }
25
- const WRITE_PARTIAL_FULL_HIGHLIGHT_LINES = 50;
26
- function highlightSingleLine(line, lang) {
27
- const highlighted = highlightCode(line, lang);
28
- return highlighted[0] ?? "";
29
- }
30
- function refreshWriteHighlightPrefix(cache) {
31
- const prefixCount = Math.min(WRITE_PARTIAL_FULL_HIGHLIGHT_LINES, cache.normalizedLines.length);
32
- if (prefixCount === 0)
33
- return;
34
- const prefixSource = cache.normalizedLines.slice(0, prefixCount).join("\n");
35
- const prefixHighlighted = highlightCode(prefixSource, cache.lang);
36
- for (let i = 0; i < prefixCount; i++) {
37
- cache.highlightedLines[i] =
38
- prefixHighlighted[i] ?? highlightSingleLine(cache.normalizedLines[i] ?? "", cache.lang);
39
- }
40
- }
41
- function rebuildWriteHighlightCacheFull(rawPath, fileContent) {
42
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
43
- if (!lang)
44
- return undefined;
45
- const displayContent = normalizeDisplayText(fileContent);
46
- const normalized = replaceTabs(displayContent);
47
- return {
48
- rawPath,
49
- lang,
50
- rawContent: fileContent,
51
- normalizedLines: normalized.split("\n"),
52
- highlightedLines: highlightCode(normalized, lang),
53
- };
54
- }
55
- function updateWriteHighlightCacheIncremental(cache, rawPath, fileContent) {
56
- const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
57
- if (!lang)
58
- return undefined;
59
- if (!cache)
60
- return rebuildWriteHighlightCacheFull(rawPath, fileContent);
61
- if (cache.lang !== lang || cache.rawPath !== rawPath)
62
- return rebuildWriteHighlightCacheFull(rawPath, fileContent);
63
- if (!fileContent.startsWith(cache.rawContent))
64
- return rebuildWriteHighlightCacheFull(rawPath, fileContent);
65
- if (fileContent.length === cache.rawContent.length)
66
- return cache;
67
- const deltaRaw = fileContent.slice(cache.rawContent.length);
68
- const deltaDisplay = normalizeDisplayText(deltaRaw);
69
- const deltaNormalized = replaceTabs(deltaDisplay);
70
- cache.rawContent = fileContent;
71
- if (cache.normalizedLines.length === 0) {
72
- cache.normalizedLines.push("");
73
- cache.highlightedLines.push("");
74
- }
75
- const segments = deltaNormalized.split("\n");
76
- const lastIndex = cache.normalizedLines.length - 1;
77
- cache.normalizedLines[lastIndex] += segments[0];
78
- cache.highlightedLines[lastIndex] = highlightSingleLine(cache.normalizedLines[lastIndex], cache.lang);
79
- for (let i = 1; i < segments.length; i++) {
80
- cache.normalizedLines.push(segments[i]);
81
- cache.highlightedLines.push(highlightSingleLine(segments[i], cache.lang));
82
- }
83
- refreshWriteHighlightPrefix(cache);
84
- return cache;
85
- }
86
18
  function trimTrailingEmptyLines(lines) {
87
19
  let end = lines.length;
88
20
  while (end > 0 && lines[end - 1] === "") {
@@ -90,7 +22,7 @@ function trimTrailingEmptyLines(lines) {
90
22
  }
91
23
  return lines.slice(0, end);
92
24
  }
93
- function formatWriteCall(args, options, theme, cache) {
25
+ function formatWriteCall(args, options, theme) {
94
26
  const rawPath = str(args?.file_path ?? args?.path);
95
27
  const fileContent = str(args?.content);
96
28
  const path = rawPath !== null ? shortenPath(rawPath) : null;
@@ -102,7 +34,7 @@ function formatWriteCall(args, options, theme, cache) {
102
34
  else if (fileContent) {
103
35
  const lang = rawPath ? getLanguageFromPath(rawPath) : undefined;
104
36
  const renderedLines = lang
105
- ? (cache?.highlightedLines ?? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang))
37
+ ? highlightCode(replaceTabs(normalizeDisplayText(fileContent)), lang)
106
38
  : normalizeDisplayText(fileContent).split("\n");
107
39
  const lines = trimTrailingEmptyLines(renderedLines);
108
40
  const totalLines = lines.length;
@@ -178,33 +110,6 @@ export function createWriteToolDefinition(cwd, options) {
178
110
  })();
179
111
  }));
180
112
  },
181
- renderCall(args, theme, context) {
182
- const renderArgs = args;
183
- const rawPath = str(renderArgs?.file_path ?? renderArgs?.path);
184
- const fileContent = str(renderArgs?.content);
185
- const component = context.lastComponent ?? new WriteCallRenderComponent();
186
- if (fileContent !== null) {
187
- component.cache = context.argsComplete
188
- ? rebuildWriteHighlightCacheFull(rawPath, fileContent)
189
- : updateWriteHighlightCacheIncremental(component.cache, rawPath, fileContent);
190
- }
191
- else {
192
- component.cache = undefined;
193
- }
194
- component.setText(formatWriteCall(renderArgs, { expanded: context.expanded, isPartial: context.isPartial }, theme, component.cache));
195
- return component;
196
- },
197
- renderResult(result, _options, theme, context) {
198
- const output = formatWriteResult({ ...result, isError: context.isError }, theme);
199
- if (!output) {
200
- const component = context.lastComponent ?? new Container();
201
- component.clear();
202
- return component;
203
- }
204
- const text = context.lastComponent ?? new Text("", 0, 0);
205
- text.setText(output);
206
- return text;
207
- },
208
113
  };
209
114
  }
210
115
  export function createWriteTool(cwd, options) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Utilities for formatting keybinding hints in the UI.
3
3
  */
4
- import { getKeybindings } from "../../../../tui/index.js";
4
+ import { getKeybindings } from "../../../core/keybindings.js";
5
5
  import { theme } from "../theme/theme.js";
6
6
  function formatKeyPart(part, options) {
7
7
  const displayPart = process.platform === "darwin" && part.toLowerCase() === "alt" ? "option" : part;
@@ -1,27 +1,21 @@
1
1
  /**
2
- * Shared utility for truncating text to visual lines (accounting for line wrapping).
3
- * Used by both tool-execution.ts and bash-execution.ts for consistent behavior.
2
+ * Shared utility for truncating text to visual lines.
3
+ * Simplified for headless mode returns lines directly without Text component.
4
4
  */
5
- import { Text } from "../../../../tui/index.js";
6
5
  /**
7
6
  * Truncate text to a maximum number of visual lines (from the end).
8
- * This accounts for line wrapping based on terminal width.
9
7
  *
10
8
  * @param text - The text content (may contain newlines)
11
9
  * @param maxVisualLines - Maximum number of visual lines to show
12
- * @param width - Terminal/render width
13
- * @param paddingX - Horizontal padding for Text component (default 0).
14
- * Use 0 when result will be placed in a Box (Box adds its own padding).
15
- * Use 1 when result will be placed in a plain Container.
10
+ * @param _width - Ignored in headless mode
11
+ * @param _paddingX - Ignored in headless mode
16
12
  * @returns The truncated visual lines and count of skipped lines
17
13
  */
18
- export function truncateToVisualLines(text, maxVisualLines, width, paddingX = 0) {
14
+ export function truncateToVisualLines(text, maxVisualLines, _width, _paddingX = 0) {
19
15
  if (!text) {
20
16
  return { visualLines: [], skippedCount: 0 };
21
17
  }
22
- // Create a temporary Text component to render and get visual lines
23
- const tempText = new Text(text, paddingX, 0);
24
- const allVisualLines = tempText.render(width);
18
+ const allVisualLines = text.split("\n");
25
19
  if (allVisualLines.length <= maxVisualLines) {
26
20
  return { visualLines: allVisualLines, skippedCount: 0 };
27
21
  }
@@ -1,6 +1,5 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { getCapabilities, } from "../../../../tui/index.js";
4
3
  import chalk from "chalk";
5
4
  import { Type } from "typebox";
6
5
  import { Compile } from "typebox/compile";
@@ -443,7 +442,7 @@ function loadThemeJson(name) {
443
442
  return parseThemeJsonContent(name, content);
444
443
  }
445
444
  function createTheme(themeJson, mode, sourcePath) {
446
- const colorMode = mode ?? (getCapabilities().trueColor ? "truecolor" : "256color");
445
+ const colorMode = mode ?? "256color";
447
446
  const resolvedColors = resolveThemeColors(themeJson.colors, themeJson.vars);
448
447
  const fgColors = {};
449
448
  const bgColors = {};
@@ -28,7 +28,7 @@ const cache = new Map();
28
28
  export function clearAllowedModelsCache() {
29
29
  cache.clear();
30
30
  }
31
- const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow supportsImages } }`;
31
+ const QUERY = `query AvailableBaseModels { availableBaseModels { name provider userModelId agentEnabled creditInputPer1M creditOutputPer1M creditCachedInputPer1M creditCacheReadPer1M creditCacheWritePer1M contextWindow supportsImages supportsReasoning isDefault isVisionDefault } }`;
32
32
  /**
33
33
  * Fetch the whitelist of allowed base models. Throws on any failure with a
34
34
  * message tailored for an operator running `spectral serve` — the caller
@@ -104,6 +104,15 @@ export async function fetchAllowedModels(opts) {
104
104
  const supportsImages = typeof row?.supportsImages === "boolean"
105
105
  ? row.supportsImages
106
106
  : null;
107
+ const supportsReasoning = typeof row?.supportsReasoning === "boolean"
108
+ ? row.supportsReasoning
109
+ : null;
110
+ const isDefault = typeof row?.isDefault === "boolean"
111
+ ? row.isDefault
112
+ : null;
113
+ const isVisionDefault = typeof row?.isVisionDefault === "boolean"
114
+ ? row.isVisionDefault
115
+ : null;
107
116
  const model = {
108
117
  modelId: name,
109
118
  displayName: name,
@@ -115,6 +124,9 @@ export async function fetchAllowedModels(opts) {
115
124
  creditCacheWritePer1M: asOptionalNumber(row?.creditCacheWritePer1M),
116
125
  contextWindow,
117
126
  supportsImages,
127
+ supportsReasoning,
128
+ isDefault,
129
+ isVisionDefault,
118
130
  };
119
131
  if (typeof row?.userModelId === "string") {
120
132
  model.userModelId = row.userModelId;
@@ -49,7 +49,7 @@
49
49
  * instance is reused across `prompt()` calls).
50
50
  */
51
51
  import { createJiti } from "@mariozechner/jiti";
52
- import { AuthStorage, createAgentSession, DefaultResourceLoader, ModelRegistry, SessionManager, } from "../pi/coding-agent/index.js";
52
+ import { AuthStorage, createAgentSession, DefaultResourceLoader, ModelRegistry, SessionManager, SettingsManager, } from "../pi/coding-agent/index.js";
53
53
  import { randomUUID } from "node:crypto";
54
54
  import { existsSync, statSync } from "node:fs";
55
55
  import { dirname, join, resolve } from "node:path";
@@ -219,6 +219,7 @@ const REASONING_SUPPORT_PREFIXES = [
219
219
  "claude-opus-4",
220
220
  "o3", "o4",
221
221
  "deepseek-r1",
222
+ "deepseek-v4",
222
223
  "gemini-2.5",
223
224
  ];
224
225
  /** Check if a modelId prefix indicates reasoning/thinking support. */
@@ -226,6 +227,29 @@ function supportsReasoning(modelId) {
226
227
  const bare = bareModelId(modelId);
227
228
  return REASONING_SUPPORT_PREFIXES.some((p) => modelId.startsWith(p) || bare.startsWith(p));
228
229
  }
230
+ function inferSyntheticOpenAICompat(model) {
231
+ const bare = bareModelId(model.modelId);
232
+ const isDeepSeek = model.provider === "deepseek" ||
233
+ model.modelId.startsWith("deepseek/") ||
234
+ bare.startsWith("deepseek");
235
+ if (isDeepSeek) {
236
+ return {
237
+ thinkingFormat: "deepseek",
238
+ requiresReasoningContentOnAssistantMessages: true,
239
+ supportsDeveloperRole: false,
240
+ };
241
+ }
242
+ if (model.provider === "openrouter") {
243
+ const compat = {
244
+ thinkingFormat: "openrouter",
245
+ };
246
+ if (model.modelId.startsWith("anthropic/")) {
247
+ compat.cacheControlFormat = "anthropic";
248
+ }
249
+ return compat;
250
+ }
251
+ return undefined;
252
+ }
229
253
  /**
230
254
  * Calculate credits from token usage using per-model credit rates.
231
255
  *
@@ -575,6 +599,29 @@ export class PiBridge {
575
599
  }
576
600
  this.allowedModels = allowedModels;
577
601
  this.registerSyntheticProviders(allowedModels);
602
+ // Build an in-memory SettingsManager seeded with admin-configured
603
+ // defaults from the backend. findInitialModel() will pick up the
604
+ // isDefault model; the vision extension can query isVisionDefault.
605
+ const settingsOverrides = {};
606
+ const defaultModel = allowedModels.find((m) => m.isDefault);
607
+ if (defaultModel) {
608
+ const proxyProvider = defaultModel.provider === "anthropic"
609
+ ? SPECTRAL_PROXY_ANTHROPIC
610
+ : SPECTRAL_PROXY_OPENAI;
611
+ settingsOverrides.defaultProvider = proxyProvider;
612
+ settingsOverrides.defaultModel = defaultModel.modelId;
613
+ console.info(`✓ Default model from backend: ${proxyProvider}/${defaultModel.modelId}`);
614
+ }
615
+ const defaultVisionModel = allowedModels.find((m) => m.isVisionDefault);
616
+ if (defaultVisionModel) {
617
+ const proxyProvider = defaultVisionModel.provider === "anthropic"
618
+ ? SPECTRAL_PROXY_ANTHROPIC
619
+ : SPECTRAL_PROXY_OPENAI;
620
+ settingsOverrides.defaultVisionProvider = proxyProvider;
621
+ settingsOverrides.defaultVisionModel = defaultVisionModel.modelId;
622
+ console.info(`✓ Default vision model from backend: ${proxyProvider}/${defaultVisionModel.modelId}`);
623
+ }
624
+ const settingsManager = SettingsManager.inMemory(settingsOverrides);
578
625
  console.info(`✓ Inference routed via backend proxy (${allowedModels.length} model(s) available)`);
579
626
  const result = await createAgentSession({
580
627
  cwd: this.opts.cwd,
@@ -582,6 +629,7 @@ export class PiBridge {
582
629
  sessionManager,
583
630
  authStorage,
584
631
  modelRegistry: this.modelRegistry,
632
+ settingsManager,
585
633
  });
586
634
  this.session = result.session;
587
635
  // Headless UI context: forwards extension notify() calls as wire events
@@ -649,7 +697,7 @@ export class PiBridge {
649
697
  // at our synthetic proxy provider so auth resolves to the machine JWT.
650
698
  provider: SPECTRAL_PROXY_ANTHROPIC,
651
699
  baseUrl,
652
- reasoning: supportsReasoning(m.modelId),
700
+ reasoning: m.supportsReasoning ?? supportsReasoning(m.modelId),
653
701
  input: m.supportsImages !== false ? ["text", "image"] : ["text"],
654
702
  // Real pricing so pi can compute accurate token costs.
655
703
  cost: pricing
@@ -679,7 +727,7 @@ export class PiBridge {
679
727
  // breaking auth lookup against our synthetic proxy provider.
680
728
  provider: SPECTRAL_PROXY_OPENAI,
681
729
  baseUrl,
682
- reasoning: supportsReasoning(m.modelId),
730
+ reasoning: m.supportsReasoning ?? supportsReasoning(m.modelId),
683
731
  input: m.supportsImages !== false ? ["text", "image"] : ["text"],
684
732
  // Real pricing so pi can compute accurate token costs.
685
733
  cost: pricing
@@ -687,6 +735,7 @@ export class PiBridge {
687
735
  : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
688
736
  contextWindow: m.contextWindow ?? 0,
689
737
  maxTokens: 0,
738
+ compat: inferSyntheticOpenAICompat(m),
690
739
  };
691
740
  }),
692
741
  });
@@ -710,13 +759,14 @@ export class PiBridge {
710
759
  api: "openai-completions",
711
760
  provider: SPECTRAL_PROXY_USER_MODEL,
712
761
  baseUrl,
713
- reasoning: supportsReasoning(m.modelId),
762
+ reasoning: m.supportsReasoning ?? supportsReasoning(m.modelId),
714
763
  input: m.supportsImages !== false ? ["text", "image"] : ["text"],
715
764
  cost: pricing
716
765
  ? { input: pricing.input, output: pricing.output, cacheRead: pricing.cacheRead, cacheWrite: pricing.cacheWrite }
717
766
  : { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
718
767
  contextWindow: m.contextWindow ?? 0,
719
768
  maxTokens: 0,
769
+ compat: inferSyntheticOpenAICompat(m),
720
770
  };
721
771
  }),
722
772
  });
@@ -1177,6 +1227,8 @@ export class PiBridge {
1177
1227
  const done = details.results.filter(r => r.exitCode !== undefined && r.exitCode !== -1).length;
1178
1228
  const running = total - done;
1179
1229
  const currentAgent = details.results.find(r => r.exitCode === -1 || r.exitCode === undefined)?.agent;
1230
+ // Extract streaming text from partialResult content (set by text_delta forwarding).
1231
+ const streamingText = extractTextFromContent(partialResult?.content);
1180
1232
  const se = {
1181
1233
  type: "subagent_progress",
1182
1234
  toolCallId: ev.toolCallId,
@@ -1190,6 +1242,7 @@ export class PiBridge {
1190
1242
  : mode === "chain"
1191
1243
  ? `Chain: step ${details.results.length}/${total}`
1192
1244
  : `Running ${currentAgent ?? "subagent"}...`,
1245
+ streamingText: mode === "single" ? streamingText : undefined,
1193
1246
  };
1194
1247
  try {
1195
1248
  this.opts.emit(se);
@@ -1173,6 +1173,10 @@ export class SessionStreamManager {
1173
1173
  prunePersistedHistoryAfterCompaction(this.store, stream.sessionId, stream.bridge);
1174
1174
  }
1175
1175
  persistObservationalMemorySnapshot(this.store, stream.sessionId, stream.bridge);
1176
+ // Drain the prompt queue after compaction finishes. During
1177
+ // compaction, auto-dequeue in agent_end is skipped to prevent the
1178
+ // lossy prompt() guard from dropping queued items.
1179
+ this.maybeAutoDequeue(stream);
1176
1180
  // After compaction the session context has been reduced; push updated
1177
1181
  // context-window stats to all subscribers so the frontend's context
1178
1182
  // bar refreshes immediately instead of waiting for the next turn.
@@ -1287,8 +1291,10 @@ export class SessionStreamManager {
1287
1291
  // pending, check the persistent prompt queue. If there's a queued
1288
1292
  // prompt, start it immediately without broadcasting agent_end —
1289
1293
  // the frontend transitions seamlessly to the next turn.
1294
+ // Skip if compaction is in-flight (e.g. fork-compact just started
1295
+ // above) — compaction_end will drain the queue when it finishes.
1290
1296
  if (!stream.loopActive || !stream.loopOriginalPrompt) {
1291
- if (this.maybeAutoDequeue(stream))
1297
+ if (!stream.compacting && this.maybeAutoDequeue(stream))
1292
1298
  return;
1293
1299
  }
1294
1300
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -1,248 +0,0 @@
1
- /**
2
- * ANSI escape code to HTML converter.
3
- *
4
- * Converts terminal ANSI color/style codes to HTML with inline styles.
5
- * Supports:
6
- * - Standard foreground colors (30-37) and bright variants (90-97)
7
- * - Standard background colors (40-47) and bright variants (100-107)
8
- * - 256-color palette (38;5;N and 48;5;N)
9
- * - RGB true color (38;2;R;G;B and 48;2;R;G;B)
10
- * - Text styles: bold (1), dim (2), italic (3), underline (4)
11
- * - Reset (0)
12
- */
13
- // Standard ANSI color palette (0-15)
14
- const ANSI_COLORS = [
15
- "#000000", // 0: black
16
- "#800000", // 1: red
17
- "#008000", // 2: green
18
- "#808000", // 3: yellow
19
- "#000080", // 4: blue
20
- "#800080", // 5: magenta
21
- "#008080", // 6: cyan
22
- "#c0c0c0", // 7: white
23
- "#808080", // 8: bright black
24
- "#ff0000", // 9: bright red
25
- "#00ff00", // 10: bright green
26
- "#ffff00", // 11: bright yellow
27
- "#0000ff", // 12: bright blue
28
- "#ff00ff", // 13: bright magenta
29
- "#00ffff", // 14: bright cyan
30
- "#ffffff", // 15: bright white
31
- ];
32
- /**
33
- * Convert 256-color index to hex.
34
- */
35
- function color256ToHex(index) {
36
- // Standard colors (0-15)
37
- if (index < 16) {
38
- return ANSI_COLORS[index];
39
- }
40
- // Color cube (16-231): 6x6x6 = 216 colors
41
- if (index < 232) {
42
- const cubeIndex = index - 16;
43
- const r = Math.floor(cubeIndex / 36);
44
- const g = Math.floor((cubeIndex % 36) / 6);
45
- const b = cubeIndex % 6;
46
- const toComponent = (n) => (n === 0 ? 0 : 55 + n * 40);
47
- const toHex = (n) => toComponent(n).toString(16).padStart(2, "0");
48
- return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
49
- }
50
- // Grayscale (232-255): 24 shades
51
- const gray = 8 + (index - 232) * 10;
52
- const grayHex = gray.toString(16).padStart(2, "0");
53
- return `#${grayHex}${grayHex}${grayHex}`;
54
- }
55
- /**
56
- * Escape HTML special characters.
57
- */
58
- function escapeHtml(text) {
59
- return text
60
- .replace(/&/g, "&amp;")
61
- .replace(/</g, "&lt;")
62
- .replace(/>/g, "&gt;")
63
- .replace(/"/g, "&quot;")
64
- .replace(/'/g, "&#039;");
65
- }
66
- function createEmptyStyle() {
67
- return {
68
- fg: null,
69
- bg: null,
70
- bold: false,
71
- dim: false,
72
- italic: false,
73
- underline: false,
74
- };
75
- }
76
- function styleToInlineCSS(style) {
77
- const parts = [];
78
- if (style.fg)
79
- parts.push(`color:${style.fg}`);
80
- if (style.bg)
81
- parts.push(`background-color:${style.bg}`);
82
- if (style.bold)
83
- parts.push("font-weight:bold");
84
- if (style.dim)
85
- parts.push("opacity:0.6");
86
- if (style.italic)
87
- parts.push("font-style:italic");
88
- if (style.underline)
89
- parts.push("text-decoration:underline");
90
- return parts.join(";");
91
- }
92
- function hasStyle(style) {
93
- return style.fg !== null || style.bg !== null || style.bold || style.dim || style.italic || style.underline;
94
- }
95
- /**
96
- * Parse ANSI SGR (Select Graphic Rendition) codes and update style.
97
- */
98
- function applySgrCode(params, style) {
99
- let i = 0;
100
- while (i < params.length) {
101
- const code = params[i];
102
- if (code === 0) {
103
- // Reset all
104
- style.fg = null;
105
- style.bg = null;
106
- style.bold = false;
107
- style.dim = false;
108
- style.italic = false;
109
- style.underline = false;
110
- }
111
- else if (code === 1) {
112
- style.bold = true;
113
- }
114
- else if (code === 2) {
115
- style.dim = true;
116
- }
117
- else if (code === 3) {
118
- style.italic = true;
119
- }
120
- else if (code === 4) {
121
- style.underline = true;
122
- }
123
- else if (code === 22) {
124
- // Reset bold/dim
125
- style.bold = false;
126
- style.dim = false;
127
- }
128
- else if (code === 23) {
129
- style.italic = false;
130
- }
131
- else if (code === 24) {
132
- style.underline = false;
133
- }
134
- else if (code >= 30 && code <= 37) {
135
- // Standard foreground colors
136
- style.fg = ANSI_COLORS[code - 30];
137
- }
138
- else if (code === 38) {
139
- // Extended foreground color
140
- if (params[i + 1] === 5 && params.length > i + 2) {
141
- // 256-color: 38;5;N
142
- style.fg = color256ToHex(params[i + 2]);
143
- i += 2;
144
- }
145
- else if (params[i + 1] === 2 && params.length > i + 4) {
146
- // RGB: 38;2;R;G;B
147
- const r = params[i + 2];
148
- const g = params[i + 3];
149
- const b = params[i + 4];
150
- style.fg = `rgb(${r},${g},${b})`;
151
- i += 4;
152
- }
153
- }
154
- else if (code === 39) {
155
- // Default foreground
156
- style.fg = null;
157
- }
158
- else if (code >= 40 && code <= 47) {
159
- // Standard background colors
160
- style.bg = ANSI_COLORS[code - 40];
161
- }
162
- else if (code === 48) {
163
- // Extended background color
164
- if (params[i + 1] === 5 && params.length > i + 2) {
165
- // 256-color: 48;5;N
166
- style.bg = color256ToHex(params[i + 2]);
167
- i += 2;
168
- }
169
- else if (params[i + 1] === 2 && params.length > i + 4) {
170
- // RGB: 48;2;R;G;B
171
- const r = params[i + 2];
172
- const g = params[i + 3];
173
- const b = params[i + 4];
174
- style.bg = `rgb(${r},${g},${b})`;
175
- i += 4;
176
- }
177
- }
178
- else if (code === 49) {
179
- // Default background
180
- style.bg = null;
181
- }
182
- else if (code >= 90 && code <= 97) {
183
- // Bright foreground colors
184
- style.fg = ANSI_COLORS[code - 90 + 8];
185
- }
186
- else if (code >= 100 && code <= 107) {
187
- // Bright background colors
188
- style.bg = ANSI_COLORS[code - 100 + 8];
189
- }
190
- // Ignore unrecognized codes
191
- i++;
192
- }
193
- }
194
- // Match ANSI escape sequences: ESC[ followed by params and ending with 'm'
195
- const ANSI_REGEX = /\x1b\[([\d;]*)m/g;
196
- /**
197
- * Convert ANSI-escaped text to HTML with inline styles.
198
- */
199
- export function ansiToHtml(text) {
200
- const style = createEmptyStyle();
201
- let result = "";
202
- let lastIndex = 0;
203
- let inSpan = false;
204
- // Reset regex state
205
- ANSI_REGEX.lastIndex = 0;
206
- let match = ANSI_REGEX.exec(text);
207
- while (match !== null) {
208
- // Add text before this escape sequence
209
- const beforeText = text.slice(lastIndex, match.index);
210
- if (beforeText) {
211
- result += escapeHtml(beforeText);
212
- }
213
- // Parse SGR parameters
214
- const paramStr = match[1];
215
- const params = paramStr ? paramStr.split(";").map((p) => parseInt(p, 10) || 0) : [0];
216
- // Close existing span if we have one
217
- if (inSpan) {
218
- result += "</span>";
219
- inSpan = false;
220
- }
221
- // Apply the codes
222
- applySgrCode(params, style);
223
- // Open new span if we have any styling
224
- if (hasStyle(style)) {
225
- result += `<span style="${styleToInlineCSS(style)}">`;
226
- inSpan = true;
227
- }
228
- lastIndex = match.index + match[0].length;
229
- match = ANSI_REGEX.exec(text);
230
- }
231
- // Add remaining text
232
- const remainingText = text.slice(lastIndex);
233
- if (remainingText) {
234
- result += escapeHtml(remainingText);
235
- }
236
- // Close any open span
237
- if (inSpan) {
238
- result += "</span>";
239
- }
240
- return result;
241
- }
242
- /**
243
- * Convert array of ANSI-escaped lines to HTML.
244
- * Each line is wrapped in a div element.
245
- */
246
- export function ansiLinesToHtml(lines) {
247
- return lines.map((line) => `<div class="ansi-line">${ansiToHtml(line) || "&nbsp;"}</div>`).join("");
248
- }