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.
@@ -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
- this.session = new PtyTerminalSession(
86
- {
87
- command: options.command,
88
- cwd: options.cwd,
89
- cols,
90
- rows,
91
- scrollback: this.config.scrollbackLines,
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
- onData: () => {
96
- // Don't call scrollToBottom() here - pty-session handles auto-follow at render time
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
- // In hands-free mode (user hasn't taken over): send exited notification and auto-close immediately
114
- if (this.state === "hands-free" && this.options.onHandsFreeUpdate && this.sessionId) {
115
- // Flush any pending output before sending exited notification
116
- if (this.hasUnsentData || this.updateMode === "interval") {
117
- this.emitHandsFreeUpdate();
118
- this.hasUnsentData = false;
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
- // Interactive mode (or user took over): show exit state with countdown
136
- this.stopHandsFreeUpdates();
137
- this.state = "exited";
138
- this.exitCountdown = this.config.exitAutoCloseDelay;
139
- this.startExitCountdown();
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
- // Initialize hands-free mode if requested
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
- return {
194
- output: "",
195
- truncated: false,
196
- totalBytes: 0,
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(); // Reset quiet timer since we just sent
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 id = sessionManager.add(this.options.command, this.session, this.options.name, this.options.reason);
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(true);
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+Q to detach`
1008
- : "You took over • Ctrl+Q to detach";
1069
+ ? `You took over • ${sanitizedReason} • Ctrl+B background`
1070
+ : "You took over • Ctrl+B background";
1009
1071
  } else {
1010
1072
  hint = sanitizedReason
1011
- ? `Ctrl+Q to detach • ${sanitizedReason}`
1012
- : "Ctrl+Q to detach";
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 • Shift+Up/Down scroll")));
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.6.3",
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.dataHandler?.(data);
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.dataHandler?.(segment.text);
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 exitHandler
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.exitHandler?.(exitCode, signal);
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
  }
@@ -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+Q to detach`
371
- : "Reattached • Ctrl+Q to detach";
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) {