pi-interactive-shell 0.6.3 → 0.7.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 +37 -0
- package/README.md +101 -12
- package/SKILL.md +86 -17
- package/config.ts +10 -0
- package/headless-monitor.ts +171 -0
- package/index.ts +745 -367
- package/overlay-component.ts +134 -72
- package/package.json +2 -1
- package/pty-session.ts +36 -4
- package/reattach-overlay.ts +31 -4
- package/session-manager.ts +94 -45
- package/tool-schema.ts +61 -2
- package/types.ts +11 -3
package/overlay-component.ts
CHANGED
|
@@ -82,42 +82,24 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
82
82
|
const cols = Math.max(20, overlayWidth - 4);
|
|
83
83
|
const rows = Math.max(3, overlayHeight - CHROME_LINES);
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
ansiReemit: this.config.ansiReemit,
|
|
85
|
+
const ptyEvents = {
|
|
86
|
+
onData: () => {
|
|
87
|
+
this.debouncedRender();
|
|
88
|
+
if (this.state === "hands-free" && this.updateMode === "on-quiet") {
|
|
89
|
+
this.hasUnsentData = true;
|
|
90
|
+
this.resetQuietTimer();
|
|
91
|
+
}
|
|
93
92
|
},
|
|
94
|
-
{
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
// Debounce render to batch rapid updates and reduce flicker
|
|
98
|
-
this.debouncedRender();
|
|
99
|
-
|
|
100
|
-
// Track activity for on-quiet mode
|
|
101
|
-
if (this.state === "hands-free" && this.updateMode === "on-quiet") {
|
|
102
|
-
this.hasUnsentData = true;
|
|
103
|
-
this.resetQuietTimer();
|
|
104
|
-
}
|
|
105
|
-
},
|
|
106
|
-
onExit: () => {
|
|
107
|
-
// Guard: if already finished (e.g., timeout fired), don't process exit
|
|
108
|
-
if (this.finished) return;
|
|
109
|
-
|
|
110
|
-
// Stop timeout to prevent double done() call
|
|
111
|
-
this.stopTimeout();
|
|
93
|
+
onExit: () => {
|
|
94
|
+
if (this.finished) return;
|
|
95
|
+
this.stopTimeout();
|
|
112
96
|
|
|
113
|
-
|
|
114
|
-
if (this.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
// Now send exited notification
|
|
97
|
+
if (this.state === "hands-free" && this.sessionId) {
|
|
98
|
+
if (this.hasUnsentData || this.updateMode === "interval") {
|
|
99
|
+
this.emitHandsFreeUpdate();
|
|
100
|
+
this.hasUnsentData = false;
|
|
101
|
+
}
|
|
102
|
+
if (this.options.onHandsFreeUpdate) {
|
|
121
103
|
this.options.onHandsFreeUpdate({
|
|
122
104
|
status: "exited",
|
|
123
105
|
sessionId: this.sessionId,
|
|
@@ -127,30 +109,44 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
127
109
|
totalCharsSent: this.totalCharsSent,
|
|
128
110
|
budgetExhausted: this.budgetExhausted,
|
|
129
111
|
});
|
|
130
|
-
// Auto-close immediately in hands-free mode - agent should get control back
|
|
131
|
-
this.finishWithExit();
|
|
132
|
-
return;
|
|
133
112
|
}
|
|
113
|
+
this.finishWithExit();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
134
116
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
this.tui.requestRender();
|
|
141
|
-
},
|
|
117
|
+
this.stopHandsFreeUpdates();
|
|
118
|
+
this.state = "exited";
|
|
119
|
+
this.exitCountdown = this.config.exitAutoCloseDelay;
|
|
120
|
+
this.startExitCountdown();
|
|
121
|
+
this.tui.requestRender();
|
|
142
122
|
},
|
|
143
|
-
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (options.existingSession) {
|
|
126
|
+
this.session = options.existingSession;
|
|
127
|
+
this.session.setEventHandlers(ptyEvents);
|
|
128
|
+
this.session.resize(cols, rows);
|
|
129
|
+
} else {
|
|
130
|
+
this.session = new PtyTerminalSession(
|
|
131
|
+
{
|
|
132
|
+
command: options.command,
|
|
133
|
+
cwd: options.cwd,
|
|
134
|
+
cols,
|
|
135
|
+
rows,
|
|
136
|
+
scrollback: this.config.scrollbackLines,
|
|
137
|
+
ansiReemit: this.config.ansiReemit,
|
|
138
|
+
},
|
|
139
|
+
ptyEvents,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
144
142
|
|
|
145
143
|
// Initialize hands-free mode settings
|
|
146
144
|
this.updateMode = options.handsFreeUpdateMode ?? config.handsFreeUpdateMode;
|
|
147
145
|
this.currentUpdateInterval = options.handsFreeUpdateInterval ?? config.handsFreeUpdateInterval;
|
|
148
146
|
this.currentQuietThreshold = options.handsFreeQuietThreshold ?? config.handsFreeQuietThreshold;
|
|
149
147
|
|
|
150
|
-
|
|
151
|
-
if (options.mode === "hands-free") {
|
|
148
|
+
if (options.mode === "hands-free" || options.mode === "dispatch") {
|
|
152
149
|
this.state = "hands-free";
|
|
153
|
-
// Use provided sessionId or generate one
|
|
154
150
|
this.sessionId = options.sessionId ?? generateSessionId(options.name);
|
|
155
151
|
sessionManager.registerActive({
|
|
156
152
|
id: this.sessionId,
|
|
@@ -158,6 +154,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
158
154
|
reason: options.reason,
|
|
159
155
|
write: (data) => this.session.write(data),
|
|
160
156
|
kill: () => this.killSession(),
|
|
157
|
+
background: () => this.backgroundSession(),
|
|
161
158
|
getOutput: (options) => this.getOutputSinceLastCheck(options),
|
|
162
159
|
getStatus: () => this.getSessionStatus(),
|
|
163
160
|
getRuntime: () => this.getRuntime(),
|
|
@@ -169,12 +166,38 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
169
166
|
this.startHandsFreeUpdates();
|
|
170
167
|
}
|
|
171
168
|
|
|
172
|
-
// Start auto-kill timeout if specified
|
|
173
169
|
if (options.timeout && options.timeout > 0) {
|
|
174
170
|
this.timeoutTimer = setTimeout(() => {
|
|
175
171
|
this.finishWithTimeout();
|
|
176
172
|
}, options.timeout);
|
|
177
173
|
}
|
|
174
|
+
|
|
175
|
+
if (options.existingSession && options.existingSession.exited) {
|
|
176
|
+
queueMicrotask(() => {
|
|
177
|
+
if (this.finished) return;
|
|
178
|
+
this.stopTimeout();
|
|
179
|
+
if (this.state === "hands-free" && this.sessionId) {
|
|
180
|
+
if (this.options.onHandsFreeUpdate) {
|
|
181
|
+
this.options.onHandsFreeUpdate({
|
|
182
|
+
status: "exited",
|
|
183
|
+
sessionId: this.sessionId,
|
|
184
|
+
runtime: Date.now() - this.startTime,
|
|
185
|
+
tail: [],
|
|
186
|
+
tailTruncated: false,
|
|
187
|
+
totalCharsSent: this.totalCharsSent,
|
|
188
|
+
budgetExhausted: this.budgetExhausted,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
this.finishWithExit();
|
|
192
|
+
} else {
|
|
193
|
+
this.stopHandsFreeUpdates();
|
|
194
|
+
this.state = "exited";
|
|
195
|
+
this.exitCountdown = this.config.exitAutoCloseDelay;
|
|
196
|
+
this.startExitCountdown();
|
|
197
|
+
this.tui.requestRender();
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
178
201
|
}
|
|
179
202
|
|
|
180
203
|
// Public methods for non-blocking mode (agent queries)
|
|
@@ -187,14 +210,18 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
187
210
|
|
|
188
211
|
/** Get rendered terminal output (last N lines, truncated if too large) */
|
|
189
212
|
getOutputSinceLastCheck(options: { skipRateLimit?: boolean; lines?: number; maxChars?: number; offset?: number; drain?: boolean; incremental?: boolean } | boolean = false): { output: string; truncated: boolean; totalBytes: number; totalLines?: number; hasMore?: boolean; rateLimited?: boolean; waitSeconds?: number } {
|
|
190
|
-
// Guard: if session is finished (PTY disposed), return empty output
|
|
191
|
-
// This handles race conditions where query arrives after Ctrl+T transfer
|
|
192
213
|
if (this.finished) {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
214
|
+
if (this.completionResult?.completionOutput) {
|
|
215
|
+
const lines = this.completionResult.completionOutput.lines;
|
|
216
|
+
const output = lines.join("\n");
|
|
217
|
+
return {
|
|
218
|
+
output,
|
|
219
|
+
truncated: this.completionResult.completionOutput.truncated,
|
|
220
|
+
totalBytes: output.length,
|
|
221
|
+
totalLines: this.completionResult.completionOutput.totalLines,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return { output: "", truncated: false, totalBytes: 0 };
|
|
198
225
|
}
|
|
199
226
|
|
|
200
227
|
// Handle legacy boolean parameter
|
|
@@ -304,7 +331,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
304
331
|
const totalBytes = output.length;
|
|
305
332
|
const truncated = tailResult.lines.length >= requestedLines || tailResult.truncatedByChars;
|
|
306
333
|
|
|
307
|
-
return { output, truncated, totalBytes };
|
|
334
|
+
return { output, truncated, totalBytes, totalLines: tailResult.totalLinesInBuffer };
|
|
308
335
|
}
|
|
309
336
|
|
|
310
337
|
/** Get current session status */
|
|
@@ -399,18 +426,15 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
399
426
|
}
|
|
400
427
|
}, 2000);
|
|
401
428
|
|
|
402
|
-
// Fallback interval (always runs, ensures updates even during continuous output)
|
|
403
429
|
this.handsFreeInterval = setInterval(() => {
|
|
404
430
|
if (this.state === "hands-free") {
|
|
405
|
-
// In on-quiet mode, only emit if we have unsent data (interval is fallback)
|
|
406
431
|
if (this.updateMode === "on-quiet") {
|
|
407
|
-
if (this.hasUnsentData) {
|
|
432
|
+
if (this.hasUnsentData && this.options.onHandsFreeUpdate) {
|
|
408
433
|
this.emitHandsFreeUpdate();
|
|
409
434
|
this.hasUnsentData = false;
|
|
410
|
-
this.stopQuietTimer();
|
|
435
|
+
this.stopQuietTimer();
|
|
411
436
|
}
|
|
412
437
|
} else {
|
|
413
|
-
// In interval mode, always emit
|
|
414
438
|
this.emitHandsFreeUpdate();
|
|
415
439
|
}
|
|
416
440
|
}
|
|
@@ -468,13 +492,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
468
492
|
if (clamped === this.currentUpdateInterval) return;
|
|
469
493
|
this.currentUpdateInterval = clamped;
|
|
470
494
|
|
|
471
|
-
// Restart the interval with new timing
|
|
472
495
|
if (this.handsFreeInterval) {
|
|
473
496
|
clearInterval(this.handsFreeInterval);
|
|
474
497
|
this.handsFreeInterval = setInterval(() => {
|
|
475
498
|
if (this.state === "hands-free") {
|
|
476
499
|
if (this.updateMode === "on-quiet") {
|
|
477
|
-
if (this.hasUnsentData) {
|
|
500
|
+
if (this.hasUnsentData && this.options.onHandsFreeUpdate) {
|
|
478
501
|
this.emitHandsFreeUpdate();
|
|
479
502
|
this.hasUnsentData = false;
|
|
480
503
|
this.stopQuietTimer();
|
|
@@ -613,6 +636,20 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
613
636
|
this.tui.requestRender();
|
|
614
637
|
}
|
|
615
638
|
|
|
639
|
+
/** Capture output for dispatch completion notifications */
|
|
640
|
+
private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
|
|
641
|
+
const result = this.session.getTailLines({
|
|
642
|
+
lines: this.config.completionNotifyLines,
|
|
643
|
+
ansi: false,
|
|
644
|
+
maxChars: this.config.completionNotifyMaxChars,
|
|
645
|
+
});
|
|
646
|
+
return {
|
|
647
|
+
lines: result.lines,
|
|
648
|
+
totalLines: result.totalLinesInBuffer,
|
|
649
|
+
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
616
653
|
/** Capture output for transfer action (Ctrl+T or dialog) */
|
|
617
654
|
private captureTransferOutput(): InteractiveShellResult["transferred"] {
|
|
618
655
|
const maxLines = this.config.transferLines;
|
|
@@ -698,6 +735,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
698
735
|
|
|
699
736
|
const handoffPreview = this.maybeBuildHandoffPreview("exit");
|
|
700
737
|
const handoff = this.maybeWriteHandoffSnapshot("exit");
|
|
738
|
+
const completionOutput = this.captureCompletionOutput();
|
|
701
739
|
this.session.dispose();
|
|
702
740
|
const result: InteractiveShellResult = {
|
|
703
741
|
exitCode: this.session.exitCode,
|
|
@@ -706,6 +744,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
706
744
|
cancelled: false,
|
|
707
745
|
sessionId: this.sessionId ?? undefined,
|
|
708
746
|
userTookOver: this.userTookOver,
|
|
747
|
+
completionOutput,
|
|
709
748
|
handoffPreview,
|
|
710
749
|
handoff,
|
|
711
750
|
};
|
|
@@ -722,6 +761,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
722
761
|
this.done(result);
|
|
723
762
|
}
|
|
724
763
|
|
|
764
|
+
backgroundSession(): void {
|
|
765
|
+
this.finishWithBackground();
|
|
766
|
+
}
|
|
767
|
+
|
|
725
768
|
private finishWithBackground(): void {
|
|
726
769
|
if (this.finished) return;
|
|
727
770
|
this.finished = true;
|
|
@@ -731,7 +774,10 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
731
774
|
|
|
732
775
|
const handoffPreview = this.maybeBuildHandoffPreview("detach");
|
|
733
776
|
const handoff = this.maybeWriteHandoffSnapshot("detach");
|
|
734
|
-
const
|
|
777
|
+
const addOptions = this.sessionId
|
|
778
|
+
? { id: this.sessionId, noAutoCleanup: this.options.mode === "dispatch" }
|
|
779
|
+
: undefined;
|
|
780
|
+
const id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason, addOptions);
|
|
735
781
|
const result: InteractiveShellResult = {
|
|
736
782
|
exitCode: null,
|
|
737
783
|
backgrounded: true,
|
|
@@ -747,8 +793,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
747
793
|
|
|
748
794
|
// In non-blocking mode (no onHandsFreeUpdate), keep session registered
|
|
749
795
|
// so agent can query completion result. Agent's query will unregister.
|
|
796
|
+
// Use releaseId=false because the background session now owns the ID.
|
|
750
797
|
if (this.options.onHandsFreeUpdate) {
|
|
751
|
-
this.unregisterActiveSession(
|
|
798
|
+
this.unregisterActiveSession(false);
|
|
752
799
|
}
|
|
753
800
|
|
|
754
801
|
this.done(result);
|
|
@@ -763,6 +810,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
763
810
|
|
|
764
811
|
const handoffPreview = this.maybeBuildHandoffPreview("kill");
|
|
765
812
|
const handoff = this.maybeWriteHandoffSnapshot("kill");
|
|
813
|
+
const completionOutput = this.captureCompletionOutput();
|
|
766
814
|
this.session.kill();
|
|
767
815
|
this.session.dispose();
|
|
768
816
|
const result: InteractiveShellResult = {
|
|
@@ -771,6 +819,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
771
819
|
cancelled: true,
|
|
772
820
|
sessionId: this.sessionId ?? undefined,
|
|
773
821
|
userTookOver: this.userTookOver,
|
|
822
|
+
completionOutput,
|
|
774
823
|
handoffPreview,
|
|
775
824
|
handoff,
|
|
776
825
|
};
|
|
@@ -795,6 +844,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
795
844
|
|
|
796
845
|
// Capture output BEFORE killing the session
|
|
797
846
|
const transferred = this.captureTransferOutput();
|
|
847
|
+
const completionOutput = this.captureCompletionOutput();
|
|
798
848
|
const handoffPreview = this.maybeBuildHandoffPreview("transfer");
|
|
799
849
|
const handoff = this.maybeWriteHandoffSnapshot("transfer");
|
|
800
850
|
|
|
@@ -808,6 +858,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
808
858
|
sessionId: this.sessionId ?? undefined,
|
|
809
859
|
userTookOver: this.userTookOver,
|
|
810
860
|
transferred,
|
|
861
|
+
completionOutput,
|
|
811
862
|
handoffPreview,
|
|
812
863
|
handoff,
|
|
813
864
|
};
|
|
@@ -851,6 +902,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
851
902
|
this.stopHandsFreeUpdates();
|
|
852
903
|
const handoffPreview = this.maybeBuildHandoffPreview("timeout");
|
|
853
904
|
const handoff = this.maybeWriteHandoffSnapshot("timeout");
|
|
905
|
+
const completionOutput = this.captureCompletionOutput();
|
|
854
906
|
this.session.kill();
|
|
855
907
|
this.session.dispose();
|
|
856
908
|
const result: InteractiveShellResult = {
|
|
@@ -860,6 +912,7 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
860
912
|
timedOut: true,
|
|
861
913
|
sessionId: this.sessionId ?? undefined,
|
|
862
914
|
userTookOver: this.userTookOver,
|
|
915
|
+
completionOutput,
|
|
863
916
|
handoffPreview,
|
|
864
917
|
handoff,
|
|
865
918
|
};
|
|
@@ -891,6 +944,15 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
891
944
|
return;
|
|
892
945
|
}
|
|
893
946
|
|
|
947
|
+
// Ctrl+B: Quick background - dismiss overlay, keep process running
|
|
948
|
+
if (matchesKey(data, "ctrl+b") && !this.session.exited) {
|
|
949
|
+
if (this.state === "hands-free") {
|
|
950
|
+
this.triggerUserTakeover();
|
|
951
|
+
}
|
|
952
|
+
this.finishWithBackground();
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
894
956
|
if (this.state === "exited") {
|
|
895
957
|
if (data.length > 0) {
|
|
896
958
|
this.finishWithExit();
|
|
@@ -1004,12 +1066,12 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
1004
1066
|
hint = `🤖 Hands-free (${elapsed}) • Type anything to take over`;
|
|
1005
1067
|
} else if (this.userTookOver) {
|
|
1006
1068
|
hint = sanitizedReason
|
|
1007
|
-
? `You took over • ${sanitizedReason} • Ctrl+
|
|
1008
|
-
: "You took over • Ctrl+
|
|
1069
|
+
? `You took over • ${sanitizedReason} • Ctrl+B background`
|
|
1070
|
+
: "You took over • Ctrl+B background";
|
|
1009
1071
|
} else {
|
|
1010
1072
|
hint = sanitizedReason
|
|
1011
|
-
? `Ctrl+
|
|
1012
|
-
: "Ctrl+
|
|
1073
|
+
? `Ctrl+B background • ${sanitizedReason}`
|
|
1074
|
+
: "Ctrl+B background";
|
|
1013
1075
|
}
|
|
1014
1076
|
lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
|
|
1015
1077
|
lines.push(border("├" + "─".repeat(width - 2) + "┤"));
|
|
@@ -1069,9 +1131,9 @@ export class InteractiveShellOverlay implements Component, Focusable {
|
|
|
1069
1131
|
footerLines.push(row(exitMsg));
|
|
1070
1132
|
footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close)`)));
|
|
1071
1133
|
} else if (this.state === "hands-free") {
|
|
1072
|
-
footerLines.push(row(dim("🤖 Agent controlling • Type to take over • Ctrl+T transfer •
|
|
1134
|
+
footerLines.push(row(dim("🤖 Agent controlling • Type to take over • Ctrl+T transfer • Ctrl+B background")));
|
|
1073
1135
|
} else {
|
|
1074
|
-
footerLines.push(row(dim("Ctrl+T transfer • Ctrl+Q menu • Shift+Up/Down scroll")));
|
|
1136
|
+
footerLines.push(row(dim("Ctrl+T transfer • Ctrl+B background • Ctrl+Q menu • Shift+Up/Down scroll")));
|
|
1075
1137
|
}
|
|
1076
1138
|
|
|
1077
1139
|
while (footerLines.length < FOOTER_LINES) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-interactive-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Run AI coding agents as foreground subagents in pi TUI overlays with hands-free monitoring",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"reattach-overlay.ts",
|
|
16
16
|
"session-manager.ts",
|
|
17
17
|
"tool-schema.ts",
|
|
18
|
+
"headless-monitor.ts",
|
|
18
19
|
"types.ts",
|
|
19
20
|
"scripts/",
|
|
20
21
|
"banner.png",
|
package/pty-session.ts
CHANGED
|
@@ -229,6 +229,8 @@ export class PtyTerminalSession {
|
|
|
229
229
|
|
|
230
230
|
private dataHandler: ((data: string) => void) | undefined;
|
|
231
231
|
private exitHandler: ((exitCode: number, signal?: number) => void) | undefined;
|
|
232
|
+
private additionalDataListeners: Array<(data: string) => void> = [];
|
|
233
|
+
private additionalExitListeners: Array<(exitCode: number, signal?: number) => void> = [];
|
|
232
234
|
|
|
233
235
|
// Trim raw output buffer if it exceeds max size
|
|
234
236
|
private trimRawOutputIfNeeded(): void {
|
|
@@ -295,7 +297,7 @@ export class PtyTerminalSession {
|
|
|
295
297
|
await new Promise<void>((resolve) => {
|
|
296
298
|
this.xterm.write(data, () => resolve());
|
|
297
299
|
});
|
|
298
|
-
this.
|
|
300
|
+
this.notifyDataListeners(data);
|
|
299
301
|
});
|
|
300
302
|
} else {
|
|
301
303
|
// Process each segment in order, responding to DSR after writing preceding text
|
|
@@ -307,7 +309,7 @@ export class PtyTerminalSession {
|
|
|
307
309
|
await new Promise<void>((resolve) => {
|
|
308
310
|
this.xterm.write(segment.text, () => resolve());
|
|
309
311
|
});
|
|
310
|
-
this.
|
|
312
|
+
this.notifyDataListeners(segment.text);
|
|
311
313
|
}
|
|
312
314
|
// If there was a DSR after this segment, respond with current cursor position
|
|
313
315
|
if (segment.dsrAfter) {
|
|
@@ -334,10 +336,10 @@ export class PtyTerminalSession {
|
|
|
334
336
|
});
|
|
335
337
|
});
|
|
336
338
|
|
|
337
|
-
// Wait for writeQueue to drain before calling
|
|
339
|
+
// Wait for writeQueue to drain before calling exit listeners
|
|
338
340
|
// This ensures exit message is in rawOutput and xterm buffer
|
|
339
341
|
this.writeQueue.drain().then(() => {
|
|
340
|
-
this.
|
|
342
|
+
this.notifyExitListeners(exitCode, signal);
|
|
341
343
|
});
|
|
342
344
|
});
|
|
343
345
|
}
|
|
@@ -347,6 +349,36 @@ export class PtyTerminalSession {
|
|
|
347
349
|
this.exitHandler = events.onExit;
|
|
348
350
|
}
|
|
349
351
|
|
|
352
|
+
addDataListener(cb: (data: string) => void): () => void {
|
|
353
|
+
this.additionalDataListeners.push(cb);
|
|
354
|
+
return () => {
|
|
355
|
+
const idx = this.additionalDataListeners.indexOf(cb);
|
|
356
|
+
if (idx >= 0) this.additionalDataListeners.splice(idx, 1);
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
addExitListener(cb: (exitCode: number, signal?: number) => void): () => void {
|
|
361
|
+
this.additionalExitListeners.push(cb);
|
|
362
|
+
return () => {
|
|
363
|
+
const idx = this.additionalExitListeners.indexOf(cb);
|
|
364
|
+
if (idx >= 0) this.additionalExitListeners.splice(idx, 1);
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private notifyDataListeners(data: string): void {
|
|
369
|
+
this.dataHandler?.(data);
|
|
370
|
+
for (const listener of this.additionalDataListeners) {
|
|
371
|
+
listener(data);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private notifyExitListeners(exitCode: number, signal?: number): void {
|
|
376
|
+
this.exitHandler?.(exitCode, signal);
|
|
377
|
+
for (const listener of this.additionalExitListeners) {
|
|
378
|
+
listener(exitCode, signal);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
350
382
|
get exited(): boolean {
|
|
351
383
|
return this._exited;
|
|
352
384
|
}
|
package/reattach-overlay.ts
CHANGED
|
@@ -101,6 +101,19 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
|
|
105
|
+
const result = this.session.getTailLines({
|
|
106
|
+
lines: this.config.completionNotifyLines,
|
|
107
|
+
ansi: false,
|
|
108
|
+
maxChars: this.config.completionNotifyMaxChars,
|
|
109
|
+
});
|
|
110
|
+
return {
|
|
111
|
+
lines: result.lines,
|
|
112
|
+
totalLines: result.totalLinesInBuffer,
|
|
113
|
+
truncated: result.lines.length < result.totalLinesInBuffer || result.truncatedByChars,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
/** Capture output for transfer action (Ctrl+T or dialog) */
|
|
105
118
|
private captureTransferOutput(): InteractiveShellResult["transferred"] {
|
|
106
119
|
const maxLines = this.config.transferLines;
|
|
@@ -178,12 +191,14 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
178
191
|
this.stopCountdown();
|
|
179
192
|
const handoffPreview = this.maybeBuildHandoffPreview("exit");
|
|
180
193
|
const handoff = this.maybeWriteHandoffSnapshot("exit");
|
|
194
|
+
const completionOutput = this.captureCompletionOutput();
|
|
181
195
|
sessionManager.remove(this.bgSession.id);
|
|
182
196
|
this.done({
|
|
183
197
|
exitCode: this.session.exitCode,
|
|
184
198
|
signal: this.session.signal,
|
|
185
199
|
backgrounded: false,
|
|
186
200
|
cancelled: false,
|
|
201
|
+
completionOutput,
|
|
187
202
|
handoffPreview,
|
|
188
203
|
handoff,
|
|
189
204
|
});
|
|
@@ -196,6 +211,9 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
196
211
|
const handoffPreview = this.maybeBuildHandoffPreview("detach");
|
|
197
212
|
const handoff = this.maybeWriteHandoffSnapshot("detach");
|
|
198
213
|
this.session.setEventHandlers({});
|
|
214
|
+
if (this.session.exited) {
|
|
215
|
+
sessionManager.scheduleCleanup(this.bgSession.id);
|
|
216
|
+
}
|
|
199
217
|
this.done({
|
|
200
218
|
exitCode: null,
|
|
201
219
|
backgrounded: true,
|
|
@@ -212,11 +230,13 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
212
230
|
this.stopCountdown();
|
|
213
231
|
const handoffPreview = this.maybeBuildHandoffPreview("kill");
|
|
214
232
|
const handoff = this.maybeWriteHandoffSnapshot("kill");
|
|
233
|
+
const completionOutput = this.captureCompletionOutput();
|
|
215
234
|
sessionManager.remove(this.bgSession.id);
|
|
216
235
|
this.done({
|
|
217
236
|
exitCode: null,
|
|
218
237
|
backgrounded: false,
|
|
219
238
|
cancelled: true,
|
|
239
|
+
completionOutput,
|
|
220
240
|
handoffPreview,
|
|
221
241
|
handoff,
|
|
222
242
|
});
|
|
@@ -227,10 +247,10 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
227
247
|
this.finished = true;
|
|
228
248
|
this.stopCountdown();
|
|
229
249
|
|
|
230
|
-
// Capture output BEFORE removing session
|
|
231
250
|
const transferred = this.captureTransferOutput();
|
|
232
251
|
const handoffPreview = this.maybeBuildHandoffPreview("transfer");
|
|
233
252
|
const handoff = this.maybeWriteHandoffSnapshot("transfer");
|
|
253
|
+
const completionOutput = this.captureCompletionOutput();
|
|
234
254
|
|
|
235
255
|
sessionManager.remove(this.bgSession.id);
|
|
236
256
|
this.done({
|
|
@@ -239,6 +259,7 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
239
259
|
backgrounded: false,
|
|
240
260
|
cancelled: false,
|
|
241
261
|
transferred,
|
|
262
|
+
completionOutput,
|
|
242
263
|
handoffPreview,
|
|
243
264
|
handoff,
|
|
244
265
|
});
|
|
@@ -256,6 +277,12 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
256
277
|
return;
|
|
257
278
|
}
|
|
258
279
|
|
|
280
|
+
// Ctrl+B: Quick background - dismiss overlay, keep process running
|
|
281
|
+
if (matchesKey(data, "ctrl+b") && !this.session.exited) {
|
|
282
|
+
this.finishWithBackground();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
259
286
|
if (this.state === "exited") {
|
|
260
287
|
if (data.length > 0) {
|
|
261
288
|
this.finishAndClose();
|
|
@@ -367,8 +394,8 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
367
394
|
// Sanitize reason: collapse newlines and whitespace to single spaces for display
|
|
368
395
|
const sanitizedReason = this.bgSession.reason?.replace(/\s+/g, " ").trim();
|
|
369
396
|
const hint = sanitizedReason
|
|
370
|
-
? `Reattached • ${sanitizedReason} • Ctrl+
|
|
371
|
-
: "Reattached • Ctrl+
|
|
397
|
+
? `Reattached • ${sanitizedReason} • Ctrl+B background`
|
|
398
|
+
: "Reattached • Ctrl+B background";
|
|
372
399
|
lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
|
|
373
400
|
lines.push(border("├" + "─".repeat(width - 2) + "┤"));
|
|
374
401
|
|
|
@@ -427,7 +454,7 @@ export class ReattachOverlay implements Component, Focusable {
|
|
|
427
454
|
footerLines.push(row(exitMsg));
|
|
428
455
|
footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close)`)));
|
|
429
456
|
} else {
|
|
430
|
-
footerLines.push(row(dim("Ctrl+T transfer • Ctrl+Q menu • Shift+Up/Down scroll")));
|
|
457
|
+
footerLines.push(row(dim("Ctrl+T transfer • Ctrl+B background • Ctrl+Q menu • Shift+Up/Down scroll")));
|
|
431
458
|
}
|
|
432
459
|
|
|
433
460
|
while (footerLines.length < FOOTER_LINES) {
|