pi-sdk-web 0.3.11 → 0.3.12

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.
@@ -16,6 +16,168 @@ const BUILTIN_COMMANDS = [
16
16
  { name: 'settings', description: 'Open settings menu (not supported in web)', builtin: true, unsupported: true },
17
17
  ];
18
18
 
19
+ // ---------------------------------------------------------------------------
20
+ // ANSI escape code to HTML converter.
21
+ //
22
+ // Extensions build status/widget/notify strings with ANSI escapes (via
23
+ // ui.theme.fg/bg, e.g. magic-context's status line). The browser has no
24
+ // terminal to render them, so we convert escapes to inline-styled <span>
25
+ // here — the browser acts as the "terminal". Pi's own export-html does the
26
+ // same conversion server-side (core/export-html/ansi-to-html.ts); this is a
27
+ // port of that pure function, matching its behavior.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ // Standard ANSI color palette (0-15)
31
+ const ANSI_COLORS = [
32
+ '#000000', // 0: black
33
+ '#800000', // 1: red
34
+ '#008000', // 2: green
35
+ '#808000', // 3: yellow
36
+ '#000080', // 4: blue
37
+ '#800080', // 5: magenta
38
+ '#008080', // 6: cyan
39
+ '#c0c0c0', // 7: white
40
+ '#808080', // 8: bright black
41
+ '#ff0000', // 9: bright red
42
+ '#00ff00', // 10: bright green
43
+ '#ffff00', // 11: bright yellow
44
+ '#0000ff', // 12: bright blue
45
+ '#ff00ff', // 13: bright magenta
46
+ '#00ffff', // 14: bright cyan
47
+ '#ffffff', // 15: bright white
48
+ ];
49
+
50
+ /** Convert a 256-color index (0-255) to hex. */
51
+ function color256ToHex(index) {
52
+ if (index < 16) return ANSI_COLORS[index];
53
+ if (index < 232) {
54
+ // Color cube (16-231): 6x6x6 = 216 colors
55
+ const cubeIndex = index - 16;
56
+ const r = Math.floor(cubeIndex / 36);
57
+ const g = Math.floor((cubeIndex % 36) / 6);
58
+ const b = cubeIndex % 6;
59
+ const toComponent = (n) => (n === 0 ? 0 : 55 + n * 40);
60
+ const toHex = (n) => toComponent(n).toString(16).padStart(2, '0');
61
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
62
+ }
63
+ // Grayscale (232-255): 24 shades
64
+ const gray = 8 + (index - 232) * 10;
65
+ const grayHex = gray.toString(16).padStart(2, '0');
66
+ return `#${grayHex}${grayHex}${grayHex}`;
67
+ }
68
+
69
+ function escapeHtmlAnsi(text) {
70
+ return text
71
+ .replace(/&/g, '&amp;')
72
+ .replace(/</g, '&lt;')
73
+ .replace(/>/g, '&gt;')
74
+ .replace(/"/g, '&quot;')
75
+ .replace(/'/g, '&#039;');
76
+ }
77
+
78
+ function createEmptyStyle() {
79
+ return { fg: null, bg: null, bold: false, dim: false, italic: false, underline: false };
80
+ }
81
+
82
+ function styleToInlineCSS(style) {
83
+ const parts = [];
84
+ if (style.fg) parts.push(`color:${style.fg}`);
85
+ if (style.bg) parts.push(`background-color:${style.bg}`);
86
+ if (style.bold) parts.push('font-weight:bold');
87
+ if (style.dim) parts.push('opacity:0.6');
88
+ if (style.italic) parts.push('font-style:italic');
89
+ if (style.underline) parts.push('text-decoration:underline');
90
+ return parts.join(';');
91
+ }
92
+
93
+ function hasStyle(style) {
94
+ return style.fg !== null || style.bg !== null || style.bold || style.dim || style.italic || style.underline;
95
+ }
96
+
97
+ function applySgrCode(params, style) {
98
+ let i = 0;
99
+ while (i < params.length) {
100
+ const code = params[i];
101
+ if (code === 0) {
102
+ style.fg = null; style.bg = null; style.bold = false;
103
+ style.dim = false; style.italic = false; style.underline = false;
104
+ } else if (code === 1) {
105
+ style.bold = true;
106
+ } else if (code === 2) {
107
+ style.dim = true;
108
+ } else if (code === 3) {
109
+ style.italic = true;
110
+ } else if (code === 4) {
111
+ style.underline = true;
112
+ } else if (code === 22) {
113
+ style.bold = false; style.dim = false;
114
+ } else if (code === 23) {
115
+ style.italic = false;
116
+ } else if (code === 24) {
117
+ style.underline = false;
118
+ } else if (code >= 30 && code <= 37) {
119
+ style.fg = ANSI_COLORS[code - 30];
120
+ } else if (code === 38) {
121
+ if (params[i + 1] === 5 && params.length > i + 2) {
122
+ style.fg = color256ToHex(params[i + 2]);
123
+ i += 2;
124
+ } else if (params[i + 1] === 2 && params.length > i + 4) {
125
+ style.fg = `rgb(${params[i + 2]},${params[i + 3]},${params[i + 4]})`;
126
+ i += 4;
127
+ }
128
+ } else if (code === 39) {
129
+ style.fg = null;
130
+ } else if (code >= 40 && code <= 47) {
131
+ style.bg = ANSI_COLORS[code - 40];
132
+ } else if (code === 48) {
133
+ if (params[i + 1] === 5 && params.length > i + 2) {
134
+ style.bg = color256ToHex(params[i + 2]);
135
+ i += 2;
136
+ } else if (params[i + 1] === 2 && params.length > i + 4) {
137
+ style.bg = `rgb(${params[i + 2]},${params[i + 3]},${params[i + 4]})`;
138
+ i += 4;
139
+ }
140
+ } else if (code === 49) {
141
+ style.bg = null;
142
+ } else if (code >= 90 && code <= 97) {
143
+ style.fg = ANSI_COLORS[code - 90 + 8];
144
+ } else if (code >= 100 && code <= 107) {
145
+ style.bg = ANSI_COLORS[code - 100 + 8];
146
+ }
147
+ i++;
148
+ }
149
+ }
150
+
151
+ // Match ANSI escape sequences: ESC[ followed by params and ending with 'm'
152
+ const ANSI_REGEX = /\x1b\[([\d;]*)m/g;
153
+
154
+ /** Convert ANSI-escaped text to HTML with inline styles. */
155
+ function ansiToHtml(text) {
156
+ const style = createEmptyStyle();
157
+ let result = '';
158
+ let lastIndex = 0;
159
+ let inSpan = false;
160
+ ANSI_REGEX.lastIndex = 0;
161
+ let match = ANSI_REGEX.exec(text);
162
+ while (match !== null) {
163
+ const beforeText = text.slice(lastIndex, match.index);
164
+ if (beforeText) result += escapeHtmlAnsi(beforeText);
165
+ const params = match[1] ? match[1].split(';').map((p) => parseInt(p, 10) || 0) : [0];
166
+ if (inSpan) { result += '</span>'; inSpan = false; }
167
+ applySgrCode(params, style);
168
+ if (hasStyle(style)) {
169
+ result += `<span style="${styleToInlineCSS(style)}">`;
170
+ inSpan = true;
171
+ }
172
+ lastIndex = match.index + match[0].length;
173
+ match = ANSI_REGEX.exec(text);
174
+ }
175
+ const remainingText = text.slice(lastIndex);
176
+ if (remainingText) result += escapeHtmlAnsi(remainingText);
177
+ if (inSpan) result += '</span>';
178
+ return result;
179
+ }
180
+
19
181
  class PiWebClient {
20
182
  constructor() {
21
183
  this.ws = null;
@@ -869,7 +1031,10 @@ class PiWebClient {
869
1031
  widgetsEl.appendChild(el);
870
1032
  }
871
1033
  el.querySelector('.widget-title').textContent = key;
872
- el.querySelector('.widget-body').textContent = (req.widgetLines || []).join('\n');
1034
+ // ANSI escapes (extension theme) render as colored spans per line.
1035
+ el.querySelector('.widget-body').innerHTML = (req.widgetLines || [])
1036
+ .map((line) => ansiToHtml(String(line)))
1037
+ .join('<br>');
873
1038
  widgetsEl.style.display = 'block';
874
1039
  // Layout change: keep pinned to bottom if already there
875
1040
  if (wasAtBottom) this.scrollToBottom();
@@ -1028,9 +1193,11 @@ class PiWebClient {
1028
1193
  if (!expanded && lines.length > limit) {
1029
1194
  const visible = lines.slice(0, limit).join('\n');
1030
1195
  const hidden = lines.length - limit;
1031
- out.textContent = visible + `\n... (${hidden} more lines, click to expand)`;
1196
+ // ANSI escapes (from tool outputs) render as colored spans; the
1197
+ // truncated marker stays plain text.
1198
+ out.innerHTML = ansiToHtml(visible) + `<div class="tool-truncated">... (${hidden} more lines, click to expand)</div>`;
1032
1199
  } else {
1033
- out.textContent = full;
1200
+ out.innerHTML = ansiToHtml(full);
1034
1201
  }
1035
1202
  }
1036
1203
 
@@ -1225,9 +1392,12 @@ class PiWebClient {
1225
1392
  const text = this.inputEl.value.trim();
1226
1393
  if (!text) return;
1227
1394
  if (text.startsWith('!')) {
1228
- const command = text.slice(1).trim();
1395
+ // TUI parity: "!cmd" runs bash (output goes to LLM context);
1396
+ // "!!cmd" runs bash with excludeFromContext (output NOT sent to LLM).
1397
+ const isExcluded = text.startsWith('!!');
1398
+ const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
1229
1399
  if (command) {
1230
- this.send({ type: 'bash', command: command });
1400
+ this.send({ type: 'bash', command: command, excludeFromContext: isExcluded });
1231
1401
  }
1232
1402
  } else if (text.startsWith('/')) {
1233
1403
  // Skill commands (/skill:name args) go through prompt expansion in Pi -
@@ -1473,7 +1643,9 @@ class PiWebClient {
1473
1643
  const el = document.getElementById('ext-status');
1474
1644
  if (!el) return;
1475
1645
  const entries = Object.entries(this.extStatus);
1476
- el.textContent = entries.map(([k, v]) => `${k}: ${v}`).join(' · ');
1646
+ // ANSI escapes (from extension theme.fg/bg) render as colored spans.
1647
+ // ansiToHtml escapes the value's HTML; the key is escaped separately.
1648
+ el.innerHTML = entries.map(([k, v]) => `${this.escapeHtml(k)}: ${ansiToHtml(String(v))}`).join(' · ');
1477
1649
  el.style.display = entries.length ? 'block' : 'none';
1478
1650
  }
1479
1651
 
@@ -1578,7 +1750,8 @@ class PiWebClient {
1578
1750
  if (!container) return;
1579
1751
  const toast = document.createElement('div');
1580
1752
  toast.className = 'toast' + (type ? ` toast-${type}` : '');
1581
- toast.textContent = message;
1753
+ // Extension notify messages may carry ANSI escapes (theme.fg); render them.
1754
+ toast.innerHTML = ansiToHtml(String(message));
1582
1755
  container.appendChild(toast);
1583
1756
  setTimeout(() => {
1584
1757
  toast.classList.add('toast-hide');
@@ -1,26 +1,69 @@
1
+ import { Theme as PiTheme } from "@earendil-works/pi-coding-agent";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
1
5
  /**
2
- * Identity theme: extensions call ui.theme.fg(...) / ui.theme.bg(...) to get
3
- * ANSI-colored strings. The browser renders with CSS, so we return text
4
- * unchanged (color codes are meaningless in the DOM). Keeps extensions from
5
- * crashing on theme access; real colors can be added later.
6
+ * Build the official Pi theme (colors from the shipped dark.json) so
7
+ * extensions calling ui.theme.fg("accent", text) / .bg(...) get REAL ANSI
8
+ * escapes identical to TUI (interactive-mode.ts returns the same Theme
9
+ * instance from ctx.ui.theme). The browser renders the escapes as colored
10
+ * spans (see app.js ansiToHtml), acting as the "terminal".
11
+ *
12
+ * The Theme class and initTheme are exported by the Pi SDK; dark.json ships
13
+ * in the package dist. Constructing the Theme directly keeps us independent
14
+ * of initTheme's global side effects and gives the same result.
6
15
  */
7
- function createIdentityTheme() {
8
- return new Proxy({}, {
9
- get(_target, prop) {
10
- // theme.name / theme.isDark etc. may be accessed as properties
11
- if (prop === "name")
12
- return "dark";
13
- if (prop === "isDark")
14
- return true;
15
- // Everything else is a color function (fg/bg/...): return identity
16
- return (text) => (typeof text === "string" ? text : "");
17
- },
18
- });
16
+ function createWebTheme() {
17
+ try {
18
+ const mainEntry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
19
+ const darkJson = JSON.parse(readFileSync(join(dirname(mainEntry), "modes", "interactive", "theme", "dark.json"), "utf8"));
20
+ const fgColors = {};
21
+ const bgColors = {};
22
+ // dark.json colors refer to vars by name (e.g. "accent" -> "#8abeb7")
23
+ // or carry a literal hex. Split background keys from foreground keys by
24
+ // the explicit ThemeBg set (mirrors Pi's createTheme in theme.ts) rather
25
+ // than a "Bg" suffix: scrollbarThumb is a ThemeBg without that suffix.
26
+ const bgKeys = new Set([
27
+ "selectedBg",
28
+ "scrollbarThumb",
29
+ "searchMatchBg",
30
+ "userMessageBg",
31
+ "customMessageBg",
32
+ "toolPendingBg",
33
+ "toolSuccessBg",
34
+ "toolErrorBg",
35
+ ]);
36
+ for (const [key, value] of Object.entries(darkJson.colors)) {
37
+ const resolved = value.startsWith("#") ? value : darkJson.vars[value] ?? value;
38
+ if (bgKeys.has(key)) {
39
+ bgColors[key] = resolved;
40
+ }
41
+ else {
42
+ fgColors[key] = resolved;
43
+ }
44
+ }
45
+ return new PiTheme(fgColors, bgColors, "truecolor", { name: "dark" });
46
+ }
47
+ catch {
48
+ // Last-resort identity: return the text argument unchanged (no ANSI).
49
+ return new Proxy({}, {
50
+ get(_target, prop) {
51
+ if (prop === "name")
52
+ return "dark";
53
+ if (prop === "isDark")
54
+ return true;
55
+ return (...args) => {
56
+ const textArg = args.length > 1 ? args[1] : args[0];
57
+ return typeof textArg === "string" ? textArg : "";
58
+ };
59
+ },
60
+ });
61
+ }
19
62
  }
20
63
  export class WebUIContext {
21
64
  pending = new Map();
22
65
  sink;
23
- identityTheme = createIdentityTheme();
66
+ webTheme = createWebTheme();
24
67
  /** Latest setStatus values per key, so late-connecting browsers get current state */
25
68
  statusMap = new Map();
26
69
  constructor(sink) {
@@ -148,7 +191,7 @@ export class WebUIContext {
148
191
  // Theme
149
192
  // ------------------------------------------------------------------
150
193
  get theme() {
151
- return this.identityTheme;
194
+ return this.webTheme;
152
195
  }
153
196
  getAllThemes() {
154
197
  return [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-sdk-web",
3
- "version": "0.3.11",
3
+ "version": "0.3.12",
4
4
  "description": "Browser Web access for Pi (AI coding agent) via the Pi SDK - standalone module, zero modification to Pi itself",
5
5
  "type": "module",
6
6
  "bin": {