pi-sdk-web 0.3.11 → 0.3.13

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/dist/server.js CHANGED
@@ -418,6 +418,16 @@ export class PiWebServer {
418
418
  // Used by the sidebar Tools refresh button
419
419
  this.broadcastState();
420
420
  break;
421
+ case "set_theme": {
422
+ // Browser theme switch (dark/light): swap the extension ANSI theme.
423
+ // CSS switches instantly on the client; the server theme only
424
+ // affects extension setStatus/setWidget generated after this point
425
+ // (same as TUI - status text is fixed at setStatus time).
426
+ const name = data.name === "light" ? "light" : "dark";
427
+ this.uiContext.setWebTheme(name);
428
+ this.broadcast({ type: "theme_set", data: { name } });
429
+ break;
430
+ }
421
431
  case "bash": {
422
432
  const command = typeof data.command === "string" ? data.command : "";
423
433
  if (!command)
@@ -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;
@@ -84,6 +246,10 @@ class PiWebClient {
84
246
  return;
85
247
  }
86
248
  this.hasConnectedBefore = true;
249
+ // Sync the persisted theme to the server on first connection so
250
+ // extension ANSI colors match the CSS theme (server defaults to dark).
251
+ const stored = localStorage.getItem('piweb-theme') === 'bright' ? 'light' : (localStorage.getItem('piweb-theme') || 'light');
252
+ this.send({ type: 'set_theme', name: stored });
87
253
  };
88
254
  ws.onmessage = (ev) => this.handleMessage(ev.data);
89
255
  ws.onclose = () => {
@@ -108,20 +274,33 @@ class PiWebClient {
108
274
  // ------------------------------------------------------------------
109
275
 
110
276
  initThemeSwitch() {
111
- const stored = localStorage.getItem('piweb-theme') || 'bright';
112
- this.applyTheme(stored);
277
+ // Migrate the old 'bright' key to 'light' (renamed for TUI parity).
278
+ const stored = localStorage.getItem('piweb-theme') === 'bright' ? 'light' : (localStorage.getItem('piweb-theme') || 'light');
279
+ this.applyTheme(stored, true);
113
280
  document.querySelectorAll('.theme-option').forEach((el) => {
114
- el.addEventListener('click', () => this.applyTheme(el.dataset.theme));
281
+ el.addEventListener('click', () => this.applyTheme(el.dataset.theme, false));
115
282
  });
116
283
  }
117
284
 
118
- applyTheme(theme) {
119
- const bright = theme === 'bright';
120
- document.body.classList.toggle('theme-bright', bright);
121
- localStorage.setItem('piweb-theme', bright ? 'bright' : 'dark');
285
+ applyTheme(theme, initial) {
286
+ const light = theme === 'light';
287
+ const name = light ? 'light' : 'dark';
288
+ // CSS variables switch instantly (the "interface frame", like TUI's
289
+ // requestRender on theme change).
290
+ document.body.classList.toggle('theme-light', light);
291
+ localStorage.setItem('piweb-theme', name);
122
292
  document.querySelectorAll('.theme-option').forEach((el) => {
123
- el.classList.toggle('active', el.dataset.theme === (bright ? 'bright' : 'dark'));
293
+ el.classList.toggle('active', el.dataset.theme === name);
124
294
  });
295
+ if (initial) return;
296
+ // Tell the server to swap the extension ANSI theme: extensions that
297
+ // setStatus/setWidget AFTER this point generate colors with the new
298
+ // theme. Already-rendered status text keeps its old colors until the
299
+ // extension updates it (same as TUI - status text is fixed at setStatus
300
+ // time; theme change only repaints the frame).
301
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
302
+ this.send({ type: 'set_theme', name: name });
303
+ }
125
304
  }
126
305
 
127
306
  send(obj) {
@@ -148,6 +327,12 @@ class PiWebClient {
148
327
  case 'state':
149
328
  this.renderState(data.data);
150
329
  break;
330
+ case 'theme_set':
331
+ // Server acknowledged the theme switch. No reload needed: CSS
332
+ // variables already switched instantly and the server theme only
333
+ // affects extension setStatus/setWidget generated after this point
334
+ // (same as TUI).
335
+ break;
151
336
  case 'history':
152
337
  this.renderHistory(data.data);
153
338
  break;
@@ -869,7 +1054,10 @@ class PiWebClient {
869
1054
  widgetsEl.appendChild(el);
870
1055
  }
871
1056
  el.querySelector('.widget-title').textContent = key;
872
- el.querySelector('.widget-body').textContent = (req.widgetLines || []).join('\n');
1057
+ // ANSI escapes (extension theme) render as colored spans per line.
1058
+ el.querySelector('.widget-body').innerHTML = (req.widgetLines || [])
1059
+ .map((line) => ansiToHtml(String(line)))
1060
+ .join('<br>');
873
1061
  widgetsEl.style.display = 'block';
874
1062
  // Layout change: keep pinned to bottom if already there
875
1063
  if (wasAtBottom) this.scrollToBottom();
@@ -1028,9 +1216,11 @@ class PiWebClient {
1028
1216
  if (!expanded && lines.length > limit) {
1029
1217
  const visible = lines.slice(0, limit).join('\n');
1030
1218
  const hidden = lines.length - limit;
1031
- out.textContent = visible + `\n... (${hidden} more lines, click to expand)`;
1219
+ // ANSI escapes (from tool outputs) render as colored spans; the
1220
+ // truncated marker stays plain text.
1221
+ out.innerHTML = ansiToHtml(visible) + `<div class="tool-truncated">... (${hidden} more lines, click to expand)</div>`;
1032
1222
  } else {
1033
- out.textContent = full;
1223
+ out.innerHTML = ansiToHtml(full);
1034
1224
  }
1035
1225
  }
1036
1226
 
@@ -1225,9 +1415,12 @@ class PiWebClient {
1225
1415
  const text = this.inputEl.value.trim();
1226
1416
  if (!text) return;
1227
1417
  if (text.startsWith('!')) {
1228
- const command = text.slice(1).trim();
1418
+ // TUI parity: "!cmd" runs bash (output goes to LLM context);
1419
+ // "!!cmd" runs bash with excludeFromContext (output NOT sent to LLM).
1420
+ const isExcluded = text.startsWith('!!');
1421
+ const command = (isExcluded ? text.slice(2) : text.slice(1)).trim();
1229
1422
  if (command) {
1230
- this.send({ type: 'bash', command: command });
1423
+ this.send({ type: 'bash', command: command, excludeFromContext: isExcluded });
1231
1424
  }
1232
1425
  } else if (text.startsWith('/')) {
1233
1426
  // Skill commands (/skill:name args) go through prompt expansion in Pi -
@@ -1473,7 +1666,9 @@ class PiWebClient {
1473
1666
  const el = document.getElementById('ext-status');
1474
1667
  if (!el) return;
1475
1668
  const entries = Object.entries(this.extStatus);
1476
- el.textContent = entries.map(([k, v]) => `${k}: ${v}`).join(' · ');
1669
+ // ANSI escapes (from extension theme.fg/bg) render as colored spans.
1670
+ // ansiToHtml escapes the value's HTML; the key is escaped separately.
1671
+ el.innerHTML = entries.map(([k, v]) => `${this.escapeHtml(k)}: ${ansiToHtml(String(v))}`).join(' · ');
1477
1672
  el.style.display = entries.length ? 'block' : 'none';
1478
1673
  }
1479
1674
 
@@ -1578,7 +1773,8 @@ class PiWebClient {
1578
1773
  if (!container) return;
1579
1774
  const toast = document.createElement('div');
1580
1775
  toast.className = 'toast' + (type ? ` toast-${type}` : '');
1581
- toast.textContent = message;
1776
+ // Extension notify messages may carry ANSI escapes (theme.fg); render them.
1777
+ toast.innerHTML = ansiToHtml(String(message));
1582
1778
  container.appendChild(toast);
1583
1779
  setTimeout(() => {
1584
1780
  toast.classList.add('toast-hide');
@@ -14,7 +14,7 @@
14
14
  <span id="theme-switch">
15
15
  <span class="theme-option" data-theme="dark">Dark</span>
16
16
  <span class="theme-sep">|</span>
17
- <span class="theme-option" data-theme="bright">Bright</span>
17
+ <span class="theme-option" data-theme="light">Light</span>
18
18
  </span>
19
19
  <span id="msg-nav">
20
20
  <span class="nav-btn" id="nav-prev" title="Jump to previous user message">↑</span>
@@ -28,8 +28,8 @@
28
28
  --table-head-bg: rgba(128, 128, 128, 0.1);
29
29
  }
30
30
 
31
- /* Bright theme (colors aligned with Pi's light theme) */
32
- body.theme-bright {
31
+ /* Light theme (colors aligned with Pi's light theme) */
32
+ body.theme-light {
33
33
  --bg: #f5f5f5;
34
34
  --text: #1f2328;
35
35
  --dim: #767676;
@@ -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
+ * Load a Pi theme (dark.json/light.json colors) so extensions calling
7
+ * ui.theme.fg("accent", text) / .bg(...) get REAL ANSI escapes identical
8
+ * to TUI (interactive-mode.ts returns the same Theme instance from
9
+ * ctx.ui.theme). The browser renders the escapes as colored spans (see
10
+ * app.js ansiToHtml), acting as the "terminal".
11
+ *
12
+ * The Theme class is exported by the Pi SDK and the theme JSONs ship in the
13
+ * package dist. Constructing the Theme directly keeps us independent of
14
+ * 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(name = "dark") {
17
+ try {
18
+ const mainEntry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
19
+ const themeJson = JSON.parse(readFileSync(join(dirname(mainEntry), "modes", "interactive", "theme", `${name}.json`), "utf8"));
20
+ const fgColors = {};
21
+ const bgColors = {};
22
+ // theme 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(themeJson.colors)) {
37
+ const resolved = value.startsWith("#") ? value : themeJson.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 });
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 [];
@@ -159,6 +202,10 @@ export class WebUIContext {
159
202
  setTheme() {
160
203
  return { success: false, error: "Theme switching not supported in web mode" };
161
204
  }
205
+ /** Switch the theme used for extension ANSI colors (dark/light). */
206
+ setWebTheme(name) {
207
+ this.webTheme = createWebTheme(name);
208
+ }
162
209
  // ------------------------------------------------------------------
163
210
  // Tool output expansion (web always shows expandable blocks)
164
211
  // ------------------------------------------------------------------
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.13",
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": {