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.
- package/CHANGELOG.md +63 -0
- package/README.md +173 -0
- package/SKILL.md +368 -0
- package/config.ts +132 -0
- package/index.ts +795 -0
- package/overlay-component.ts +1211 -0
- package/package.json +56 -0
- package/pty-session.ts +561 -0
- package/scripts/fix-spawn-helper.cjs +37 -0
- package/scripts/install.js +95 -0
- package/session-manager.ts +226 -0
|
@@ -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
|
+
}
|