Files
pi-config/extensions/pi-interactive-shell/reattach-overlay.ts

447 lines
13 KiB
TypeScript

import type { Component, Focusable, TUI } from "@mariozechner/pi-tui";
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import type { Theme } from "@mariozechner/pi-coding-agent";
import { PtyTerminalSession } from "./pty-session.js";
import { sessionManager } from "./session-manager.js";
import type { InteractiveShellConfig } from "./config.js";
import {
type InteractiveShellResult,
type DialogChoice,
type OverlayState,
HEADER_LINES,
FOOTER_LINES_COMPACT,
FOOTER_LINES_DIALOG,
formatShortcut,
} from "./types.js";
import { captureCompletionOutput, captureTransferOutput, maybeBuildHandoffPreview, maybeWriteHandoffSnapshot } from "./handoff-utils.js";
export class ReattachOverlay implements Component, Focusable {
focused = false;
private tui: TUI;
private theme: Theme;
private done: (result: InteractiveShellResult) => void;
private bgSession: { id: string; command: string; reason?: string; session: PtyTerminalSession };
private config: InteractiveShellConfig;
private state: OverlayState = "running";
private dialogSelection: DialogChoice = "transfer";
private exitCountdown = 0;
private countdownInterval: ReturnType<typeof setInterval> | null = null;
private initialExitTimeout: ReturnType<typeof setTimeout> | null = null;
private lastWidth = 0;
private lastHeight = 0;
private finished = false;
private renderTimeout: ReturnType<typeof setTimeout> | null = null;
constructor(
tui: TUI,
theme: Theme,
bgSession: { id: string; command: string; reason?: string; session: PtyTerminalSession },
config: InteractiveShellConfig,
done: (result: InteractiveShellResult) => void,
private onUnfocus?: () => void,
) {
this.tui = tui;
this.theme = theme;
this.bgSession = bgSession;
this.config = config;
this.done = done;
bgSession.session.setEventHandlers({
onData: () => {
if (!bgSession.session.isScrolledUp()) {
bgSession.session.scrollToBottom();
}
this.debouncedRender();
},
onExit: () => {
if (this.finished) return;
this.state = "exited";
this.exitCountdown = this.config.exitAutoCloseDelay;
this.startExitCountdown();
this.tui.requestRender();
},
});
if (bgSession.session.exited) {
this.state = "exited";
this.exitCountdown = this.config.exitAutoCloseDelay;
this.initialExitTimeout = setTimeout(() => {
this.initialExitTimeout = null;
this.startExitCountdown();
}, 0);
}
const overlayWidth = Math.floor((tui.terminal.columns * this.config.overlayWidthPercent) / 100);
const overlayHeight = Math.floor((tui.terminal.rows * this.config.overlayHeightPercent) / 100);
const cols = Math.max(20, overlayWidth - 4);
const rows = Math.max(3, overlayHeight - (HEADER_LINES + FOOTER_LINES_COMPACT + 2));
bgSession.session.resize(cols, rows);
}
private get session(): PtyTerminalSession {
return this.bgSession.session;
}
private debouncedRender(): void {
if (this.renderTimeout) {
clearTimeout(this.renderTimeout);
}
this.renderTimeout = setTimeout(() => {
this.renderTimeout = null;
this.tui.requestRender();
}, 16);
}
private startExitCountdown(): void {
this.stopCountdown();
this.countdownInterval = setInterval(() => {
this.exitCountdown--;
if (this.exitCountdown <= 0) {
this.finishAndClose();
} else {
this.tui.requestRender();
}
}, 1000);
}
private stopCountdown(): void {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
}
private captureCompletionOutput(): InteractiveShellResult["completionOutput"] {
return captureCompletionOutput(this.session, this.config);
}
/** Capture output for transfer action (Ctrl+T or dialog) */
private captureTransferOutput(): InteractiveShellResult["transferred"] {
return captureTransferOutput(this.session, this.config);
}
private maybeBuildHandoffPreview(when: "exit" | "detach" | "kill" | "transfer"): InteractiveShellResult["handoffPreview"] | undefined {
return maybeBuildHandoffPreview(this.session, when, this.config);
}
private maybeWriteHandoffSnapshot(when: "exit" | "detach" | "kill" | "transfer"): InteractiveShellResult["handoff"] | undefined {
return maybeWriteHandoffSnapshot(this.session, when, this.config, { command: this.bgSession.command });
}
private finishAndClose(): void {
if (this.finished) return;
this.finished = true;
this.stopCountdown();
const handoffPreview = this.maybeBuildHandoffPreview("exit");
const handoff = this.maybeWriteHandoffSnapshot("exit");
const completionOutput = this.captureCompletionOutput();
sessionManager.remove(this.bgSession.id);
this.done({
exitCode: this.session.exitCode,
signal: this.session.signal,
backgrounded: false,
cancelled: false,
completionOutput,
handoffPreview,
handoff,
});
}
private finishWithBackground(): void {
if (this.finished) return;
this.finished = true;
this.stopCountdown();
const handoffPreview = this.maybeBuildHandoffPreview("detach");
const handoff = this.maybeWriteHandoffSnapshot("detach");
this.session.setEventHandlers({});
if (this.session.exited) {
sessionManager.scheduleCleanup(this.bgSession.id);
}
this.done({
exitCode: null,
backgrounded: true,
backgroundId: this.bgSession.id,
cancelled: false,
handoffPreview,
handoff,
});
}
private finishWithKill(): void {
if (this.finished) return;
this.finished = true;
this.stopCountdown();
const handoffPreview = this.maybeBuildHandoffPreview("kill");
const handoff = this.maybeWriteHandoffSnapshot("kill");
const completionOutput = this.captureCompletionOutput();
sessionManager.remove(this.bgSession.id);
this.done({
exitCode: null,
backgrounded: false,
cancelled: true,
completionOutput,
handoffPreview,
handoff,
});
}
private finishWithTransfer(): void {
if (this.finished) return;
this.finished = true;
this.stopCountdown();
const transferred = this.captureTransferOutput();
const handoffPreview = this.maybeBuildHandoffPreview("transfer");
const handoff = this.maybeWriteHandoffSnapshot("transfer");
const completionOutput = this.captureCompletionOutput();
sessionManager.remove(this.bgSession.id);
this.done({
exitCode: this.session.exitCode,
signal: this.session.signal,
backgrounded: false,
cancelled: false,
transferred,
completionOutput,
handoffPreview,
handoff,
});
}
handleInput(data: string): void {
if (this.state === "detach-dialog") {
this.handleDialogInput(data);
return;
}
if (matchesKey(data, this.config.focusShortcut)) {
this.onUnfocus?.();
return;
}
// Ctrl+T: Quick transfer - capture output and close (works in all states including "exited")
if (matchesKey(data, "ctrl+t")) {
this.finishWithTransfer();
return;
}
// Ctrl+B: Quick background - dismiss overlay, keep process running
if (matchesKey(data, "ctrl+b") && !this.session.exited) {
this.finishWithBackground();
return;
}
if (this.state === "exited") {
if (data.length > 0) {
this.finishAndClose();
}
return;
}
if (this.session.exited && this.state === "running") {
this.state = "exited";
this.exitCountdown = this.config.exitAutoCloseDelay;
this.startExitCountdown();
this.tui.requestRender();
return;
}
// Ctrl+Q opens detach dialog
if (matchesKey(data, "ctrl+q")) {
this.state = "detach-dialog";
this.dialogSelection = "transfer";
this.tui.requestRender();
return;
}
if (matchesKey(data, "shift+up")) {
this.session.scrollUp(Math.max(1, this.session.rows - 2));
this.tui.requestRender();
return;
}
if (matchesKey(data, "shift+down")) {
this.session.scrollDown(Math.max(1, this.session.rows - 2));
this.tui.requestRender();
return;
}
this.session.write(data);
}
private handleDialogInput(data: string): void {
if (matchesKey(data, "escape")) {
this.state = "running";
this.tui.requestRender();
return;
}
if (matchesKey(data, "up") || matchesKey(data, "down")) {
const options: DialogChoice[] = ["transfer", "background", "kill", "cancel"];
const currentIdx = options.indexOf(this.dialogSelection);
const direction = matchesKey(data, "up") ? -1 : 1;
const newIdx = (currentIdx + direction + options.length) % options.length;
this.dialogSelection = options[newIdx]!;
this.tui.requestRender();
return;
}
if (matchesKey(data, "enter")) {
switch (this.dialogSelection) {
case "transfer":
this.finishWithTransfer();
break;
case "kill":
this.finishWithKill();
break;
case "background":
this.finishWithBackground();
break;
case "cancel":
this.state = "running";
this.tui.requestRender();
break;
}
}
}
render(width: number): string[] {
width = Math.max(4, width);
const th = this.theme;
const borderColor = this.focused ? "border" : "borderMuted";
const border = (s: string) => th.fg(borderColor, s);
const accent = (s: string) => th.fg("accent", s);
const dim = (s: string) => th.fg("dim", s);
const warning = (s: string) => th.fg("warning", s);
const innerWidth = width - 4;
const pad = (s: string, w: number) => {
const vis = visibleWidth(s);
return s + " ".repeat(Math.max(0, w - vis));
};
const row = (content: string) => border("│ ") + pad(content, innerWidth) + border(" │");
const emptyRow = () => row("");
const lines: string[] = [];
// Sanitize command: collapse newlines and whitespace to single spaces for display
const sanitizedCommand = this.bgSession.command.replace(/\s+/g, " ").trim();
const title = truncateToWidth(sanitizedCommand, innerWidth - 30, "...");
const idLabel = `[${this.bgSession.id}]`;
const pid = `PID: ${this.session.pid}`;
lines.push(border("╭" + "─".repeat(width - 2) + "╮"));
lines.push(
row(
accent(title) +
" " +
dim(idLabel) +
" ".repeat(
Math.max(1, innerWidth - visibleWidth(title) - idLabel.length - pid.length - 1),
) +
dim(pid),
),
);
// Sanitize reason: collapse newlines and whitespace to single spaces for display
const sanitizedReason = this.bgSession.reason?.replace(/\s+/g, " ").trim();
const hint = sanitizedReason
? `Reattached • ${sanitizedReason} • Ctrl+B background`
: "Reattached • Ctrl+B background";
lines.push(row(dim(truncateToWidth(hint, innerWidth, "..."))));
lines.push(border("├" + "─".repeat(width - 2) + "┤"));
const overlayHeight = Math.floor((this.tui.terminal.rows * this.config.overlayHeightPercent) / 100);
const footerHeight = this.state === "detach-dialog" ? FOOTER_LINES_DIALOG : FOOTER_LINES_COMPACT;
const chrome = HEADER_LINES + footerHeight + 2;
const termRows = Math.max(0, overlayHeight - chrome);
if (termRows > 0) {
if (innerWidth !== this.lastWidth || termRows !== this.lastHeight) {
this.session.resize(innerWidth, termRows);
this.lastWidth = innerWidth;
this.lastHeight = termRows;
// After resize, ensure we're at the bottom to prevent flash to top
this.session.scrollToBottom();
}
const viewportLines = this.session.getViewportLines({ ansi: this.config.ansiReemit });
for (const line of viewportLines) {
lines.push(row(truncateToWidth(line, innerWidth, "")));
}
}
if (this.session.isScrolledUp()) {
const hintText = "── ↑ scrolled ──";
const padLen = Math.max(0, Math.floor((width - 2 - visibleWidth(hintText)) / 2));
lines.push(
border("├") +
dim(
" ".repeat(padLen) +
hintText +
" ".repeat(width - 2 - padLen - visibleWidth(hintText)),
) +
border("┤"),
);
} else {
lines.push(border("├" + "─".repeat(width - 2) + "┤"));
}
const footerLines: string[] = [];
const focusHint = `${formatShortcut(this.config.focusShortcut)} ${this.focused ? "unfocus" : "focus shell"}`;
if (this.state === "detach-dialog") {
footerLines.push(row(accent("Session actions:")));
const opts: Array<{ key: DialogChoice; label: string }> = [
{ key: "transfer", label: "Transfer output to agent" },
{ key: "background", label: "Run in background" },
{ key: "kill", label: "Kill process" },
{ key: "cancel", label: "Cancel (return to session)" },
];
for (const opt of opts) {
const sel = this.dialogSelection === opt.key;
footerLines.push(row((sel ? accent("▶ ") : " ") + (sel ? accent(opt.label) : opt.label)));
}
footerLines.push(row(dim("↑↓ select • Enter confirm • Esc cancel")));
} else if (this.state === "exited") {
const exitMsg =
this.session.exitCode === 0
? th.fg("success", "✓ Exited successfully")
: warning(`✗ Exited with code ${this.session.exitCode}`);
footerLines.push(row(exitMsg));
footerLines.push(row(dim(`Closing in ${this.exitCountdown}s... (any key to close) • ${focusHint}`)));
} else if (this.focused) {
footerLines.push(row(dim(`Ctrl+T transfer • Ctrl+B background • Ctrl+Q menu • Shift+Up/Down scroll • ${focusHint}`)));
} else {
footerLines.push(row(dim(focusHint)));
}
while (footerLines.length < footerHeight) {
footerLines.push(emptyRow());
}
lines.push(...footerLines);
lines.push(border("╰" + "─".repeat(width - 2) + "╯"));
return lines;
}
invalidate(): void {
this.lastWidth = 0;
this.lastHeight = 0;
}
dispose(): void {
if (this.initialExitTimeout) {
clearTimeout(this.initialExitTimeout);
this.initialExitTimeout = null;
}
if (this.renderTimeout) {
clearTimeout(this.renderTimeout);
this.renderTimeout = null;
}
this.stopCountdown();
this.session.setEventHandlers({});
}
}