create-pmos 0.15.0 → 0.16.1

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 (39) hide show
  1. package/README.md +65 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +50 -13
  5. package/workspace/bin/pmos.js +204 -51
  6. package/workspace/design-parity.js +358 -0
  7. package/workspace/doctor.js +54 -0
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/four-panel.js +345 -0
  10. package/workspace/lib/git-info.js +28 -1
  11. package/workspace/lib/mobile-nav.js +32 -2
  12. package/workspace/lib/server-discovery.js +127 -0
  13. package/workspace/package-lock.json +29 -2
  14. package/workspace/package.json +13 -5
  15. package/workspace/public/index.html +79 -409
  16. package/workspace/public/pm/app.js +242 -0
  17. package/workspace/public/pm/data.js +905 -0
  18. package/workspace/public/pm/dom.js +116 -0
  19. package/workspace/public/pm/fixture.js +979 -0
  20. package/workspace/public/pm/mobile.css +200 -0
  21. package/workspace/public/pm/mobile.js +403 -0
  22. package/workspace/public/pm/modes.js +1076 -0
  23. package/workspace/public/pm/panel-detail.js +648 -0
  24. package/workspace/public/pm/panel-initiatives.js +426 -0
  25. package/workspace/public/pm/panel-repos.js +553 -0
  26. package/workspace/public/pm/panel-tabs.js +487 -0
  27. package/workspace/public/pm/state.js +168 -0
  28. package/workspace/public/pm/tab-doc.js +279 -0
  29. package/workspace/public/pm/tab-task.js +83 -0
  30. package/workspace/public/pm/tab-term.js +560 -0
  31. package/workspace/public/pm/tokens.css +73 -0
  32. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  33. package/workspace/server.js +174 -11
  34. package/workspace/focus.test.js +0 -650
  35. package/workspace/handoff.test.js +0 -443
  36. package/workspace/profiles.test.js +0 -362
  37. package/workspace/public/app.js +0 -5560
  38. package/workspace/public/styles.css +0 -2258
  39. package/workspace/repo-scan.test.js +0 -327
@@ -0,0 +1,560 @@
1
+ /* pm/tab-term.js — the body of a `term` tab. ONE component, TWO modes.
2
+ *
3
+ * ── fixture mode (`?fixture=proto`) ──────────────────────────────────────────────────────────
4
+ * The prototype's faux terminal, ported line for line from template lines 928–939 and derivation
5
+ * lines 1981–2006: the five attach lines, the transcript in `termHist[tab.id]`, the working prompt
6
+ * with the blinking `pmblink` caret, and the fake shell's command table (which lives in
7
+ * fixture.js — `termBaseLines` / `fauxShellReply` / `termEcho` — so there is exactly one copy of
8
+ * it). Every inline style below is the prototype's string, character for character, and each
9
+ * interpolated value sits in its own `<span>` because that is what the prototype's runtime emits
10
+ * (`<span class="sc-interp">…</span>`) and the comparator reads text nodes, not properties.
11
+ * Fixture mode opens no socket and touches no PTY: it is the offline demo path and the only render
12
+ * the comparator ever sees.
13
+ *
14
+ * ── real mode (everything else) ─────────────────────────────────────────────────────────────
15
+ * A LIVE xterm.js terminal on the existing WebSocket/node-pty path — the same protocol the retired
16
+ * public/app.js spoke, preserved frame for frame:
17
+ *
18
+ * out: attach{id} · input{id,data} · resize{id,cols,rows} · detach{id} · new{name,agent,ws}
19
+ * in : hello{sessions} · sessions{sessions} · attached{id,buf,…} · data{id,data} · created{id}
20
+ *
21
+ * Three properties of that path are load-bearing and are the reason this file is written the way
22
+ * it is:
23
+ *
24
+ * 1. A SESSION SURVIVES DETACH AND RELOAD. The PTY lives in the server; the browser holds a view
25
+ * of it. Closing a tab sends `detach`, never `kill`. Reconnecting re-`attach`es and the
26
+ * server replays its ring buffer, so scrollback comes back with the page.
27
+ * 2. A TERMINAL IS NEVER REBUILT BY NAVIGATION. One xterm per tab id, kept for the tab's whole
28
+ * life. panel-tabs.js caches the pane host for the same reason; `mountTerm` is therefore
29
+ * idempotent — called on every render, it builds once and re-fits thereafter.
30
+ * 3. IT MUST BE RE-FITTED. A terminal that attaches and is never re-fitted keeps a stale grid:
31
+ * output wraps at the wrong column and a full-screen TUI paints into a box that is not there.
32
+ * It is invisible in a screenshot of an empty terminal, which is why the PRD names it "the
33
+ * quiet PTY regression". Refit is explicit here — ONE exported `refitVisible()` — and it is
34
+ * driven from every event that can change a terminal's box: a render (tab shown, split
35
+ * toggled, a panel collapsed), a ResizeObserver on the pane, window resize, tab visibility,
36
+ * and `document.fonts.ready` (xterm measures its cell from the face resolved at construction,
37
+ * so the first real metrics arrive after the woff2 lands).
38
+ *
39
+ * The xterm palette is the DESIGN's: `--term-bg` for the background — dark in both themes, D41 —
40
+ * and IBM Plex Mono at the design's 12.5px / 1.65. xterm's colour parser does not understand
41
+ * `oklch()`, so the token is resolved to a concrete sRGB value at runtime (canvas round-trip, with
42
+ * the pre-computed hex as the fallback) rather than handed to xterm as written.
43
+ */
44
+
45
+ import { el, setChildren } from './dom.js';
46
+ import { state, setState, subscribe, FIXTURE } from './state.js';
47
+ import * as FX from './fixture.js';
48
+
49
+ /* ── the design's terminal colours ───────────────────────────────────────────────────────────
50
+ * `--term-bg` is oklch(0.148 0.004 228.8) in BOTH themes. Resolved through a canvas at runtime so
51
+ * the value xterm gets is whatever the stylesheet actually says; TERM_BG_FALLBACK is that same
52
+ * oklch converted offline, for a browser that will not do the conversion for us. The ANSI ramp is
53
+ * the workspace's own, unchanged — it is the palette agent CLIs have been rendering against.
54
+ */
55
+ const TERM_BG_FALLBACK = '#090b0c';
56
+ const ANSI = {
57
+ foreground: '#c4ccce', cursor: '#47cf79', cursorAccent: '#090b0c',
58
+ selectionBackground: 'rgba(43,127,255,.3)',
59
+ black: '#090b0c', red: '#ff6467', green: '#47cf79', yellow: '#f4b93c',
60
+ blue: '#2b7fff', magenta: '#b48df4', cyan: '#53d1d0', white: '#d2d9db',
61
+ brightBlack: '#6f7d81', brightRed: '#ff6467', brightGreen: '#81e8a0', brightYellow: '#f4b93c',
62
+ brightBlue: '#2b7fff', brightMagenta: '#b48df4', brightCyan: '#53d1d0', brightWhite: '#f9fbfb',
63
+ };
64
+
65
+ /** Resolve a CSS colour value (possibly `oklch(…)`, possibly a `var()`) to something xterm parses. */
66
+ function resolveColor(value, fallback) {
67
+ let spec = value;
68
+ try {
69
+ const probe = document.createElement('span');
70
+ probe.style.cssText = 'position:absolute;left:-9999px;top:-9999px;visibility:hidden';
71
+ probe.style.color = value;
72
+ document.body.appendChild(probe);
73
+ spec = getComputedStyle(probe).color || value;
74
+ probe.remove();
75
+ } catch { /* keep the literal */ }
76
+ if (/^rgba?\(/.test(spec) || /^#/.test(spec)) return spec;
77
+ try {
78
+ const c = document.createElement('canvas');
79
+ c.width = c.height = 1;
80
+ const ctx = c.getContext('2d');
81
+ ctx.fillStyle = '#000000';
82
+ ctx.fillStyle = spec; // an unparsable value leaves the previous one
83
+ ctx.fillRect(0, 0, 1, 1);
84
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
85
+ const hex = '#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('');
86
+ if (hex !== '#000000') return hex;
87
+ } catch { /* fall through */ }
88
+ return fallback;
89
+ }
90
+
91
+ /** The xterm theme, read off the live token layer (app.js:165–179 in the retired client). */
92
+ function xtheme() {
93
+ let bg = TERM_BG_FALLBACK;
94
+ try {
95
+ const raw = getComputedStyle(document.documentElement).getPropertyValue('--term-bg').trim();
96
+ if (raw) bg = resolveColor(raw, TERM_BG_FALLBACK);
97
+ } catch { /* the fallback IS the design's value */ }
98
+ return { ...ANSI, background: bg, cursorAccent: bg };
99
+ }
100
+
101
+ /* ── module state ──────────────────────────────────────────────────────────────────────────── */
102
+
103
+ /** tab id → the mounted pane. Fixture panes and real panes are both keyed here. */
104
+ const panes = new Map();
105
+
106
+ const XTERM = () => (typeof window !== 'undefined' ? window.Terminal : null);
107
+ const FIT = () => (typeof window !== 'undefined' && window.FitAddon ? window.FitAddon.FitAddon : null);
108
+ const LINKS = () => (typeof window !== 'undefined' && window.WebLinksAddon ? window.WebLinksAddon.WebLinksAddon : null);
109
+
110
+ /* ── the socket ────────────────────────────────────────────────────────────────────────────────
111
+ * Opened LAZILY, and never in fixture mode: the fixture render must make no network call at all,
112
+ * which is what lets the comparator and no-network.test.js trust a fixture run. One socket for the
113
+ * page, shared by every terminal, reconnecting on a fixed 1.2s delay with the attach set cleared —
114
+ * so every visible terminal re-attaches and replays its ring buffer, exactly as before.
115
+ */
116
+ const SOCK = {
117
+ ws: null, open: false, tries: 0,
118
+ sessions: [], // the server's session list, module-local on purpose (see below)
119
+ attached: new Set(), // session ids this connection has attached
120
+ spawning: new Map(), // tab id → true, while a `new` is in flight
121
+ spawnQueue: [], // the SAME requests, in order — `created` carries no correlation id
122
+ timer: null,
123
+ };
124
+
125
+ /* The session heartbeat arrives about every 2s. It is deliberately NOT pushed through setState:
126
+ * a state change repaints all four panels, and repainting the window twice a second to redraw
127
+ * nothing would destroy text selection, scroll position and any in-flight edit. Nothing in this
128
+ * pane is rendered from the session list — it only decides which PTY a terminal is bound to — so
129
+ * the list stays here and the panels keep their own reads. */
130
+
131
+ function connect() {
132
+ if (FIXTURE || SOCK.ws) return;
133
+ let ws;
134
+ try {
135
+ const proto = location.protocol === 'https:' ? 'wss' : 'ws';
136
+ ws = new WebSocket(`${proto}://${location.host}`);
137
+ } catch { return; }
138
+ SOCK.ws = ws;
139
+ ws.onopen = () => {
140
+ SOCK.open = true; SOCK.tries = 0;
141
+ for (const rec of panes.values()) if (rec.kind === 'real') bind(rec);
142
+ };
143
+ ws.onclose = () => {
144
+ SOCK.open = false; SOCK.ws = null; SOCK.attached.clear();
145
+ /* An in-flight `new` is abandoned rather than retried: the server may well have started the
146
+ * session before the socket dropped, and the reconnect's `hello` will find it by name. Retrying
147
+ * blind is how a PM ends up with two shells and one tab. */
148
+ SOCK.spawning.clear(); SOCK.spawnQueue = [];
149
+ for (const rec of panes.values()) {
150
+ if (rec.kind !== 'real') continue;
151
+ rec.attachedId = null;
152
+ note(rec, 'pmos: connection to the workspace server lost — retrying');
153
+ }
154
+ clearTimeout(SOCK.timer);
155
+ SOCK.timer = setTimeout(connect, 1200);
156
+ };
157
+ ws.onerror = () => { /* onclose does the retrying */ };
158
+ ws.onmessage = (ev) => {
159
+ let m; try { m = JSON.parse(ev.data); } catch { return; }
160
+ handle(m);
161
+ };
162
+ }
163
+
164
+ function send(obj) {
165
+ if (SOCK.ws && SOCK.ws.readyState === 1) { SOCK.ws.send(JSON.stringify(obj)); return true; }
166
+ return false;
167
+ }
168
+
169
+ function handle(m) {
170
+ if (m.t === 'hello' || m.t === 'sessions') {
171
+ SOCK.sessions = Array.isArray(m.sessions) ? m.sessions : [];
172
+ if (m.t === 'hello' && Array.isArray(m.workspaces)) SOCK.workspaces = m.workspaces;
173
+ if (m.t === 'hello' && m.home) SOCK.home = m.home;
174
+ for (const rec of panes.values()) if (rec.kind === 'real') bind(rec);
175
+ return;
176
+ }
177
+ if (m.t === 'attached') {
178
+ const rec = recFor(m.id);
179
+ if (!rec) return;
180
+ rec.attachedId = m.id;
181
+ rec.noted = null;
182
+ try { rec.term.reset(); } catch { /* a disposed terminal */ }
183
+ if (m.buf) { try { rec.term.write(m.buf); } catch { /* ignore */ } }
184
+ refitVisible();
185
+ return;
186
+ }
187
+ if (m.t === 'data') {
188
+ const rec = recFor(m.id);
189
+ if (rec) { try { rec.term.write(m.data); } catch { /* ignore */ } }
190
+ return;
191
+ }
192
+ if (m.t === 'created') {
193
+ // `created` carries no correlation id, so the OLDEST outstanding request takes it. The server
194
+ // answers `new` in the order it received them, which makes the queue the correlation — matching
195
+ // by iteration order over the pane map would hand two simultaneous starts each other's shell.
196
+ const id = SOCK.spawnQueue.shift();
197
+ if (!id) return;
198
+ SOCK.spawning.delete(id);
199
+ const rec = panes.get(id);
200
+ if (!rec) { send({ t: 'detach', id: m.id }); return; } // the tab closed while we waited
201
+ rec.sessionId = m.id;
202
+ attach(rec);
203
+ return;
204
+ }
205
+ if (m.t === 'error' || m.t === 'notice') {
206
+ const line = m.error ? 'pmos: ' + m.error : 'pmos: ' + m.notice;
207
+ const id = m.error ? SOCK.spawnQueue.shift() : SOCK.spawnQueue[0];
208
+ if (m.error && id) SOCK.spawning.delete(id);
209
+ const rec = id ? panes.get(id) : null;
210
+ if (rec) note(rec, line, true);
211
+ return;
212
+ }
213
+ }
214
+
215
+ const recFor = (sessionId) => {
216
+ for (const rec of panes.values()) if (rec.kind === 'real' && rec.sessionId === sessionId) return rec;
217
+ return null;
218
+ };
219
+
220
+ /* ── binding a tab to a real session ───────────────────────────────────────────────────────────
221
+ * The tab knows the initiative slug and the session NAME (`term:<slug>:<name>` is its id); the
222
+ * server knows session ids. The join is by name within the tab's workspace, which is the same join
223
+ * the sidebar has always made — a session's name is what the PM sees and what they named it.
224
+ * `sessionId` set on the tab by the data layer wins, when there is one.
225
+ */
226
+ function resolveSessionId(tab) {
227
+ if (tab.sessionId) return tab.sessionId;
228
+ const pool = SOCK.sessions.length ? SOCK.sessions
229
+ : (Array.isArray(state.sessions) ? state.sessions : []);
230
+ const name = tab.sess;
231
+ if (!name) return null;
232
+ const byId = pool.find((s) => s && s.id === name);
233
+ if (byId) return byId.id;
234
+ const ws = workspaceId();
235
+ const inWs = pool.find((s) => s && s.name === name && (!ws || s.ws === ws));
236
+ if (inWs) return inWs.id;
237
+ const anywhere = pool.find((s) => s && s.name === name);
238
+ return anywhere ? anywhere.id : null;
239
+ }
240
+
241
+ /** The workspace a new session should start in: the selected repository when the server knows it. */
242
+ function workspaceId() {
243
+ const known = Array.isArray(SOCK.workspaces) ? SOCK.workspaces.map((w) => w.id) : [];
244
+ // `state.wsId` FIRST — it is the workspace id the data layer published for the selected
245
+ // repository. `state.product` is the repository's NAME, and reading a name as an id was only ever
246
+ // harmless because it fails over to SOCK.home, which is right when exactly one repo is
247
+ // registered. With two, a terminal would be spawned in the home tree while the PM was looking at
248
+ // the other repository's board — a PTY in the wrong working tree, reported as if it were in the
249
+ // right one. Kept as a fallback so a fixture render with no data layer still resolves.
250
+ if (state.wsId && (!known.length || known.includes(state.wsId))) return state.wsId;
251
+ if (state.product && known.includes(state.product)) return state.product;
252
+ if (SOCK.home) return SOCK.home;
253
+ return known.length ? known[0] : null;
254
+ }
255
+
256
+ /**
257
+ * Was this session started in THIS window (the `+ session` path)? Only then may a terminal spawn a
258
+ * PTY on its own: a tab opened against a session the board listed must attach to that session or
259
+ * say it is gone — silently starting a second shell with the same name would be a lie about what
260
+ * the PM is looking at.
261
+ */
262
+ function startedHere(tab) {
263
+ const list = (state.extraSess || {})[tab.slug];
264
+ return Array.isArray(list) && list.some((s) => s && s.name === tab.sess);
265
+ }
266
+
267
+ function bind(rec) {
268
+ if (rec.kind !== 'real' || !rec.term) return;
269
+ const id = resolveSessionId(rec.tab);
270
+ if (id) {
271
+ if (rec.sessionId !== id) { rec.sessionId = id; rec.attachedId = null; }
272
+ attach(rec);
273
+ return;
274
+ }
275
+ if (!SOCK.open) { note(rec, 'pmos: connecting to the workspace server…'); return; }
276
+ if (SOCK.spawning.get(rec.tab.id)) return;
277
+ if (startedHere(rec.tab)) {
278
+ // agent 'shell' deliberately: PMOS cannot know which agent CLI the PM meant from a tab alone,
279
+ // and falling back from an agent to a shell after the fact would be the dishonest version.
280
+ SOCK.spawning.set(rec.tab.id, true);
281
+ SOCK.spawnQueue.push(rec.tab.id);
282
+ note(rec, 'pmos: starting a session…', true);
283
+ const ok = send({ t: 'new', name: rec.tab.sess, agent: 'shell', ws: workspaceId() });
284
+ if (!ok) {
285
+ SOCK.spawning.delete(rec.tab.id);
286
+ SOCK.spawnQueue = SOCK.spawnQueue.filter((x) => x !== rec.tab.id);
287
+ }
288
+ return;
289
+ }
290
+ note(rec, 'pmos: no live session named "' + rec.tab.sess + '" — nothing was attached');
291
+ }
292
+
293
+ function attach(rec) {
294
+ if (!rec.sessionId || !SOCK.open) return;
295
+ if (rec.attachedId === rec.sessionId && SOCK.attached.has(rec.sessionId)) return;
296
+ if (send({ t: 'attach', id: rec.sessionId })) SOCK.attached.add(rec.sessionId);
297
+ }
298
+
299
+ /** Write a status line into the terminal itself — once per distinct message, dimmed. */
300
+ function note(rec, line, force) {
301
+ if (!rec.term) return;
302
+ if (!force && rec.noted === line) return;
303
+ rec.noted = line;
304
+ try { rec.term.write('\x1b[2m' + line + '\x1b[0m\r\n'); } catch { /* disposed */ }
305
+ }
306
+
307
+ /* ── refit ─────────────────────────────────────────────────────────────────────────────────────
308
+ * The single entry point. Coalesced into one animation frame so the six triggers below cost one
309
+ * measure per frame rather than six. A pane that is not in the document, or has no box yet, is
310
+ * skipped — fitting a zero-sized element is how a terminal ends up 1×1 and stays there.
311
+ */
312
+ let refitQueued = false;
313
+
314
+ export function refitVisible() {
315
+ if (refitQueued) return;
316
+ refitQueued = true;
317
+ const run = () => {
318
+ refitQueued = false;
319
+ for (const rec of panes.values()) {
320
+ if (rec.kind !== 'real' || !rec.fit || !rec.host) continue;
321
+ if (!rec.host.isConnected) continue;
322
+ if (!rec.host.offsetWidth || !rec.host.offsetHeight) continue;
323
+ try { rec.fit.fit(); } catch { /* a terminal mid-teardown */ }
324
+ }
325
+ };
326
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
327
+ else setTimeout(run, 16);
328
+ }
329
+
330
+ /* Every event that can change a terminal's box. `subscribe` covers the three the PRD names —
331
+ * a tab being shown, the split toggling, a panel collapsing — because each of them is a state
332
+ * change, and the shell's render has already run by the time this subscriber does. */
333
+ if (typeof window !== 'undefined') {
334
+ subscribe(() => refitVisible());
335
+ window.addEventListener('resize', () => refitVisible());
336
+ document.addEventListener('visibilitychange', () => { if (!document.hidden) refitVisible(); });
337
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => refitVisible()).catch(() => {});
338
+ }
339
+
340
+ /* ── fixture pane ──────────────────────────────────────────────────────────────────────────── */
341
+
342
+ const SCROLL_STYLE = 'flex:1;overflow-y:auto;padding:14px 16px;font-family:var(--mono);font-size:12.5px;line-height:1.65;color:#c4ccce';
343
+ const LINE_STYLE = (c) => 'color:' + c + ';white-space:pre-wrap;overflow-wrap:anywhere';
344
+ const PROMPT_ROW_STYLE = 'display:flex;gap:8px;align-items:baseline';
345
+ const PROMPT_STYLE = 'color:#47cf79;flex:none';
346
+ const INPUT_STYLE = 'flex:1;min-width:0;font-family:var(--mono);font-size:12.5px;color:#f9fbfb;caret-color:#47cf79';
347
+ const CARET_STYLE = 'width:8px;height:15px;background:#47cf79;animation:pmblink 1.1s step-end infinite;flex:none;align-self:center';
348
+
349
+ /** The fake shell (lines 1993–2006). The command table itself lives in fixture.js. */
350
+ function fauxKey(tab, e) {
351
+ if (e.key !== 'Enter') return;
352
+ const v = e.target.value.trim();
353
+ if (!v) return;
354
+ e.target.value = '';
355
+ const reply = FX.fauxShellReply(v, tab);
356
+ setState((s) => ({
357
+ termHist: {
358
+ ...s.termHist,
359
+ [tab.id]: v === 'clear' ? [] : [
360
+ ...(s.termHist[tab.id] || []),
361
+ FX.termEcho(v),
362
+ ...(reply ? [{ t: reply, c: FX.TERM_COLORS.text }] : []),
363
+ ],
364
+ },
365
+ }));
366
+ }
367
+
368
+ function buildFixture(tab) {
369
+ const input = el('input', { style: INPUT_STYLE, onKeyDown: (e) => fauxKey(rec.tab, e) });
370
+ const promptRow = el('div', { style: PROMPT_ROW_STYLE },
371
+ el('span', { style: PROMPT_STYLE }, 'root@vm:~/pmos$'),
372
+ input,
373
+ el('span', { style: CARET_STYLE }),
374
+ );
375
+ const scroll = el('div', { style: SCROLL_STYLE });
376
+ const rec = { kind: 'fixture', tab, scroll, promptRow, input, lines: [] };
377
+ return rec;
378
+ }
379
+
380
+ /**
381
+ * Paint the transcript: the five attach lines, then whatever the session has said since. Line
382
+ * nodes are REUSED rather than rebuilt — the prompt input sits in the same subtree and a rebuilt
383
+ * ancestor is a lost caret.
384
+ */
385
+ function paintFixture(rec) {
386
+ const tab = rec.tab;
387
+ const lines = [...FX.termBaseLines(tab), ...((state.termHist || {})[tab.id] || [])];
388
+ while (rec.lines.length > lines.length) rec.lines.pop();
389
+ lines.forEach((ln, i) => {
390
+ let node = rec.lines[i];
391
+ if (!node) {
392
+ const value = el('span', null, ln.t);
393
+ node = el('div', { style: LINE_STYLE(ln.c) }, value);
394
+ node._value = value;
395
+ rec.lines[i] = node;
396
+ return;
397
+ }
398
+ const want = LINE_STYLE(ln.c);
399
+ if (node.getAttribute('style') !== want) node.setAttribute('style', want);
400
+ if (node._value.textContent !== ln.t) node._value.textContent = ln.t;
401
+ });
402
+ setChildren(rec.scroll, [...rec.lines, rec.promptRow]);
403
+ }
404
+
405
+ /* ── real pane ─────────────────────────────────────────────────────────────────────────────── */
406
+
407
+ /* The CSS contract a live terminal needs, and the reason for each half of it:
408
+ * WRAP — `flex:1;min-height:0;position:relative;overflow:hidden`. It is the box the fit addon
409
+ * measures, and it must be sized by the PANEL, never by the terminal inside it.
410
+ * INNER — `position:absolute;inset:0`. Taking the terminal OUT OF FLOW is what breaks the loop:
411
+ * in flow, the xterm's rendered height feeds the pane's automatic min-height, which
412
+ * feeds the next fit, which adds rows — a terminal that grows a few thousand rows tall
413
+ * on its own. Measured, not theorised: without `inset:0` this pane fit to 209 rows in a
414
+ * 900px window.
415
+ */
416
+ const WRAP_STYLE = 'flex:1;min-height:0;position:relative;overflow:hidden;background:var(--term-bg)';
417
+ const INNER_STYLE = 'position:absolute;inset:0;padding:14px 16px';
418
+ const DEAD_STYLE = SCROLL_STYLE;
419
+
420
+ /** The honest pane for a real-mode browser with no xterm on the page. */
421
+ function buildDead(tab) {
422
+ const scroll = el('div', { style: DEAD_STYLE },
423
+ el('div', { style: LINE_STYLE(FX.TERM_COLORS.dim) },
424
+ el('span', null, 'pmos: the terminal engine did not load — /vendor/xterm.js was not served, '
425
+ + 'so nothing was attached. Run `npm install` in workspace/ and reload.')),
426
+ );
427
+ return { kind: 'dead', tab, scroll };
428
+ }
429
+
430
+ function buildReal(tab) {
431
+ const Terminal = XTERM();
432
+ if (!Terminal) return buildDead(tab);
433
+ const inner = el('div', { style: INNER_STYLE });
434
+ const host = el('div', { style: WRAP_STYLE }, inner);
435
+ const term = new Terminal({
436
+ fontFamily: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
437
+ fontSize: 12.5,
438
+ lineHeight: 1.65,
439
+ theme: xtheme(),
440
+ cursorBlink: true,
441
+ scrollback: 5000,
442
+ allowProposedApi: true,
443
+ });
444
+ const Fit = FIT();
445
+ let fit = null;
446
+ if (Fit) { fit = new Fit(); term.loadAddon(fit); }
447
+ const Links = LINKS();
448
+ if (Links) { try { term.loadAddon(new Links()); } catch { /* optional */ } }
449
+ term.open(inner);
450
+ // xterm sizes itself to its container only if the element it created fills it.
451
+ const xel = inner.querySelector('.xterm');
452
+ if (xel) { xel.style.height = '100%'; xel.style.width = '100%'; }
453
+
454
+ // ⌘K belongs to the shell's search, not to the PTY. Returning false stops xterm consuming the
455
+ // key; the event still bubbles to app.js's window listener, which is where the palette lives.
456
+ term.attachCustomKeyEventHandler((e) => !((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')));
457
+
458
+ const rec = {
459
+ kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null,
460
+ };
461
+ term.onData((d) => { if (rec.sessionId) send({ t: 'input', id: rec.sessionId, data: d }); });
462
+ term.onResize(({ cols, rows }) => {
463
+ if (rec.sessionId) send({ t: 'resize', id: rec.sessionId, cols, rows });
464
+ });
465
+ if (typeof ResizeObserver === 'function') {
466
+ rec.ro = new ResizeObserver(() => refitVisible());
467
+ try { rec.ro.observe(host); } catch { /* ignore */ }
468
+ }
469
+ return rec;
470
+ }
471
+
472
+ /* ── the exported surface ──────────────────────────────────────────────────────────────────── */
473
+
474
+ /**
475
+ * Mount (or re-show) the terminal body of `tab` inside `host` — the pane element panel-tabs.js
476
+ * keeps for this tab's whole life. Idempotent: the first call builds, every later call re-paints
477
+ * the fixture transcript or re-fits the live terminal. It never rebuilds an xterm.
478
+ */
479
+ export function mountTerm(host, tab) {
480
+ if (!host || !tab) return;
481
+ let rec = panes.get(tab.id);
482
+ if (!rec) {
483
+ rec = FIXTURE ? buildFixture(tab) : buildReal(tab);
484
+ panes.set(tab.id, rec);
485
+ if (rec.kind === 'real') connect();
486
+ }
487
+ rec.tab = tab; // the record is re-derived every render; the pane is not
488
+
489
+ if (rec.kind === 'fixture') {
490
+ paintFixture(rec);
491
+ setChildren(host, [rec.scroll]);
492
+ return;
493
+ }
494
+ if (rec.kind === 'dead') { setChildren(host, [rec.scroll]); return; }
495
+
496
+ setChildren(host, [rec.host]);
497
+ bind(rec);
498
+ refitVisible(); // the tab was just shown — this is the fit that must happen
499
+ }
500
+
501
+ /**
502
+ * Release the terminal of a tab that has been CLOSED. The PTY is not killed: it is detached, so
503
+ * the session keeps running on the server and re-opening the tab (or reloading the page) picks it
504
+ * back up with its scrollback. Killing a session is the sidebar's explicit action, never a
505
+ * side effect of closing a view of it.
506
+ */
507
+ export function disposeTerm(tab) {
508
+ const id = tab && (tab.id || tab);
509
+ const rec = panes.get(id);
510
+ if (!rec) return;
511
+ panes.delete(id);
512
+ SOCK.spawning.delete(id);
513
+ /* The queue entry STAYS: the server is still going to answer that `new`, and dropping the slot
514
+ * would let the next tab's `created` be handed the session this one asked for. `created` finds no
515
+ * pane for the id and detaches from the orphan instead. */
516
+ if (rec.kind !== 'real') return;
517
+ if (rec.ro) { try { rec.ro.disconnect(); } catch { /* ignore */ } }
518
+ if (rec.sessionId) {
519
+ send({ t: 'detach', id: rec.sessionId });
520
+ SOCK.attached.delete(rec.sessionId);
521
+ }
522
+ try { rec.term.dispose(); } catch { /* already gone */ }
523
+ if (rec.host && rec.host.parentNode) rec.host.parentNode.removeChild(rec.host);
524
+ }
525
+
526
+ /**
527
+ * Write text to a terminal as if it had been typed — the scratchpad's `⇥ send to terminal`.
528
+ * In fixture mode the transcript is panel-tabs.js's to append (it already did); here there is a
529
+ * PTY, and text that only appeared in a transcript would be text the shell never received.
530
+ */
531
+ export function sendToTerm(tab, data) {
532
+ const rec = panes.get(tab && (tab.id || tab));
533
+ if (!rec || rec.kind !== 'real' || !rec.sessionId) return false;
534
+ return send({ t: 'input', id: rec.sessionId, data });
535
+ }
536
+
537
+ /** Focus the terminal of a tab, if it is live. */
538
+ export function focusTerm(tab) {
539
+ const rec = panes.get(tab && (tab.id || tab));
540
+ if (rec && rec.kind === 'real') { try { rec.term.focus(); } catch { /* ignore */ } }
541
+ }
542
+
543
+ /* A read-only window into this module for the browser suite: cell geometry after a tab switch and
544
+ * a split is the assertion that guards the quiet PTY regression, and it cannot be read from the
545
+ * DOM alone. */
546
+ export function termProbe() {
547
+ const out = {};
548
+ for (const [id, rec] of panes) {
549
+ out[id] = rec.kind === 'real'
550
+ ? { kind: 'real', cols: rec.term.cols, rows: rec.term.rows, sessionId: rec.sessionId,
551
+ attached: rec.attachedId === rec.sessionId && !!rec.sessionId,
552
+ width: rec.host.offsetWidth, height: rec.host.offsetHeight }
553
+ : { kind: rec.kind, lines: rec.lines ? rec.lines.length : 0 };
554
+ }
555
+ return out;
556
+ }
557
+
558
+ if (typeof window !== 'undefined') window.pmTerm = { refitVisible, termProbe, panes };
559
+
560
+ export default mountTerm;
@@ -0,0 +1,73 @@
1
+ /* pm/tokens.css — the four-panel workspace token layer.
2
+ *
3
+ * The :root / [data-pm-theme="light"] blocks, the resets and the pmblink keyframe are copied
4
+ * VERBATIM from the design prototype. Do not "improve" a value here: the acceptance bar for this
5
+ * surface is a mechanical diff against that prototype, so an edit to a token is a defect.
6
+ *
7
+ * The faces are VENDORED (D62: the workspace is a local companion app that must render correctly
8
+ * with no network at all). The prototype linked Google Fonts; a CDN font is network egress and is
9
+ * forbidden here, so the same three IBM Plex Mono weights and the Space Grotesk variable face are
10
+ * declared against /vendor/. font-display:block matches the prototype's swap-free measurement —
11
+ * xterm measures its cell from whatever face is resolved at construction, and a swap mid-load
12
+ * leaves a terminal with a stale grid.
13
+ */
14
+
15
+ @font-face {
16
+ font-family: "Space Grotesk";
17
+ src: url("/vendor/space-grotesk-var.woff2") format("woff2-variations");
18
+ font-weight: 300 700;
19
+ font-style: normal;
20
+ font-display: block;
21
+ }
22
+ @font-face {
23
+ font-family: "IBM Plex Mono";
24
+ src: url("/vendor/ibm-plex-mono-400.woff2") format("woff2");
25
+ font-weight: 400;
26
+ font-style: normal;
27
+ font-display: block;
28
+ }
29
+ @font-face {
30
+ font-family: "IBM Plex Mono";
31
+ src: url("/vendor/ibm-plex-mono-500.woff2") format("woff2");
32
+ font-weight: 500;
33
+ font-style: normal;
34
+ font-display: block;
35
+ }
36
+ @font-face {
37
+ font-family: "IBM Plex Mono";
38
+ src: url("/vendor/ibm-plex-mono-700.woff2") format("woff2");
39
+ font-weight: 700;
40
+ font-style: normal;
41
+ font-display: block;
42
+ }
43
+
44
+ :root{
45
+ --background:oklch(0.148 0.004 228.8);--foreground:oklch(0.987 0.002 197.1);
46
+ --card:oklch(0.218 0.008 223.9);--secondary:oklch(0.274 0.006 286.033);
47
+ --muted:oklch(0.275 0.011 216.9);--muted-foreground:oklch(0.723 0.014 214.4);
48
+ --accent:oklch(0.275 0.011 216.9);--border:oklch(1 0 0 / 10%);--input:oklch(1 0 0 / 15%);
49
+ --ring:oklch(0.56 0.021 213.5);--sidebar:oklch(0.218 0.008 223.9);
50
+ --brand:oklch(0.623 0.214 259.815);--brand-wash:color-mix(in oklab, oklch(0.623 0.214 259.815) 12%, transparent);
51
+ --success:oklch(0.76 0.17 152);--warning:oklch(0.82 0.15 82);--info:oklch(0.79 0.11 195);
52
+ --purple:oklch(0.72 0.15 300);--destructive:oklch(0.704 0.191 22.216);
53
+ --term-bg:oklch(0.148 0.004 228.8);
54
+ --mono:"IBM Plex Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
55
+ --sans:"Space Grotesk",ui-sans-serif,system-ui,sans-serif;
56
+ }
57
+ [data-pm-theme="light"]{
58
+ --background:oklch(1 0 0);--foreground:oklch(0.148 0.004 228.8);
59
+ --card:oklch(1 0 0);--secondary:oklch(0.967 0.001 286.375);
60
+ --muted:oklch(0.963 0.002 197.1);--muted-foreground:oklch(0.56 0.021 213.5);
61
+ --accent:oklch(0.963 0.002 197.1);--border:oklch(0.925 0.005 214.3);--input:oklch(0.925 0.005 214.3);
62
+ --ring:oklch(0.723 0.014 214.4);--sidebar:oklch(0.987 0.002 197.1);
63
+ --brand:oklch(0.488 0.243 264.376);--brand-wash:color-mix(in oklab, oklch(0.488 0.243 264.376) 7%, transparent);
64
+ --success:oklch(0.52 0.14 152);--warning:oklch(0.55 0.12 72);--info:oklch(0.52 0.09 195);
65
+ --purple:oklch(0.5 0.2 300);--destructive:oklch(0.577 0.245 27.325);
66
+ --term-bg:oklch(0.148 0.004 228.8);
67
+ }
68
+ @keyframes pmblink{0%,49%{opacity:1}50%,100%{opacity:0}}
69
+ *{box-sizing:border-box}
70
+ body{margin:0;background:oklch(0.148 0.004 228.8);-webkit-font-smoothing:antialiased}
71
+ a{color:oklch(0.623 0.214 259.815)}a:hover{color:oklch(0.72 0.19 259.8)}
72
+ button{font:inherit;color:inherit;background:none;border:0;cursor:pointer;text-align:left;padding:0}
73
+ input,textarea{font:inherit;color:inherit;background:none;border:0;outline:none;padding:0;resize:none}