pi-kanban 0.3.1 → 2.0.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/CHANGELOG.md +30 -0
- package/README.md +61 -21
- package/cli.js +1586 -0
- package/extensions/kanban.ts +330 -367
- package/extensions/package.json +1 -0
- package/extensions/tools/ask.ts +85 -0
- package/extensions/tools/board-api.ts +142 -0
- package/extensions/tools/cli-path.ts +34 -0
- package/extensions/tools/dispatch.ts +56 -0
- package/extensions/tools/inbox.ts +86 -0
- package/extensions/tools/kanban-command.ts +27 -0
- package/extensions/tools/scratchpad.ts +70 -0
- package/extensions/tsconfig.json +16 -0
- package/lib/agent-dir.js +20 -0
- package/lib/agent-log.js +64 -0
- package/lib/ask.js +252 -0
- package/lib/atomic-write.js +30 -0
- package/lib/board-dir.js +63 -0
- package/lib/claude-settings.js +21 -0
- package/lib/companion-host.js +142 -0
- package/lib/companions.js +136 -0
- package/lib/contain.js +78 -0
- package/lib/dispatch-reports.js +86 -0
- package/lib/file-url.js +38 -0
- package/lib/folder-dialog.js +91 -0
- package/lib/frame-policy.js +79 -0
- package/lib/git-branch.js +37 -102
- package/lib/http-error.js +7 -0
- package/lib/inline-assets.js +238 -0
- package/lib/linked-docs.js +56 -0
- package/lib/live-sessions.js +86 -0
- package/lib/model-windows.js +48 -0
- package/lib/net-guard.js +277 -0
- package/lib/one-line.js +6 -0
- package/lib/open-editor.js +173 -0
- package/lib/panes.js +213 -0
- package/lib/parsers.js +1434 -0
- package/lib/pi-agents.js +178 -0
- package/lib/pi-git-branch.js +112 -0
- package/lib/pi-models.js +34 -0
- package/lib/pi-sessions.js +240 -0
- package/lib/pi-transcript.js +354 -0
- package/lib/preview-action.js +55 -0
- package/lib/priority.js +45 -0
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +191 -0
- package/lib/scratch-files.js +47 -0
- package/lib/scratchpad-dir.js +95 -0
- package/lib/session-cache.js +32 -0
- package/lib/session-events.js +275 -0
- package/lib/session-pins.js +99 -0
- package/lib/show.js +279 -0
- package/lib/stamped-json-file.js +49 -0
- package/lib/task-counts.js +90 -0
- package/lib/task-dir.js +26 -0
- package/lib/task-store.js +44 -47
- package/lib/terminal-client.js +248 -0
- package/lib/terminal-host.js +113 -0
- package/lib/terminal.js +1116 -0
- package/lib/user-groups.js +384 -0
- package/lib/worktrees.js +132 -0
- package/package.json +39 -15
- package/public/app.js +14349 -3448
- package/public/fonts/OFL-IBM-Plex.txt +93 -0
- package/public/fonts/OFL-Playfair-Display.txt +93 -0
- package/public/fonts/fonts.css +244 -0
- package/public/fonts/ibm-plex-mono-cyrillic-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-cyrillic-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-latin-ext-600.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-400.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-500.woff2 +0 -0
- package/public/fonts/ibm-plex-mono-vietnamese-600.woff2 +0 -0
- package/public/fonts/playfair-display-cyrillic-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-400.woff2 +0 -0
- package/public/fonts/playfair-display-latin-ext-400.woff2 +0 -0
- package/public/fonts/playfair-display-vietnamese-400.woff2 +0 -0
- package/public/group-tree.js +21 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/icon-svg.svg +7 -5
- package/public/index.html +493 -328
- package/public/link-url.js +18 -0
- package/public/manifest.json +7 -7
- package/public/project-match.js +34 -0
- package/public/style.css +8301 -2251
- package/public/sw.js +4 -3
- package/public/terminal-frame.js +437 -0
- package/public/terminal.html +26 -0
- package/public/themes.css +876 -0
- package/public/tree-highlight.js +196 -0
- package/server.js +3368 -467
- package/skills/ask/SKILL.md +29 -0
- package/skills/ask/references/playground.md +25 -0
- package/skills/dispatch/SKILL.md +51 -0
- package/skills/dispatch/references/orchestration-patterns.md +50 -0
- package/skills/kanban/SKILL.md +46 -0
- package/skills/show/SKILL.md +41 -0
- package/skills/show/references/NOTICE.md +27 -0
- package/skills/show/references/chart.md +58 -0
- package/skills/show/references/diagram/architecture.md +18 -0
- package/skills/show/references/diagram/er.md +10 -0
- package/skills/show/references/diagram/flow.md +13 -0
- package/skills/show/references/diagram/layers.md +10 -0
- package/skills/show/references/diagram/sequence.md +19 -0
- package/skills/show/references/diagram/state.md +12 -0
- package/skills/show/references/diagram/swimlane.md +10 -0
- package/skills/show/references/diagram/timeline.md +9 -0
- package/skills/show/references/diagram/tree.md +10 -0
- package/skills/show/references/diagram.md +87 -0
- package/skills/show/references/explain.md +100 -0
- package/skills/show/references/html-contract.md +18 -0
- package/skills/show/references/mockup.md +30 -0
- package/skills/visualize/SKILL.md +73 -0
- package/skills/visualize/blocks.html +68 -0
- package/skills/visualize/template.html +179 -0
- package/lib/pi-parsers.js +0 -1000
- package/themes/kanban-dark-default.json +0 -21
- package/themes/kanban-default.json +0 -21
- package/themes/pi-dark.json +0 -21
- package/themes/pi-light.json +0 -21
package/public/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const CACHE_NAME = 'pi-kanban-
|
|
2
|
-
const PRECACHE_URLS = ['/', '/style.css', '/app.js'];
|
|
1
|
+
const CACHE_NAME = 'pi-kanban-v1';
|
|
2
|
+
const PRECACHE_URLS = ['/', '/style.css', '/project-match.js', '/link-url.js', '/group-tree.js', '/app.js'];
|
|
3
3
|
|
|
4
4
|
let cachePromise = null;
|
|
5
5
|
function getCache() {
|
|
@@ -45,7 +45,8 @@ self.addEventListener('fetch', (event) => {
|
|
|
45
45
|
event.respondWith(
|
|
46
46
|
fetch(event.request)
|
|
47
47
|
.then(r => cacheResponse(event.request, r))
|
|
48
|
-
|
|
48
|
+
// respondWith(undefined) throws, so a miss while the server is down must still be a Response.
|
|
49
|
+
.catch(() => caches.match(event.request).then(cached => cached || Response.error()))
|
|
49
50
|
);
|
|
50
51
|
return;
|
|
51
52
|
}
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
// The terminal half of the board's TERMINAL region. The board frames this page from the other
|
|
2
|
+
// loopback name (see terminalFrameOrigin in app.js), a different site, so Chrome runs it in its own
|
|
3
|
+
// renderer process: keys, output and acks never wait behind board work. The board keeps every decision
|
|
4
|
+
// (when to open, retries, prompts, focus); this page keeps xterm and the socket.
|
|
5
|
+
((root) => {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const ACK_BATCH_BYTES = 32 * 1024;
|
|
9
|
+
const MSG = 'pk-term:';
|
|
10
|
+
|
|
11
|
+
const modsOf = (e) =>
|
|
12
|
+
[e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'].filter(Boolean).join('+');
|
|
13
|
+
|
|
14
|
+
// A claim names a key by its physical code, or by its character when the layout moves it. A press
|
|
15
|
+
// that AltGr turns into a character (Polish AltGr+S is 'ś') is matched by its character only.
|
|
16
|
+
function keySigs(e) {
|
|
17
|
+
const mods = modsOf(e);
|
|
18
|
+
return [
|
|
19
|
+
e.code && !e.getModifierState?.('AltGraph') && `${mods}|c:${e.code}`,
|
|
20
|
+
typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`,
|
|
21
|
+
].filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const claimSig = (c) => keySigs(c)[0];
|
|
25
|
+
|
|
26
|
+
const isClaimed = (claims, e) => keySigs(e).some((s) => claims.has(s));
|
|
27
|
+
|
|
28
|
+
function parentOriginOf(hash) {
|
|
29
|
+
const m = /[#&]p=([^&]*)/.exec(hash || '');
|
|
30
|
+
if (!m) return null;
|
|
31
|
+
try {
|
|
32
|
+
return new URL(decodeURIComponent(m[1])).origin;
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function tailOf(buf, count) {
|
|
39
|
+
if (!buf) return '';
|
|
40
|
+
const lines = [];
|
|
41
|
+
for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
|
|
42
|
+
const text = buf.getLine(i)?.translateToString(true) ?? '';
|
|
43
|
+
if (text.trim() || lines.length) lines.unshift(text);
|
|
44
|
+
}
|
|
45
|
+
return lines.join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PATH_ROOT = String.raw`(?:[A-Za-z]:[\\/]|\/(?=[\w.@+-]+\/)|\\\\(?=[\w.$-]+\\))`;
|
|
49
|
+
// A quoted path may hold spaces; a bare one ends at the first.
|
|
50
|
+
const PATH_RE = new RegExp(
|
|
51
|
+
[
|
|
52
|
+
String.raw`"(${PATH_ROOT}[^"<>|*?]*[^\s"<>|*?])"`,
|
|
53
|
+
String.raw`'(${PATH_ROOT}[^'<>|*?]*[^\s'<>|*?])'`,
|
|
54
|
+
String.raw`(?<![\w/\\:.~-])${PATH_ROOT}[^\s"'<>|*?${'`'}]*[^\s"'<>|*?${'`'}.,;:!)\]}]`,
|
|
55
|
+
].join('|'),
|
|
56
|
+
'g',
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
function pathsIn(text) {
|
|
60
|
+
return Array.from(text.matchAll(PATH_RE), (m) => {
|
|
61
|
+
const quoted = m[1] ?? m[2];
|
|
62
|
+
const found = quoted ?? m[0];
|
|
63
|
+
return { index: m.index + (quoted ? 1 : 0), text: found, path: found.replace(/(?::\d+){1,2}$/, '') };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Cell x of each character, because a wide character spans two cells and translateToString counts it once.
|
|
68
|
+
function lineCells(line, cols, reuse) {
|
|
69
|
+
let text = '';
|
|
70
|
+
const xs = [];
|
|
71
|
+
for (let x = 0; x < cols; x++) {
|
|
72
|
+
const cell = line.getCell(x, reuse);
|
|
73
|
+
if (!cell || cell.getWidth() === 0) continue;
|
|
74
|
+
const chars = cell.getChars() || ' ';
|
|
75
|
+
for (let i = 0; i < chars.length; i++) xs.push(x);
|
|
76
|
+
text += chars;
|
|
77
|
+
}
|
|
78
|
+
return { text, xs };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const QUERIES = [
|
|
82
|
+
{ final: 'c' },
|
|
83
|
+
{ prefix: '>', final: 'c' },
|
|
84
|
+
{ final: 'n' },
|
|
85
|
+
{ prefix: '?', final: 'n' },
|
|
86
|
+
{ intermediates: '$', final: 'p' },
|
|
87
|
+
{ prefix: '?', intermediates: '$', final: 'p' },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
function oscColor(hex) {
|
|
91
|
+
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The key changes with any color, so a new palette of the same lightness is reported too.
|
|
97
|
+
function schemeOf(theme) {
|
|
98
|
+
const m = /^#([0-9a-f]{6})$/i.exec(theme?.background || '');
|
|
99
|
+
if (!m) return null;
|
|
100
|
+
const [r, g, b] = [0, 2, 4].map((i) => Number.parseInt(m[1].slice(i, i + 2), 16));
|
|
101
|
+
return { dark: 0.299 * r + 0.587 * g + 0.114 * b < 128, key: hashOf(JSON.stringify(theme)) };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hashOf(text) {
|
|
105
|
+
let h = 0x811c9dc5;
|
|
106
|
+
for (let i = 0; i < text.length; i++) h = Math.imul(h ^ text.charCodeAt(i), 0x01000193);
|
|
107
|
+
return (h >>> 0).toString(16);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createTerminalFrame(win) {
|
|
111
|
+
const doc = win.document;
|
|
112
|
+
const parentOrigin = parentOriginOf(win.location.hash);
|
|
113
|
+
const host = doc.getElementById('host');
|
|
114
|
+
const state = {
|
|
115
|
+
term: null,
|
|
116
|
+
fit: null,
|
|
117
|
+
webgl: null,
|
|
118
|
+
initing: false,
|
|
119
|
+
ws: null,
|
|
120
|
+
attached: false,
|
|
121
|
+
ackPending: 0,
|
|
122
|
+
ackTimer: null,
|
|
123
|
+
claims: new Set(),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
function up(t, data) {
|
|
127
|
+
if (parentOrigin) win.parent.postMessage({ ...data, type: MSG + t }, parentOrigin);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function send(msg) {
|
|
131
|
+
if (state.ws?.readyState === win.WebSocket.OPEN) state.ws.send(JSON.stringify(msg));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The WebGL renderer keeps its last frame and glyph atlas while the frame is 0×0, and comes back
|
|
135
|
+
// showing stale cells or backgrounds with no text. xterm does not repaint on its own.
|
|
136
|
+
function repaint() {
|
|
137
|
+
const term = state.term;
|
|
138
|
+
if (!term) return;
|
|
139
|
+
try {
|
|
140
|
+
state.webgl?.clearTextureAtlas();
|
|
141
|
+
} catch (_) {}
|
|
142
|
+
term.refresh(0, term.rows - 1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The fit addon measures 0×0 while the board's pane is display:none.
|
|
146
|
+
function fit() {
|
|
147
|
+
if (!state.fit || !host.offsetWidth || !host.offsetHeight) return;
|
|
148
|
+
try {
|
|
149
|
+
state.fit.fit();
|
|
150
|
+
} catch (_) {}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function setBg(options) {
|
|
154
|
+
doc.documentElement.style.setProperty('--bg', options?.theme?.background || '');
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sendScheme() {
|
|
158
|
+
const theme = state.term.options.theme;
|
|
159
|
+
const scheme = schemeOf(theme);
|
|
160
|
+
if (!scheme || !state.attached) return;
|
|
161
|
+
send({ t: 'scheme', ...scheme, fg: oscColor(theme.foreground), bg: oscColor(theme.background) });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function applyTheme(options) {
|
|
165
|
+
Object.assign(state.term.options, options);
|
|
166
|
+
setBg(options);
|
|
167
|
+
repaint();
|
|
168
|
+
sendScheme();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function applyFont(size) {
|
|
172
|
+
const term = state.term;
|
|
173
|
+
term.options.fontSize = size;
|
|
174
|
+
fit();
|
|
175
|
+
doc.fonts
|
|
176
|
+
?.load(`${size}px ${term.options.fontFamily}`)
|
|
177
|
+
.then(repaint)
|
|
178
|
+
.catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function forwardClaimed(e) {
|
|
182
|
+
if (!isClaimed(state.claims, e)) return false;
|
|
183
|
+
e.preventDefault();
|
|
184
|
+
up('key', { key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey });
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function keyFilter(e) {
|
|
189
|
+
if (e.type !== 'keydown') return true;
|
|
190
|
+
if (forwardClaimed(e)) return false;
|
|
191
|
+
const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
|
|
192
|
+
if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || state.term.hasSelection())) {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
win.navigator.clipboard?.writeText(state.term.getSelection()).catch(() => {});
|
|
195
|
+
state.term.clearSelection();
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
// The browser's own paste event reaches xterm's textarea.
|
|
199
|
+
return !(ctrlOnly && e.code === 'KeyV');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// xterm measures its cell when it opens or a font option changes, not when a web font finishes
|
|
203
|
+
// loading. Measured on the fallback font, the cell is shorter, fit picks more rows than the pane
|
|
204
|
+
// shows, and the first hello asks the PTY for that size.
|
|
205
|
+
function init(o) {
|
|
206
|
+
if (state.initing) return;
|
|
207
|
+
state.initing = true;
|
|
208
|
+
const loaded = doc.fonts?.load(`${o.fontSize}px ${o.fontFamily}`);
|
|
209
|
+
if (loaded) loaded.catch(() => {}).then(() => build(o));
|
|
210
|
+
else build(o);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function build(o) {
|
|
214
|
+
const term = new win.Terminal({
|
|
215
|
+
fontFamily: o.fontFamily,
|
|
216
|
+
fontSize: o.fontSize,
|
|
217
|
+
scrollback: o.scrollback,
|
|
218
|
+
windowsPty: o.windowsPty,
|
|
219
|
+
cursorBlink: true,
|
|
220
|
+
allowProposedApi: true,
|
|
221
|
+
// xterm's default asks with confirm() and opens a window, which an installed app window blocks.
|
|
222
|
+
linkHandler: { activate: (_e, uri) => up('link', { href: uri }), allowNonHttpProtocols: true },
|
|
223
|
+
...o.themeOptions,
|
|
224
|
+
});
|
|
225
|
+
state.term = term;
|
|
226
|
+
state.fit = new win.FitAddon.FitAddon();
|
|
227
|
+
term.loadAddon(state.fit);
|
|
228
|
+
term.loadAddon(new win.Unicode11Addon.Unicode11Addon());
|
|
229
|
+
term.unicode.activeVersion = '11';
|
|
230
|
+
// Link providers scan a line only when the pointer moves onto it, never on output.
|
|
231
|
+
term.loadAddon(new win.WebLinksAddon.WebLinksAddon((_e, uri) => up('link', { href: uri })));
|
|
232
|
+
const cellReuse = term.buffer.active.getNullCell();
|
|
233
|
+
term.registerLinkProvider({
|
|
234
|
+
provideLinks(y, done) {
|
|
235
|
+
const line = term.buffer.active.getLine(y - 1);
|
|
236
|
+
if (!line || !/[\\/]/.test(line.translateToString(true))) return done(undefined);
|
|
237
|
+
const { text, xs } = lineCells(line, term.cols, cellReuse);
|
|
238
|
+
const links = pathsIn(text).map((p) => ({
|
|
239
|
+
text: p.text,
|
|
240
|
+
range: { start: { x: xs[p.index] + 1, y }, end: { x: xs[p.index + p.text.length - 1] + 1, y } },
|
|
241
|
+
activate: () => up('link', { href: p.path }),
|
|
242
|
+
}));
|
|
243
|
+
done(links.length ? links : undefined);
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
term.open(host);
|
|
247
|
+
try {
|
|
248
|
+
const gl = new win.WebglAddon.WebglAddon();
|
|
249
|
+
gl.onContextLoss(() => {
|
|
250
|
+
gl.dispose();
|
|
251
|
+
state.webgl = null;
|
|
252
|
+
repaint();
|
|
253
|
+
});
|
|
254
|
+
term.loadAddon(gl);
|
|
255
|
+
state.webgl = gl;
|
|
256
|
+
} catch (_) {
|
|
257
|
+
// No WebGL: xterm falls back to its DOM renderer.
|
|
258
|
+
}
|
|
259
|
+
setBg(o.themeOptions);
|
|
260
|
+
applyFont(o.fontSize);
|
|
261
|
+
term.attachCustomKeyEventHandler(keyFilter);
|
|
262
|
+
// After a click on the scrollbar, or before the board's focus message lands, the frame has focus
|
|
263
|
+
// but xterm's textarea does not, and the board's keys must still get out.
|
|
264
|
+
doc.addEventListener('keydown', (e) => {
|
|
265
|
+
if (e.target !== term.textarea) forwardClaimed(e);
|
|
266
|
+
});
|
|
267
|
+
term.textarea.addEventListener('focus', () => {
|
|
268
|
+
up('focused');
|
|
269
|
+
send({ t: 'resize', cols: term.cols, rows: term.rows, claim: true });
|
|
270
|
+
});
|
|
271
|
+
// xterm reports focus only when its textarea gains or loses it, not when an app turns focus reporting
|
|
272
|
+
// (DECSET 1004) on. After a reload xterm is focused before the replayed output sets the mode, so the
|
|
273
|
+
// app never gets a focus-in and draws its cursor as unfocused.
|
|
274
|
+
term.parser.registerCsiHandler({ prefix: '?', final: 'h' }, (params) => {
|
|
275
|
+
if (!params.includes(1004) || term.modes.sendFocusMode) return false;
|
|
276
|
+
win.queueMicrotask(() => {
|
|
277
|
+
if (term.modes.sendFocusMode && doc.hasFocus() && doc.activeElement === term.textarea) {
|
|
278
|
+
send({ t: 'in', d: '\x1b[I' });
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
return false;
|
|
282
|
+
});
|
|
283
|
+
// The server's mirror answers the app's queries, with this viewer's colors when it owns the PTY.
|
|
284
|
+
// An answer from here would repeat it, at this viewer's size, and claim the PTY.
|
|
285
|
+
for (const id of QUERIES) term.parser.registerCsiHandler(id, () => true);
|
|
286
|
+
term.parser.registerDcsHandler({ intermediates: '$', final: 'q' }, () => true);
|
|
287
|
+
for (const code of [10, 11]) term.parser.registerOscHandler(code, (data) => data === '?');
|
|
288
|
+
term.onData((d) => send({ t: 'in', d }));
|
|
289
|
+
term.onResize(({ cols, rows }) => send({ t: 'resize', cols, rows }));
|
|
290
|
+
// One fit per frame while a drag resizes the pane. The atlas is rebuilt only when the frame comes
|
|
291
|
+
// back from 0×0, which is also how a hidden pane shows again.
|
|
292
|
+
let frame = 0;
|
|
293
|
+
let visible = false;
|
|
294
|
+
new win.ResizeObserver(() => {
|
|
295
|
+
if (frame) return;
|
|
296
|
+
frame = win.requestAnimationFrame(() => {
|
|
297
|
+
frame = 0;
|
|
298
|
+
const nowVisible = host.offsetWidth > 0;
|
|
299
|
+
fit();
|
|
300
|
+
if (nowVisible && !visible) repaint();
|
|
301
|
+
visible = nowVisible;
|
|
302
|
+
});
|
|
303
|
+
}).observe(host);
|
|
304
|
+
// Vimium eats Escape inside a text field and only blurs it, so the key never reaches pi.
|
|
305
|
+
// A blur no click caused, while this frame keeps focus, is that Escape. When the board takes
|
|
306
|
+
// focus back, this document loses it, so that blur is not one.
|
|
307
|
+
let pointerDown = false;
|
|
308
|
+
doc.addEventListener('mousedown', () => (pointerDown = true), true);
|
|
309
|
+
doc.addEventListener('mouseup', () => (pointerDown = false), true);
|
|
310
|
+
term.textarea.addEventListener('blur', () => {
|
|
311
|
+
if (pointerDown) return;
|
|
312
|
+
win.requestAnimationFrame(() => {
|
|
313
|
+
if (!state.attached || !host.offsetWidth) return;
|
|
314
|
+
if (!doc.hasFocus() || doc.activeElement !== doc.body) return;
|
|
315
|
+
send({ t: 'in', d: '\x1b' });
|
|
316
|
+
term.focus();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
up('term');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function ack(ws, n) {
|
|
323
|
+
if (state.ws !== ws) return;
|
|
324
|
+
state.ackPending += n;
|
|
325
|
+
const flush = () => {
|
|
326
|
+
win.clearTimeout(state.ackTimer);
|
|
327
|
+
state.ackTimer = null;
|
|
328
|
+
if (state.ws === ws && state.ackPending) send({ t: 'ack', n: state.ackPending });
|
|
329
|
+
state.ackPending = 0;
|
|
330
|
+
};
|
|
331
|
+
if (state.ackPending >= ACK_BATCH_BYTES) flush();
|
|
332
|
+
else if (!state.ackTimer) state.ackTimer = win.setTimeout(flush, 50);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
|
|
336
|
+
// session had already queued would still be drawn after it.
|
|
337
|
+
function clearScreen() {
|
|
338
|
+
state.term.write('\x1bc', repaint);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function detach() {
|
|
342
|
+
const ws = state.ws;
|
|
343
|
+
state.ws = null;
|
|
344
|
+
state.attached = false;
|
|
345
|
+
win.clearTimeout(state.ackTimer);
|
|
346
|
+
state.ackTimer = null;
|
|
347
|
+
state.ackPending = 0;
|
|
348
|
+
if (ws) {
|
|
349
|
+
ws.onclose = null;
|
|
350
|
+
ws.close();
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function open({ socketId, hello, reset }) {
|
|
355
|
+
detach();
|
|
356
|
+
up('opening', { socketId });
|
|
357
|
+
const term = state.term;
|
|
358
|
+
let outputSent = false;
|
|
359
|
+
if (reset) clearScreen();
|
|
360
|
+
fit();
|
|
361
|
+
const proto = win.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
362
|
+
const ws = new win.WebSocket(`${proto}//${win.location.host}/api/terminal/ws`);
|
|
363
|
+
ws.binaryType = 'arraybuffer';
|
|
364
|
+
state.ws = ws;
|
|
365
|
+
ws.onopen = () => ws.send(JSON.stringify({ ...hello, t: 'hello', cols: term.cols, rows: term.rows }));
|
|
366
|
+
ws.onmessage = (ev) => {
|
|
367
|
+
if (state.ws !== ws) return;
|
|
368
|
+
if (typeof ev.data !== 'string') {
|
|
369
|
+
const bytes = new Uint8Array(ev.data);
|
|
370
|
+
term.write(bytes, () => {
|
|
371
|
+
ack(ws, bytes.length);
|
|
372
|
+
if (!outputSent && state.ws === ws && tailOf(term.buffer.active, 1)) {
|
|
373
|
+
outputSent = true;
|
|
374
|
+
up('output', { socketId });
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
let msg;
|
|
380
|
+
try {
|
|
381
|
+
msg = JSON.parse(ev.data);
|
|
382
|
+
} catch (_) {
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (msg.t === 'ready') {
|
|
386
|
+
state.attached = true;
|
|
387
|
+
// A retry keeps the last screen until the replay arrives.
|
|
388
|
+
if (!reset) clearScreen();
|
|
389
|
+
send({ t: 'resize', cols: term.cols, rows: term.rows });
|
|
390
|
+
sendScheme();
|
|
391
|
+
} else if (msg.t === 'exit') state.attached = false;
|
|
392
|
+
up('ws', { socketId, msg, ...(msg.t === 'exit' && { tail: tailOf(term.buffer.active, 6) }) });
|
|
393
|
+
};
|
|
394
|
+
ws.onclose = (ev) => {
|
|
395
|
+
if (state.ws !== ws) return;
|
|
396
|
+
state.ws = null;
|
|
397
|
+
state.attached = false;
|
|
398
|
+
up('close', { socketId, code: ev.code });
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function onMessage(e) {
|
|
403
|
+
if (e.source !== win.parent || e.origin !== parentOrigin) return;
|
|
404
|
+
const m = e.data;
|
|
405
|
+
if (typeof m?.type !== 'string' || !m.type.startsWith(MSG)) return;
|
|
406
|
+
if (m.type === `${MSG}init`) return init(m);
|
|
407
|
+
if (!state.term) return;
|
|
408
|
+
switch (m.type.slice(MSG.length)) {
|
|
409
|
+
case 'open':
|
|
410
|
+
return open(m);
|
|
411
|
+
case 'detach':
|
|
412
|
+
return detach();
|
|
413
|
+
case 'theme':
|
|
414
|
+
return applyTheme(m.options);
|
|
415
|
+
case 'font':
|
|
416
|
+
return applyFont(m.size);
|
|
417
|
+
case 'claims':
|
|
418
|
+
state.claims = new Set((m.keys || []).map(claimSig));
|
|
419
|
+
return;
|
|
420
|
+
case 'focus':
|
|
421
|
+
return state.term.focus();
|
|
422
|
+
case 'refresh':
|
|
423
|
+
fit();
|
|
424
|
+
return repaint();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
win.addEventListener('message', onMessage);
|
|
429
|
+
up('loaded');
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (typeof module === 'object' && module.exports) {
|
|
433
|
+
module.exports = { createTerminalFrame, claimSig, isClaimed, parentOriginOf, tailOf, pathsIn, schemeOf };
|
|
434
|
+
} else {
|
|
435
|
+
createTerminalFrame(root);
|
|
436
|
+
}
|
|
437
|
+
})(typeof window === 'undefined' ? globalThis : window);
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Terminal</title>
|
|
6
|
+
<link rel="stylesheet" href="/fonts/fonts.css">
|
|
7
|
+
<link rel="stylesheet" href="/vendor/xterm/xterm.css">
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg, transparent); }
|
|
10
|
+
#host { height: 100%; }
|
|
11
|
+
#host .xterm { height: 100%; }
|
|
12
|
+
/* xterm.css paints the viewport #000; it shows below the last row when the height is not a whole number of rows. */
|
|
13
|
+
/* xterm draws its own scrollbar; the viewport's native one would show at the bottom-right corner. */
|
|
14
|
+
#host .xterm .xterm-viewport { background-color: var(--bg, transparent); scrollbar-width: none; }
|
|
15
|
+
</style>
|
|
16
|
+
</head>
|
|
17
|
+
<body>
|
|
18
|
+
<div id="host"></div>
|
|
19
|
+
<script src="/vendor/xterm/xterm.js"></script>
|
|
20
|
+
<script src="/vendor/xterm/addon-fit.js"></script>
|
|
21
|
+
<script src="/vendor/xterm/addon-webgl.js"></script>
|
|
22
|
+
<script src="/vendor/xterm/addon-unicode11.js"></script>
|
|
23
|
+
<script src="/vendor/xterm/addon-web-links.js"></script>
|
|
24
|
+
<script src="/terminal-frame.js"></script>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|