pi-interactive-shell 0.3.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.
@@ -0,0 +1,1211 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
5
+ import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
6
+ import type { Theme } from "@mariozechner/pi-coding-agent";
7
+ import { PtyTerminalSession } from "./pty-session.js";
8
+ import { sessionManager, generateSessionId } from "./session-manager.js";
9
+ import type { InteractiveShellConfig } from "./config.js";
10
+
11
+ export interface InteractiveShellResult {
12
+ exitCode: number | null;
13
+ signal?: number;
14
+ backgrounded: boolean;
15
+ backgroundId?: string;
16
+ cancelled: boolean;
17
+ timedOut?: boolean;
18
+ sessionId?: string;
19
+ userTookOver?: boolean;
20
+ handoffPreview?: {
21
+ type: "tail";
22
+ when: "exit" | "detach" | "kill" | "timeout";
23
+ lines: string[];
24
+ };
25
+ handoff?: {
26
+ type: "snapshot";
27
+ when: "exit" | "detach" | "kill" | "timeout";
28
+ transcriptPath: string;
29
+ linesWritten: number;
30
+ };
31
+ }
32
+
33
+ export interface HandsFreeUpdate {
34
+ status: "running" | "user-takeover" | "exited";
35
+ sessionId: string;
36
+ runtime: number;
37
+ tail: string[];
38
+ tailTruncated: boolean;
39
+ userTookOver?: boolean;
40
+ // Budget tracking
41
+ totalCharsSent?: number;
42
+ budgetExhausted?: boolean;
43
+ }
44
+
45
+ export interface InteractiveShellOptions {
46
+ command: string;
47
+ cwd?: string;
48
+ name?: string;
49
+ reason?: string;
50
+ handoffPreviewEnabled?: boolean;
51
+ handoffPreviewLines?: number;
52
+ handoffPreviewMaxChars?: number;
53
+ handoffSnapshotEnabled?: boolean;
54
+ handoffSnapshotLines?: number;
55
+ handoffSnapshotMaxChars?: number;
56
+ // Hands-free mode
57
+ mode?: "interactive" | "hands-free";
58
+ sessionId?: string; // Pre-generated sessionId for hands-free mode
59
+ handsFreeUpdateMode?: "on-quiet" | "interval";
60
+ handsFreeUpdateInterval?: number;
61
+ handsFreeQuietThreshold?: number;
62
+ handsFreeUpdateMaxChars?: number;
63
+ handsFreeMaxTotalChars?: number;
64
+ onHandsFreeUpdate?: (update: HandsFreeUpdate) => void;
65
+ // Auto-kill timeout
66
+ timeout?: number;
67
+ }
68
+
69
+ type DialogChoice = "kill" | "background" | "cancel";
70
+ type OverlayState = "running" | "exited" | "detach-dialog" | "hands-free";
71
+
72
+ function formatDuration(ms: number): string {
73
+ const seconds = Math.floor(ms / 1000);
74
+ if (seconds < 60) return `${seconds}s`;
75
+ const minutes = Math.floor(seconds / 60);
76
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
77
+ const hours = Math.floor(minutes / 60);
78
+ return `${hours}h ${minutes % 60}m`;
79
+ }
80
+
81
+ const FOOTER_LINES = 5;
82
+ const HEADER_LINES = 4;
83
+ const CHROME_LINES = HEADER_LINES + FOOTER_LINES + 2;
84
+
85
+ export class InteractiveShellOverlay implements Component, Focusable {
86
+ focused = false;
87
+
88
+ private tui: TUI;
89
+ private theme: Theme;
90
+ private done: (result: InteractiveShellResult) => void;
91
+ private session: PtyTerminalSession;
92
+ private options: InteractiveShellOptions;
93
+ private config: InteractiveShellConfig;
94
+
95
+ private state: OverlayState = "running";
96
+ private dialogSelection: DialogChoice = "background";
97
+ private exitCountdown = 0;
98
+ private lastEscapeTime = 0;
99
+ private countdownInterval: ReturnType<typeof setInterval> | null = null;
100
+ private lastWidth = 0;
101
+ private lastHeight = 0;
102
+ // Hands-free mode
103
+ private userTookOver = false;
104
+ private handsFreeInterval: ReturnType<typeof setInterval> | null = null;
105
+ private handsFreeInitialTimeout: ReturnType<typeof setTimeout> | null = null;
106
+ private startTime = Date.now();
107
+ private sessionId: string | null = null;
108
+ private sessionUnregistered = false;
109
+ // Timeout
110
+ private timeoutTimer: ReturnType<typeof setTimeout> | null = null;
111
+ private timedOut = false;
112
+ // Prevent double done() calls
113
+ private finished = false;
114
+ // Budget tracking for hands-free updates
115
+ private totalCharsSent = 0;
116
+ private budgetExhausted = false;
117
+ private currentUpdateInterval: number;
118
+ private currentQuietThreshold: number;
119
+ private updateMode: "on-quiet" | "interval";
120
+ private lastDataTime = 0;
121
+ private quietTimer: ReturnType<typeof setTimeout> | null = null;
122
+ private hasUnsentData = false;
123
+
124
+ constructor(
125
+ tui: TUI,
126
+ theme: Theme,
127
+ options: InteractiveShellOptions,
128
+ config: InteractiveShellConfig,
129
+ done: (result: InteractiveShellResult) => void,
130
+ ) {
131
+ this.tui = tui;
132
+ this.theme = theme;
133
+ this.options = options;
134
+ this.config = config;
135
+ this.done = done;
136
+
137
+ const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
138
+ const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
139
+ const cols = Math.max(20, overlayWidth - 4);
140
+ const rows = Math.max(3, overlayHeight - CHROME_LINES);
141
+
142
+ this.session = new PtyTerminalSession(
143
+ {
144
+ command: options.command,
145
+ cwd: options.cwd,
146
+ cols,
147
+ rows,
148
+ scrollback: this.config.scrollbackLines,
149
+ ansiReemit: this.config.ansiReemit,
150
+ },
151
+ {
152
+ onData: () => {
153
+ if (!this.session.isScrolledUp()) {
154
+ this.session.scrollToBottom();
155
+ }
156
+ this.tui.requestRender();
157
+
158
+ // Track activity for on-quiet mode
159
+ if (this.state === "hands-free" && this.updateMode === "on-quiet") {
160
+ this.lastDataTime = Date.now();
161
+ this.hasUnsentData = true;
162
+ this.resetQuietTimer();
163
+ }
164
+ },
165
+ onExit: () => {
166
+ // Guard: if already finished (e.g., timeout fired), don't process exit
167
+ if (this.finished) return;
168
+
169
+ // Stop timeout to prevent double done() call
170
+ this.stopTimeout();
171
+
172
+ // Send final update with any unsent data, then "exited" notification
173
+ if (this.state === "hands-free" && this.options.onHandsFreeUpdate && this.sessionId) {
174
+ // Flush any pending output before sending exited notification
175
+ if (this.hasUnsentData || this.updateMode === "interval") {
176
+ this.emitHandsFreeUpdate();
177
+ this.hasUnsentData = false;
178
+ }
179
+ // Now send exited notification
180
+ this.options.onHandsFreeUpdate({
181
+ status: "exited",
182
+ sessionId: this.sessionId,
183
+ runtime: Date.now() - this.startTime,
184
+ tail: [],
185
+ tailTruncated: false,
186
+ totalCharsSent: this.totalCharsSent,
187
+ budgetExhausted: this.budgetExhausted,
188
+ });
189
+ this.unregisterActiveSession();
190
+ }
191
+ this.stopHandsFreeUpdates();
192
+ this.state = "exited";
193
+ this.exitCountdown = this.config.exitAutoCloseDelay;
194
+ this.startExitCountdown();
195
+ this.tui.requestRender();
196
+ },
197
+ },
198
+ );
199
+
200
+ // Initialize hands-free mode settings
201
+ this.updateMode = options.handsFreeUpdateMode ?? config.handsFreeUpdateMode;
202
+ this.currentUpdateInterval = options.handsFreeUpdateInterval ?? config.handsFreeUpdateInterval;
203
+ this.currentQuietThreshold = options.handsFreeQuietThreshold ?? config.handsFreeQuietThreshold;
204
+
205
+ // Initialize hands-free mode if requested
206
+ if (options.mode === "hands-free") {
207
+ this.state = "hands-free";
208
+ // Use provided sessionId or generate one
209
+ this.sessionId = options.sessionId ?? generateSessionId(options.name);
210
+ sessionManager.registerActive(
211
+ this.sessionId,
212
+ options.command,
213
+ (data) => this.session.write(data),
214
+ (intervalMs) => this.setUpdateInterval(intervalMs),
215
+ (thresholdMs) => this.setQuietThreshold(thresholdMs),
216
+ );
217
+ this.startHandsFreeUpdates();
218
+ }
219
+
220
+ // Start auto-kill timeout if specified
221
+ if (options.timeout && options.timeout > 0) {
222
+ this.timeoutTimer = setTimeout(() => {
223
+ this.finishWithTimeout();
224
+ }, options.timeout);
225
+ }
226
+ }
227
+
228
+ private startExitCountdown(): void {
229
+ this.stopCountdown();
230
+ this.countdownInterval = setInterval(() => {
231
+ this.exitCountdown--;
232
+ if (this.exitCountdown <= 0) {
233
+ this.finishWithExit();
234
+ } else {
235
+ this.tui.requestRender();
236
+ }
237
+ }, 1000);
238
+ }
239
+
240
+ private stopCountdown(): void {
241
+ if (this.countdownInterval) {
242
+ clearInterval(this.countdownInterval);
243
+ this.countdownInterval = null;
244
+ }
245
+ }
246
+
247
+ private startHandsFreeUpdates(): void {
248
+ // Send initial update after a short delay (let process start)
249
+ this.handsFreeInitialTimeout = setTimeout(() => {
250
+ this.handsFreeInitialTimeout = null;
251
+ if (this.state === "hands-free") {
252
+ this.emitHandsFreeUpdate();
253
+ }
254
+ }, 2000);
255
+
256
+ // Fallback interval (always runs, ensures updates even during continuous output)
257
+ this.handsFreeInterval = setInterval(() => {
258
+ if (this.state === "hands-free") {
259
+ // In on-quiet mode, only emit if we have unsent data (interval is fallback)
260
+ if (this.updateMode === "on-quiet") {
261
+ if (this.hasUnsentData) {
262
+ this.emitHandsFreeUpdate();
263
+ this.hasUnsentData = false;
264
+ this.stopQuietTimer(); // Reset quiet timer since we just sent
265
+ }
266
+ } else {
267
+ // In interval mode, always emit
268
+ this.emitHandsFreeUpdate();
269
+ }
270
+ }
271
+ }, this.currentUpdateInterval);
272
+ }
273
+
274
+ /** Reset the quiet timer - called on each data event in on-quiet mode */
275
+ private resetQuietTimer(): void {
276
+ this.stopQuietTimer();
277
+ this.quietTimer = setTimeout(() => {
278
+ this.quietTimer = null;
279
+ if (this.state === "hands-free" && this.hasUnsentData) {
280
+ this.emitHandsFreeUpdate();
281
+ this.hasUnsentData = false;
282
+ }
283
+ }, this.currentQuietThreshold);
284
+ }
285
+
286
+ private stopQuietTimer(): void {
287
+ if (this.quietTimer) {
288
+ clearTimeout(this.quietTimer);
289
+ this.quietTimer = null;
290
+ }
291
+ }
292
+
293
+ /** Update the hands-free update interval dynamically */
294
+ setUpdateInterval(intervalMs: number): void {
295
+ const clamped = Math.max(5000, Math.min(300000, intervalMs));
296
+ if (clamped === this.currentUpdateInterval) return;
297
+ this.currentUpdateInterval = clamped;
298
+
299
+ // Restart the interval with new timing
300
+ if (this.handsFreeInterval) {
301
+ clearInterval(this.handsFreeInterval);
302
+ this.handsFreeInterval = setInterval(() => {
303
+ if (this.state === "hands-free") {
304
+ if (this.updateMode === "on-quiet") {
305
+ if (this.hasUnsentData) {
306
+ this.emitHandsFreeUpdate();
307
+ this.hasUnsentData = false;
308
+ this.stopQuietTimer();
309
+ }
310
+ } else {
311
+ this.emitHandsFreeUpdate();
312
+ }
313
+ }
314
+ }, this.currentUpdateInterval);
315
+ }
316
+ }
317
+
318
+ /** Update the quiet threshold dynamically */
319
+ setQuietThreshold(thresholdMs: number): void {
320
+ const clamped = Math.max(1000, Math.min(30000, thresholdMs));
321
+ if (clamped === this.currentQuietThreshold) return;
322
+ this.currentQuietThreshold = clamped;
323
+ }
324
+
325
+ private stopHandsFreeUpdates(): void {
326
+ if (this.handsFreeInitialTimeout) {
327
+ clearTimeout(this.handsFreeInitialTimeout);
328
+ this.handsFreeInitialTimeout = null;
329
+ }
330
+ if (this.handsFreeInterval) {
331
+ clearInterval(this.handsFreeInterval);
332
+ this.handsFreeInterval = null;
333
+ }
334
+ this.stopQuietTimer();
335
+ }
336
+
337
+ private stopTimeout(): void {
338
+ if (this.timeoutTimer) {
339
+ clearTimeout(this.timeoutTimer);
340
+ this.timeoutTimer = null;
341
+ }
342
+ }
343
+
344
+ private unregisterActiveSession(): void {
345
+ if (this.sessionId && !this.sessionUnregistered) {
346
+ sessionManager.unregisterActive(this.sessionId);
347
+ this.sessionUnregistered = true;
348
+ }
349
+ }
350
+
351
+ private emitHandsFreeUpdate(): void {
352
+ if (!this.options.onHandsFreeUpdate || !this.sessionId) return;
353
+
354
+ const maxChars = this.options.handsFreeUpdateMaxChars ?? this.config.handsFreeUpdateMaxChars;
355
+ const maxTotalChars = this.options.handsFreeMaxTotalChars ?? this.config.handsFreeMaxTotalChars;
356
+
357
+ let tail: string[] = [];
358
+ let truncated = false;
359
+
360
+ // Only include content if budget not exhausted
361
+ if (!this.budgetExhausted) {
362
+ // Get incremental output since last update
363
+ let newOutput = this.session.getRawStream({ sinceLast: true, stripAnsi: true });
364
+
365
+ // Truncate if exceeds per-update limit
366
+ if (newOutput.length > maxChars) {
367
+ newOutput = newOutput.slice(-maxChars);
368
+ truncated = true;
369
+ }
370
+
371
+ // Check total budget
372
+ if (this.totalCharsSent + newOutput.length > maxTotalChars) {
373
+ // Truncate to fit remaining budget
374
+ const remaining = maxTotalChars - this.totalCharsSent;
375
+ if (remaining > 0) {
376
+ newOutput = newOutput.slice(-remaining);
377
+ truncated = true;
378
+ } else {
379
+ newOutput = "";
380
+ }
381
+ this.budgetExhausted = true;
382
+ }
383
+
384
+ if (newOutput.length > 0) {
385
+ this.totalCharsSent += newOutput.length;
386
+ // Split into lines for the tail array
387
+ tail = newOutput.split("\n");
388
+ }
389
+ }
390
+
391
+ this.options.onHandsFreeUpdate({
392
+ status: "running",
393
+ sessionId: this.sessionId,
394
+ runtime: Date.now() - this.startTime,
395
+ tail,
396
+ tailTruncated: truncated,
397
+ totalCharsSent: this.totalCharsSent,
398
+ budgetExhausted: this.budgetExhausted,
399
+ });
400
+ }
401
+
402
+ private triggerUserTakeover(): void {
403
+ if (this.state !== "hands-free" || !this.sessionId) return;
404
+
405
+ // Flush any pending output before stopping updates
406
+ // In interval mode, hasUnsentData is not tracked, so always flush
407
+ if (this.hasUnsentData || this.updateMode === "interval") {
408
+ this.emitHandsFreeUpdate();
409
+ this.hasUnsentData = false;
410
+ }
411
+
412
+ this.stopHandsFreeUpdates();
413
+ // Unregister from active sessions since user took over
414
+ this.unregisterActiveSession();
415
+ this.state = "running";
416
+ this.userTookOver = true;
417
+
418
+ // Notify agent that user took over
419
+ this.options.onHandsFreeUpdate?.({
420
+ status: "user-takeover",
421
+ sessionId: this.sessionId,
422
+ runtime: Date.now() - this.startTime,
423
+ tail: [],
424
+ tailTruncated: false,
425
+ userTookOver: true,
426
+ totalCharsSent: this.totalCharsSent,
427
+ budgetExhausted: this.budgetExhausted,
428
+ });
429
+
430
+ this.tui.requestRender();
431
+ }
432
+
433
+ private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "timeout"): InteractiveShellResult["handoffPreview"] | undefined {
434
+ const enabled = this.options.handoffPreviewEnabled ?? this.config.handoffPreviewEnabled;
435
+ if (!enabled) return undefined;
436
+
437
+ const lines = this.options.handoffPreviewLines ?? this.config.handoffPreviewLines;
438
+ const maxChars = this.options.handoffPreviewMaxChars ?? this.config.handoffPreviewMaxChars;
439
+ if (lines <= 0 || maxChars <= 0) return undefined;
440
+
441
+ const tail = this.session.getTailLines({
442
+ lines,
443
+ ansi: false,
444
+ maxChars,
445
+ });
446
+
447
+ return { type: "tail", when, lines: tail };
448
+ }
449
+
450
+ private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "timeout"): InteractiveShellResult["handoff"] | undefined {
451
+ const enabled = this.options.handoffSnapshotEnabled ?? this.config.handoffSnapshotEnabled;
452
+ if (!enabled) return undefined;
453
+
454
+ const lines = this.options.handoffSnapshotLines ?? this.config.handoffSnapshotLines;
455
+ const maxChars = this.options.handoffSnapshotMaxChars ?? this.config.handoffSnapshotMaxChars;
456
+ if (lines <= 0 || maxChars <= 0) return undefined;
457
+
458
+ const baseDir = join(homedir(), ".pi", "agent", "cache", "interactive-shell");
459
+ mkdirSync(baseDir, { recursive: true });
460
+
461
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
462
+ const pid = this.session.pid;
463
+ const filename = `snapshot-${timestamp}-pid${pid}.log`;
464
+ const transcriptPath = join(baseDir, filename);
465
+
466
+ const tail = this.session.getTailLines({
467
+ lines,
468
+ ansi: this.config.ansiReemit,
469
+ maxChars,
470
+ });
471
+
472
+ const header = [
473
+ `# interactive-shell snapshot (${when})`,
474
+ `time: ${new Date().toISOString()}`,
475
+ `command: ${this.options.command}`,
476
+ `cwd: ${this.options.cwd ?? ""}`,
477
+ `pid: ${pid}`,
478
+ `exitCode: ${this.session.exitCode ?? ""}`,
479
+ `signal: ${this.session.signal ?? ""}`,
480
+ `lines: ${tail.length} (requested ${lines}, maxChars ${maxChars})`,
481
+ "",
482
+ ].join("\n");
483
+
484
+ writeFileSync(transcriptPath, header + tail.join("\n") + "\n", { encoding: "utf-8" });
485
+
486
+ return { type: "snapshot", when, transcriptPath, linesWritten: tail.length };
487
+ }
488
+
489
+ private finishWithExit(): void {
490
+ if (this.finished) return;
491
+ this.finished = true;
492
+ this.stopCountdown();
493
+ this.stopTimeout();
494
+ this.stopHandsFreeUpdates();
495
+ this.unregisterActiveSession();
496
+ const handoffPreview = this.maybeBuildHandoffPreview("exit");
497
+ const handoff = this.maybeWriteHandoffSnapshot("exit");
498
+ this.session.dispose();
499
+ this.done({
500
+ exitCode: this.session.exitCode,
501
+ signal: this.session.signal,
502
+ backgrounded: false,
503
+ cancelled: false,
504
+ sessionId: this.sessionId ?? undefined,
505
+ userTookOver: this.userTookOver,
506
+ handoffPreview,
507
+ handoff,
508
+ });
509
+ }
510
+
511
+ private finishWithBackground(): void {
512
+ if (this.finished) return;
513
+ this.finished = true;
514
+ this.stopCountdown();
515
+ this.stopTimeout();
516
+ this.stopHandsFreeUpdates();
517
+ this.unregisterActiveSession();
518
+ const handoffPreview = this.maybeBuildHandoffPreview("detach");
519
+ const handoff = this.maybeWriteHandoffSnapshot("detach");
520
+ const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason);
521
+ this.done({
522
+ exitCode: null,
523
+ backgrounded: true,
524
+ backgroundId: id,
525
+ cancelled: false,
526
+ sessionId: this.sessionId ?? undefined,
527
+ userTookOver: this.userTookOver,
528
+ handoffPreview,
529
+ handoff,
530
+ });
531
+ }
532
+
533
+ private finishWithKill(): void {
534
+ if (this.finished) return;
535
+ this.finished = true;
536
+ this.stopCountdown();
537
+ this.stopTimeout();
538
+ this.stopHandsFreeUpdates();
539
+ this.unregisterActiveSession();
540
+ const handoffPreview = this.maybeBuildHandoffPreview("kill");
541
+ const handoff = this.maybeWriteHandoffSnapshot("kill");
542
+ this.session.kill();
543
+ this.session.dispose();
544
+ this.done({
545
+ exitCode: null,
546
+ backgrounded: false,
547
+ cancelled: true,
548
+ sessionId: this.sessionId ?? undefined,
549
+ userTookOver: this.userTookOver,
550
+ handoffPreview,
551
+ handoff,
552
+ });
553
+ }
554
+
555
+ private finishWithTimeout(): void {
556
+ if (this.finished) return;
557
+ this.finished = true;
558
+ this.stopCountdown();
559
+ this.stopTimeout();
560
+
561
+ // Send final update with any unsent data, then "exited" notification (for timeout)
562
+ if (this.state === "hands-free" && this.options.onHandsFreeUpdate && this.sessionId) {
563
+ // Flush any pending output before sending exited notification
564
+ if (this.hasUnsentData || this.updateMode === "interval") {
565
+ this.emitHandsFreeUpdate();
566
+ this.hasUnsentData = false;
567
+ }
568
+ // Now send exited notification (timedOut is indicated in final tool result)
569
+ this.options.onHandsFreeUpdate({
570
+ status: "exited",
571
+ sessionId: this.sessionId,
572
+ runtime: Date.now() - this.startTime,
573
+ tail: [],
574
+ tailTruncated: false,
575
+ totalCharsSent: this.totalCharsSent,
576
+ budgetExhausted: this.budgetExhausted,
577
+ });
578
+ }
579
+
580
+ this.stopHandsFreeUpdates();
581
+ this.unregisterActiveSession();
582
+ this.timedOut = true;
583
+ const handoffPreview = this.maybeBuildHandoffPreview("timeout");
584
+ const handoff = this.maybeWriteHandoffSnapshot("timeout");
585
+ this.session.kill();
586
+ this.session.dispose();
587
+ this.done({
588
+ exitCode: null,
589
+ backgrounded: false,
590
+ cancelled: false,
591
+ timedOut: true,
592
+ sessionId: this.sessionId ?? undefined,
593
+ userTookOver: this.userTookOver,
594
+ handoffPreview,
595
+ handoff,
596
+ });
597
+ }
598
+
599
+ private handleDoubleEscape(): boolean {
600
+ const now = Date.now();
601
+ if (now - this.lastEscapeTime < this.config.doubleEscapeThreshold) {
602
+ this.lastEscapeTime = 0;
603
+ return true;
604
+ }
605
+ this.lastEscapeTime = now;
606
+ return false;
607
+ }
608
+
609
+ handleInput(data: string): void {
610
+ if (this.state === "detach-dialog") {
611
+ this.handleDialogInput(data);
612
+ return;
613
+ }
614
+
615
+ if (this.state === "exited") {
616
+ if (data.length > 0) {
617
+ this.finishWithExit();
618
+ }
619
+ return;
620
+ }
621
+
622
+ // Double-escape detection (works in both hands-free and running)
623
+ if (matchesKey(data, "escape")) {
624
+ if (this.handleDoubleEscape()) {
625
+ // If in hands-free mode, trigger takeover first (notifies agent)
626
+ if (this.state === "hands-free") {
627
+ this.triggerUserTakeover();
628
+ }
629
+ this.state = "detach-dialog";
630
+ this.dialogSelection = "background";
631
+ this.tui.requestRender();
632
+ return;
633
+ }
634
+ // Single escape goes to subprocess (no takeover)
635
+ this.session.write("\u001b");
636
+ return;
637
+ }
638
+
639
+ // Scroll does NOT trigger takeover
640
+ if (matchesKey(data, "shift+up")) {
641
+ this.session.scrollUp(Math.max(1, this.session.rows - 2));
642
+ this.tui.requestRender();
643
+ return;
644
+ }
645
+ if (matchesKey(data, "shift+down")) {
646
+ this.session.scrollDown(Math.max(1, this.session.rows - 2));
647
+ this.tui.requestRender();
648
+ return;
649
+ }
650
+
651
+ // Any other input in hands-free mode triggers user takeover
652
+ if (this.state === "hands-free") {
653
+ this.triggerUserTakeover();
654
+ // Fall through to send the input to subprocess
655
+ }
656
+
657
+ this.session.write(data);
658
+ }
659
+
660
+ private handleDialogInput(data: string): void {
661
+ if (matchesKey(data, "escape")) {
662
+ this.state = "running";
663
+ this.tui.requestRender();
664
+ return;
665
+ }
666
+
667
+ if (matchesKey(data, "up") || matchesKey(data, "down")) {
668
+ const options: DialogChoice[] = ["kill", "background", "cancel"];
669
+ const currentIdx = options.indexOf(this.dialogSelection);
670
+ const direction = matchesKey(data, "up") ? -1 : 1;
671
+ const newIdx = (currentIdx + direction + options.length) % options.length;
672
+ this.dialogSelection = options[newIdx]!;
673
+ this.tui.requestRender();
674
+ return;
675
+ }
676
+
677
+ if (matchesKey(data, "enter")) {
678
+ switch (this.dialogSelection) {
679
+ case "kill":
680
+ this.finishWithKill();
681
+ break;
682
+ case "background":
683
+ this.finishWithBackground();
684
+ break;
685
+ case "cancel":
686
+ this.state = "running";
687
+ this.tui.requestRender();
688
+ break;
689
+ }
690
+ }
691
+ }
692
+
693
+ render(width: number): string[] {
694
+ const th = this.theme;
695
+ const border = (s: string) => th.fg("border", s);
696
+ const accent = (s: string) => th.fg("accent", s);
697
+ const dim = (s: string) => th.fg("dim", s);
698
+ const warning = (s: string) => th.fg("warning", s);
699
+
700
+ const innerWidth = width - 4;
701
+ const pad = (s: string, w: number) => {
702
+ const vis = visibleWidth(s);
703
+ return s + " ".repeat(Math.max(0, w - vis));
704
+ };
705
+ const row = (content: string) => border("│ ") + pad(content, innerWidth) + border(" │");
706
+ const emptyRow = () => row("");
707
+
708
+ const lines: string[] = [];
709
+
710
+ const title = truncateToWidth(this.options.command, innerWidth - 20, "...");
711
+ const pid = `PID: ${this.session.pid}`;
712
+ lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
713
+ lines.push(
714
+ row(
715
+ accent(title) +
716
+ " ".repeat(Math.max(1, innerWidth - visibleWidth(title) - pid.length)) +
717
+ dim(pid),
718
+ ),
719
+ );
720
+ let hint: string;
721
+ if (this.state === "hands-free") {
722
+ const elapsed = formatDuration(Date.now() - this.startTime);
723
+ hint = `🤖 Hands-free (${elapsed}) • Type anything to take over`;
724
+ } else if (this.userTookOver) {
725
+ hint = this.options.reason
726
+ ? `You took over • ${this.options.reason} • Double-Escape to detach`
727
+ : "You took over • Double-Escape to detach";
728
+ } else {
729
+ hint = this.options.reason
730
+ ? `Double-Escape to detach • ${this.options.reason}`
731
+ : "Double-Escape to detach";
732
+ }
733
+ lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
734
+ lines.push(border("├" + "─".repeat(width - 2) + "┤"));
735
+
736
+ const overlayHeight = Math.floor((this.tui.terminal.rows * this.config.overlayHeightPercent) / 100);
737
+ const termRows = Math.max(3, overlayHeight - CHROME_LINES);
738
+
739
+ if (innerWidth !== this.lastWidth || termRows !== this.lastHeight) {
740
+ this.session.resize(innerWidth, termRows);
741
+ this.lastWidth = innerWidth;
742
+ this.lastHeight = termRows;
743
+ }
744
+
745
+ const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
746
+ for (const line of viewportLines) {
747
+ lines.push(row(truncateToWidth(line, innerWidth, "")));
748
+ }
749
+
750
+ if (this.session.isScrolledUp()) {
751
+ const hintText = "── ↑ scrolled (Shift+Down) ──";
752
+ const padLen = Math.max(0, Math.floor((width - 2 - visibleWidth(hintText)) / 2));
753
+ lines.push(
754
+ border("├") +
755
+ dim(
756
+ " ".repeat(padLen) +
757
+ hintText +
758
+ " ".repeat(width - 2 - padLen - visibleWidth(hintText)),
759
+ ) +
760
+ border("┤"),
761
+ );
762
+ } else {
763
+ lines.push(border("├" + "─".repeat(width - 2) + "┤"));
764
+ }
765
+
766
+ const footerLines: string[] = [];
767
+
768
+ if (this.state === "detach-dialog") {
769
+ footerLines.push(row(accent("Detach from session:")));
770
+ const opts: Array<{ key: DialogChoice; label: string }> = [
771
+ { key: "kill", label: "Kill process" },
772
+ { key: "background", label: "Run in background" },
773
+ { key: "cancel", label: "Cancel (return to session)" },
774
+ ];
775
+ for (const opt of opts) {
776
+ const sel = this.dialogSelection === opt.key;
777
+ footerLines.push(row((sel ? accent("▶ ") : " ") + (sel ? accent(opt.label) : opt.label)));
778
+ }
779
+ footerLines.push(row(dim("↑↓ select • Enter confirm • Esc cancel")));
780
+ } else if (this.state === "exited") {
781
+ const exitMsg =
782
+ this.session.exitCode === 0
783
+ ? th.fg("success", "✓ Exited successfully")
784
+ : warning(`✗ Exited with code ${this.session.exitCode}`);
785
+ footerLines.push(row(exitMsg));
786
+ footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close)`)));
787
+ } else if (this.state === "hands-free") {
788
+ footerLines.push(row(dim("🤖 Agent controlling • Type to take over • Shift+Up/Down scroll")));
789
+ } else {
790
+ footerLines.push(row(dim("Shift+Up/Down scroll • Double-Esc detach • Ctrl+C interrupt")));
791
+ }
792
+
793
+ while (footerLines.length < FOOTER_LINES) {
794
+ footerLines.push(emptyRow());
795
+ }
796
+ lines.push(...footerLines);
797
+
798
+ lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
799
+
800
+ return lines;
801
+ }
802
+
803
+ invalidate(): void {
804
+ this.lastWidth = 0;
805
+ this.lastHeight = 0;
806
+ }
807
+
808
+ dispose(): void {
809
+ this.stopCountdown();
810
+ this.stopTimeout();
811
+ this.stopHandsFreeUpdates();
812
+ // Safety cleanup in case dispose() is called without going through finishWith*
813
+ this.unregisterActiveSession();
814
+ }
815
+ }
816
+
817
+ export class ReattachOverlay implements Component, Focusable {
818
+ focused = false;
819
+
820
+ private tui: TUI;
821
+ private theme: Theme;
822
+ private done: (result: InteractiveShellResult) => void;
823
+ private bgSession: { id: string; command: string; reason?: string; session: PtyTerminalSession };
824
+ private config: InteractiveShellConfig;
825
+
826
+ private state: OverlayState = "running";
827
+ private dialogSelection: DialogChoice = "background";
828
+ private exitCountdown = 0;
829
+ private lastEscapeTime = 0;
830
+ private countdownInterval: ReturnType<typeof setInterval> | null = null;
831
+ private initialExitTimeout: ReturnType<typeof setTimeout> | null = null;
832
+ private lastWidth = 0;
833
+ private lastHeight = 0;
834
+ private finished = false;
835
+
836
+ constructor(
837
+ tui: TUI,
838
+ theme: Theme,
839
+ bgSession: { id: string; command: string; reason?: string; session: PtyTerminalSession },
840
+ config: InteractiveShellConfig,
841
+ done: (result: InteractiveShellResult) => void,
842
+ ) {
843
+ this.tui = tui;
844
+ this.theme = theme;
845
+ this.bgSession = bgSession;
846
+ this.config = config;
847
+ this.done = done;
848
+
849
+ bgSession.session.setEventHandlers({
850
+ onData: () => {
851
+ if (!bgSession.session.isScrolledUp()) {
852
+ bgSession.session.scrollToBottom();
853
+ }
854
+ this.tui.requestRender();
855
+ },
856
+ onExit: () => {
857
+ if (this.finished) return;
858
+ this.state = "exited";
859
+ this.exitCountdown = this.config.exitAutoCloseDelay;
860
+ this.startExitCountdown();
861
+ this.tui.requestRender();
862
+ },
863
+ });
864
+
865
+ if (bgSession.session.exited) {
866
+ this.state = "exited";
867
+ this.exitCountdown = this.config.exitAutoCloseDelay;
868
+ this.initialExitTimeout = setTimeout(() => {
869
+ this.initialExitTimeout = null;
870
+ this.startExitCountdown();
871
+ }, 0);
872
+ }
873
+
874
+ const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
875
+ const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
876
+ const cols = Math.max(20, overlayWidth - 4);
877
+ const rows = Math.max(3, overlayHeight - CHROME_LINES);
878
+ bgSession.session.resize(cols, rows);
879
+ }
880
+
881
+ private get session(): PtyTerminalSession {
882
+ return this.bgSession.session;
883
+ }
884
+
885
+ private startExitCountdown(): void {
886
+ this.stopCountdown();
887
+ this.countdownInterval = setInterval(() => {
888
+ this.exitCountdown--;
889
+ if (this.exitCountdown <= 0) {
890
+ this.finishAndClose();
891
+ } else {
892
+ this.tui.requestRender();
893
+ }
894
+ }, 1000);
895
+ }
896
+
897
+ private stopCountdown(): void {
898
+ if (this.countdownInterval) {
899
+ clearInterval(this.countdownInterval);
900
+ this.countdownInterval = null;
901
+ }
902
+ }
903
+
904
+ private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill"): InteractiveShellResult["handoffPreview"] | undefined {
905
+ if (!this.config.handoffPreviewEnabled) return undefined;
906
+ const lines = this.config.handoffPreviewLines;
907
+ const maxChars = this.config.handoffPreviewMaxChars;
908
+ if (lines <= 0 || maxChars <= 0) return undefined;
909
+
910
+ const tail = this.session.getTailLines({
911
+ lines,
912
+ ansi: false,
913
+ maxChars,
914
+ });
915
+
916
+ return { type: "tail", when, lines: tail };
917
+ }
918
+
919
+ private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill"): InteractiveShellResult["handoff"] | undefined {
920
+ if (!this.config.handoffSnapshotEnabled) return undefined;
921
+ const lines = this.config.handoffSnapshotLines;
922
+ const maxChars = this.config.handoffSnapshotMaxChars;
923
+ if (lines <= 0 || maxChars <= 0) return undefined;
924
+
925
+ const baseDir = join(homedir(), ".pi", "agent", "cache", "interactive-shell");
926
+ mkdirSync(baseDir, { recursive: true });
927
+
928
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
929
+ const pid = this.session.pid;
930
+ const filename = `snapshot-${timestamp}-pid${pid}.log`;
931
+ const transcriptPath = join(baseDir, filename);
932
+
933
+ const tail = this.session.getTailLines({
934
+ lines,
935
+ ansi: this.config.ansiReemit,
936
+ maxChars,
937
+ });
938
+
939
+ const header = [
940
+ `# interactive-shell snapshot (${when})`,
941
+ `time: ${new Date().toISOString()}`,
942
+ `command: ${this.bgSession.command}`,
943
+ `pid: ${pid}`,
944
+ `exitCode: ${this.session.exitCode ?? ""}`,
945
+ `signal: ${this.session.signal ?? ""}`,
946
+ `lines: ${tail.length} (requested ${lines}, maxChars ${maxChars})`,
947
+ "",
948
+ ].join("\n");
949
+
950
+ writeFileSync(transcriptPath, header + tail.join("\n") + "\n", { encoding: "utf-8" });
951
+
952
+ return { type: "snapshot", when, transcriptPath, linesWritten: tail.length };
953
+ }
954
+
955
+ private finishAndClose(): void {
956
+ if (this.finished) return;
957
+ this.finished = true;
958
+ this.stopCountdown();
959
+ const handoffPreview = this.maybeBuildHandoffPreview("exit");
960
+ const handoff = this.maybeWriteHandoffSnapshot("exit");
961
+ sessionManager.remove(this.bgSession.id);
962
+ this.done({
963
+ exitCode: this.session.exitCode,
964
+ signal: this.session.signal,
965
+ backgrounded: false,
966
+ cancelled: false,
967
+ handoffPreview,
968
+ handoff,
969
+ });
970
+ }
971
+
972
+ private finishWithBackground(): void {
973
+ if (this.finished) return;
974
+ this.finished = true;
975
+ this.stopCountdown();
976
+ const handoffPreview = this.maybeBuildHandoffPreview("detach");
977
+ const handoff = this.maybeWriteHandoffSnapshot("detach");
978
+ this.session.setEventHandlers({});
979
+ this.done({
980
+ exitCode: null,
981
+ backgrounded: true,
982
+ backgroundId: this.bgSession.id,
983
+ cancelled: false,
984
+ handoffPreview,
985
+ handoff,
986
+ });
987
+ }
988
+
989
+ private finishWithKill(): void {
990
+ if (this.finished) return;
991
+ this.finished = true;
992
+ this.stopCountdown();
993
+ const handoffPreview = this.maybeBuildHandoffPreview("kill");
994
+ const handoff = this.maybeWriteHandoffSnapshot("kill");
995
+ sessionManager.remove(this.bgSession.id);
996
+ this.done({
997
+ exitCode: null,
998
+ backgrounded: false,
999
+ cancelled: true,
1000
+ handoffPreview,
1001
+ handoff,
1002
+ });
1003
+ }
1004
+
1005
+ private handleDoubleEscape(): boolean {
1006
+ const now = Date.now();
1007
+ if (now - this.lastEscapeTime < this.config.doubleEscapeThreshold) {
1008
+ this.lastEscapeTime = 0;
1009
+ return true;
1010
+ }
1011
+ this.lastEscapeTime = now;
1012
+ return false;
1013
+ }
1014
+
1015
+ handleInput(data: string): void {
1016
+ if (this.state === "detach-dialog") {
1017
+ this.handleDialogInput(data);
1018
+ return;
1019
+ }
1020
+
1021
+ if (this.state === "exited") {
1022
+ if (data.length > 0) {
1023
+ this.finishAndClose();
1024
+ }
1025
+ return;
1026
+ }
1027
+
1028
+ if (this.session.exited && this.state === "running") {
1029
+ this.state = "exited";
1030
+ this.exitCountdown = this.config.exitAutoCloseDelay;
1031
+ this.startExitCountdown();
1032
+ this.tui.requestRender();
1033
+ return;
1034
+ }
1035
+
1036
+ if (matchesKey(data, "escape")) {
1037
+ if (this.handleDoubleEscape()) {
1038
+ this.state = "detach-dialog";
1039
+ this.dialogSelection = "background";
1040
+ this.tui.requestRender();
1041
+ return;
1042
+ }
1043
+ this.session.write("\u001b");
1044
+ return;
1045
+ }
1046
+
1047
+ if (matchesKey(data, "shift+up")) {
1048
+ this.session.scrollUp(Math.max(1, this.session.rows - 2));
1049
+ this.tui.requestRender();
1050
+ return;
1051
+ }
1052
+ if (matchesKey(data, "shift+down")) {
1053
+ this.session.scrollDown(Math.max(1, this.session.rows - 2));
1054
+ this.tui.requestRender();
1055
+ return;
1056
+ }
1057
+
1058
+ this.session.write(data);
1059
+ }
1060
+
1061
+ private handleDialogInput(data: string): void {
1062
+ if (matchesKey(data, "escape")) {
1063
+ this.state = "running";
1064
+ this.tui.requestRender();
1065
+ return;
1066
+ }
1067
+
1068
+ if (matchesKey(data, "up") || matchesKey(data, "down")) {
1069
+ const options: DialogChoice[] = ["kill", "background", "cancel"];
1070
+ const currentIdx = options.indexOf(this.dialogSelection);
1071
+ const direction = matchesKey(data, "up") ? -1 : 1;
1072
+ const newIdx = (currentIdx + direction + options.length) % options.length;
1073
+ this.dialogSelection = options[newIdx]!;
1074
+ this.tui.requestRender();
1075
+ return;
1076
+ }
1077
+
1078
+ if (matchesKey(data, "enter")) {
1079
+ switch (this.dialogSelection) {
1080
+ case "kill":
1081
+ this.finishWithKill();
1082
+ break;
1083
+ case "background":
1084
+ this.finishWithBackground();
1085
+ break;
1086
+ case "cancel":
1087
+ this.state = "running";
1088
+ this.tui.requestRender();
1089
+ break;
1090
+ }
1091
+ }
1092
+ }
1093
+
1094
+ render(width: number): string[] {
1095
+ const th = this.theme;
1096
+ const border = (s: string) => th.fg("border", s);
1097
+ const accent = (s: string) => th.fg("accent", s);
1098
+ const dim = (s: string) => th.fg("dim", s);
1099
+ const warning = (s: string) => th.fg("warning", s);
1100
+
1101
+ const innerWidth = width - 4;
1102
+ const pad = (s: string, w: number) => {
1103
+ const vis = visibleWidth(s);
1104
+ return s + " ".repeat(Math.max(0, w - vis));
1105
+ };
1106
+ const row = (content: string) => border("│ ") + pad(content, innerWidth) + border(" │");
1107
+ const emptyRow = () => row("");
1108
+
1109
+ const lines: string[] = [];
1110
+
1111
+ const title = truncateToWidth(this.bgSession.command, innerWidth - 30, "...");
1112
+ const idLabel = `[${this.bgSession.id}]`;
1113
+ const pid = `PID: ${this.session.pid}`;
1114
+
1115
+ lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
1116
+ lines.push(
1117
+ row(
1118
+ accent(title) +
1119
+ " " +
1120
+ dim(idLabel) +
1121
+ " ".repeat(
1122
+ Math.max(1, innerWidth - visibleWidth(title) - idLabel.length - pid.length - 1),
1123
+ ) +
1124
+ dim(pid),
1125
+ ),
1126
+ );
1127
+ const hint = this.bgSession.reason
1128
+ ? `Reattached • ${this.bgSession.reason} • Double-Escape to detach`
1129
+ : "Reattached • Double-Escape to detach";
1130
+ lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
1131
+ lines.push(border("├" + "─".repeat(width - 2) + "┤"));
1132
+
1133
+ const overlayHeight = Math.floor((this.tui.terminal.rows * this.config.overlayHeightPercent) / 100);
1134
+ const termRows = Math.max(3, overlayHeight - CHROME_LINES);
1135
+
1136
+ if (innerWidth !== this.lastWidth || termRows !== this.lastHeight) {
1137
+ this.session.resize(innerWidth, termRows);
1138
+ this.lastWidth = innerWidth;
1139
+ this.lastHeight = termRows;
1140
+ }
1141
+
1142
+ const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
1143
+ for (const line of viewportLines) {
1144
+ lines.push(row(truncateToWidth(line, innerWidth, "")));
1145
+ }
1146
+
1147
+ if (this.session.isScrolledUp()) {
1148
+ const hintText = "── ↑ scrolled ──";
1149
+ const padLen = Math.max(0, Math.floor((width - 2 - visibleWidth(hintText)) / 2));
1150
+ lines.push(
1151
+ border("├") +
1152
+ dim(
1153
+ " ".repeat(padLen) +
1154
+ hintText +
1155
+ " ".repeat(width - 2 - padLen - visibleWidth(hintText)),
1156
+ ) +
1157
+ border("┤"),
1158
+ );
1159
+ } else {
1160
+ lines.push(border("├" + "─".repeat(width - 2) + "┤"));
1161
+ }
1162
+
1163
+ const footerLines: string[] = [];
1164
+
1165
+ if (this.state === "detach-dialog") {
1166
+ footerLines.push(row(accent("Detach from session:")));
1167
+ const opts: Array<{ key: DialogChoice; label: string }> = [
1168
+ { key: "kill", label: "Kill process" },
1169
+ { key: "background", label: "Run in background" },
1170
+ { key: "cancel", label: "Cancel (return to session)" },
1171
+ ];
1172
+ for (const opt of opts) {
1173
+ const sel = this.dialogSelection === opt.key;
1174
+ footerLines.push(row((sel ? accent("▶ ") : " ") + (sel ? accent(opt.label) : opt.label)));
1175
+ }
1176
+ footerLines.push(row(dim("↑↓ select • Enter confirm • Esc cancel")));
1177
+ } else if (this.state === "exited") {
1178
+ const exitMsg =
1179
+ this.session.exitCode === 0
1180
+ ? th.fg("success", "✓ Exited successfully")
1181
+ : warning(`✗ Exited with code ${this.session.exitCode}`);
1182
+ footerLines.push(row(exitMsg));
1183
+ footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close)`)));
1184
+ } else {
1185
+ footerLines.push(row(dim("Shift+Up/Down scroll • Double-Esc detach")));
1186
+ }
1187
+
1188
+ while (footerLines.length < FOOTER_LINES) {
1189
+ footerLines.push(emptyRow());
1190
+ }
1191
+ lines.push(...footerLines);
1192
+
1193
+ lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
1194
+
1195
+ return lines;
1196
+ }
1197
+
1198
+ invalidate(): void {
1199
+ this.lastWidth = 0;
1200
+ this.lastHeight = 0;
1201
+ }
1202
+
1203
+ dispose(): void {
1204
+ if (this.initialExitTimeout) {
1205
+ clearTimeout(this.initialExitTimeout);
1206
+ this.initialExitTimeout = null;
1207
+ }
1208
+ this.stopCountdown();
1209
+ this.session.setEventHandlers({});
1210
+ }
1211
+ }