Files
oh-my-pi/packages/coding-agent/src/modes/components/pause-screen.ts
T
can1357 369a0d879e feat(coding-agent/modes): displayed session title in pause screen
- Included the current session name in the fullscreen pause UI.
- Updated `renderPauseScreen` and `InteractiveModeContext` to propagate and display the session title.
- Added test coverage for both full and compact pause screen layouts.
2026-07-11 17:37:19 +02:00

209 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Fullscreen `/pause` screen.
*
* `/pause` engages the process-global {@link agentPauseGate}, freezing every
* agent loop in the process (main agent, in-process subagents, advisor) at its
* next safe boundary — nothing is aborted, so a later resume continues exactly
* where each loop parked. While engaged, this component owns the alternate
* screen (the `runStartupSplash` idiom) and paints a large pause glyph with a
* live hold timer; esc / enter / space / ctrl+c releases the gate.
*
* Use case: freeze a busy session, hand-edit the repo, resume, then explain
* the change via a normal steering message.
*/
import { agentPauseGate } from "@oh-my-pi/pi-agent-core";
import {
type Component,
matchesKey,
type OverlayFocusOwner,
type OverlayHandle,
type OverlayOptions,
visibleWidth,
} from "@oh-my-pi/pi-tui";
import { formatDuration } from "../../slash-commands/helpers/format";
import { theme } from "../theme/theme";
import { matchesAppInterrupt } from "../utils/keybinding-matchers";
/**
* Slice of `InteractiveModeContext` the pause screen drives. Narrow so tests
* can exercise the full engage → hold → release lifecycle without a real TUI.
*/
export interface PauseScreenHost {
ui: {
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle;
setFocus(component: Component): void;
requestRender(): void;
readonly terminal: { readonly rows: number };
};
showStatus(message: string, options?: { dim?: boolean }): void;
readonly sessionName?: string;
}
/** Refresh cadence for the live "paused for" clock. */
const TICK_MS = 1_000;
/** Pause-bar glyph geometry (rows × columns of full blocks per bar). */
const BAR_ROWS = 7;
const BAR_WIDTH = 5;
const BAR_GAP = 4;
/** Below either bound the full scene cannot breathe; drop to the compact card. */
const MIN_FULL_WIDTH = 64;
const MIN_FULL_HEIGHT = 18;
const TITLE = "P A U S E D";
const BODY_LINES = [
"Main agent, subagents, and advisor hold at their next step.",
"In-flight calls finish; nothing new starts until you resume.",
] as const;
const RESUME_HINT = "esc · enter · space — resume";
function centerLine(line: string, width: number): string {
const pad = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
return pad > 0 ? " ".repeat(pad) + line : line;
}
/** Live hold clock, seconds-precise: `0:07`, `12:34`, `1:02:03`. */
function formatClock(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const seconds = totalSeconds % 60;
const minutes = Math.floor(totalSeconds / 60) % 60;
const hours = Math.floor(totalSeconds / 3600);
if (hours > 0) return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
/**
* Paint the pause scene as exactly `height` rows, vertically centered.
* Exported for tests.
*/
export function renderPauseScreen(width: number, height: number, elapsedMs: number, sessionName?: string): string[] {
const compact = width < MIN_FULL_WIDTH || height < MIN_FULL_HEIGHT;
const content: string[] = [];
if (compact) {
if (sessionName) {
content.push(centerLine(theme.bold(sessionName), width));
content.push("");
}
content.push(centerLine(theme.bold(theme.fg("accent", `▌▌ ${TITLE}`)), width));
content.push("");
content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width));
content.push(centerLine(theme.fg("dim", "esc to resume"), width));
} else {
if (sessionName) {
content.push(centerLine(theme.bold(sessionName), width));
content.push("");
content.push("");
}
const bar = "█".repeat(BAR_WIDTH);
const glyphRow = `${bar}${" ".repeat(BAR_GAP)}${bar}`;
for (let i = 0; i < BAR_ROWS; i++) {
content.push(centerLine(theme.fg("accent", glyphRow), width));
}
content.push("");
content.push(centerLine(theme.bold(theme.fg("accent", TITLE)), width));
content.push("");
for (const line of BODY_LINES) {
content.push(centerLine(theme.fg("muted", line), width));
}
content.push("");
content.push(centerLine(theme.fg("dim", `paused for ${formatClock(elapsedMs)}`), width));
content.push("");
content.push(centerLine(theme.fg("dim", RESUME_HINT), width));
}
const topPad = Math.max(0, Math.floor((height - content.length) / 2));
const lines: string[] = new Array(topPad).fill("");
lines.push(...content);
while (lines.length < height) lines.push("");
return lines.slice(0, Math.max(1, height));
}
/** Fullscreen overlay component; resolves {@link run} when a resume key lands. */
export class PauseScreenComponent implements Component, OverlayFocusOwner {
#timer: NodeJS.Timeout | undefined;
#done = Promise.withResolvers<void>();
#disposed = false;
#startedAt = Date.now();
constructor(readonly host: PauseScreenHost) {}
/** Start the clock; resolves once the user asks to resume. */
run(): Promise<void> {
this.#startedAt = agentPauseGate.pausedAt ?? Date.now();
this.#timer ??= setInterval(() => {
if (!this.#disposed) this.host.ui.requestRender();
}, TICK_MS);
this.host.ui.requestRender();
return this.#done.promise;
}
dispose(): void {
this.#disposed = true;
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = undefined;
}
}
ownsOverlayFocusTarget(component: Component): boolean {
return component === this;
}
handleInput(data: string): void {
// Every dismissal path resumes — including ctrl+c, which must never
// double as "abort agents" while the whole point of the screen is that
// nothing gets lost.
if (
matchesAppInterrupt(data) ||
matchesKey(data, "enter") ||
matchesKey(data, "return") ||
matchesKey(data, "space") ||
matchesKey(data, "ctrl+c")
) {
if (!this.#disposed) this.#done.resolve();
}
}
render(width: number): readonly string[] {
const elapsed = Date.now() - this.#startedAt;
return renderPauseScreen(
Math.max(1, width),
Math.max(1, this.host.ui.terminal.rows),
elapsed,
this.host.sessionName,
);
}
}
/**
* Engage the global pause gate and hold the fullscreen pause screen until the
* user resumes. No-op when the gate is already engaged. Always releases the
* gate on the way out (including teardown throws) — a leaked pause would
* freeze every agent in the process with no UI left to release it.
*/
export async function runPauseScreen(host: PauseScreenHost): Promise<void> {
if (!agentPauseGate.pause()) return;
const component = new PauseScreenComponent(host);
const overlay = host.ui.showOverlay(component, {
width: "100%",
maxHeight: "100%",
anchor: "top-left",
margin: 0,
fullscreen: true,
});
try {
host.ui.setFocus(component);
await component.run();
} finally {
component.dispose();
host.ui.setFocus(component);
overlay.hide();
const heldMs = agentPauseGate.resume();
if (heldMs !== undefined) {
host.showStatus(`Resumed after ${formatDuration(heldMs)} — agents are running again.`);
}
}
}