@docyrus/docyrus 0.0.21 → 0.0.22
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/main.js +5 -2
- package/main.js.map +2 -2
- package/package.json +5 -2
- package/resources/pi-agent/extensions/pi-bash-live-view/LICENSE +21 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/README.md +19 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/index.ts +52 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/package.json +61 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-execute.ts +97 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-kill.ts +25 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/pty-session.ts +143 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/spawn-helper.ts +31 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/terminal-emulator.ts +439 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/truncate.ts +68 -0
- package/resources/pi-agent/extensions/pi-bash-live-view/widget.ts +114 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
|
|
3
|
+
const require = createRequire(import.meta.url);
|
|
4
|
+
|
|
5
|
+
type StyleMode = 'default' | 'rgb' | 'palette';
|
|
6
|
+
|
|
7
|
+
type CellStyle = {
|
|
8
|
+
bold: boolean;
|
|
9
|
+
dim: boolean;
|
|
10
|
+
italic: boolean;
|
|
11
|
+
underline: boolean;
|
|
12
|
+
inverse: boolean;
|
|
13
|
+
invisible: boolean;
|
|
14
|
+
strikethrough: boolean;
|
|
15
|
+
fgMode: StyleMode;
|
|
16
|
+
fg: number;
|
|
17
|
+
bgMode: StyleMode;
|
|
18
|
+
bg: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type SnapshotCell = {
|
|
22
|
+
ch: string;
|
|
23
|
+
style: CellStyle;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type SnapshotLine = SnapshotCell[];
|
|
27
|
+
export type TerminalSnapshot = SnapshotLine[];
|
|
28
|
+
|
|
29
|
+
type TerminalUpdatePayload = {
|
|
30
|
+
elapsedMs: number;
|
|
31
|
+
snapshot: TerminalSnapshot;
|
|
32
|
+
inAltScreen: boolean;
|
|
33
|
+
inSyncRender: boolean;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type XtermCellLike = {
|
|
37
|
+
isBold?: () => boolean;
|
|
38
|
+
isDim?: () => boolean;
|
|
39
|
+
isItalic?: () => boolean;
|
|
40
|
+
isUnderline?: () => boolean;
|
|
41
|
+
isInverse?: () => boolean;
|
|
42
|
+
isInvisible?: () => boolean;
|
|
43
|
+
isStrikethrough?: () => boolean;
|
|
44
|
+
isFgDefault?: () => boolean;
|
|
45
|
+
isFgRGB?: () => boolean;
|
|
46
|
+
isFgPalette?: () => boolean;
|
|
47
|
+
isBgDefault?: () => boolean;
|
|
48
|
+
isBgRGB?: () => boolean;
|
|
49
|
+
isBgPalette?: () => boolean;
|
|
50
|
+
getFgColor?: () => number;
|
|
51
|
+
getBgColor?: () => number;
|
|
52
|
+
getWidth: () => number;
|
|
53
|
+
getChars: () => string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
type XtermBufferLineLike = {
|
|
57
|
+
isWrapped?: boolean;
|
|
58
|
+
translateToString: (trimRight?: boolean) => string;
|
|
59
|
+
getCell: (x: number, cell?: XtermCellLike) => XtermCellLike | undefined;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
type XtermBufferLike = {
|
|
63
|
+
baseY: number;
|
|
64
|
+
length: number;
|
|
65
|
+
getLine: (y: number) => XtermBufferLineLike | undefined;
|
|
66
|
+
getNullCell: () => XtermCellLike;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
type XtermTerminalLike = {
|
|
70
|
+
rows: number;
|
|
71
|
+
cols: number;
|
|
72
|
+
buffer: {
|
|
73
|
+
active: XtermBufferLike;
|
|
74
|
+
normal: XtermBufferLike;
|
|
75
|
+
};
|
|
76
|
+
write: (data: string, callback: () => void) => void;
|
|
77
|
+
dispose?: () => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
type XtermTerminalCtor = new (options: {
|
|
81
|
+
cols: number;
|
|
82
|
+
rows: number;
|
|
83
|
+
scrollback: number;
|
|
84
|
+
allowProposedApi: boolean;
|
|
85
|
+
convertEol: boolean;
|
|
86
|
+
}) => XtermTerminalLike;
|
|
87
|
+
|
|
88
|
+
let TerminalCtor: XtermTerminalCtor | null = null;
|
|
89
|
+
|
|
90
|
+
function ensureXtermHeadlessLoaded(): XtermTerminalCtor {
|
|
91
|
+
if (!globalThis.window) {
|
|
92
|
+
(globalThis as typeof globalThis & { window: object }).window = {};
|
|
93
|
+
}
|
|
94
|
+
if (!TerminalCtor) {
|
|
95
|
+
({ Terminal: TerminalCtor } = require('@xterm/headless') as { Terminal: XtermTerminalCtor });
|
|
96
|
+
}
|
|
97
|
+
return TerminalCtor;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function defaultStyle(): CellStyle {
|
|
101
|
+
return {
|
|
102
|
+
bold: false,
|
|
103
|
+
dim: false,
|
|
104
|
+
italic: false,
|
|
105
|
+
underline: false,
|
|
106
|
+
inverse: false,
|
|
107
|
+
invisible: false,
|
|
108
|
+
strikethrough: false,
|
|
109
|
+
fgMode: 'default',
|
|
110
|
+
fg: 0,
|
|
111
|
+
bgMode: 'default',
|
|
112
|
+
bg: 0,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function cloneStyle(style: CellStyle): CellStyle {
|
|
117
|
+
return {
|
|
118
|
+
bold: style.bold,
|
|
119
|
+
dim: style.dim,
|
|
120
|
+
italic: style.italic,
|
|
121
|
+
underline: style.underline,
|
|
122
|
+
inverse: style.inverse,
|
|
123
|
+
invisible: style.invisible,
|
|
124
|
+
strikethrough: style.strikethrough,
|
|
125
|
+
fgMode: style.fgMode,
|
|
126
|
+
fg: style.fg,
|
|
127
|
+
bgMode: style.bgMode,
|
|
128
|
+
bg: style.bg,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function cloneSnapshot(snapshot: TerminalSnapshot): TerminalSnapshot {
|
|
133
|
+
return snapshot.map((line) => line.map((cell) => ({ ...cell, style: cloneStyle(cell.style ?? defaultStyle()) })));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function styleKey(style: CellStyle): string {
|
|
137
|
+
return [
|
|
138
|
+
style.bold ? 'b' : '-',
|
|
139
|
+
style.dim ? 'd' : '-',
|
|
140
|
+
style.italic ? 'i' : '-',
|
|
141
|
+
style.underline ? 'u' : '-',
|
|
142
|
+
style.inverse ? 'v' : '-',
|
|
143
|
+
style.invisible ? 'x' : '-',
|
|
144
|
+
style.strikethrough ? 's' : '-',
|
|
145
|
+
`fg:${style.fgMode}:${style.fg}`,
|
|
146
|
+
`bg:${style.bgMode}:${style.bg}`,
|
|
147
|
+
].join('');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function rgbToSgr(isForeground: boolean, value: number): string {
|
|
151
|
+
const r = (value >> 16) & 255;
|
|
152
|
+
const g = (value >> 8) & 255;
|
|
153
|
+
const b = value & 255;
|
|
154
|
+
return isForeground ? `38;2;${r};${g};${b}` : `48;2;${r};${g};${b}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function paletteToSgr(isForeground: boolean, index: number): string {
|
|
158
|
+
return isForeground ? `38;5;${index}` : `48;5;${index}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function styleToAnsi(style: CellStyle): string {
|
|
162
|
+
const codes = ['0'];
|
|
163
|
+
if (style.bold) codes.push('1');
|
|
164
|
+
if (style.dim) codes.push('2');
|
|
165
|
+
if (style.italic) codes.push('3');
|
|
166
|
+
if (style.underline) codes.push('4');
|
|
167
|
+
if (style.inverse) codes.push('7');
|
|
168
|
+
if (style.invisible) codes.push('8');
|
|
169
|
+
if (style.strikethrough) codes.push('9');
|
|
170
|
+
if (style.fgMode === 'rgb') codes.push(rgbToSgr(true, style.fg));
|
|
171
|
+
else if (style.fgMode === 'palette') codes.push(paletteToSgr(true, style.fg));
|
|
172
|
+
if (style.bgMode === 'rgb') codes.push(rgbToSgr(false, style.bg));
|
|
173
|
+
else if (style.bgMode === 'palette') codes.push(paletteToSgr(false, style.bg));
|
|
174
|
+
return `\x1b[${codes.join(';')}m`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function snapshotToAnsiContentLines(snapshot: TerminalSnapshot): string[] {
|
|
178
|
+
return snapshot.map((line) => {
|
|
179
|
+
let out = '';
|
|
180
|
+
let current = defaultStyle();
|
|
181
|
+
let currentKey = styleKey(current);
|
|
182
|
+
for (const cell of line) {
|
|
183
|
+
const style = cell.style ?? defaultStyle();
|
|
184
|
+
const nextKey = styleKey(style);
|
|
185
|
+
if (nextKey !== currentKey) {
|
|
186
|
+
out += styleToAnsi(style);
|
|
187
|
+
current = cloneStyle(style);
|
|
188
|
+
currentKey = nextKey;
|
|
189
|
+
}
|
|
190
|
+
out += cell.ch;
|
|
191
|
+
}
|
|
192
|
+
return `${out}\x1b[0m`.replace(/\s+\x1b\[0m$/, '\x1b[0m');
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function sanitizeOutput(text: string): string {
|
|
197
|
+
return text.replace(/\r/g, '').replace(/\u0000/g, '').replace(/\p{Cf}/gu, '');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function extractTextFromBuffer(buffer: XtermBufferLike): string {
|
|
201
|
+
const logicalLines: string[] = [];
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < buffer.length; i += 1) {
|
|
204
|
+
const line = buffer.getLine(i);
|
|
205
|
+
const text = sanitizeOutput(line?.translateToString(true) ?? '');
|
|
206
|
+
if (line?.isWrapped && logicalLines.length > 0) {
|
|
207
|
+
logicalLines[logicalLines.length - 1] += text;
|
|
208
|
+
} else {
|
|
209
|
+
logicalLines.push(text);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
while (logicalLines.length > 0 && logicalLines[logicalLines.length - 1] === '') {
|
|
214
|
+
logicalLines.pop();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const text = logicalLines.join('\n').trimEnd();
|
|
218
|
+
return text.length === 0 ? '(no output)' : `${text}\n`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizePaletteColor(mode: StyleMode, value: number): { mode: StyleMode; value: number } {
|
|
222
|
+
if (mode !== 'palette') return { mode, value };
|
|
223
|
+
if (value < 0 || value > 255) {
|
|
224
|
+
return { mode: 'default', value: 0 };
|
|
225
|
+
}
|
|
226
|
+
return { mode: 'palette', value };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function styleFromCell(cell: XtermCellLike | undefined): CellStyle {
|
|
230
|
+
const rawFgMode: StyleMode = cell?.isFgDefault?.()
|
|
231
|
+
? 'default'
|
|
232
|
+
: cell?.isFgRGB?.()
|
|
233
|
+
? 'rgb'
|
|
234
|
+
: cell?.isFgPalette?.()
|
|
235
|
+
? 'palette'
|
|
236
|
+
: 'default';
|
|
237
|
+
const rawBgMode: StyleMode = cell?.isBgDefault?.()
|
|
238
|
+
? 'default'
|
|
239
|
+
: cell?.isBgRGB?.()
|
|
240
|
+
? 'rgb'
|
|
241
|
+
: cell?.isBgPalette?.()
|
|
242
|
+
? 'palette'
|
|
243
|
+
: 'default';
|
|
244
|
+
|
|
245
|
+
const fg = normalizePaletteColor(rawFgMode, cell?.getFgColor?.() ?? 0);
|
|
246
|
+
const bg = normalizePaletteColor(rawBgMode, cell?.getBgColor?.() ?? 0);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
bold: Boolean(cell?.isBold?.()),
|
|
250
|
+
dim: Boolean(cell?.isDim?.()),
|
|
251
|
+
italic: Boolean(cell?.isItalic?.()),
|
|
252
|
+
underline: Boolean(cell?.isUnderline?.()),
|
|
253
|
+
inverse: Boolean(cell?.isInverse?.()),
|
|
254
|
+
invisible: Boolean(cell?.isInvisible?.()),
|
|
255
|
+
strikethrough: Boolean(cell?.isStrikethrough?.()),
|
|
256
|
+
fgMode: fg.mode,
|
|
257
|
+
fg: fg.value,
|
|
258
|
+
bgMode: bg.mode,
|
|
259
|
+
bg: bg.value,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function createXterm(cols: number, rows: number, scrollback: number): XtermTerminalLike {
|
|
264
|
+
const Terminal = ensureXtermHeadlessLoaded();
|
|
265
|
+
return new Terminal({
|
|
266
|
+
cols,
|
|
267
|
+
rows,
|
|
268
|
+
scrollback,
|
|
269
|
+
allowProposedApi: true,
|
|
270
|
+
convertEol: true,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function snapshotTerminal(term: XtermTerminalLike): TerminalSnapshot {
|
|
275
|
+
const buffer = term.buffer.active;
|
|
276
|
+
const start = buffer.baseY;
|
|
277
|
+
const lines: TerminalSnapshot = [];
|
|
278
|
+
const scratchCell = buffer.getNullCell();
|
|
279
|
+
for (let y = 0; y < term.rows; y++) {
|
|
280
|
+
const row: SnapshotLine = [];
|
|
281
|
+
const line = buffer.getLine(start + y);
|
|
282
|
+
for (let x = 0; x < term.cols; x++) {
|
|
283
|
+
const cell = line?.getCell(x, scratchCell);
|
|
284
|
+
if (!cell) {
|
|
285
|
+
row.push({ ch: ' ', style: defaultStyle() });
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (cell.getWidth() === 0) continue;
|
|
289
|
+
row.push({
|
|
290
|
+
ch: cell.getChars() || ' ',
|
|
291
|
+
style: styleFromCell(cell),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
lines.push(row);
|
|
295
|
+
}
|
|
296
|
+
return lines;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function createDecPrivateModeTracker(onModeChange: (mode: number, enabled: boolean) => void) {
|
|
300
|
+
let state: 'ground' | 'escape' | 'csi' = 'ground';
|
|
301
|
+
let privateMarker = false;
|
|
302
|
+
let params = '';
|
|
303
|
+
|
|
304
|
+
function reset() {
|
|
305
|
+
state = 'ground';
|
|
306
|
+
privateMarker = false;
|
|
307
|
+
params = '';
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function finalize(finalByte: string) {
|
|
311
|
+
if (!privateMarker || (finalByte !== 'h' && finalByte !== 'l')) {
|
|
312
|
+
reset();
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const enabled = finalByte === 'h';
|
|
316
|
+
for (const part of params.split(';')) {
|
|
317
|
+
if (!part) continue;
|
|
318
|
+
const mode = Number(part);
|
|
319
|
+
if (!Number.isInteger(mode)) continue;
|
|
320
|
+
onModeChange(mode, enabled);
|
|
321
|
+
}
|
|
322
|
+
reset();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
push(text: string) {
|
|
327
|
+
for (const ch of text) {
|
|
328
|
+
if (state === 'ground') {
|
|
329
|
+
if (ch === '\x1b') state = 'escape';
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (state === 'escape') {
|
|
333
|
+
if (ch === '[') {
|
|
334
|
+
state = 'csi';
|
|
335
|
+
privateMarker = false;
|
|
336
|
+
params = '';
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
state = ch === '\x1b' ? 'escape' : 'ground';
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (state === 'csi') {
|
|
343
|
+
if (ch === '?') {
|
|
344
|
+
if (params.length === 0 && !privateMarker) {
|
|
345
|
+
privateMarker = true;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
reset();
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if ((ch >= '0' && ch <= '9') || ch === ';') {
|
|
352
|
+
params += ch;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (ch >= '@' && ch <= '~') {
|
|
356
|
+
finalize(ch);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
reset();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function createTerminalEmulator({ cols, rows, scrollback = 10_000 }: { cols: number; rows: number; scrollback?: number }) {
|
|
367
|
+
const term = createXterm(cols, rows, scrollback);
|
|
368
|
+
const listeners = new Set<(payload: TerminalUpdatePayload) => void>();
|
|
369
|
+
let writeChain: Promise<void | TerminalUpdatePayload> = Promise.resolve();
|
|
370
|
+
let inAltScreen = false;
|
|
371
|
+
let inSyncRender = false;
|
|
372
|
+
let lastCompletedSnapshot = snapshotTerminal(term);
|
|
373
|
+
let latestSnapshot = cloneSnapshot(lastCompletedSnapshot);
|
|
374
|
+
let lastElapsedMs = 0;
|
|
375
|
+
const modeTracker = createDecPrivateModeTracker((mode, enabled) => {
|
|
376
|
+
if (mode === 2026) {
|
|
377
|
+
inSyncRender = enabled;
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
if (mode === 1049 || mode === 1047 || mode === 47) {
|
|
381
|
+
inAltScreen = enabled;
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
function emitUpdate(payload: TerminalUpdatePayload) {
|
|
386
|
+
for (const listener of listeners) listener(payload);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function consumeProcessStdout(chunk: string, { elapsedMs = lastElapsedMs }: { elapsedMs?: number } = {}) {
|
|
390
|
+
lastElapsedMs = elapsedMs;
|
|
391
|
+
modeTracker.push(chunk);
|
|
392
|
+
writeChain = writeChain.then(() => new Promise<TerminalUpdatePayload>((resolve) => {
|
|
393
|
+
term.write(chunk, () => {
|
|
394
|
+
latestSnapshot = snapshotTerminal(term);
|
|
395
|
+
const renderableSnapshot = inSyncRender ? lastCompletedSnapshot : latestSnapshot;
|
|
396
|
+
if (!inSyncRender) lastCompletedSnapshot = cloneSnapshot(latestSnapshot);
|
|
397
|
+
const payload: TerminalUpdatePayload = {
|
|
398
|
+
elapsedMs: lastElapsedMs,
|
|
399
|
+
snapshot: cloneSnapshot(renderableSnapshot),
|
|
400
|
+
inAltScreen,
|
|
401
|
+
inSyncRender,
|
|
402
|
+
};
|
|
403
|
+
emitUpdate(payload);
|
|
404
|
+
resolve(payload);
|
|
405
|
+
});
|
|
406
|
+
}));
|
|
407
|
+
return writeChain;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function getViewportSnapshot(): TerminalSnapshot {
|
|
411
|
+
return cloneSnapshot(inSyncRender ? lastCompletedSnapshot : latestSnapshot);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function getStrippedTextIncludingEntireScrollback(): string {
|
|
415
|
+
return extractTextFromBuffer(term.buffer.normal);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return {
|
|
419
|
+
cols,
|
|
420
|
+
rows,
|
|
421
|
+
consumeProcessStdout,
|
|
422
|
+
whenIdle() {
|
|
423
|
+
return writeChain.then(() => undefined);
|
|
424
|
+
},
|
|
425
|
+
subscribe(listener: (payload: TerminalUpdatePayload) => void) {
|
|
426
|
+
listeners.add(listener);
|
|
427
|
+
return () => listeners.delete(listener);
|
|
428
|
+
},
|
|
429
|
+
getState() {
|
|
430
|
+
return { inAltScreen, inSyncRender, elapsedMs: lastElapsedMs };
|
|
431
|
+
},
|
|
432
|
+
getViewportSnapshot,
|
|
433
|
+
getStrippedTextIncludingEntireScrollback,
|
|
434
|
+
dispose() {
|
|
435
|
+
term.dispose?.();
|
|
436
|
+
listeners.clear();
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { DEFAULT_MAX_BYTES, formatSize, truncateTail } from '@mariozechner/pi-coding-agent';
|
|
6
|
+
|
|
7
|
+
function getTempFilePath() {
|
|
8
|
+
const id = randomBytes(8).toString('hex');
|
|
9
|
+
return join(tmpdir(), `pi-bash-${id}.log`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function maybePersistFullOutput(fullOutput: string) {
|
|
13
|
+
if (Buffer.byteLength(fullOutput, 'utf-8') <= DEFAULT_MAX_BYTES) return undefined;
|
|
14
|
+
const tempFilePath = getTempFilePath();
|
|
15
|
+
writeFileSync(tempFilePath, fullOutput, 'utf-8');
|
|
16
|
+
return tempFilePath;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function buildTruncationNotice(truncation: ReturnType<typeof truncateTail>, fullOutput: string, tempFilePath?: string) {
|
|
20
|
+
const startLine = truncation.totalLines - truncation.outputLines + 1;
|
|
21
|
+
const endLine = truncation.totalLines;
|
|
22
|
+
|
|
23
|
+
if (truncation.lastLinePartial) {
|
|
24
|
+
const lastLineSize = formatSize(Buffer.byteLength(fullOutput.split('\n').pop() || '', 'utf-8'));
|
|
25
|
+
return `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${tempFilePath}]`;
|
|
26
|
+
}
|
|
27
|
+
if (truncation.truncatedBy === 'lines') {
|
|
28
|
+
return `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${tempFilePath}]`;
|
|
29
|
+
}
|
|
30
|
+
return `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${tempFilePath}]`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function buildSuccessfulBashResult(fullOutput: string) {
|
|
34
|
+
const truncation = truncateTail(fullOutput);
|
|
35
|
+
let outputText = truncation.content || '(no output)';
|
|
36
|
+
const tempFilePath = truncation.truncated ? maybePersistFullOutput(fullOutput) : undefined;
|
|
37
|
+
let details;
|
|
38
|
+
|
|
39
|
+
if (truncation.truncated) {
|
|
40
|
+
details = {
|
|
41
|
+
truncation,
|
|
42
|
+
fullOutputPath: tempFilePath,
|
|
43
|
+
};
|
|
44
|
+
outputText += buildTruncationNotice(truncation, fullOutput, tempFilePath);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
content: [{ type: 'text' as const, text: outputText }],
|
|
49
|
+
details,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function buildExitCodeError(fullOutput: string, exitCode: number) {
|
|
54
|
+
const result = buildSuccessfulBashResult(fullOutput);
|
|
55
|
+
const outputText = result.content[0]?.text || '(no output)';
|
|
56
|
+
return new Error(`${outputText}\n\nCommand exited with code ${exitCode}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildAbortError(fullOutput: string) {
|
|
60
|
+
const output = fullOutput === '(no output)' ? '' : fullOutput;
|
|
61
|
+
return new Error(output ? `${output}\n\nCommand aborted` : 'Command aborted');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function buildTimeoutError(fullOutput: string, timeout: number) {
|
|
65
|
+
const output = fullOutput === '(no output)' ? '' : fullOutput;
|
|
66
|
+
return new Error(output ? `${output}\n\nCommand timed out after ${timeout} seconds` : `Command timed out after ${timeout} seconds`);
|
|
67
|
+
}
|
|
68
|
+
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@mariozechner/pi-coding-agent';
|
|
2
|
+
import { snapshotToAnsiContentLines } from './terminal-emulator.ts';
|
|
3
|
+
import type { PtyTerminalSession } from './pty-session.ts';
|
|
4
|
+
|
|
5
|
+
export const WIDGET_PREFIX = 'pi-bash-live-view/live/';
|
|
6
|
+
const DEFAULT_TITLE = 'Live terminal';
|
|
7
|
+
const DEFAULT_ACCENT_COLOR = '77;163;255';
|
|
8
|
+
|
|
9
|
+
export type LiveSession = {
|
|
10
|
+
id: string;
|
|
11
|
+
startedAt: number;
|
|
12
|
+
rows: number;
|
|
13
|
+
visible: boolean;
|
|
14
|
+
disposed: boolean;
|
|
15
|
+
timer?: NodeJS.Timeout;
|
|
16
|
+
session: PtyTerminalSession;
|
|
17
|
+
requestRender?: () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function formatElapsed(ms: number): string {
|
|
21
|
+
const totalSeconds = Math.max(0, ms / 1000);
|
|
22
|
+
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`;
|
|
23
|
+
const wholeSeconds = Math.floor(totalSeconds);
|
|
24
|
+
const hours = Math.floor(wholeSeconds / 3600);
|
|
25
|
+
const minutes = Math.floor((wholeSeconds % 3600) / 60);
|
|
26
|
+
const seconds = wholeSeconds % 60;
|
|
27
|
+
if (hours > 0) return `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
|
28
|
+
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function buildTopBorder(title: string, innerWidth: number, elapsedMs: number): string {
|
|
32
|
+
const timer = ` ${formatElapsed(elapsedMs)} `;
|
|
33
|
+
const rawTitle = title ? ` ${title} ` : '';
|
|
34
|
+
const titleText = rawTitle.slice(0, Math.max(0, innerWidth - timer.length));
|
|
35
|
+
const fill = '─'.repeat(Math.max(0, innerWidth - titleText.length - timer.length));
|
|
36
|
+
return `${titleText}${fill}${timer}`.padEnd(innerWidth, '─').slice(0, innerWidth);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fitAnsiLine(line: string, width: number): string {
|
|
40
|
+
let out = '';
|
|
41
|
+
let visible = 0;
|
|
42
|
+
let i = 0;
|
|
43
|
+
while (i < line.length && visible < width) {
|
|
44
|
+
if (line[i] === '\x1b' && line[i + 1] === '[') {
|
|
45
|
+
const match = line.slice(i).match(/^\x1b\[[0-9;]*m/);
|
|
46
|
+
if (match) {
|
|
47
|
+
out += match[0];
|
|
48
|
+
i += match[0].length;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
out += line[i];
|
|
53
|
+
visible += 1;
|
|
54
|
+
i += 1;
|
|
55
|
+
}
|
|
56
|
+
return `${out}\x1b[0m${' '.repeat(Math.max(0, width - visible))}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function buildWidgetAnsiLines({
|
|
60
|
+
title = DEFAULT_TITLE,
|
|
61
|
+
snapshot,
|
|
62
|
+
width,
|
|
63
|
+
rows,
|
|
64
|
+
elapsedMs = 0,
|
|
65
|
+
accentColor = DEFAULT_ACCENT_COLOR,
|
|
66
|
+
}: {
|
|
67
|
+
title?: string;
|
|
68
|
+
snapshot: ReturnType<PtyTerminalSession['getViewportSnapshot']>;
|
|
69
|
+
width: number;
|
|
70
|
+
rows: number;
|
|
71
|
+
elapsedMs?: number;
|
|
72
|
+
accentColor?: string;
|
|
73
|
+
}): string[] {
|
|
74
|
+
const accent = `\x1b[38;2;${accentColor}m`;
|
|
75
|
+
const reset = '\x1b[0m';
|
|
76
|
+
const innerWidth = Math.max(10, width - 2);
|
|
77
|
+
const top = `${accent}╭${buildTopBorder(title, innerWidth, elapsedMs)}╮${reset}`;
|
|
78
|
+
const bottom = `${accent}╰${'─'.repeat(innerWidth)}╯${reset}`;
|
|
79
|
+
const bodySource = snapshotToAnsiContentLines(snapshot).slice(-rows);
|
|
80
|
+
const body = [];
|
|
81
|
+
for (let i = 0; i < rows; i += 1) {
|
|
82
|
+
const line = fitAnsiLine(bodySource[i] ?? '', innerWidth);
|
|
83
|
+
body.push(`${accent}│${reset}${line}${accent}│${reset}`);
|
|
84
|
+
}
|
|
85
|
+
return [top, ...body, bottom];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function makeWidgetFactory(session: LiveSession) {
|
|
89
|
+
return (tui: any) => {
|
|
90
|
+
session.requestRender = () => tui.requestRender();
|
|
91
|
+
return {
|
|
92
|
+
invalidate() {},
|
|
93
|
+
render(width: number) {
|
|
94
|
+
return buildWidgetAnsiLines({
|
|
95
|
+
snapshot: session.session.getViewportSnapshot(),
|
|
96
|
+
width,
|
|
97
|
+
rows: session.rows,
|
|
98
|
+
elapsedMs: Date.now() - session.startedAt,
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function showWidget(ctx: ExtensionContext, session: LiveSession) {
|
|
106
|
+
if (!ctx.hasUI || session.visible || session.disposed) return;
|
|
107
|
+
session.visible = true;
|
|
108
|
+
ctx.ui.setWidget(`${WIDGET_PREFIX}${session.id}`, makeWidgetFactory(session));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function hideWidget(ctx: ExtensionContext | null, session: LiveSession) {
|
|
112
|
+
if (!ctx || !ctx.hasUI) return;
|
|
113
|
+
ctx.ui.setWidget(`${WIDGET_PREFIX}${session.id}`, undefined);
|
|
114
|
+
}
|