@lzdi/pty-remote-cli 0.1.3

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.
@@ -0,0 +1,457 @@
1
+ import xtermHeadless from '@xterm/headless';
2
+ import type { IBufferCell, IBufferLine } from '@xterm/headless';
3
+
4
+ import {
5
+ cloneTerminalFrameSnapshot,
6
+ encodeTerminalFrameStyle,
7
+ type TerminalFrameLine,
8
+ type TerminalFramePatch,
9
+ type TerminalFrameSnapshot,
10
+ type TerminalFrameStyle
11
+ } from '@lzdi/pty-remote-protocol/terminal-frame.ts';
12
+
13
+ const STYLE_FLAG_BOLD = 1 << 0;
14
+ const STYLE_FLAG_ITALIC = 1 << 1;
15
+ const STYLE_FLAG_DIM = 1 << 2;
16
+ const STYLE_FLAG_UNDERLINE = 1 << 3;
17
+ const STYLE_FLAG_BLINK = 1 << 4;
18
+ const STYLE_FLAG_INVERSE = 1 << 5;
19
+ const STYLE_FLAG_INVISIBLE = 1 << 6;
20
+ const STYLE_FLAG_STRIKETHROUGH = 1 << 7;
21
+ const STYLE_FLAG_OVERLINE = 1 << 8;
22
+ const DEFAULT_SCROLLBACK = 500;
23
+ const { Terminal } = xtermHeadless;
24
+ type HeadlessTerminal = InstanceType<typeof xtermHeadless.Terminal>;
25
+
26
+ interface MaterializedSnapshot {
27
+ lineKeys: string[];
28
+ snapshot: TerminalFrameSnapshot;
29
+ }
30
+
31
+ interface HeadlessTerminalFrameStateOptions {
32
+ cols: number;
33
+ maxLines?: number;
34
+ rows: number;
35
+ scrollback?: number;
36
+ }
37
+
38
+ const DEFAULT_MAX_LINES = 500;
39
+
40
+ function sanitizeCols(cols: number): number {
41
+ return Number.isFinite(cols) ? Math.max(20, Math.min(Math.floor(cols), 400)) : 120;
42
+ }
43
+
44
+ function sanitizeRows(rows: number): number {
45
+ return Number.isFinite(rows) ? Math.max(8, Math.min(Math.floor(rows), 200)) : 32;
46
+ }
47
+
48
+ function createHeadlessTerminal(cols: number, rows: number, scrollback: number): HeadlessTerminal {
49
+ return new Terminal({
50
+ allowProposedApi: true,
51
+ cols,
52
+ rows,
53
+ convertEol: false,
54
+ cursorBlink: false,
55
+ cursorStyle: 'bar',
56
+ disableStdin: true,
57
+ logLevel: 'off',
58
+ scrollback
59
+ });
60
+ }
61
+
62
+ function buildLineKey(line: TerminalFrameLine): string {
63
+ return `${line.wrapped ? '1' : '0'}:${line.runs.map((run) => `${run.style}:${run.text}`).join('|')}`;
64
+ }
65
+
66
+ function getChangedRange(previous: string[], next: string[]): {
67
+ deleteCount: number;
68
+ insertCount: number;
69
+ start: number;
70
+ } {
71
+ let start = 0;
72
+ const sharedLength = Math.min(previous.length, next.length);
73
+ while (start < sharedLength && previous[start] === next[start]) {
74
+ start += 1;
75
+ }
76
+
77
+ let previousEnd = previous.length - 1;
78
+ let nextEnd = next.length - 1;
79
+ while (previousEnd >= start && nextEnd >= start && previous[previousEnd] === next[nextEnd]) {
80
+ previousEnd -= 1;
81
+ nextEnd -= 1;
82
+ }
83
+
84
+ return {
85
+ start,
86
+ deleteCount: Math.max(0, previousEnd - start + 1),
87
+ insertCount: Math.max(0, nextEnd - start + 1)
88
+ };
89
+ }
90
+
91
+ function hasMetaChanged(previous: TerminalFrameSnapshot, next: TerminalFrameSnapshot): boolean {
92
+ return (
93
+ previous.cols !== next.cols ||
94
+ previous.rows !== next.rows ||
95
+ previous.tailStart !== next.tailStart ||
96
+ previous.cursorX !== next.cursorX ||
97
+ previous.cursorY !== next.cursorY ||
98
+ previous.viewportY !== next.viewportY ||
99
+ previous.baseY !== next.baseY ||
100
+ previous.totalLines !== next.totalLines
101
+ );
102
+ }
103
+
104
+ function normalizeStyleColor(mode: TerminalFrameStyle['fgMode'], value: number): number {
105
+ if (mode === 'default') {
106
+ return 0;
107
+ }
108
+ return value;
109
+ }
110
+
111
+ export class HeadlessTerminalFrameState {
112
+ private terminal: HeadlessTerminal;
113
+
114
+ private materializedSnapshot: MaterializedSnapshot;
115
+
116
+ private readonly maxLines: number;
117
+
118
+ private readonly scrollback: number;
119
+
120
+ private generation = 0;
121
+
122
+ private pendingWriteChain: Promise<void> = Promise.resolve();
123
+
124
+ constructor(options: HeadlessTerminalFrameStateOptions) {
125
+ const cols = sanitizeCols(options.cols);
126
+ const rows = sanitizeRows(options.rows);
127
+ this.maxLines = Number.isFinite(options.maxLines) ? Math.max(50, Math.floor(options.maxLines ?? DEFAULT_MAX_LINES)) : DEFAULT_MAX_LINES;
128
+ this.scrollback = options.scrollback ?? Math.max(DEFAULT_SCROLLBACK, this.maxLines);
129
+ this.terminal = createHeadlessTerminal(cols, rows, this.scrollback);
130
+ this.materializedSnapshot = this.materializeSnapshot(null, 0);
131
+ }
132
+
133
+ dispose(): void {
134
+ this.terminal.dispose();
135
+ }
136
+
137
+ getSnapshot(): TerminalFrameSnapshot {
138
+ return cloneTerminalFrameSnapshot(this.materializedSnapshot.snapshot);
139
+ }
140
+
141
+ async flush(): Promise<void> {
142
+ await this.pendingWriteChain;
143
+ }
144
+
145
+ reset(sessionId: string | null): TerminalFramePatch {
146
+ const previousTerminal = this.terminal;
147
+ const previousWriteChain = this.pendingWriteChain;
148
+ this.generation += 1;
149
+ this.terminal = createHeadlessTerminal(
150
+ this.materializedSnapshot.snapshot.cols || 120,
151
+ this.materializedSnapshot.snapshot.rows || 32,
152
+ this.scrollback
153
+ );
154
+ void previousWriteChain.finally(() => {
155
+ previousTerminal.dispose();
156
+ });
157
+ this.materializedSnapshot = this.materializeSnapshot(sessionId, 0);
158
+ return this.createResetPatch();
159
+ }
160
+
161
+ resize(cols: number, rows: number): TerminalFramePatch | null {
162
+ const nextCols = sanitizeCols(cols);
163
+ const nextRows = sanitizeRows(rows);
164
+ if (this.materializedSnapshot.snapshot.cols === nextCols && this.materializedSnapshot.snapshot.rows === nextRows) {
165
+ return null;
166
+ }
167
+
168
+ this.terminal.resize(nextCols, nextRows);
169
+ return this.commitFrame({ forceReset: true });
170
+ }
171
+
172
+ enqueueOutput(chunk: string): Promise<TerminalFramePatch | null> {
173
+ const writeGeneration = this.generation;
174
+ const writeTerminal = this.terminal;
175
+ const writeTask = this.pendingWriteChain
176
+ .catch(() => undefined)
177
+ .then(
178
+ () =>
179
+ new Promise<TerminalFramePatch | null>((resolve, reject) => {
180
+ if (writeGeneration !== this.generation || writeTerminal !== this.terminal) {
181
+ resolve(null);
182
+ return;
183
+ }
184
+
185
+ writeTerminal.write(chunk, () => {
186
+ if (writeGeneration !== this.generation || writeTerminal !== this.terminal) {
187
+ resolve(null);
188
+ return;
189
+ }
190
+
191
+ try {
192
+ resolve(this.commitFrame());
193
+ } catch (error) {
194
+ reject(error);
195
+ }
196
+ });
197
+ })
198
+ );
199
+
200
+ this.pendingWriteChain = writeTask.then(() => undefined, () => undefined);
201
+ return writeTask;
202
+ }
203
+
204
+ createResetPatch(): TerminalFramePatch {
205
+ return {
206
+ sessionId: this.materializedSnapshot.snapshot.sessionId,
207
+ baseRevision: 0,
208
+ revision: this.materializedSnapshot.snapshot.revision,
209
+ ops: [
210
+ {
211
+ type: 'reset',
212
+ snapshot: cloneTerminalFrameSnapshot(this.materializedSnapshot.snapshot)
213
+ }
214
+ ]
215
+ };
216
+ }
217
+
218
+ private commitFrame(options: { forceReset?: boolean } = {}): TerminalFramePatch | null {
219
+ const previous = this.materializedSnapshot;
220
+ const nextRevision = previous.snapshot.revision + 1;
221
+ const next = this.materializeSnapshot(previous.snapshot.sessionId, nextRevision);
222
+ const metaChanged = hasMetaChanged(previous.snapshot, next.snapshot);
223
+ const shift = next.snapshot.tailStart - previous.snapshot.tailStart;
224
+ const shiftPatch = shift > 0 ? this.createTailShiftPatch(previous, next, shift) : null;
225
+ const range = shiftPatch ? null : getChangedRange(previous.lineKeys, next.lineKeys);
226
+ const linesChanged = shiftPatch !== null || (range ? range.deleteCount > 0 || range.insertCount > 0 : false);
227
+
228
+ if (!options.forceReset && !metaChanged && !linesChanged) {
229
+ return null;
230
+ }
231
+
232
+ const changedSpan = shiftPatch
233
+ ? shiftPatch.trimCount + shiftPatch.range.deleteCount + shiftPatch.range.insertCount
234
+ : (range?.deleteCount ?? 0) + (range?.insertCount ?? 0);
235
+ const shouldReset =
236
+ options.forceReset === true ||
237
+ previous.snapshot.sessionId !== next.snapshot.sessionId ||
238
+ next.snapshot.tailStart < previous.snapshot.tailStart ||
239
+ (shift > 0 && shiftPatch === null) ||
240
+ (linesChanged && changedSpan > Math.max(20, Math.floor(next.snapshot.lines.length * 0.6)));
241
+
242
+ this.materializedSnapshot = next;
243
+
244
+ if (shouldReset) {
245
+ return this.createResetPatch();
246
+ }
247
+
248
+ const ops: TerminalFramePatch['ops'] = [];
249
+ if (shiftPatch) {
250
+ if (shiftPatch.trimCount > 0) {
251
+ ops.push({
252
+ type: 'trimHead',
253
+ count: shiftPatch.trimCount
254
+ });
255
+ }
256
+ if (shiftPatch.range.deleteCount > 0 || shiftPatch.range.insertCount > 0) {
257
+ ops.push({
258
+ type: 'spliceLines',
259
+ start: shiftPatch.range.start,
260
+ deleteCount: shiftPatch.range.deleteCount,
261
+ lines: shiftPatch.lines.map((line) => ({
262
+ wrapped: line.wrapped,
263
+ runs: line.runs.map((run) => ({
264
+ style: run.style,
265
+ text: run.text
266
+ }))
267
+ }))
268
+ });
269
+ }
270
+ } else if (range && (range.deleteCount > 0 || range.insertCount > 0)) {
271
+ ops.push({
272
+ type: 'spliceLines',
273
+ start: range.start,
274
+ deleteCount: range.deleteCount,
275
+ lines: next.snapshot.lines.slice(range.start, range.start + range.insertCount).map((line) => ({
276
+ wrapped: line.wrapped,
277
+ runs: line.runs.map((run) => ({
278
+ style: run.style,
279
+ text: run.text
280
+ }))
281
+ }))
282
+ });
283
+ }
284
+
285
+ if (metaChanged) {
286
+ ops.push({
287
+ type: 'meta',
288
+ cols: next.snapshot.cols,
289
+ rows: next.snapshot.rows,
290
+ tailStart: next.snapshot.tailStart,
291
+ cursorX: next.snapshot.cursorX,
292
+ cursorY: next.snapshot.cursorY,
293
+ viewportY: next.snapshot.viewportY,
294
+ baseY: next.snapshot.baseY,
295
+ totalLines: next.snapshot.totalLines
296
+ });
297
+ }
298
+
299
+ return {
300
+ sessionId: next.snapshot.sessionId,
301
+ baseRevision: previous.snapshot.revision,
302
+ revision: next.snapshot.revision,
303
+ ops
304
+ };
305
+ }
306
+
307
+ private createTailShiftPatch(
308
+ previous: MaterializedSnapshot,
309
+ next: MaterializedSnapshot,
310
+ shift: number
311
+ ): {
312
+ lines: TerminalFrameLine[];
313
+ range: { deleteCount: number; insertCount: number; start: number };
314
+ trimCount: number;
315
+ } | null {
316
+ if (shift <= 0 || shift > previous.lineKeys.length) {
317
+ return null;
318
+ }
319
+
320
+ const trimmedPrevious = previous.lineKeys.slice(shift);
321
+ const range = getChangedRange(trimmedPrevious, next.lineKeys);
322
+ const changedSpan = range.deleteCount + range.insertCount;
323
+ if (changedSpan > Math.max(8, Math.floor(next.lineKeys.length * 0.2))) {
324
+ return null;
325
+ }
326
+
327
+ return {
328
+ trimCount: shift,
329
+ range,
330
+ lines: next.snapshot.lines.slice(range.start, range.start + range.insertCount)
331
+ };
332
+ }
333
+
334
+ private materializeSnapshot(sessionId: string | null, revision: number): MaterializedSnapshot {
335
+ const buffer = this.terminal.buffer.active;
336
+ const reusableCell = buffer.getNullCell();
337
+ const lines: TerminalFrameLine[] = [];
338
+ const lineKeys: string[] = [];
339
+
340
+ const tailCount = Math.min(this.maxLines, buffer.length);
341
+ const tailStart = Math.max(0, buffer.length - tailCount);
342
+
343
+ for (let lineIndex = tailStart; lineIndex < buffer.length; lineIndex += 1) {
344
+ const line = buffer.getLine(lineIndex);
345
+ if (!line) {
346
+ continue;
347
+ }
348
+ const materializedLine = this.materializeLine(line, reusableCell);
349
+ lines.push(materializedLine);
350
+ lineKeys.push(buildLineKey(materializedLine));
351
+ }
352
+
353
+ return {
354
+ snapshot: {
355
+ sessionId,
356
+ revision,
357
+ cols: this.terminal.cols,
358
+ rows: this.terminal.rows,
359
+ tailStart,
360
+ cursorX: buffer.cursorX,
361
+ cursorY: buffer.cursorY,
362
+ viewportY: buffer.viewportY,
363
+ baseY: buffer.baseY,
364
+ totalLines: lines.length,
365
+ lines
366
+ },
367
+ lineKeys
368
+ };
369
+ }
370
+
371
+ private materializeLine(line: IBufferLine, reusableCell: IBufferCell): TerminalFrameLine {
372
+ const endColumn = this.resolveLineEndColumn(line, reusableCell);
373
+ if (endColumn === 0) {
374
+ return {
375
+ wrapped: line.isWrapped,
376
+ runs: []
377
+ };
378
+ }
379
+
380
+ const runs: TerminalFrameLine['runs'] = [];
381
+ let currentStyle = '';
382
+ let currentText = '';
383
+
384
+ for (let column = 0; column < endColumn; column += 1) {
385
+ const cell = line.getCell(column, reusableCell);
386
+ if (!cell || cell.getWidth() === 0) {
387
+ continue;
388
+ }
389
+
390
+ const style = this.encodeCellStyle(cell);
391
+ const chars = cell.getChars() || ' '.repeat(Math.max(1, cell.getWidth()));
392
+
393
+ if (style === currentStyle) {
394
+ currentText += chars;
395
+ continue;
396
+ }
397
+
398
+ if (currentText) {
399
+ runs.push({
400
+ style: currentStyle,
401
+ text: currentText
402
+ });
403
+ }
404
+
405
+ currentStyle = style;
406
+ currentText = chars;
407
+ }
408
+
409
+ if (currentText || runs.length === 0) {
410
+ runs.push({
411
+ style: currentStyle,
412
+ text: currentText
413
+ });
414
+ }
415
+
416
+ return {
417
+ wrapped: line.isWrapped,
418
+ runs
419
+ };
420
+ }
421
+
422
+ private resolveLineEndColumn(line: IBufferLine, reusableCell: IBufferCell): number {
423
+ for (let column = line.length - 1; column >= 0; column -= 1) {
424
+ const cell = line.getCell(column, reusableCell);
425
+ if (!cell || cell.getWidth() === 0) {
426
+ continue;
427
+ }
428
+ if (cell.getChars() || !cell.isAttributeDefault()) {
429
+ return column + Math.max(1, cell.getWidth());
430
+ }
431
+ }
432
+ return 0;
433
+ }
434
+
435
+ private encodeCellStyle(cell: IBufferCell): string {
436
+ const fgMode = cell.isFgRGB() ? 'rgb' : cell.isFgPalette() ? 'palette' : 'default';
437
+ const bgMode = cell.isBgRGB() ? 'rgb' : cell.isBgPalette() ? 'palette' : 'default';
438
+ const style: TerminalFrameStyle = {
439
+ flags:
440
+ (cell.isBold() ? STYLE_FLAG_BOLD : 0) |
441
+ (cell.isItalic() ? STYLE_FLAG_ITALIC : 0) |
442
+ (cell.isDim() ? STYLE_FLAG_DIM : 0) |
443
+ (cell.isUnderline() ? STYLE_FLAG_UNDERLINE : 0) |
444
+ (cell.isBlink() ? STYLE_FLAG_BLINK : 0) |
445
+ (cell.isInverse() ? STYLE_FLAG_INVERSE : 0) |
446
+ (cell.isInvisible() ? STYLE_FLAG_INVISIBLE : 0) |
447
+ (cell.isStrikethrough() ? STYLE_FLAG_STRIKETHROUGH : 0) |
448
+ (cell.isOverline() ? STYLE_FLAG_OVERLINE : 0),
449
+ fgMode,
450
+ fg: normalizeStyleColor(fgMode, cell.getFgColor()),
451
+ bgMode,
452
+ bg: normalizeStyleColor(bgMode, cell.getBgColor())
453
+ };
454
+
455
+ return encodeTerminalFrameStyle(style);
456
+ }
457
+ }
@@ -0,0 +1,164 @@
1
+ import path from 'node:path';
2
+
3
+ import type { ProjectSessionSummary } from '@lzdi/pty-remote-protocol/protocol.ts';
4
+ import { listProjectSessions } from './project-history.ts';
5
+
6
+ type ThreadsCommand = 'list' | 'ids';
7
+
8
+ interface ThreadsCliOptions {
9
+ command: ThreadsCommand;
10
+ cwd: string;
11
+ json: boolean;
12
+ limit: number;
13
+ }
14
+
15
+ function printUsage(): void {
16
+ console.log(`Usage:
17
+ node src/cli-main.ts threads [list|ids] [--cwd <path>] [--limit <n>] [--json]
18
+
19
+ Examples:
20
+ node src/cli-main.ts threads
21
+ node src/cli-main.ts threads list --limit 20
22
+ node src/cli-main.ts threads ids --cwd .
23
+ node src/cli-main.ts threads list --json`);
24
+ }
25
+
26
+ function truncate(value: string, maxChars: number): string {
27
+ if (value.length <= maxChars) {
28
+ return value;
29
+ }
30
+ return `${value.slice(0, Math.max(0, maxChars - 3))}...`;
31
+ }
32
+
33
+ function formatTimestamp(value: string): string {
34
+ const date = new Date(value);
35
+ if (Number.isNaN(date.getTime())) {
36
+ return value;
37
+ }
38
+
39
+ const year = String(date.getFullYear());
40
+ const month = String(date.getMonth() + 1).padStart(2, '0');
41
+ const day = String(date.getDate()).padStart(2, '0');
42
+ const hours = String(date.getHours()).padStart(2, '0');
43
+ const minutes = String(date.getMinutes()).padStart(2, '0');
44
+ const seconds = String(date.getSeconds()).padStart(2, '0');
45
+ return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
46
+ }
47
+
48
+ function printSessionTable(sessions: ProjectSessionSummary[]): void {
49
+ const rows = sessions.map((session) => ({
50
+ sessionId: session.sessionId,
51
+ messages: String(session.messageCount),
52
+ updatedAt: formatTimestamp(session.updatedAt),
53
+ preview: truncate(session.preview, 96)
54
+ }));
55
+
56
+ const sessionIdWidth = Math.max('sessionId'.length, ...rows.map((row) => row.sessionId.length));
57
+ const messagesWidth = Math.max('msgs'.length, ...rows.map((row) => row.messages.length));
58
+ const updatedAtWidth = Math.max('updatedAt'.length, ...rows.map((row) => row.updatedAt.length));
59
+
60
+ console.log(
61
+ `${'sessionId'.padEnd(sessionIdWidth)} ${'msgs'.padEnd(messagesWidth)} ${'updatedAt'.padEnd(updatedAtWidth)} preview`
62
+ );
63
+ console.log(
64
+ `${'-'.repeat(sessionIdWidth)} ${'-'.repeat(messagesWidth)} ${'-'.repeat(updatedAtWidth)} ${'-'.repeat(24)}`
65
+ );
66
+
67
+ for (const row of rows) {
68
+ console.log(
69
+ `${row.sessionId.padEnd(sessionIdWidth)} ${row.messages.padEnd(messagesWidth)} ${row.updatedAt.padEnd(updatedAtWidth)} ${row.preview}`
70
+ );
71
+ }
72
+ }
73
+
74
+ function parseThreadsArgs(argv: string[]): ThreadsCliOptions | null {
75
+ let command: ThreadsCommand = 'list';
76
+ let cwd = process.cwd();
77
+ let limit = 12;
78
+ let json = false;
79
+
80
+ const args = [...argv];
81
+ if (args[0] === 'list' || args[0] === 'ids') {
82
+ command = args.shift() as ThreadsCommand;
83
+ }
84
+
85
+ while (args.length > 0) {
86
+ const arg = args.shift();
87
+ if (!arg) {
88
+ continue;
89
+ }
90
+
91
+ if (arg === '--help' || arg === '-h') {
92
+ printUsage();
93
+ return null;
94
+ }
95
+
96
+ if (arg === '--json') {
97
+ json = true;
98
+ continue;
99
+ }
100
+
101
+ if (arg === '--cwd') {
102
+ const value = args.shift();
103
+ if (!value) {
104
+ throw new Error('Missing value for --cwd');
105
+ }
106
+ cwd = path.resolve(value);
107
+ continue;
108
+ }
109
+
110
+ if (arg === '--limit') {
111
+ const value = args.shift();
112
+ const parsed = Number.parseInt(value ?? '', 10);
113
+ if (!Number.isFinite(parsed) || parsed <= 0) {
114
+ throw new Error(`Invalid --limit value: ${value ?? '<empty>'}`);
115
+ }
116
+ limit = parsed;
117
+ continue;
118
+ }
119
+
120
+ throw new Error(`Unknown argument: ${arg}`);
121
+ }
122
+
123
+ return {
124
+ command,
125
+ cwd,
126
+ json,
127
+ limit
128
+ };
129
+ }
130
+
131
+ export async function runThreadsCli(argv: string[]): Promise<number> {
132
+ const options = parseThreadsArgs(argv);
133
+ if (!options) {
134
+ return 0;
135
+ }
136
+
137
+ const sessions = await listProjectSessions(options.cwd, options.limit);
138
+
139
+ if (options.command === 'ids') {
140
+ const ids = sessions.map((session) => session.sessionId);
141
+ if (options.json) {
142
+ console.log(JSON.stringify(ids, null, 2));
143
+ return 0;
144
+ }
145
+
146
+ for (const sessionId of ids) {
147
+ console.log(sessionId);
148
+ }
149
+ return 0;
150
+ }
151
+
152
+ if (options.json) {
153
+ console.log(JSON.stringify(sessions, null, 2));
154
+ return 0;
155
+ }
156
+
157
+ if (sessions.length === 0) {
158
+ console.log(`No Claude sessions found for ${options.cwd}`);
159
+ return 0;
160
+ }
161
+
162
+ printSessionTable(sessions);
163
+ return 0;
164
+ }