Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
446
extensions/pi-interactive-shell/reattach-overlay.ts
Normal file
446
extensions/pi-interactive-shell/reattach-overlay.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
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({});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user