@ogulcancelik/pi-spar 0.1.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.
Files changed (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +58 -0
  3. package/core.ts +879 -0
  4. package/index.ts +760 -0
  5. package/package.json +41 -0
  6. package/peek.ts +683 -0
package/peek.ts ADDED
@@ -0,0 +1,683 @@
1
+ /**
2
+ * Spar Peek - Overlay component for viewing spar sessions
3
+ *
4
+ * Renders spar conversations using the same components as pi's interactive mode,
5
+ * so peek looks like "pi inside pi" — same message styling, same tool rendering,
6
+ * same everything.
7
+ */
8
+
9
+ import type { Theme } from "@mariozechner/pi-coding-agent";
10
+ import {
11
+ SessionManager,
12
+ getMarkdownTheme,
13
+ AssistantMessageComponent,
14
+ ToolExecutionComponent,
15
+ UserMessageComponent,
16
+ } from "@mariozechner/pi-coding-agent";
17
+ import { getModelAlias } from "./core.js";
18
+ import type { AssistantMessage } from "@mariozechner/pi-ai";
19
+ import {
20
+ Container,
21
+ matchesKey,
22
+ truncateToWidth,
23
+ visibleWidth,
24
+ type TUI,
25
+ } from "@mariozechner/pi-tui";
26
+ import * as fs from "fs";
27
+ import * as net from "net";
28
+ import * as os from "os";
29
+ import * as path from "path";
30
+
31
+ // =============================================================================
32
+ // Constants
33
+ // =============================================================================
34
+
35
+ const SESSION_DIR = path.join(os.homedir(), ".pi", "agent", "spar", "sessions");
36
+
37
+ function getSocketPath(sessionId: string): string {
38
+ return `/tmp/pi-spar-${sessionId}.sock`;
39
+ }
40
+
41
+ function getSessionFile(sessionId: string): string {
42
+ return path.join(SESSION_DIR, `${sessionId}.jsonl`);
43
+ }
44
+
45
+ // =============================================================================
46
+ // Session Helpers
47
+ // =============================================================================
48
+
49
+ export interface PeekableSession {
50
+ name: string;
51
+ active: boolean;
52
+ messageCount: number;
53
+ model: string;
54
+ lastActivity: number;
55
+ }
56
+
57
+ export function listPeekableSessions(): PeekableSession[] {
58
+ if (!fs.existsSync(SESSION_DIR)) return [];
59
+
60
+ const sessions: PeekableSession[] = [];
61
+
62
+ for (const f of fs.readdirSync(SESSION_DIR)) {
63
+ if (!f.endsWith(".jsonl")) continue;
64
+ const name = f.replace(".jsonl", "");
65
+ const active = fs.existsSync(getSocketPath(name));
66
+
67
+ let messageCount = 0;
68
+ let model = "";
69
+ let lastActivity = 0;
70
+
71
+ try {
72
+ const infoPath = path.join(SESSION_DIR, `${name}.info.json`);
73
+ if (fs.existsSync(infoPath)) {
74
+ const info = JSON.parse(fs.readFileSync(infoPath, "utf-8"));
75
+ messageCount = info.messageCount ?? 0;
76
+ lastActivity = info.lastActivity ?? info.createdAt ?? 0;
77
+ const fullModel = info.model || info.modelId || "";
78
+ model = getModelAlias(fullModel) || fullModel.split(":").pop()?.slice(0, 8) || "?";
79
+ }
80
+ } catch {}
81
+
82
+ sessions.push({ name, active, messageCount, model, lastActivity });
83
+ }
84
+
85
+ sessions.sort((a, b) => {
86
+ if (a.active !== b.active) return a.active ? -1 : 1;
87
+ return b.lastActivity - a.lastActivity;
88
+ });
89
+
90
+ return sessions;
91
+ }
92
+
93
+ export function formatAge(timestamp: number): string {
94
+ if (!timestamp) return "";
95
+ const seconds = Math.floor((Date.now() - timestamp) / 1000);
96
+ if (seconds < 60) return `${seconds}s`;
97
+ const minutes = Math.floor(seconds / 60);
98
+ if (minutes < 60) return `${minutes}m`;
99
+ const hours = Math.floor(minutes / 60);
100
+ if (hours < 24) return `${hours}h`;
101
+ const days = Math.floor(hours / 24);
102
+ return `${days}d`;
103
+ }
104
+
105
+ export function sessionExists(name: string): boolean {
106
+ return fs.existsSync(getSessionFile(name));
107
+ }
108
+
109
+ export function isSessionActive(name: string): boolean {
110
+ return fs.existsSync(getSocketPath(name));
111
+ }
112
+
113
+ export function findRecentSession(sessionManager: any): string | null {
114
+ try {
115
+ const entries = sessionManager.getEntries();
116
+ for (let i = entries.length - 1; i >= 0; i--) {
117
+ const entry = entries[i];
118
+ if (entry.type === "message") {
119
+ const msg = (entry as any).message;
120
+ if (msg?.role === "assistant" && Array.isArray(msg.content)) {
121
+ for (const c of msg.content) {
122
+ if (c.type === "toolCall" && c.name === "spar" && c.arguments?.session) {
123
+ return c.arguments.session;
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ } catch {}
130
+ return null;
131
+ }
132
+
133
+ export function findActiveSession(): string | null {
134
+ try {
135
+ const sockets = fs.readdirSync("/tmp").filter(f => f.startsWith("pi-spar-") && f.endsWith(".sock"));
136
+ if (sockets.length > 0) {
137
+ return sockets[0].replace("pi-spar-", "").replace(".sock", "");
138
+ }
139
+ } catch {}
140
+ return null;
141
+ }
142
+
143
+ // =============================================================================
144
+ // Peek Overlay — "pi inside pi"
145
+ //
146
+ // Uses the same UserMessageComponent, AssistantMessageComponent, and
147
+ // ToolExecutionComponent that pi's interactive mode uses, wrapped in a
148
+ // scrollable bordered overlay.
149
+ // =============================================================================
150
+
151
+ export class SparPeekOverlay {
152
+ private tui: TUI;
153
+ private theme: Theme;
154
+ private done: () => void;
155
+ private sessionId: string;
156
+ private sessionFile: string;
157
+ private modelName: string = "";
158
+
159
+ // Session state
160
+ private sm: SessionManager | null = null;
161
+ private lastFileSize: number = 0;
162
+
163
+ // UI state — the inner chat container holds the real pi components
164
+ private chatContainer: Container;
165
+ private scrollOffset = 0;
166
+ private followMode = true;
167
+
168
+ // Streaming state (from socket)
169
+ private socket: net.Socket | null = null;
170
+ private socketBuffer: string = "";
171
+ private status: "thinking" | "streaming" | "tool" | "done" = "done";
172
+ private toolName?: string;
173
+
174
+ // Streaming components — mirrors interactive-mode's approach
175
+ private streamingComponent: AssistantMessageComponent | null = null;
176
+ private streamingMessage: AssistantMessage | null = null;
177
+ private pendingTools = new Map<string, ToolExecutionComponent>();
178
+
179
+ // Polling
180
+ private pollInterval: ReturnType<typeof setInterval> | null = null;
181
+
182
+ // Render cache
183
+ private cachedLines: string[] | null = null;
184
+ private cachedWidth: number | null = null;
185
+
186
+ constructor(tui: TUI, theme: Theme, sessionId: string, done: () => void) {
187
+ this.tui = tui;
188
+ this.theme = theme;
189
+ this.sessionId = sessionId;
190
+ this.sessionFile = getSessionFile(sessionId);
191
+ this.done = done;
192
+
193
+ this.chatContainer = new Container();
194
+
195
+ this.loadModelName();
196
+ this.loadSession();
197
+ this.rebuildChat();
198
+ this.connectSocket();
199
+ this.pollInterval = setInterval(() => this.poll(), 200);
200
+ }
201
+
202
+ // =========================================================================
203
+ // Session loading
204
+ // =========================================================================
205
+
206
+ private loadModelName(): void {
207
+ try {
208
+ const infoPath = path.join(SESSION_DIR, `${this.sessionId}.info.json`);
209
+ if (fs.existsSync(infoPath)) {
210
+ const info = JSON.parse(fs.readFileSync(infoPath, "utf-8"));
211
+ const fullModel = info.model || info.modelId || "";
212
+ this.modelName = getModelAlias(fullModel) || fullModel.split(":").pop()?.slice(0, 12) || "";
213
+ }
214
+ } catch {}
215
+ }
216
+
217
+ private loadSession(): void {
218
+ try {
219
+ if (fs.existsSync(this.sessionFile)) {
220
+ this.sm = SessionManager.open(this.sessionFile);
221
+ const stats = fs.statSync(this.sessionFile);
222
+ this.lastFileSize = stats.size;
223
+ }
224
+ } catch {
225
+ this.sm = null;
226
+ }
227
+ }
228
+
229
+ // =========================================================================
230
+ // Chat rebuild — uses real pi components
231
+ // =========================================================================
232
+
233
+ private rebuildChat(): void {
234
+ this.cachedLines = null;
235
+ this.cachedWidth = null;
236
+ this.chatContainer.clear();
237
+ this.pendingTools.clear();
238
+
239
+ if (!this.sm) return;
240
+
241
+ const context = this.sm.buildSessionContext();
242
+
243
+ for (const message of context.messages) {
244
+ if (message.role === "user") {
245
+ const text = this.getUserText(message);
246
+ if (text) {
247
+ this.chatContainer.addChild(
248
+ new UserMessageComponent(text, getMarkdownTheme()),
249
+ );
250
+ }
251
+ } else if (message.role === "assistant") {
252
+ this.chatContainer.addChild(
253
+ new AssistantMessageComponent(message, false, getMarkdownTheme()),
254
+ );
255
+ // Render tool call components (same pattern as interactive-mode)
256
+ for (const content of message.content) {
257
+ if (content.type === "toolCall") {
258
+ const component = new ToolExecutionComponent(
259
+ content.name,
260
+ content.arguments,
261
+ {},
262
+ undefined, // no custom tool definitions for spar peers
263
+ this.tui,
264
+ );
265
+ this.chatContainer.addChild(component);
266
+
267
+ // Handle aborted/error assistant messages — mark tools as failed
268
+ // instead of leaving them pending (same as interactive-mode)
269
+ if (message.stopReason === "aborted" || message.stopReason === "error") {
270
+ const errorMessage = message.errorMessage || (
271
+ message.stopReason === "aborted" ? "Operation aborted" : "Error"
272
+ );
273
+ component.updateResult({
274
+ content: [{ type: "text", text: errorMessage }],
275
+ isError: true,
276
+ });
277
+ } else {
278
+ this.pendingTools.set(content.id, component);
279
+ }
280
+ }
281
+ }
282
+ } else if (message.role === "toolResult") {
283
+ const component = this.pendingTools.get(message.toolCallId);
284
+ if (component) {
285
+ component.updateResult(message);
286
+ this.pendingTools.delete(message.toolCallId);
287
+ }
288
+ }
289
+ }
290
+
291
+ this.pendingTools.clear();
292
+ }
293
+
294
+ private getUserText(message: any): string {
295
+ const content = message.content;
296
+ if (typeof content === "string") return content;
297
+ if (Array.isArray(content)) {
298
+ return content
299
+ .filter((c: any) => c.type === "text" && c.text)
300
+ .map((c: any) => c.text)
301
+ .join("\n");
302
+ }
303
+ return "";
304
+ }
305
+
306
+ // =========================================================================
307
+ // Socket connection — live streaming from active spar
308
+ // =========================================================================
309
+
310
+ private connectSocket(): void {
311
+ const socketPath = getSocketPath(this.sessionId);
312
+ if (!fs.existsSync(socketPath)) return;
313
+
314
+ try {
315
+ this.socket = net.connect(socketPath);
316
+ this.socketBuffer = "";
317
+ this.status = "thinking";
318
+
319
+ this.socket.on("error", () => {
320
+ this.socket = null;
321
+ });
322
+
323
+ this.socket.on("close", () => {
324
+ this.socket = null;
325
+ this.status = "done";
326
+ this.cleanupStreaming();
327
+ this.loadSession();
328
+ this.rebuildChat();
329
+ this.tui.requestRender();
330
+ });
331
+
332
+ this.socket.on("data", (data) => {
333
+ this.socketBuffer += data.toString();
334
+ const lines = this.socketBuffer.split("\n");
335
+ // Last element is either empty (if data ended with \n) or an incomplete line
336
+ this.socketBuffer = lines.pop() ?? "";
337
+ for (const line of lines) {
338
+ if (!line.trim()) continue;
339
+ try {
340
+ const event = JSON.parse(line);
341
+ this.handleEvent(event);
342
+ } catch {}
343
+ }
344
+ });
345
+ } catch {
346
+ this.socket = null;
347
+ }
348
+ }
349
+
350
+ private handleEvent(event: any): void {
351
+ if (event.type === "sync") {
352
+ // Sync event on connect — rebuild from file, then apply streaming state
353
+ this.loadSession();
354
+ this.rebuildChat();
355
+ this.status = event.status || "thinking";
356
+ this.toolName = event.toolName;
357
+
358
+ // If there's a partial message, use it directly (faithful reconstruction)
359
+ if (event.partialMessage) {
360
+ this.streamingMessage = event.partialMessage;
361
+ this.streamingComponent = new AssistantMessageComponent(
362
+ undefined, false, getMarkdownTheme(),
363
+ );
364
+ this.chatContainer.addChild(this.streamingComponent);
365
+ this.streamingComponent.updateContent(this.streamingMessage);
366
+ // Restore any tool components from the partial message
367
+ this.syncToolComponentsFromMessage();
368
+ } else if (event.thinking || event.text) {
369
+ // Fallback for older core.ts that doesn't send partialMessage
370
+ this.ensureStreamingComponent();
371
+ if (this.streamingMessage) {
372
+ if (event.thinking) {
373
+ this.streamingMessage.content.push({
374
+ type: "thinking", thinking: event.thinking,
375
+ } as any);
376
+ }
377
+ if (event.text) {
378
+ this.streamingMessage.content.push({
379
+ type: "text", text: event.text,
380
+ } as any);
381
+ }
382
+ this.streamingComponent?.updateContent(this.streamingMessage);
383
+ }
384
+ }
385
+ } else if (event.type === "message_start") {
386
+ // New assistant message — create streaming component (same as interactive-mode)
387
+ if (event.message?.role === "assistant") {
388
+ this.cleanupStreaming();
389
+ this.streamingMessage = event.message;
390
+ this.streamingComponent = new AssistantMessageComponent(
391
+ undefined, false, getMarkdownTheme(),
392
+ );
393
+ this.chatContainer.addChild(this.streamingComponent);
394
+ this.streamingComponent.updateContent(this.streamingMessage);
395
+ this.status = "thinking";
396
+ }
397
+ } else if (event.type === "message_update") {
398
+ if (event.message?.role === "assistant") {
399
+ // Use the full partial message from the event — same as interactive-mode
400
+ this.ensureStreamingComponent();
401
+ this.streamingMessage = event.message;
402
+ this.streamingComponent!.updateContent(this.streamingMessage);
403
+
404
+ // Update status from the delta type
405
+ const delta = event.assistantMessageEvent;
406
+ if (delta?.type === "thinking_delta") {
407
+ this.status = "thinking";
408
+ } else if (delta?.type === "text_delta") {
409
+ this.status = "streaming";
410
+ }
411
+
412
+ // Create/update tool components from the message content
413
+ // (same pattern as interactive-mode lines 2163-2179)
414
+ this.syncToolComponentsFromMessage();
415
+ }
416
+ } else if (event.type === "message_end") {
417
+ if (this.streamingComponent && this.streamingMessage) {
418
+ if (event.message?.role === "assistant") {
419
+ this.streamingMessage = event.message;
420
+ this.streamingComponent.updateContent(this.streamingMessage);
421
+
422
+ // Handle aborted/error — mark pending tools as failed
423
+ if (this.streamingMessage.stopReason === "aborted" || this.streamingMessage.stopReason === "error") {
424
+ const errorMessage = this.streamingMessage.errorMessage || "Error";
425
+ for (const [, component] of this.pendingTools) {
426
+ component.updateResult({
427
+ content: [{ type: "text", text: errorMessage }],
428
+ isError: true,
429
+ });
430
+ }
431
+ this.pendingTools.clear();
432
+ } else {
433
+ // Args are complete — trigger diff computation for edit tools
434
+ for (const [, component] of this.pendingTools) {
435
+ component.setArgsComplete();
436
+ }
437
+ }
438
+ }
439
+ this.streamingComponent = null;
440
+ this.streamingMessage = null;
441
+ }
442
+ } else if (event.type === "tool_execution_start") {
443
+ this.status = "tool";
444
+ this.toolName = event.toolName;
445
+ if (event.toolCallId && !this.pendingTools.has(event.toolCallId)) {
446
+ const component = new ToolExecutionComponent(
447
+ event.toolName, event.args, {}, undefined, this.tui,
448
+ );
449
+ this.chatContainer.addChild(component);
450
+ this.pendingTools.set(event.toolCallId, component);
451
+ }
452
+ } else if (event.type === "tool_execution_update") {
453
+ if (event.toolCallId) {
454
+ const component = this.pendingTools.get(event.toolCallId);
455
+ if (component && event.partialResult) {
456
+ component.updateResult({ ...event.partialResult, isError: false }, true);
457
+ }
458
+ }
459
+ } else if (event.type === "tool_execution_end") {
460
+ if (event.toolCallId) {
461
+ const component = this.pendingTools.get(event.toolCallId);
462
+ if (component) {
463
+ component.updateResult({ ...event.result, isError: event.isError ?? false });
464
+ this.pendingTools.delete(event.toolCallId);
465
+ }
466
+ }
467
+ } else if (event.type === "agent_end") {
468
+ this.cleanupStreaming();
469
+ this.loadSession();
470
+ this.rebuildChat();
471
+ this.status = "done";
472
+ }
473
+
474
+ this.invalidateCache();
475
+ if (this.followMode) this.scrollOffset = 999999;
476
+ this.tui.requestRender();
477
+ }
478
+
479
+ /**
480
+ * Walk streamingMessage.content and create/update ToolExecutionComponents
481
+ * for any tool calls. Same pattern as interactive-mode lines 2163-2179.
482
+ */
483
+ private syncToolComponentsFromMessage(): void {
484
+ if (!this.streamingMessage) return;
485
+ for (const content of this.streamingMessage.content) {
486
+ if (content.type === "toolCall") {
487
+ if (!this.pendingTools.has(content.id)) {
488
+ const component = new ToolExecutionComponent(
489
+ content.name, content.arguments, {}, undefined, this.tui,
490
+ );
491
+ this.chatContainer.addChild(component);
492
+ this.pendingTools.set(content.id, component);
493
+ } else {
494
+ const component = this.pendingTools.get(content.id)!;
495
+ component.updateArgs(content.arguments);
496
+ }
497
+ }
498
+ }
499
+ }
500
+
501
+ // =========================================================================
502
+ // Streaming helpers — mirrors interactive-mode's streaming approach
503
+ // =========================================================================
504
+
505
+ private ensureStreamingComponent(): void {
506
+ if (this.streamingComponent) return;
507
+
508
+ this.streamingMessage = {
509
+ role: "assistant",
510
+ content: [],
511
+ api: "" as any,
512
+ provider: "" as any,
513
+ model: "",
514
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
515
+ stopReason: "stop" as any,
516
+ timestamp: Date.now(),
517
+ };
518
+ this.streamingComponent = new AssistantMessageComponent(
519
+ undefined,
520
+ false,
521
+ getMarkdownTheme(),
522
+ );
523
+ this.chatContainer.addChild(this.streamingComponent);
524
+ this.streamingComponent.updateContent(this.streamingMessage);
525
+ }
526
+
527
+ private cleanupStreaming(): void {
528
+ if (this.streamingComponent) {
529
+ this.chatContainer.removeChild(this.streamingComponent);
530
+ this.streamingComponent = null;
531
+ this.streamingMessage = null;
532
+ }
533
+ this.pendingTools.clear();
534
+ }
535
+
536
+ // =========================================================================
537
+ // Polling
538
+ // =========================================================================
539
+
540
+ private poll(): void {
541
+ if (!this.socket && isSessionActive(this.sessionId)) {
542
+ this.connectSocket();
543
+ }
544
+
545
+ try {
546
+ const stats = fs.statSync(this.sessionFile);
547
+ if (stats.size !== this.lastFileSize) {
548
+ this.loadSession();
549
+ // Only rebuild if we're not actively streaming
550
+ if (!this.streamingComponent) {
551
+ this.rebuildChat();
552
+ }
553
+ this.invalidateCache();
554
+ if (this.followMode) this.scrollOffset = 999999;
555
+ this.tui.requestRender();
556
+ }
557
+ } catch {}
558
+ }
559
+
560
+ // =========================================================================
561
+ // Input handling
562
+ // =========================================================================
563
+
564
+ handleInput(data: string): void {
565
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c") || matchesKey(data, "q")) {
566
+ this.dispose();
567
+ this.done();
568
+ } else if (matchesKey(data, "up") || matchesKey(data, "k")) {
569
+ this.followMode = false;
570
+ this.scrollOffset = Math.max(0, this.scrollOffset - 1);
571
+ this.tui.requestRender();
572
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
573
+ this.scrollOffset++;
574
+ this.tui.requestRender();
575
+ } else if (matchesKey(data, "pageup") || matchesKey(data, "ctrl+u")) {
576
+ this.followMode = false;
577
+ this.scrollOffset = Math.max(0, this.scrollOffset - 15);
578
+ this.tui.requestRender();
579
+ } else if (matchesKey(data, "pagedown") || matchesKey(data, "ctrl+d")) {
580
+ this.scrollOffset += 15;
581
+ this.tui.requestRender();
582
+ } else if (data === "g") {
583
+ this.followMode = false;
584
+ this.scrollOffset = 0;
585
+ this.tui.requestRender();
586
+ } else if (data === "G") {
587
+ this.followMode = true;
588
+ this.scrollOffset = 999999;
589
+ this.tui.requestRender();
590
+ }
591
+ }
592
+
593
+ // =========================================================================
594
+ // Rendering — bordered chrome around the real pi chat container
595
+ // =========================================================================
596
+
597
+ private invalidateCache(): void {
598
+ this.cachedLines = null;
599
+ this.cachedWidth = null;
600
+ }
601
+
602
+ invalidate(): void {
603
+ this.chatContainer.invalidate();
604
+ this.invalidateCache();
605
+ }
606
+
607
+ render(width: number): string[] {
608
+ const th = this.theme;
609
+ const innerW = Math.max(20, width - 2);
610
+
611
+ // ── Header ──
612
+ const title = ` ${this.sessionId} `;
613
+ const modelTag = this.modelName ? `[${this.modelName}] ` : "";
614
+ const statusIcon = { thinking: "◐", streaming: "●", tool: "◑", done: "✓" }[this.status] || "○";
615
+ const statusColor = { thinking: "warning", streaming: "success", tool: "accent", done: "success" }[this.status] || "muted";
616
+ const statusText = ` ${statusIcon} ${this.status} `;
617
+ const headerContent = title + modelTag;
618
+ const headerPad = Math.max(0, innerW - visibleWidth(headerContent) - visibleWidth(statusText));
619
+
620
+ const lines: string[] = [];
621
+ lines.push(
622
+ th.fg("border", "╭") +
623
+ th.fg("accent", title) +
624
+ th.fg("dim", modelTag) +
625
+ th.fg("border", "─".repeat(headerPad)) +
626
+ th.fg(statusColor as any, statusText) +
627
+ th.fg("border", "╮"),
628
+ );
629
+
630
+ // ── Content — rendered by the real pi components ──
631
+ let contentLines: string[];
632
+ if (this.cachedLines && this.cachedWidth === innerW) {
633
+ contentLines = this.cachedLines;
634
+ } else {
635
+ contentLines = this.chatContainer.render(innerW);
636
+ this.cachedLines = contentLines;
637
+ this.cachedWidth = innerW;
638
+ }
639
+
640
+ // ── Scrolling ──
641
+ const termRows = this.tui.terminal.rows;
642
+ const maxHeight = Math.min(60, termRows - 4);
643
+ const chromeLines = 4; // header + separator + footer + bottom border
644
+ const maxVisible = Math.max(10, maxHeight - chromeLines);
645
+ const maxScroll = Math.max(0, contentLines.length - maxVisible);
646
+ this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
647
+
648
+ const visible = contentLines.slice(this.scrollOffset, this.scrollOffset + maxVisible);
649
+ for (const line of visible) {
650
+ const padded = line + " ".repeat(Math.max(0, innerW - visibleWidth(line)));
651
+ lines.push(th.fg("border", "│") + truncateToWidth(padded, innerW) + th.fg("border", "│"));
652
+ }
653
+
654
+ // ── Footer ──
655
+ const scrollInfo = contentLines.length > maxVisible
656
+ ? `${this.scrollOffset + 1}-${Math.min(this.scrollOffset + maxVisible, contentLines.length)}/${contentLines.length}`
657
+ : `${contentLines.length}L`;
658
+ const followIcon = this.followMode ? th.fg("success", "●") : th.fg("dim", "○");
659
+
660
+ lines.push(th.fg("border", "├" + "─".repeat(innerW) + "┤"));
661
+ const footer = ` ${scrollInfo} ${followIcon} │ j/k scroll │ g/G top/end │ q close `;
662
+ const footerPad = " ".repeat(Math.max(0, innerW - visibleWidth(footer)));
663
+ lines.push(th.fg("border", "│") + th.fg("dim", footer) + footerPad + th.fg("border", "│"));
664
+ lines.push(th.fg("border", "╰" + "─".repeat(innerW) + "╯"));
665
+
666
+ return lines;
667
+ }
668
+
669
+ // =========================================================================
670
+ // Cleanup
671
+ // =========================================================================
672
+
673
+ dispose(): void {
674
+ if (this.pollInterval) {
675
+ clearInterval(this.pollInterval);
676
+ this.pollInterval = null;
677
+ }
678
+ if (this.socket) {
679
+ this.socket.end();
680
+ this.socket = null;
681
+ }
682
+ }
683
+ }