/** * crew-editor.ts — the inline agent panel's keyboard half. * * A `CustomEditor` wrapper (pi-subtask's `SubtaskEditor` pattern): pressing * `↓` on an empty prompt moves the cursor into the agent rows rendered by the * crew widget; `↑`/`↓` navigate, `enter` opens the agent's live transcript * pane, `x` cancels/dismisses, `escape` returns to typing. * * While an agent is viewed, typed text goes to the viewed agent (Claude * Code's `@name` convention — the editor border is relabeled), pageUp/ * pageDown scroll, and `enter` steers. Normally the FULL-SCREEN overlay * (agent-view-overlay.ts) owns the keyboard while open; this branch is the * fallback for when the overlay could not spawn (e.g. a stale ctx between * keypress and open), so the user is never stuck viewing an agent neither * surface controls. Neither path is a session switch: the main conversation * is never torn down to look at an agent. * * Every unhandled key falls through to `super.handleInput`, so the user is * never trapped in a mode. All state lives in panel-store (shared with the * widget renderer); this class only translates keys into state changes and * host calls. */ import type { KeybindingsManager } from "@earendil-works/pi-coding-agent"; import { CustomEditor } from "@earendil-works/pi-coding-agent"; import { type EditorTheme, matchesKey, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; import { ACTIVE } from "../rail.ts"; import type { PanelKeys, PanelTarget } from "./panel-selection.ts"; import { dispatchPanelKey } from "./panel-selection.ts"; import { getPanelSelection, getViewedAgent, panelRows, setPanelSelection } from "./panel-store.ts"; export const AGENT_LABEL_MAX = 24; /** * `name` as it paints in the editor border: whitespace collapsed to one line * (the label is spliced INTO the editor's top border, so a newline would tear * the frame) and the legacy `role->agent` separator normalized to the RAIL * active marker (§4 consistency fix). The mechanism itself is untouched. */ export function agentBorderLabel(name: string): string { return truncateToWidth(name.replace(/->/g, ACTIVE).replace(/\s+/g, " ").trim(), AGENT_LABEL_MAX); } export interface CrewEditorOptions { /** Open the transcript pane on the given agent. */ onOpenPane: (target: PanelTarget) => void; /** Close the pane and return to the main conversation. */ onClosePane: () => void; /** Scroll the open pane by ±wrapped lines. */ onScrollPane: (delta: number) => void; /** Steer the viewed agent with the typed message. */ onSteer: (target: PanelTarget, message: string) => void; /** `x`: cancel a running agent's run, or dismiss a finished one. */ onAct: (target: PanelTarget, finished: boolean) => void; /** Idle `↓`+`enter` at the MAIN row opens the Agents & Jobs browser — * the one-keystroke entry from the widget's status line (maintainer * design 2026-09-14: counts row → ↓ → enter → browser popup). When * absent, main-row enter stays a no-op (legacy behavior). */ onOpenBrowser?: () => void; } export class CrewInlineEditor extends CustomEditor { private readonly options: CrewEditorOptions; constructor(tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager, options: CrewEditorOptions) { super(tui, theme, keybindings); this.options = options; } private panelKeys(data: string): PanelKeys { return { up: matchesKey(data, "up"), down: matchesKey(data, "down"), enter: matchesKey(data, "return"), escape: matchesKey(data, "escape"), act: matchesKey(data, "x"), }; } /** * Apply a dispatch result. `closePaneOnMain` is true while the pane is open: * `enter` on the main row then closes the pane instead of doing nothing. */ private applyDispatch(data: string, rows: ReturnType, keys: PanelKeys, closePaneOnMain: boolean): void { const selection = getPanelSelection(); const result = dispatchPanelKey(keys, rows, selection, { holdAtMain: closePaneOnMain }); switch (result.action.kind) { case "none": { // Not a navigation key: hand the editor the exact key and clear // the cursor so typing proceeds normally (pi-subtask §2.5). setPanelSelection(null); super.handleInput(data); return; } case "consumed": { setPanelSelection(result.selection); return; } case "open": { setPanelSelection(null); const target = result.action.target; if (target) this.options.onOpenPane(target); else if (closePaneOnMain) this.options.onClosePane(); // Idle `enter` at the MAIN row = the browser entry (↓ + enter from // the status line). Legacy no-op preserved when unwired. else this.options.onOpenBrowser?.(); return; } case "act": { setPanelSelection(result.selection); const target = result.action.target; const row = rows.find((r) => r.runId === target.runId && r.taskId === target.taskId); this.options.onAct(target, row?.finished ?? false); return; } } } handleInput(data: string): void { const rows = panelRows(); const viewed = getViewedAgent(); // ── Pane open: typing goes to the viewed agent ───────────────────── // Navigation still works inside the pane: move the cursor over the // rows and `enter` switches the pane to another agent (or closes it // on the main row) — in place, no session involved. if (viewed) { if (getPanelSelection() !== null) { this.applyDispatch(data, rows, this.panelKeys(data), true); return; } if (matchesKey(data, "down") && this.getText() === "" && rows.length > 0) { setPanelSelection("main"); return; } if (matchesKey(data, "escape")) { this.options.onClosePane(); return; } if (matchesKey(data, "pageUp")) { this.options.onScrollPane(10); return; } if (matchesKey(data, "pageDown")) { this.options.onScrollPane(-10); return; } if (matchesKey(data, "return")) { const text = (this.getExpandedText?.() ?? this.getText()).trim(); if (!text) { // Empty enter: hand it back to the editor rather than // swallowing the key (keeps the "never stick" contract). super.handleInput(data); return; } if (text.startsWith("/")) { // Slash commands still act on the main session's command // executor, like Claude Code's transcript view. super.handleInput(data); return; } // Shift+Enter (newline) never reaches here: matchesKey // distinguishes the shifted sequence from plain return. this.setText(""); this.options.onSteer(viewed, text); return; } super.handleInput(data); return; } // ── Idle: `↓` on an empty prompt enters the panel ────────────────── // The browser extends the entry to the jobs-only state: `↓` works even // with zero agent rows when the browser is wired (the state machine // already lands at `main`; `enter` there opens the browser). if (getPanelSelection() === null) { if (matchesKey(data, "down") && this.getText() === "" && (rows.length > 0 || this.options.onOpenBrowser)) { // SINGLE-LINE WIDGET: dispatch over EMPTY rows — the cursor // lands on the ONE status line (rendered with its own ❯ // prefix by the widget), never on invisible agent rows. const result = dispatchPanelKey(this.panelKeys(data), [], null); setPanelSelection(result.selection); return; } super.handleInput(data); return; } // ── Navigating: consume or fall through ──────────────────────────── // SINGLE-LINE WIDGET: navigating over [] keeps the cursor on the one // status line (main); enter opens the browser, escape/up returns. this.applyDispatch(data, [], this.panelKeys(data), false); } /** * Relabel the editor's top border with the viewed agent, so it is * unambiguous where typed text lands — pi-subtask's `@name` marker. */ render(width: number): string[] { const lines = super.render(width); const viewed = getViewedAgent(); if (!viewed) return lines; const rows = panelRows(); const row = rows.find((r) => r.runId === viewed.runId && r.taskId === viewed.taskId); const name = agentBorderLabel(row?.name ?? viewed.taskId.slice(-AGENT_LABEL_MAX)); if (name && lines.length > 0) { const label = ` @${name} `; const labelWidth = visibleWidth(label); if (visibleWidth(lines[0]) >= labelWidth + 4) { lines[0] = truncateToWidth(lines[0], width - labelWidth - 2, "") + label + "──"; } } return lines; } }