create-pmos 0.14.0 → 0.16.0
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/README.md +57 -0
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/workspace/README.md +44 -12
- package/workspace/design-parity.js +358 -0
- package/workspace/lib/console-state.js +48 -1
- package/workspace/lib/engine/workflow-state.js +13 -3
- package/workspace/lib/find-browser.js +60 -0
- package/workspace/lib/focus.js +967 -0
- package/workspace/lib/four-panel.js +345 -0
- package/workspace/lib/git-info.js +28 -1
- package/workspace/lib/handoff.js +280 -0
- package/workspace/lib/layout.js +68 -46
- package/workspace/lib/mobile-nav.js +157 -0
- package/workspace/lib/profiles.js +285 -0
- package/workspace/lib/repo-scan.js +469 -0
- package/workspace/package-lock.json +29 -2
- package/workspace/package.json +14 -4
- package/workspace/public/index.html +79 -279
- package/workspace/public/pm/app.js +242 -0
- package/workspace/public/pm/data.js +877 -0
- package/workspace/public/pm/dom.js +116 -0
- package/workspace/public/pm/fixture.js +979 -0
- package/workspace/public/pm/mobile.css +200 -0
- package/workspace/public/pm/mobile.js +403 -0
- package/workspace/public/pm/modes.js +1076 -0
- package/workspace/public/pm/panel-detail.js +648 -0
- package/workspace/public/pm/panel-initiatives.js +426 -0
- package/workspace/public/pm/panel-repos.js +460 -0
- package/workspace/public/pm/panel-tabs.js +487 -0
- package/workspace/public/pm/state.js +164 -0
- package/workspace/public/pm/tab-doc.js +279 -0
- package/workspace/public/pm/tab-task.js +83 -0
- package/workspace/public/pm/tab-term.js +560 -0
- package/workspace/public/pm/tokens.css +73 -0
- package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
- package/workspace/server.js +1072 -31
- package/workspace/public/app.js +0 -3450
- package/workspace/public/styles.css +0 -1444
|
@@ -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}
|
|
Binary file
|