From 1b3a4f20a2d811d77096714f53c6a0b83f5b93ee Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 22 Aug 2026 09:59:39 +0800 Subject: [PATCH 1/3] feat(ui): share one chrome vocabulary across OpenPI screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three full-screen views had each grown their own copy of the same chrome and drifted apart in the details you notice without being able to name: one framed the body in `border`, another in `borderMuted`; one wrote `... 3 more`, another `… 3 more`; hints were a single dim run in which the keys you are meant to press read exactly as faint as the prose describing them. extensions/shared/screen-chrome.ts owns that vocabulary now — screenTitleLine, panelFrame, hintLine, overflowNote — and /subagents, /ps, /workflows, /tasks, the changed-files view, the sessions picker and ask-user all render through it. Keys read a step brighter than their labels, pre-styled labels are passed through instead of repainted, and every added hint row is paid for out of the body so no overlay grew. Quieter status colour below the editor: renderNavigationMetrics paints the metrics tail muted while a run is healthy and borrows the status colour only for the one count that carries a settled outcome. The tasks census drops its bold/dim zebra and its always-zero segments for colour-coded chips (`4 tasks · 3 done · 1 in progress`), and one STATUS_COLOR map ends the widget/list disagreement over what colour in-progress work is. Migrated onto 0.3.1 rather than over it: the subagent dashboard keeps upstream's content-fit height, spinner, activity labels and metadata shedding, and the takeover view keeps its three-rule chrome; only the frame and the hint styling come from the shared module. --- extensions/ask-user/index.ts | 44 ++-- extensions/background-terminals/src/ui/ps.ts | 193 ++++++++---------- extensions/git-info/src/changed-files-view.ts | 61 ++++-- extensions/sessions/index.ts | 115 ++++++----- extensions/shared/below-editor-navigation.ts | 26 +++ extensions/shared/screen-chrome.test.ts | 96 +++++++++ extensions/shared/screen-chrome.ts | 133 ++++++++++++ extensions/subagents/navigation.test.ts | 44 ++++ extensions/subagents/navigation.ts | 18 +- extensions/subagents/src/ui/takeover.ts | 105 +++++----- extensions/tasks/ui.test.ts | 30 +-- extensions/tasks/ui.ts | 141 +++++++------ extensions/workflows/dashboard.ts | 120 ++++++----- extensions/workflows/navigation.ts | 18 +- 14 files changed, 761 insertions(+), 383 deletions(-) create mode 100644 extensions/shared/screen-chrome.test.ts create mode 100644 extensions/shared/screen-chrome.ts diff --git a/extensions/ask-user/index.ts b/extensions/ask-user/index.ts index 53301493..4a3e522b 100644 --- a/extensions/ask-user/index.ts +++ b/extensions/ask-user/index.ts @@ -15,36 +15,37 @@ import type { import { Editor, type EditorTheme, + type Focusable, Key, matchesKey, Text, truncateToWidth, - type Focusable, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; import { Cause, Effect, Exit } from "effect"; -import { Type, type Static } from "typebox"; -import { sanitizeTerminalText } from "../shared/terminal-text.ts"; +import { type Static, Type } from "typebox"; import { PLAN_MODE_CHANNEL, type PlanModeState, } from "../shared/plan-mode-state.ts"; +import { hintLine } from "../shared/screen-chrome.ts"; import { OPENPI_SETUP_EPISODE_CHANNEL, type OpenPiSetupEpisodeState, } from "../shared/setup-episode-state.ts"; +import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { OPENPI_TOOL_SURFACE, patchOwnedTools, } from "../shared/tool-surface.ts"; import { createHumanHandoffToolDefinition } from "./handoff.ts"; import { - BRACKETED_PASTE_END, - BRACKETED_PASTE_START, - MAX_ANSWER_DRAFT_UTF8_BYTES, answerDraftByteLength, answerDraftFits, + BRACKETED_PASTE_END, + BRACKETED_PASTE_START, longerThanAnswerDraftLimit, + MAX_ANSWER_DRAFT_UTF8_BYTES, prospectiveAnswerDraftFits, sanitizeAnswerDraftEditorInput, } from "./limits.ts"; @@ -63,7 +64,7 @@ const MAX_OPTIONS = 5; /** Preview lines rendered before the tail is summarized. */ const PREVIEW_MAX_LINES = 20; -export { MAX_ANSWER_DRAFT_UTF8_BYTES, answerDraftFits } from "./limits.ts"; +export { answerDraftFits, MAX_ANSWER_DRAFT_UTF8_BYTES } from "./limits.ts"; const OptionSchema = Type.Object({ label: Type.String({ @@ -949,9 +950,15 @@ export default function askUser(pi: ExtensionAPI) { ); lines.push(""); add( - theme.fg( - "dim", - ` ↑↓ choose • 1-${params.questions.length} edit answer • Enter open/submit • Esc dismiss`, + hintLine( + theme, + [ + ["↑↓", "choose"], + [`1-${params.questions.length}`, "edit answer"], + ["enter", "open/submit"], + ["esc", "dismiss"], + ], + width, ), ); add(theme.fg("accent", "─".repeat(width))); @@ -1043,11 +1050,20 @@ export default function askUser(pi: ExtensionAPI) { lines.push(""); add( - theme.fg( - "dim", + hintLine( + theme, editMode - ? " Enter save answer • Esc keep draft and return" - : ` ↑↓ or 1-${currentOptions.length} select • Tab add notes • Enter save draft answer • Esc dismiss`, + ? [ + ["enter", "save answer"], + ["esc", "keep draft and return"], + ] + : [ + [`↑↓ or 1-${currentOptions.length}`, "select"], + ["tab", "add notes"], + ["enter", "save draft answer"], + ["esc", "dismiss"], + ], + width, ), ); add(theme.fg("accent", "─".repeat(width))); diff --git a/extensions/background-terminals/src/ui/ps.ts b/extensions/background-terminals/src/ui/ps.ts index ec5c2840..bcd11e86 100644 --- a/extensions/background-terminals/src/ui/ps.ts +++ b/extensions/background-terminals/src/ui/ps.ts @@ -15,6 +15,12 @@ import type { import { formatSize } from "@earendil-works/pi-coding-agent"; import type { Component, TUI } from "@earendil-works/pi-tui"; import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { + hintLine, + overflowNote, + panelFrame, + screenTitleLine, +} from "../../../shared/screen-chrome.ts"; import { formatDuration, formatElapsed, @@ -217,24 +223,6 @@ class TerminalDashboard implements Component { } } - private pad(text: string, width: number): string { - const truncated = truncateToWidth(text, width); - return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); - } - - private borderSegment(width: number, title: string): string { - const theme = this.theme; - const label = title - ? ` ${truncateToWidth(title, Math.max(0, width - 3))} ` - : ""; - const labelWidth = visibleWidth(label); - return ( - theme.fg("border", "─") + - (label ? theme.fg("text", label) : "") + - theme.fg("border", "─".repeat(Math.max(0, width - 1 - labelWidth))) - ); - } - render(width: number): string[] { const theme = this.theme; const terminals = this.terminals(); @@ -245,64 +233,34 @@ class TerminalDashboard implements Component { // chat, editor, and extra footer lines while leaving pi's final footer // row visible. const bodyHeight = Math.max(6, rows - 5); - const innerWidth = width - 2; - - const lines: string[] = []; - - // Header: title left, count right - const headerLeft = theme.fg("accent", theme.bold("Background terminals")); - const headerRight = theme.fg( - "muted", - `${terminals.length} terminal${terminals.length === 1 ? "" : "s"}`, - ); - const headerPad = Math.max( - 1, - width - visibleWidth(headerLeft) - visibleWidth(headerRight) - 4, - ); - lines.push( - truncateToWidth( - ` ${headerLeft}${" ".repeat(headerPad)}${headerRight} `, + const running = terminals.filter((s) => s.status === "running").length; + const keys = (binding: Parameters[0]) => + configuredKeys(this.keybindings, binding); + + return [ + screenTitleLine( + theme, + "Background terminals", + `${terminals.length} terminal${terminals.length === 1 ? "" : "s"}`, width, ), - ); - - // Top border with panel title - const running = terminals.filter((s) => s.status === "running").length; - lines.push( - theme.fg("border", "╭") + - this.borderSegment( - innerWidth, - `terminals · ${running} running / ${terminals.length}`, - ) + - theme.fg("border", "╮"), - ); - - // Rows - const divider = theme.fg("border", "│"); - const rowLines = this.renderRows(terminals, innerWidth, bodyHeight); - for (let i = 0; i < bodyHeight; i++) { - lines.push(divider + this.pad(rowLines[i] ?? "", innerWidth) + divider); - } - - // Bottom border - lines.push( - theme.fg("border", "╰") + - theme.fg("border", "─".repeat(Math.max(0, innerWidth))) + - theme.fg("border", "╯"), - ); - - // Hints - lines.push( - truncateToWidth( - theme.fg( - "dim", - ` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} inspect · x kill · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`, - ), + ...panelFrame(theme, { + label: `terminals · ${running}/${terminals.length} running`, + rows: this.renderRows(terminals, width - 2, bodyHeight), + width, + height: bodyHeight + 2, + }), + hintLine( + theme, + [ + [`${keys("tui.select.up")}/${keys("tui.select.down")}/jk`, "select"], + [keys("tui.select.confirm"), "inspect"], + ["x", "kill"], + [keys("tui.select.cancel"), "close"], + ], width, ), - ); - - return lines; + ]; } private renderRows( @@ -359,13 +317,13 @@ class TerminalDashboard implements Component { out.push(truncateToWidth(leftTruncated + " ".repeat(gap) + right, width)); } - if (start > 0) { - out[0] = truncateToWidth(theme.fg("dim", ` ... ${start} more`), width); - } + if (start > 0) out[0] = overflowNote(theme, start, width, "above"); if (start + height < terminals.length) { - out[out.length - 1] = truncateToWidth( - theme.fg("dim", ` ... ${terminals.length - start - height} more`), + out[out.length - 1] = overflowNote( + theme, + terminals.length - start - height, width, + "below", ); } return out; @@ -514,35 +472,43 @@ class TerminalDetailView implements Component { render(width: number): string[] { const theme = this.theme; - const border = theme.fg("borderAccent", "─".repeat(Math.max(1, width))); + // One accent rule opens and closes the overlay; interior seams stay quiet + // so the output, not the frame, is what the eye lands on. + const edge = theme.fg("borderAccent", "─".repeat(Math.max(1, width))); + const seam = theme.fg("borderMuted", "─".repeat(Math.max(1, width))); const lines: string[] = []; const snap = this.snap(); if (!snap) { - lines.push(border); + lines.push(edge); lines.push(theme.fg("dim", `${this.id} is no longer tracked`)); - lines.push(border); + lines.push(edge); return lines; } - lines.push(border); - const header = + lines.push(edge); + const dot = theme.fg("dim", " · "); + const header = [ `${statusGlyph(snap, theme)} ` + - theme.fg("accent", theme.bold(`${snap.id} · ${oneLine(snap.title)}`)) + - theme.fg( - "muted", - ` · ${snap.status} · ${formatElapsed(snap)} · pid ${snap.pid ?? "?"}`, - ) + - (snap.status !== "running" - ? theme.fg("muted", ` · ${formatExit(snap)}`) - : "") + - (snap.status === "running" && snap.timeoutAt !== undefined - ? theme.fg( - "warning", - ` · ${formatDuration((snap.timeoutAt - Date.now()) / 1_000)} remaining`, - ) - : "") + - theme.fg("dim", ` · ${snap.cwd}`); + theme.fg("accent", theme.bold(`${snap.id} · ${oneLine(snap.title)}`)), + statusWord(snap, theme), + theme.fg("muted", formatElapsed(snap)), + theme.fg("muted", `pid ${snap.pid ?? "?"}`), + ...(snap.status !== "running" + ? [theme.fg("muted", formatExit(snap))] + : []), + ...(snap.status === "running" && snap.timeoutAt !== undefined + ? [ + theme.fg( + "warning", + `${formatDuration((snap.timeoutAt - Date.now()) / 1_000)} left`, + ), + ] + : []), + theme.fg("dim", snap.cwd), + ] + .filter(Boolean) + .join(dot); lines.push(truncateToWidth(header, width)); lines.push( truncateToWidth( @@ -550,7 +516,7 @@ class TerminalDetailView implements Component { width, ), ); - lines.push(border); + lines.push(seam); // Stream tab line: which stream is active, both sizes. const active = this.stream; @@ -561,7 +527,7 @@ class TerminalDetailView implements Component { : theme.fg("dim", `${name} (${formatSize(size)})`); lines.push( truncateToWidth( - ` ${tab("stdout", snap.stdout.totalBytes)}${theme.fg("dim", " | ")}${tab("stderr", snap.stderr.totalBytes)}${theme.fg("dim", " — t to switch")}`, + ` ${tab("stdout", snap.stdout.totalBytes)}${theme.fg("dim", " · ")}${tab("stderr", snap.stderr.totalBytes)}${theme.fg("dim", " t")} ${theme.fg("dim", "switch")}`, width, ), ); @@ -616,7 +582,7 @@ class TerminalDetailView implements Component { if (this.scrollOffset > 0) { body.push( truncateToWidth( - theme.fg("dim", `... ${this.scrollOffset} lines below · ↓/pgdn`), + theme.fg("dim", `… ${this.scrollOffset} lines below · ↓/pgdn`), width, ), ); @@ -624,17 +590,30 @@ class TerminalDetailView implements Component { while (body.length < viewport) body.push(""); lines.push(...body.slice(0, viewport)); - lines.push(border); + lines.push(seam); + const keys = (binding: Parameters[0]) => + configuredKeys(this.keybindings, binding); lines.push( - truncateToWidth( - theme.fg( - "dim", - `${configuredKeys(this.keybindings, "tui.select.cancel")} back · t stdout/stderr · x kill · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")}/jk scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page · g/G top/bottom`, - ), + hintLine( + theme, + [ + [keys("tui.select.cancel"), "back"], + ["t", "stdout/stderr"], + ["x", "kill"], + [ + `${keys("tui.editor.cursorUp")}/${keys("tui.editor.cursorDown")}/jk`, + "scroll", + ], + [ + `${keys("tui.editor.pageUp")}/${keys("tui.editor.pageDown")}`, + "page", + ], + ["g/G", "top/bottom"], + ], width, ), ); - lines.push(border); + lines.push(edge); return lines; } diff --git a/extensions/git-info/src/changed-files-view.ts b/extensions/git-info/src/changed-files-view.ts index eda1db9b..4eae0383 100644 --- a/extensions/git-info/src/changed-files-view.ts +++ b/extensions/git-info/src/changed-files-view.ts @@ -7,6 +7,7 @@ import { visibleWidth, } from "@earendil-works/pi-tui"; import { Effect } from "effect"; +import { hintLine } from "../../shared/screen-chrome.ts"; import { sanitizeTerminalText } from "../../shared/terminal-text.ts"; import { runCommand } from "./process.ts"; @@ -174,6 +175,15 @@ export async function showChangedFiles( return Math.max(8, Math.floor(tui.terminal.rows * 0.8) - 2); } + /** + * Rows the diff pane actually paints: one row of the frame's budget goes + * to the hint line below it. Scroll limits must use this and not + * bodyHeight(), or the last diff line can never be scrolled into view. + */ + function visibleRows() { + return Math.max(4, bodyHeight() - 1); + } + function ensureSelectedFileVisible() { const visibleFiles = Math.max(1, Math.floor(bodyHeight() / 2)); if (selectedIndex < sidebarOffset) sidebarOffset = selectedIndex; @@ -192,7 +202,7 @@ export async function showChangedFiles( function moveDiff(amount: number) { const maxOffset = Math.max( 0, - files[selectedIndex]!.diff.length - bodyHeight(), + files[selectedIndex]!.diff.length - visibleRows(), ); diffOffset = Math.max(0, Math.min(maxOffset, diffOffset + amount)); tui.requestRender(); @@ -216,13 +226,18 @@ export async function showChangedFiles( return theme.fg("text", expanded); } + /** + * Frame edge with an optional label set into it. Muted, not accent: the + * frame is not the content, and the accent is spent on the focused pane + * seam where it actually tells you something. + */ function border(width: number, label: string, top: boolean) { const left = top ? "┌" : "└"; const right = top ? "┐" : "┘"; - const text = `─ ${label} `; + const text = label ? `─ ${label} ` : "─"; const remaining = Math.max(0, width - visibleWidth(text) - 2); return theme.fg( - "borderAccent", + "borderMuted", truncateToWidth( `${left}${text}${"─".repeat(remaining)}${right}`, width, @@ -289,11 +304,11 @@ export async function showChangedFiles( return; } if (matchesKey(data, Key.ctrl("d"))) { - moveDiff(Math.max(1, Math.floor(bodyHeight() / 2))); + moveDiff(Math.max(1, Math.floor(visibleRows() / 2))); return; } if (matchesKey(data, Key.ctrl("u"))) { - moveDiff(-Math.max(1, Math.floor(bodyHeight() / 2))); + moveDiff(-Math.max(1, Math.floor(visibleRows() / 2))); return; } if (matchesKey(data, Key.home) || data === "g") { @@ -304,21 +319,23 @@ export async function showChangedFiles( if (matchesKey(data, Key.end) || data === "G") { diffOffset = Math.max( 0, - files[selectedIndex]!.diff.length - bodyHeight(), + files[selectedIndex]!.diff.length - visibleRows(), ); tui.requestRender(); } } function render(width: number) { - const height = bodyHeight(); + const height = visibleRows(); const sidebarWidth = Math.min( 48, Math.max(24, Math.floor(width * 0.34)), ); const diffWidth = Math.max(1, width - sidebarWidth - 3); const selectedFile = files[selectedIndex]!; - const title = `local changes · ${files.length} ${files.length === 1 ? "file" : "files"} · ${focus === "files" ? "FILES" : "DIFF"}`; + // No shouted FILES/DIFF badge: the accent-lit pane seam already says + // where the keyboard is, without a second thing to read. + const title = `local changes · ${files.length} ${files.length === 1 ? "file" : "files"}`; const lines = [border(width, title, true)]; for (let row = 0; row < height; row += 1) { @@ -329,7 +346,7 @@ export async function showChangedFiles( if (file) { const isSelected = fileIndex === selectedIndex; if (row % 2 === 0) { - const marker = isSelected ? "› " : " "; + const marker = isSelected ? "❯ " : " "; const isBinary = file.additions === null || file.deletions === null; const stats = isBinary @@ -386,11 +403,27 @@ export async function showChangedFiles( ); } - const help = - focus === "files" - ? "j/k or ↑/↓ select · enter/space/l open diff · esc close" - : "j/k or ↑/↓ scroll · ctrl-d/u page · g/G top/bottom · esc/h files"; - lines.push(border(width, help, false)); + lines.push(border(width, "", false)); + // Hints below the frame rather than crammed into its bottom edge, with + // keys a step brighter than the words describing them. + lines.push( + hintLine( + theme, + focus === "files" + ? [ + ["j/k or ↑/↓", "select"], + ["enter/space/l", "open diff"], + ["esc", "close"], + ] + : [ + ["j/k or ↑/↓", "scroll"], + ["ctrl-d/u", "page"], + ["g/G", "top/bottom"], + ["esc/h", "files"], + ], + width, + ), + ); return lines; } diff --git a/extensions/sessions/index.ts b/extensions/sessions/index.ts index b085a8d5..aafb876b 100644 --- a/extensions/sessions/index.ts +++ b/extensions/sessions/index.ts @@ -5,22 +5,24 @@ import type { } from "@earendil-works/pi-coding-agent"; import { DynamicBorder, - SessionManager, keyHint, + SessionManager, } from "@earendil-works/pi-coding-agent"; import { CancellableLoader, Container, Key, - SelectList, + matchesKey, type SelectItem, + SelectList, Spacer, Text, - matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui"; +import { hintLine } from "../shared/screen-chrome.ts"; +import { createSessionStatsLoader, type SessionStats } from "./git-stats.js"; import { buildPreviewError, buildSessionDescription, @@ -28,14 +30,13 @@ import { buildSessionPreview, buildSessionSearchEntries, filterSessionEntries, + formatRelativeTime, getSessionPaneLayout, - parseLimit, type PreviewBlock, + parseLimit, type SessionInfoLike, type SessionPreview, - formatRelativeTime, } from "./sessions.js"; -import { createSessionStatsLoader, type SessionStats } from "./git-stats.js"; const DEFAULT_VISIBLE = 12; const SNIPPET_MAX = 60; @@ -357,31 +358,20 @@ const renderPreview = ( ): { lines: string[]; totalLines: number; maxScroll: number } => { const raw: string[] = []; + // Left-aligned, like every other OpenPI heading: centred titles read as a + // different application than the panel they sit inside, and they wander as + // the pane resizes. if (!preview) { - raw.push( - " ".repeat(Math.max(0, Math.floor((width - 7) / 2))) + - themeText(theme, "title", "Preview"), - ); - raw.push( - " ".repeat(Math.max(0, Math.floor((width - 25) / 2))) + - themeText(theme, "subtitle", "Loading selected session…"), - ); + raw.push(themeText(theme, "title", "Preview")); + raw.push(themeText(theme, "subtitle", "Loading selected session…")); } else { - const titleStr = "Thread Preview"; - const titleColor = + const titleStr = "Thread preview"; + raw.push( focus === "preview" ? themeText(theme, "title", titleStr) - : themeText(theme, "subtitle", titleStr); - const titlePadding = " ".repeat( - Math.max(0, Math.floor((width - titleStr.length) / 2)), - ); - raw.push(`${titlePadding}${titleColor}`); - - const subStr = preview.subtitle; - const subPadding = " ".repeat( - Math.max(0, Math.floor((width - visibleWidth(subStr)) / 2)), + : themeText(theme, "subtitle", titleStr), ); - raw.push(`${subPadding}${themeText(theme, "subtitle", subStr)}`); + raw.push(themeText(theme, "subtitle", preview.subtitle)); raw.push(themeText(theme, "rule", "─".repeat(Math.max(0, width)))); @@ -732,7 +722,7 @@ async function showSessionPicker( if (isLoading) { container.addChild(new Spacer(1)); container.addChild( - new Text(theme.fg("muted", " Loading sessions..."), 1, 0), + new Text(theme.fg("muted", " Loading sessions…"), 1, 0), ); container.addChild(new Spacer(1)); } else { @@ -762,7 +752,15 @@ async function showSessionPicker( } container.addChild( new Text( - theme.fg("dim", "↑↓ navigate • enter open • esc cancel"), + hintLine( + theme, + [ + ["↑↓", "navigate"], + ["enter", "open"], + ["esc", "cancel"], + ], + Math.max(1, width - 2), + ), 1, 0, ), @@ -777,29 +775,29 @@ async function showSessionPicker( listWidth: number, previewWidth: number, ): string => { - const leftTitle = " Switch Thread "; + const leftTitle = " sessions "; + const rightTitle = " preview "; const left = `┌─${leftTitle}${"─".repeat(Math.max(0, listWidth - leftTitle.length - 2))}`; - const right = "─".repeat(Math.max(0, previewWidth - 1)) + "┐"; - return `${theme.fg("border", left)}${theme.fg("border", "─┬─")}${theme.fg("border", right)}`; + const right = + `─${rightTitle}${"─".repeat(Math.max(0, previewWidth - rightTitle.length - 2))}` + + "┐"; + return `${theme.fg("borderMuted", left)}${theme.fg("borderMuted", "─┬─")}${theme.fg("borderMuted", right)}`; }; const buildBottomBorder = ( listWidth: number, previewWidth: number, - previewStats: string, ): string => { - const help = showAllWorkspaces - ? " Opt+W/Ctrl+T current workspace · Esc close " - : " Opt+W/Ctrl+T all workspaces · Esc close "; const left = `└${"─".repeat(Math.max(0, listWidth - 1))}`; - const right = `${"─".repeat(Math.max(0, previewWidth - help.length - 1))}${help}┘`; - return `${theme.fg("border", left)}${theme.fg("border", "─┴─")}${theme.fg("border", right)}`; + const right = `${"─".repeat(Math.max(0, previewWidth - 1))}┘`; + return `${theme.fg("borderMuted", left)}${theme.fg("borderMuted", "─┴─")}${theme.fg("borderMuted", right)}`; }; const renderSplitPane = (width: number): string[] => { const layout = getSessionPaneLayout(width); const termRows = Math.max(12, tui.terminal?.rows ?? 24); - const contentHeight = Math.max(8, termRows - 2); + // Frame (2) + hint line (1). + const contentHeight = Math.max(8, termRows - 3); const filterLine = filter.length ? `${theme.fg("muted", "Filter: ")}${theme.fg("text", filter)}` : `${theme.fg("muted", "Filter: ")}${theme.fg("dim", "type to filter")}`; @@ -861,26 +859,41 @@ async function showSessionPicker( previewScrollOffset, renderedPreview.maxScroll, ); - const modeHints = `t ${toolsExpanded ? "compact" : "tools"} • h ${thinkingVisible ? "hide thinking" : "thinking"}`; - const previewStats = - renderedPreview.maxScroll > 0 - ? ` ${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines} • pgup/pgdn • ${modeHints} ` - : ` esc/enter • ${modeHints} `; + // Hints live on their own line under the frame instead of being packed + // into the bottom border, where they had to compete with the border for + // the same row and lost the keys in a wall of dim text. + const hints = hintLine( + theme, + [ + renderedPreview.maxScroll > 0 + ? ([ + "", + `${previewScrollOffset + 1}-${Math.min(previewScrollOffset + contentHeight, renderedPreview.totalLines)}/${renderedPreview.totalLines}`, + ] as const) + : undefined, + renderedPreview.maxScroll > 0 + ? (["pgup/pgdn", "scroll"] as const) + : undefined, + ["t", toolsExpanded ? "compact" : "tools"], + ["h", thinkingVisible ? "hide thinking" : "thinking"], + [ + "opt+w/ctrl+t", + showAllWorkspaces ? "current workspace" : "all workspaces", + ], + ["esc", "close"], + ], + width, + ); const lines = [buildTopBorder(layout.listWidth, layout.previewWidth)]; for (let i = 0; i < contentHeight; i++) { const left = padAnsiRight(leftLines[i] ?? "", layout.listWidth); const right = renderedPreview.lines[i] ?? " ".repeat(layout.previewWidth); - lines.push(`${left}${theme.fg("border", " │ ")}${right}`); + lines.push(`${left}${theme.fg("borderMuted", " │ ")}${right}`); } - lines.push( - buildBottomBorder( - layout.listWidth, - layout.previewWidth, - previewStats, - ), - ); + lines.push(buildBottomBorder(layout.listWidth, layout.previewWidth)); + lines.push(hints); return lines.map((line) => truncateToWidth(line, width, "", true)); }; diff --git a/extensions/shared/below-editor-navigation.ts b/extensions/shared/below-editor-navigation.ts index 0081199e..ad105f2a 100644 --- a/extensions/shared/below-editor-navigation.ts +++ b/extensions/shared/below-editor-navigation.ts @@ -1,6 +1,7 @@ import type { AppKeybinding, KeybindingsManager, + Theme, } from "@earendil-works/pi-coding-agent"; import type { AutocompleteProvider, @@ -328,6 +329,31 @@ export class BelowEditorNavigationEditor implements EditorComponent, Focusable { } } +/** + * Metrics tail for a below-editor strip: quiet values, quieter separators, and + * a hint that recedes furthest. + * + * The whole tail used to be painted in the status colour, which made a routine + * "0/1 agents · 9m51s · ↓ to manage" shout as loudly as a failure. The status + * already has a coloured square on the left edge, so the tail only borrows that + * colour for the one count that carries the outcome — and only once the run has + * settled, where the colour means something. + */ +export function renderNavigationMetrics( + theme: Theme, + parts: readonly (string | undefined)[], + hint: string, + emphasis?: Parameters[0], +) { + const present = parts.filter((part): part is string => Boolean(part)); + const styled = present.map((part, index) => + index === 0 && emphasis + ? theme.fg(emphasis, part) + : theme.fg("muted", part), + ); + return [...styled, theme.fg("dim", hint)].join(theme.fg("dim", " · ")); +} + /** Fit a left label and right metrics into exactly one bounded terminal row. */ export function fitNavigationSides(left: string, right: string, width: number) { const boundedRight = truncateToWidth(right, Math.max(0, width - 4), "…"); diff --git a/extensions/shared/screen-chrome.test.ts b/extensions/shared/screen-chrome.test.ts new file mode 100644 index 00000000..f82415df --- /dev/null +++ b/extensions/shared/screen-chrome.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { + hintLine, + overflowNote, + panelFrame, + screenTitleLine, +} from "./screen-chrome.ts"; + +const theme = { + fg: (color: string, text: string) => `<${color}>${text}`, + bold: (text: string) => text, +} as unknown as Parameters[0]; + +const plain = { + fg: (_color: string, text: string) => text, + bold: (text: string) => text, +} as unknown as Parameters[0]; + +test("keys read brighter than what they do, and a notice takes the line", () => { + const line = hintLine( + theme, + [["esc", "close"], ["x", ""], ["", "3-9/40"], undefined], + 120, + ); + assert.match(line, /esc<\/muted> close<\/dim>/); + // A bare key is still a key; a keyless segment is plain status text. + assert.match(line, /x<\/muted>/); + assert.match(line, /3-9\/40<\/dim>/); + assert.equal( + hintLine(theme, [["esc", "close"]], 120, "report saved"), + " report saved", + ); +}); + +test("the frame is padded to an exact height and never exceeds its width", () => { + const lines = panelFrame(plain, { + label: "agents · 1/3 settled", + rows: ["one", "two"], + width: 40, + height: 6, + }); + assert.equal(lines.length, 6); + for (const line of lines) assert.equal(visibleWidth(line), 40); + // A narrow frame still closes: the label is what gives way, not the border. + for (const width of [4, 10, 20]) { + const narrow = panelFrame(plain, { + label: "a very long panel label", + rows: [], + width, + height: 3, + }); + assert.equal(narrow.length, 3); + for (const line of narrow) assert.equal(visibleWidth(line), width); + } +}); + +test("pre-styled labels and metas are passed through, not repainted", () => { + // A task census colours each state itself; wrapping it in one more colour + // would only apply up to its first inner reset. Real SGR runs, not the fake + // theme's tags: detection keys on ESC, so tags alone would never take the + // pass-through branch and this test would pass on a repainting version. + const census = "\u001b[32m3 done\u001b[0m"; + assert.equal( + panelFrame(theme, { + label: census, + rows: [], + width: 40, + height: 3, + })[0]!.includes(` ${census} `), + false, + ); + assert.match( + panelFrame(theme, { label: census, rows: [], width: 40, height: 3 })[0]!, + /\u001b\[32m3 done\u001b\[0m/, + ); + const titled = screenTitleLine(theme, "Tasks", census, 40); + assert.match(titled, /\u001b\[32m3 done\u001b\[0m/); + assert.equal(titled.includes(`${census}`), false); + assert.match(screenTitleLine(theme, "Tasks", "4 items", 40), /4 items { + for (const width of [3, 12, 30, 80]) { + const line = screenTitleLine( + plain, + "Background terminals", + "9 running", + width, + ); + assert.ok(visibleWidth(line) <= width, `width ${width}: ${line}`); + assert.equal(line.includes("\n"), false); + } + assert.ok(visibleWidth(overflowNote(plain, 3, 12, "below")) <= 12); +}); diff --git a/extensions/shared/screen-chrome.ts b/extensions/shared/screen-chrome.ts new file mode 100644 index 00000000..c02d57dd --- /dev/null +++ b/extensions/shared/screen-chrome.ts @@ -0,0 +1,133 @@ +import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; + +/** + * Only the two paint calls this chrome makes, following the footer's precedent: + * views that carry a narrowed theme object can use it without a cast. + */ +type Theme = Pick; + +/** + * Some labels arrive already styled (a task census colours each state). Painting + * a colour over them would apply only up to their first inner reset, leaving the + * rest a different shade than the caller asked for, so pre-styled text is passed + * through untouched. + */ +const isStyled = (text: string) => text.includes("\u001b"); + +/** + * The chrome every full-screen OpenPI view shares: a title line, one bordered + * panel, a hint line, and one way to say "there is more above/below". + * + * Three views had grown their own copy of this (`/subagents`, `/ps`, + * `/workflows`), and they had drifted apart in the details you notice without + * being able to name: one framed the body in `border`, another in + * `borderMuted`; one wrote `... 3 more` and another `… 3 more`; hints were a + * single dim run in which the keys you are supposed to press read exactly as + * faint as the prose describing them. Fixing that in one place is also the only + * way it stays fixed. + */ + +/** Title left, quiet census right, with the same one-column inset as the panel. */ +export function screenTitleLine( + theme: Theme, + title: string, + meta: string, + width: number, +) { + const left = ` ${theme.bold(theme.fg("accent", title))}`; + const right = meta ? `${isStyled(meta) ? meta : theme.fg("dim", meta)} ` : ""; + const rightWidth = visibleWidth(right); + const fittedLeft = truncateToWidth( + left, + Math.max(0, width - rightWidth - 1), + "…", + ); + const pad = Math.max(1, width - visibleWidth(fittedLeft) - rightWidth); + return truncateToWidth(fittedLeft + " ".repeat(pad) + right, width, ""); +} + +/** + * Bordered panel with an optional label set into the top edge, padded to an + * exact height so a view's overlay never changes size as content streams in. + */ +export function panelFrame( + theme: Theme, + options: { + label?: string; + rows: readonly string[]; + width: number; + height: number; + }, +) { + const { label = "", rows, width, height } = options; + const inner = Math.max(0, width - 2); + const border = (text: string) => theme.fg("borderMuted", text); + // The label rides the border rather than sitting above it, so it reads as + // this panel's name instead of competing with the screen title. + const clippedLabel = label + ? truncateToWidth(` ${label} `, Math.max(0, inner - 2)) + : ""; + const labelText = clippedLabel + ? isStyled(clippedLabel) + ? clippedLabel + : theme.fg("muted", clippedLabel) + : ""; + const dashes = Math.max(0, inner - visibleWidth(labelText) - 1); + const lines = [border("╭─") + labelText + border("─".repeat(dashes) + "╮")]; + const bodyHeight = Math.max(0, height - 2); + for (let index = 0; index < bodyHeight; index += 1) { + const clipped = truncateToWidth(rows[index] ?? "", inner, "…"); + const pad = Math.max(0, inner - visibleWidth(clipped)); + lines.push(border("│") + clipped + " ".repeat(pad) + border("│")); + } + lines.push(border("╰" + "─".repeat(inner) + "╯")); + return lines; +} + +/** One `keys label` pair of a hint line. */ +export type ScreenHint = readonly [keys: string, label: string]; + +/** + * Keys read one step brighter than what they do, so the line scans as a + * keyboard legend instead of a sentence. A notice takes the whole line when + * there is one: it is the answer to what you just pressed, and the legend can + * wait a beat. + */ +export function hintLine( + theme: Theme, + hints: readonly (ScreenHint | undefined)[], + width: number, + notice?: string, +) { + if (notice) { + return truncateToWidth(theme.fg("accent", ` ${notice}`), width, "…"); + } + const parts = hints + .filter((hint): hint is ScreenHint => Boolean(hint)) + .map(([keys, label]) => { + // An empty key slot is a plain status segment (a scroll position, say); + // an empty label is a bare key. Both stay dimmer than a real key. + if (!keys) return theme.fg("dim", label); + if (!label) return theme.fg("muted", keys); + return `${theme.fg("muted", keys)} ${theme.fg("dim", label)}`; + }); + return truncateToWidth( + ` ${parts.join(theme.fg("dim", " · "))}`, + width, + theme.fg("dim", "…"), + ); +} + +/** Single vocabulary for a clipped list: `… 3 more agents`. */ +export function overflowNote( + theme: Theme, + count: number, + width: number, + noun = "", +) { + return truncateToWidth( + theme.fg("dim", ` … ${count} more${noun ? ` ${noun}` : ""}`), + width, + ); +} diff --git a/extensions/subagents/navigation.test.ts b/extensions/subagents/navigation.test.ts index 240f08ee..3e7ea81a 100644 --- a/extensions/subagents/navigation.test.ts +++ b/extensions/subagents/navigation.test.ts @@ -36,6 +36,11 @@ function snapshot( }; } +const markingTheme = { + fg: (color: string, text: string) => `<${color}>${text}`, + bold: (text: string) => text, +} as unknown as Theme; + const theme = { fg: (_color: string, text: string) => text, bold: (text: string) => text, @@ -107,3 +112,42 @@ test("subagent strip matches Workflow's bounded one-line affordance", () => { widget.dispose(); } }); + +test("the metrics tail stays quiet while a run is healthy", () => { + const strip = new BelowEditorStripState(); + const render = (status: SubagentSnapshot["status"]) => { + const entry = selectSubagentStripEntry( + [ + snapshot( + "sa-1", + status, + Date.now() - 2_000, + status === "running" ? undefined : Date.now(), + ), + ], + 0, + ); + const widget = new SubagentStripWidget( + { requestRender() {} } as unknown as TUI, + markingTheme, + strip, + () => entry, + ); + try { + return widget.render(400)[0]!; + } finally { + widget.dispose(); + } + }; + + // A routine run borrows no status colour in its tail: the coloured square on + // the left already carries the state, and hints recede furthest of all. + const running = render("running"); + assert.match(running, /0\/1 agents<\/muted>/); + assert.match(running, /↓ to manage<\/dim>/); + assert.doesNotMatch(running, /0\/1 agents/); + + // Once settled, the one count that carries the outcome takes the colour. + assert.match(render("error"), /1\/1 agents<\/error>/); + assert.match(render("done"), /1\/1 agents<\/success>/); +}); diff --git a/extensions/subagents/navigation.ts b/extensions/subagents/navigation.ts index 499b21d6..585cb905 100644 --- a/extensions/subagents/navigation.ts +++ b/extensions/subagents/navigation.ts @@ -2,6 +2,7 @@ import type { Theme } from "@earendil-works/pi-coding-agent"; import type { TUI } from "@earendil-works/pi-tui"; import { fitNavigationSides, + renderNavigationMetrics, type BelowEditorStripState, } from "../shared/below-editor-navigation.ts"; import { @@ -107,15 +108,16 @@ export class SubagentStripWidget { const left = ` ${marker} ${statusSquare(snapshot, this.theme)} ${title}${model ? this.theme.fg("dim", ` · ${model}`) : ""}`; const settled = counts.done + counts.failed; const total = counts.running + settled; - const metrics = [ - `${settled}/${total} agents`, - formatElapsed(snapshot), - formatContextUtilization(snapshot.usage), + const right = renderNavigationMetrics( + this.theme, + [ + `${settled}/${total} agents`, + formatElapsed(snapshot), + formatContextUtilization(snapshot.usage), + ], this.strip.focused ? "enter open · ↑ back" : "↓ to manage", - ] - .filter((part): part is string => Boolean(part)) - .join(" · "); - const right = this.theme.fg(statusColor(snapshot.status), metrics); + snapshot.status === "running" ? undefined : statusColor(snapshot.status), + ); return [fitNavigationSides(left, right, width)]; } } diff --git a/extensions/subagents/src/ui/takeover.ts b/extensions/subagents/src/ui/takeover.ts index 77dbf24c..e33d5fff 100644 --- a/extensions/subagents/src/ui/takeover.ts +++ b/extensions/subagents/src/ui/takeover.ts @@ -13,15 +13,20 @@ import type { } from "@earendil-works/pi-coding-agent"; import type { Component, Focusable, TUI } from "@earendil-works/pi-tui"; import { Input, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import { + hintLine, + panelFrame, + type ScreenHint, +} from "../../../shared/screen-chrome.ts"; import { sanitizeTerminalText } from "../../../shared/terminal-text.ts"; import { formatElapsed, type SubagentSnapshot } from "../domain.ts"; import { formatContextUtilization } from "../format.ts"; import type { SubagentReadModel } from "../manager.ts"; import { - SPINNER_INTERVAL_MS, - TranscriptRenderer, buildTranscriptLines, + SPINNER_INTERVAL_MS, spinnerFrame, + TranscriptRenderer, } from "./transcript.ts"; export function sanitizeSubagentDisplayLine(value: string) { @@ -258,24 +263,6 @@ export class SubagentDashboard implements Component { } } - private pad(text: string, width: number): string { - const truncated = truncateToWidth(text, width); - return truncated + " ".repeat(Math.max(0, width - visibleWidth(truncated))); - } - - private borderSegment(width: number, title: string): string { - const theme = this.theme; - const label = title - ? ` ${truncateToWidth(title, Math.max(0, width - 3))} ` - : ""; - const labelWidth = visibleWidth(label); - return ( - theme.fg("border", "─") + - (label ? theme.fg("text", label) : "") + - theme.fg("border", "─".repeat(Math.max(0, width - 1 - labelWidth))) - ); - } - render(width: number): string[] { const theme = this.theme; const subs = this.subs(); @@ -289,7 +276,6 @@ export class SubagentDashboard implements Component { subs.length > maxBodyHeight ? maxBodyHeight : Math.max(1, subs.length); const innerWidth = Math.max(0, width - 2); - const lines: string[] = []; const running = subs.filter((snap) => snap.status === "running").length; const done = subs.filter((snap) => snap.status === "done").length; const failed = subs.filter((snap) => snap.status === "error").length; @@ -301,37 +287,30 @@ export class SubagentDashboard implements Component { ] .filter(Boolean) .join(" · ") || "no agents"; - lines.push( - theme.fg("border", "╭") + - this.borderSegment(innerWidth, `Subagents · ${summary}`) + - theme.fg("border", "╮"), - ); - - const divider = theme.fg("border", "│"); const rowLines = this.renderRows(subs, innerWidth, bodyHeight, now); - for (const row of rowLines) { - lines.push(divider + this.pad(row, innerWidth) + divider); - } - - // Bottom border - lines.push( - theme.fg("border", "╰") + - theme.fg("border", "─".repeat(innerWidth)) + - theme.fg("border", "╯"), - ); - - // Hints - lines.push( - truncateToWidth( - theme.fg( - "dim", - ` ${configuredKeys(this.keybindings, "tui.select.up")}/${configuredKeys(this.keybindings, "tui.select.down")}/jk select · ${configuredKeys(this.keybindings, "tui.select.confirm")} take over · x abort · ${configuredKeys(this.keybindings, "tui.select.cancel")} close`, - ), + const keys = (binding: Parameters[0]) => + configuredKeys(this.keybindings, binding); + // Shared chrome, so this panel and its hints read the same as /ps and + // /workflows. The frame is padded to the rows it was given, which keeps + // this view's content-fit height rather than reintroducing a fixed one. + return [ + ...panelFrame(theme, { + label: `Subagents · ${summary}`, + rows: rowLines, + width, + height: rowLines.length + 2, + }), + hintLine( + theme, + [ + [`${keys("tui.select.up")}/${keys("tui.select.down")}/jk`, "select"], + [keys("tui.select.confirm"), "take over"], + ["x", "abort"], + [keys("tui.select.cancel"), "close"], + ], width, ), - ); - - return lines; + ]; } private renderRows( @@ -694,13 +673,29 @@ export class TakeoverView implements Component, Focusable { ), ); lines.push(...this.input.render(width)); - const hints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll · ${configuredKeys(this.keybindings, "tui.editor.pageUp")}/${configuredKeys(this.keybindings, "tui.editor.pageDown")} page`; - const compactHints = `${configuredKeys(this.keybindings, "tui.input.submit")} send · ${configuredKeys(this.keybindings, "app.interrupt")} back · ${configuredKeys(this.keybindings, "app.clear")} abort run · ${configuredKeys(this.keybindings, "tui.editor.cursorUp")}/${configuredKeys(this.keybindings, "tui.editor.cursorDown")} scroll`; + const keys = (binding: Parameters[0]) => + configuredKeys(this.keybindings, binding); + const editing: ScreenHint[] = [ + [keys("tui.input.submit"), "send"], + [keys("app.interrupt"), "back"], + [keys("app.clear"), "abort run"], + [ + `${keys("tui.editor.cursorUp")}/${keys("tui.editor.cursorDown")}`, + "scroll", + ], + ]; + // Same drop-the-page-hint fallback as before, measured on the styled line + // so the keys-brighter-than-labels styling cannot change what fits. + const full = hintLine( + theme, + [ + ...editing, + [`${keys("tui.editor.pageUp")}/${keys("tui.editor.pageDown")}`, "page"], + ], + width, + ); lines.push( - truncateToWidth( - theme.fg("dim", visibleWidth(hints) <= width ? hints : compactHints), - width, - ), + visibleWidth(full) <= width ? full : hintLine(theme, editing, width), ); lines.push(this.rule(width, theme.fg("borderAccent", "─"))); return lines; diff --git a/extensions/tasks/ui.test.ts b/extensions/tasks/ui.test.ts index 41ed04be..32ec637a 100644 --- a/extensions/tasks/ui.test.ts +++ b/extensions/tasks/ui.test.ts @@ -67,7 +67,7 @@ test("persistent task widget matches a compact Claude-style task panel", () => { // hides the dropped one, so including it would not add up against the rows. assert.match( lines[0]!, - /◆ Tasks\s+3 tasks \(1 done, 1 in progress, 1 open\)/, + /◆ Tasks\s+3 tasks · 1 done · 1 in progress · 1 open/, ); assert.match(lines[0]!, /\/tasks/); assert.doesNotMatch(lines[0]!, /ctrl\+shift\+t/); @@ -89,6 +89,8 @@ test("persistent task widget matches a compact Claude-style task panel", () => { 18, ); assert.ok(narrow.every((line) => visibleWidth(line) <= 18)); + // Right-edge hints are dropped, not truncated, when the census needs the room. + assert.doesNotMatch(narrow[0]!, /\/tas/); assert.deepEqual( renderTaskWidget( { @@ -177,10 +179,7 @@ test("collapsed tool results remain bounded", () => { assert.match(output, /… 3 more/); // The census header replaces the old "N total · revision N" footer: it says // more, and it says it before the rows rather than after them. - assert.match( - output.split("\n")[0]!, - /8 tasks \(0 done, 0 in progress, 8 open\)/, - ); + assert.match(output.split("\n")[0]!, /8 tasks · all open/); }); test("a record without counts shows no census rather than a wrong one", () => { @@ -278,12 +277,12 @@ test("settled subjects are struck through, live ones are not", () => { } }); -test("the census line always shows the same three states, plus exceptions", () => { +test("the census line names only the states that are actually occupied", () => { const summary = (items: Parameters[0]) => renderTaskSummary(taskCounts(items), theme); - // done/in progress/open are listed even at zero, so the line keeps a stable - // shape as work moves between them instead of reflowing on every update. + // Zero-count states are dropped: a segment costs a slot to say nothing, and + // one appearing when work starts is a signal rather than a glitch. assert.equal( summary([ { id: 1, subject: "a", status: "done", note: "ok" }, @@ -291,21 +290,24 @@ test("the census line always shows the same three states, plus exceptions", () = { id: 3, subject: "c", status: "done", note: "ok" }, { id: 4, subject: "d", status: "in_progress" }, ]), - "4 tasks (3 done, 1 in progress, 0 open)", + "4 tasks · 3 done · 1 in progress", ); - // blocked and dropped are exceptional; they earn a slot only when non-zero. - // Order runs by how far along the work is, so blocked sits ahead of open. + // A single status covering the whole batch collapses to "all", so the count + // is not repeated on both sides of the separator. assert.equal( summary([{ id: 1, subject: "a", status: "pending" }]), - "1 task (0 done, 0 in progress, 1 open)", + "1 task · all open", ); + assert.equal(summary([]), "no tasks"); + + // Order runs by how far along the work is, so blocked sits ahead of open. assert.equal( summary([ { id: 1, subject: "a", status: "blocked", note: "waiting" }, { id: 2, subject: "b", status: "dropped", note: "cut" }, ]), - "2 tasks (0 done, 0 in progress, 1 blocked, 0 open, 1 dropped)", + "2 tasks · 1 blocked · 1 dropped", ); }); @@ -332,6 +334,6 @@ test("the tool result header counts the batch, not the rows it happens to show", ); assert.match( component.render(100).join("\n").split("\n")[0]!, - /4 tasks \(3 done, 1 in progress, 0 open\)/, + /4 tasks · 3 done · 1 in progress/, ); }); diff --git a/extensions/tasks/ui.ts b/extensions/tasks/ui.ts index 1c39ca80..9b632415 100644 --- a/extensions/tasks/ui.ts +++ b/extensions/tasks/ui.ts @@ -4,15 +4,35 @@ import type { Theme, } from "@earendil-works/pi-coding-agent"; import { + type Component, Key, matchesKey, Text, - truncateToWidth, - type Component, type TUI, + truncateToWidth, + visibleWidth, } from "@earendil-works/pi-tui"; +import { fitNavigationSides } from "../shared/below-editor-navigation.ts"; +import { + hintLine, + panelFrame, + screenTitleLine, +} from "../shared/screen-chrome.ts"; import type { TaskItem, TaskSnapshot } from "./tasks.ts"; +/** + * One colour per status, shared by every surface. The widget used to paint + * in-progress amber while the full list painted it accent, so the same item + * changed colour depending on where you looked at it. + */ +const STATUS_COLOR = { + pending: "muted", + in_progress: "accent", + blocked: "warning", + done: "success", + dropped: "error", +} as const satisfies Record; + const STATUS_ICON: Record = { pending: "○", in_progress: "●", @@ -86,45 +106,41 @@ export function taskCounts(items: readonly TaskItem[]): TaskCounts { } /** - * One-line census: `4 tasks (3 done, 1 in progress, 0 open)`. + * One-line census: `4 tasks · 3 done · 1 open`. * - * Counts carry the weight and the words recede, so the shape of the batch - * reads before any individual row does. `done`, `in progress`, and `open` are - * always listed even at zero — a stable set of three keeps the line from - * reflowing as work moves between them. `blocked` and `dropped` are - * exceptional and appear only when they are not zero. + * Colour carries the status and the total anchors the line, so nothing needs + * bold numbers alternating with dim words — that zebra was the loudest thing + * on screen and said the least. Zeros are dropped: "0 in progress" costs a + * segment to tell you nothing, and a segment appearing when work starts is a + * signal, not a glitch. When one status covers everything the redundant count + * collapses to `all`, so a fresh batch reads `8 tasks · all open` rather than + * `8 tasks · 8 open`. * * Takes counts rather than items because a view often shows a bounded subset * of rows; the header must describe the whole batch regardless. */ export function renderTaskSummary(counts: TaskCounts, theme: Theme): string { - const number = (value: number) => theme.bold(theme.fg("text", String(value))); - const dim = (text: string) => theme.fg("dim", text); // Coerced, not trusted: these counts can arrive from a tool-result record // persisted by an older build, where a missing key would render the literal // word "undefined" (or "NaN tasks") into the header. const count = (status: TaskItem["status"]) => Number.isFinite(counts[status]) ? counts[status] : 0; const total = Number.isFinite(counts.total) ? counts.total : 0; + if (total <= 0) return theme.fg("dim", "no tasks"); + const present = SUMMARY_ORDER.filter((status) => count(status) > 0); // Built segment by segment rather than by wrapping the whole line: each // styled run emits its own reset, so an outer color would stop applying at // the first inner one. - const parts = SUMMARY_ORDER.filter( - (status) => - status === "done" || - status === "in_progress" || - status === "pending" || - count(status) > 0, - ).map((status) => `${number(count(status))} ${dim(SUMMARY_LABEL[status])}`); + const chips = present.map((status) => + theme.fg( + STATUS_COLOR[status], + `${present.length === 1 && count(status) === total ? "all" : count(status)} ${SUMMARY_LABEL[status]}`, + ), + ); return [ - number(total), - " ", - dim(total === 1 ? "task" : "tasks"), - " ", - dim("("), - parts.join(dim(", ")), - dim(")"), - ].join(""); + theme.fg("dim", `${total} ${total === 1 ? "task" : "tasks"}`), + ...chips, + ].join(theme.fg("dim", " · ")); } export interface TaskToolDetails { @@ -150,16 +166,7 @@ export function renderTaskRows( // (all of them) — would otherwise shift every subject sideways by a column. const idWidth = Math.max(...items.map((item) => `T${item.id}`.length), 3); return items.flatMap((item) => { - const color = - item.status === "done" - ? "success" - : item.status === "blocked" - ? "warning" - : item.status === "dropped" - ? "error" - : item.status === "in_progress" - ? "accent" - : "muted"; + const color = STATUS_COLOR[item.status]; // No `[status]` text: the icon, its color, and the subject's own weight // already say it, and repeating it in words crowded every row. const id = `T${item.id}`.padStart(idWidth); @@ -215,17 +222,23 @@ export function renderTaskWidget( const hasOverflow = actionable.length > TASK_WIDGET_LIMIT; const toggleHint = hasOverflow - ? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}` + ? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}` : ""; // Same census as the full list and the /tasks screen. Counted over `tracked` // rather than every item, because the widget deliberately hides dropped work // and a total that included it would not add up against the rows shown. + // + // Hints sit on the right edge instead of trailing the census, so the eye lands + // on state first and the keystrokes stay out of the way until wanted. They are + // dropped rather than truncated when the terminal is too narrow to hold both. + const label = + theme.fg("accent", "◆ ") + theme.fg("text", theme.bold("Tasks")); + const left = `${label} ${renderTaskSummary(taskCounts(tracked), theme)}`; + const hint = theme.fg("dim", `/tasks${toggleHint}`); const header = - theme.fg("accent", "◆ ") + - theme.fg("text", theme.bold("Tasks")) + - " " + - renderTaskSummary(taskCounts(tracked), theme) + - theme.fg("dim", ` · /tasks${toggleHint}`); + visibleWidth(left) + visibleWidth(hint) + 3 <= width + ? fitNavigationSides(left, hint, width) + : left; const visible = expanded ? actionable : actionable.slice(0, TASK_WIDGET_LIMIT); @@ -235,12 +248,7 @@ export function renderTaskWidget( const idWidth = Math.max(...visible.map((i) => `T${i.id}`.length), 3); const lines = [truncateToWidth(header, width)]; for (const [index, item] of visible.entries()) { - const color = - item.status === "in_progress" - ? "warning" - : item.status === "blocked" - ? "error" - : "muted"; + const color = STATUS_COLOR[item.status]; const branch = index === visible.length - 1 && hidden === 0 ? "╰─" : "├─"; lines.push( truncateToWidth( @@ -379,27 +387,36 @@ class TasksScreen implements Component { } render(width: number) { - const body = renderTaskRows(this.snapshot.items, this.theme, width - 4); - const rows = Math.max(8, (this.tui.terminal.rows || 30) - 8); + const theme = this.theme; + const counts = taskCounts(this.snapshot.items); + const body = renderTaskRows(this.snapshot.items, theme, width - 4); + // Title (1) + frame (2) + hint (1): one row more chrome than the old bare + // rule, so the body gives one back and the screen keeps its total height. + const rows = Math.max(8, (this.tui.terminal.rows || 30) - 9); const maxOffset = Math.max(0, body.length - rows); this.offset = Math.min(this.offset, maxOffset); const visible = body.slice(this.offset, this.offset + rows); - const lines = [ - truncateToWidth( - `${this.theme.fg("accent", this.theme.bold("Session tasks"))} ${renderTaskSummary(taskCounts(this.snapshot.items), this.theme)}`, + // Framed like /subagents, /ps, and /workflows rather than a bare rule: a + // full-screen view of a list is the same object in each of them, and it + // should not look like a different control here. + return [ + screenTitleLine(theme, "Session tasks", "", width), + ...panelFrame(theme, { + label: renderTaskSummary(counts, theme), + rows: visible.map((line) => ` ${line}`), width, - ), - this.theme.fg("border", "─".repeat(Math.max(0, width))), - ...visible.map((line) => truncateToWidth(` ${line}`, width)), - ]; - while (lines.length < rows + 2) lines.push(""); - lines.push( - truncateToWidth( - this.theme.fg("dim", "j/k or ↑/↓ scroll · pgup/pgdn page · esc close"), + height: rows + 2, + }), + hintLine( + theme, + [ + ["j/k or ↑/↓", "scroll"], + ["pgup/pgdn", "page"], + ["esc", "close"], + ], width, ), - ); - return lines; + ]; } invalidate() {} diff --git a/extensions/workflows/dashboard.ts b/extensions/workflows/dashboard.ts index 3003a782..9893f40b 100644 --- a/extensions/workflows/dashboard.ts +++ b/extensions/workflows/dashboard.ts @@ -14,51 +14,57 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { - getAgentDir, type ExtensionContext, + getAgentDir, type KeybindingsManager, } from "@earendil-works/pi-coding-agent"; import { Key, matchesKey, + type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi, - type TUI, } from "@earendil-works/pi-tui"; +import { + panelFrame, + type ScreenHint, + screenTitleLine, + hintLine as sharedHintLine, +} from "../shared/screen-chrome.ts"; +import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { isAcceptanceLedger } from "./acceptance.ts"; +import { projectWorkflowGraph } from "./graph-projection.ts"; import { + classifyInterruptedInvocation, + decodeInvocationRecord, +} from "./invocation-ledger.ts"; +import { + type AgentRecord, + type AgentUsage, agentContext, + aggregateUsage, countStates, formatElapsed, formatUsage, - aggregateUsage, isWorkflowRunId, MAX_LOG_TEXT, + type PhaseGroup, phaseGroups, - resultJson, resolveWorkflowRunTarget, + resultJson, + SQUARE, sanitizeLine, shortenHome, stateSquare, statusColor, statusWord, - SQUARE, type Theme, - type AgentRecord, - type AgentUsage, - type PhaseGroup, type TranscriptEntry, type WorkflowDetails, type WorkflowLogEntry, workflowGraphRecords, } from "./model.ts"; -import { sanitizeTerminalText } from "../shared/terminal-text.ts"; -import { projectWorkflowGraph } from "./graph-projection.ts"; -import { - classifyInterruptedInvocation, - decodeInvocationRecord, -} from "./invocation-ledger.ts"; import { writeFileAtomic } from "./serialization.ts"; const NOTICE_TTL_MS = 4000; @@ -887,23 +893,7 @@ export class WorkflowDashboard { width: number, height: number, ): string[] { - const theme = this.theme; - const inner = Math.max(0, width - 2); - const border = (s: string) => theme.fg("borderMuted", s); - const titleText = truncateToWidth(` ${title} `, Math.max(0, inner - 2)); - const dashes = Math.max(0, inner - visibleWidth(titleText) - 1); - const lines: string[] = [ - border("╭─") + titleText + border("─".repeat(dashes) + "╮"), - ]; - const bodyHeight = Math.max(0, height - 2); - for (let i = 0; i < bodyHeight; i++) { - const row = rows[i] ?? ""; - const clipped = truncateToWidth(row, inner, "…"); - const pad = Math.max(0, inner - visibleWidth(clipped)); - lines.push(border("│") + clipped + " ".repeat(pad) + border("│")); - } - lines.push(border("╰" + "─".repeat(inner) + "╯")); - return lines; + return panelFrame(this.theme, { label: title, rows, width, height }); } /** Scroll window keeping `selected` visible. */ @@ -924,25 +914,21 @@ export class WorkflowDashboard { return this.keybindings.getKeys(binding).join("/") || "unbound"; } - private hintLine(hint: string, width: number): string { - const theme = this.theme; - if (this.notice) - return truncateToWidth(theme.fg("accent", ` ${this.notice}`), width); - return truncateToWidth(theme.fg("dim", ` ${hint}`), width); + private hintLine(hints: readonly ScreenHint[], width: number): string { + return sharedHintLine(this.theme, hints, width, this.notice); } private renderList(width: number, height: number): string[] { const theme = this.theme; const lines: string[] = []; - const header = this.split( - " " + theme.bold(theme.fg("accent", "Workflows")), - theme.fg( - "dim", - `${this.entries.length} run${this.entries.length === 1 ? "" : "s"} `, + lines.push( + screenTitleLine( + theme, + "Workflows", + `${this.entries.length} run${this.entries.length === 1 ? "" : "s"}`, + width, ), - width, ); - lines.push(header); const panelHeight = height - 2; const bodyHeight = Math.max(0, panelHeight - 2); @@ -957,7 +943,7 @@ export class WorkflowDashboard { ), ); lines.push( - this.hintLine(`${this.keys("tui.select.cancel")} close`, width), + this.hintLine([[this.keys("tui.select.cancel"), "close"]], width), ); return lines; } @@ -991,7 +977,15 @@ export class WorkflowDashboard { lines.push(...this.panel("Runs", rows, width, panelHeight)); lines.push( this.hintLine( - `${this.keys("tui.select.up")}/${this.keys("tui.select.down")} select · ${this.keys("tui.select.confirm")} open · x stop · ${this.keys("tui.select.cancel")} close`, + [ + [ + `${this.keys("tui.select.up")}/${this.keys("tui.select.down")}`, + "select", + ], + [this.keys("tui.select.confirm"), "open"], + ["x", "stop"], + [this.keys("tui.select.cancel"), "close"], + ], width, ), ); @@ -1170,11 +1164,32 @@ export class WorkflowDashboard { ); } - const hint = + const hints: ScreenHint[] = this.detailFocus === "phases" - ? `j/k select phase · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} agents · ${this.keys("tui.select.cancel")} back · x stop · s save report` - : `j/k select agent · h/${this.keys("tui.editor.cursorLeft")}/${this.keys("tui.select.cancel")} phases · l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")} details · x stop · s save report`; - lines.push(this.hintLine(hint, width)); + ? [ + ["j/k", "select phase"], + [ + `l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")}`, + "agents", + ], + [this.keys("tui.select.cancel"), "back"], + ["x", "stop"], + ["s", "save report"], + ] + : [ + ["j/k", "select agent"], + [ + `h/${this.keys("tui.editor.cursorLeft")}/${this.keys("tui.select.cancel")}`, + "phases", + ], + [ + `l/${this.keys("tui.editor.cursorRight")}/${this.keys("tui.select.confirm")}`, + "details", + ], + ["x", "stop"], + ["s", "save report"], + ]; + lines.push(this.hintLine(hints, width)); return lines; } @@ -1261,7 +1276,12 @@ export class WorkflowDashboard { lines.push(...this.panel(position, visible, width, panelHeight)); lines.push( this.hintLine( - "j/k scroll · ctrl-u/d page · g/G top/bottom · h/left/esc back", + [ + ["j/k", "scroll"], + ["ctrl-u/d", "page"], + ["g/G", "top/bottom"], + ["h/left/esc", "back"], + ], width, ), ); diff --git a/extensions/workflows/navigation.ts b/extensions/workflows/navigation.ts index ee221d53..256bd5fd 100644 --- a/extensions/workflows/navigation.ts +++ b/extensions/workflows/navigation.ts @@ -4,6 +4,7 @@ import { BelowEditorStripState, belowEditorStripInput, fitNavigationSides, + renderNavigationMetrics, } from "../shared/below-editor-navigation.ts"; import { sanitizeTerminalText } from "../shared/terminal-text.ts"; import { @@ -79,15 +80,16 @@ export class WorkflowStripWidget { const rawContext = details.currentPhase ?? details.description; const context = rawContext ? cleanLine(rawContext) : undefined; const left = ` ${marker} ${statusSquare(details.status, this.theme)} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`; - const metrics = [ - `${settled}/${details.agents.length} agents`, - formatElapsed(details.startedAt, details.finishedAt), - tokenCount > 0 ? `${formatTokens(tokenCount)} tokens` : undefined, + const right = renderNavigationMetrics( + this.theme, + [ + `${settled}/${details.agents.length} agents`, + formatElapsed(details.startedAt, details.finishedAt), + tokenCount > 0 ? `${formatTokens(tokenCount)} tokens` : undefined, + ], this.strip.focused ? "enter open · ↑ back" : "↓ to manage", - ] - .filter((part): part is string => Boolean(part)) - .join(" · "); - const right = this.theme.fg(statusColor(details.status), metrics); + details.status === "running" ? undefined : statusColor(details.status), + ); return [fitNavigationSides(left, right, width)]; } } From 715f1fc4e3adcb8ef4ff77cf39064258ce393a24 Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 22 Aug 2026 10:25:39 +0800 Subject: [PATCH 2/3] perf(subagents): stabilize delegate tool surface --- README.md | 4 +- extensions/capabilities/index.test.ts | 10 +++ .../shared/tool-surface.integration.test.ts | 14 ++-- extensions/shared/tool-surface.test.ts | 17 +++- extensions/shared/tool-surface.ts | 5 +- extensions/subagents/index.test.ts | 79 ++++++++++++++++++- extensions/subagents/index.ts | 12 +-- 7 files changed, 115 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c63d2a26..8e1493ed 100644 --- a/README.md +++ b/README.md @@ -453,7 +453,7 @@ pi install npm:pi-intercom
模型工具速查 -Capability discovery 默认是 `explicit`:普通父 Session 不常驻任何 OpenPI 模型工具,首轮保持 Pi 原生 `read`、`bash`、`edit`、`write`。用户明确要求结构化搜索、Subagent、Workflow、后台进程或 Session Goal/Tasks 时,OpenPI 在 `before_agent_start` 直接加载对应能力组;明确询问 OpenPI capabilities/tools/features 时显示 `openpi_load_tools`。可通过 `/openpi-setup` 显式选择 `adaptive`:此时只让小型 `openpi_load_tools` 网关常驻,模型可在判断任务确实受益时自主加载一个能力组。该选择也授权模型启动该组内的昂贵工作,因此不作为默认值。条件句(例如 “If you delegate…”)不会被当成显式委派意图。能力组在当前 Session 内单调保持,避免反复增删工具破坏缓存。组内管理工具仍只在资源成功创建或状态确实存在后出现。Mode / Setup / Context 工具独立跟随实时状态显示和隐藏。Background、Subagent 与 Workflow 的 Skill 文件仍随包发布,但只在对应能力触发后提示读取,不常驻普通系统 Prompt。 +Capability discovery 默认是 `explicit`:普通父 Session 不常驻任何 OpenPI 模型工具,首轮保持 Pi 原生 `read`、`bash`、`edit`、`write`。用户明确要求结构化搜索、Subagent、Workflow、后台进程或 Session Goal/Tasks 时,OpenPI 在 `before_agent_start` 直接加载对应能力组;明确询问 OpenPI capabilities/tools/features 时显示 `openpi_load_tools`。可通过 `/openpi-setup` 显式选择 `adaptive`:此时只让小型 `openpi_load_tools` 网关常驻,模型可在判断任务确实受益时自主加载一个能力组。该选择也授权模型启动该组内的昂贵工作,因此不作为默认值。条件句(例如 “If you delegate…”)不会被当成显式委派意图。能力组在当前 Session 内单调保持,避免反复增删工具破坏缓存。Delegate 一经加载便一次性开放完整、稳定的 Subagent 工具族;资源不存在时由工具执行层明确返回空状态或 fail-closed,而不再按实例生命周期改变模型接口。其他组内管理工具仍只在资源成功创建或状态确实存在后出现。Mode / Setup / Context 工具独立跟随实时状态显示和隐藏。Background、Subagent 与 Workflow 的 Skill 文件仍随包发布,但只在对应能力触发后提示读取,不常驻普通系统 Prompt。 普通产品默认采用 Pi-native execution:保留 Pi 原生完整历史、工具输出上限、Session compaction、显式 Bash timeout 与 provider loop,不再额外做固定事务投影、成功 Bash 二次裁剪、测试 timeout 改写、重复失败硬拦或恢复/轨迹提示。OpenPI 只保留独立的工作区安全边界:阻止未授权删除 pre-existing 路径,并从实际文件状态识别本轮通过原生写入、文字重定向或 literal `mkdir -p` 创建的 scratch,避免误拦其清理。旧执行策略仅保留为受 benchmark root 门控的实验 profile,不会进入普通 Session。 @@ -461,7 +461,7 @@ Capability discovery 默认是 `explicit`:普通父 Session 不常驻任何 Op | -------------------------------------------------------------------------------------------------------- | ------------------------------ | -------------------------------- | | `openpi_load_tools` | 列出或加载可选工具组 | 明确询问;或启用 `adaptive` | | `bg_start`, `bg_status`, `bg_list`, `bg_watch`, `bg_kill` | 后台进程生命周期 | 明确意图或 adaptive;启动后展开 | -| `subagent_spawn`, `subagent_check`, `subagent_list`, `subagent_wait`, `subagent_send`, `subagent_cancel` | 独立子 Agent | 明确意图或 adaptive;创建后展开 | +| `subagent_spawn`, `subagent_check`, `subagent_list`, `subagent_wait`, `subagent_send`, `subagent_cancel` | 独立子 Agent | 明确意图或 adaptive;整组稳定加载 | | `workflow`, `workflow_status`, `workflow_stop` | 动态多阶段编排与运行管理 | 明确意图或 adaptive;运行后展开 | | `tasks_add`, `tasks_update`, `tasks_list` | Session 工作项 | 明确意图或 adaptive;存在后展开 | | `get_goal`, `create_goal`, `update_goal` | Session Goal | 明确意图或 adaptive;存在后展开 | diff --git a/extensions/capabilities/index.test.ts b/extensions/capabilities/index.test.ts index 7d01474d..d05d8384 100644 --- a/extensions/capabilities/index.test.ts +++ b/extensions/capabilities/index.test.ts @@ -37,6 +37,11 @@ function harness(options: { discovery?: "explicit" | "adaptive" } = {}) { "fd", "rg", "subagent_spawn", + "subagent_wait", + "subagent_cancel", + "subagent_send", + "subagent_check", + "subagent_list", ]; let active = [...available]; const tools = new Map(); @@ -198,6 +203,11 @@ test("an explicit subagent request loads delegation directly", () => { "edit", "write", "subagent_spawn", + "subagent_wait", + "subagent_cancel", + "subagent_send", + "subagent_check", + "subagent_list", ]); assert.match(JSON.stringify(results), /skills\/subagents\/SKILL\.md/); }); diff --git a/extensions/shared/tool-surface.integration.test.ts b/extensions/shared/tool-surface.integration.test.ts index 2ffb4093..ec16058a 100644 --- a/extensions/shared/tool-surface.integration.test.ts +++ b/extensions/shared/tool-surface.integration.test.ts @@ -270,7 +270,7 @@ test("real Pi session starts with the compact OpenPI parent surface", async () = ); }); -test("real Pi session rebuilds tools and prompt after a capability and owner state activate its family", async () => { +test("real Pi session exposes one stable subagent family when delegate loads", async () => { let controller: Parameters[0] | undefined; const controllerFactory: ExtensionFactory = (pi) => { controller = pi; @@ -288,9 +288,8 @@ test("real Pi session rebuilds tools and prompt after a capability and owner sta "write", ]); patchOwnedTools(controller, "subagents", { - disable: OPENPI_TOOL_SURFACE.subagents.deferred, + enable: OPENPI_TOOL_SURFACE.subagents.entry, }); - const gateway = session.getToolDefinition("openpi_load_tools"); assert.ok(gateway); await gateway.execute( @@ -305,11 +304,12 @@ test("real Pi session rebuilds tools and prompt after a capability and owner sta "bash", "edit", "write", - "subagent_spawn", + ...OPENPI_TOOL_SURFACE.subagents.entry, ]); + const loadedPrompt = session.systemPrompt; patchOwnedTools(controller, "subagents", { - enable: OPENPI_TOOL_SURFACE.subagents.deferred, + enable: OPENPI_TOOL_SURFACE.subagents.entry, }); assert.deepEqual(session.getActiveToolNames(), [ @@ -317,10 +317,10 @@ test("real Pi session rebuilds tools and prompt after a capability and owner sta "bash", "edit", "write", - "subagent_spawn", - ...OPENPI_TOOL_SURFACE.subagents.deferred, + ...OPENPI_TOOL_SURFACE.subagents.entry, ]); assert.notEqual(session.systemPrompt, beforePrompt); + assert.equal(session.systemPrompt, loadedPrompt); }, [SUBAGENTS_EXTENSION], ); diff --git a/extensions/shared/tool-surface.test.ts b/extensions/shared/tool-surface.test.ts index 675d48f7..39564fd1 100644 --- a/extensions/shared/tool-surface.test.ts +++ b/extensions/shared/tool-surface.test.ts @@ -56,6 +56,8 @@ test("owner patch starts from the latest tool list and preserves foreign tools", "read", "third_party_tool", "subagent_spawn", + "subagent_cancel", + "subagent_send", "subagent_check", "subagent_list", ]); @@ -69,6 +71,8 @@ test("owner patch starts from the latest tool list and preserves foreign tools", "read", "third_party_tool", "subagent_spawn", + "subagent_cancel", + "subagent_send", "subagent_check", "subagent_list", "late_third_party_tool", @@ -149,14 +153,17 @@ test("an explicitly bound inline owner controls only its declared source", () => }); test("owner patch is a no-op when the desired surface is already active", () => { - const h = harness(["read", "openpi_load_tools", "subagent_spawn"]); + const h = harness([ + "read", + "openpi_load_tools", + ...OPENPI_TOOL_SURFACE.subagents.entry, + ]); resetOpenPiToolSurface(h.pi); loadOpenPiCapabilities(h.pi, ["delegate"]); h.writes.length = 0; assert.equal( patchOwnedTools(h.pi, "subagents", { - enable: ["subagent_spawn"], - disable: ["subagent_wait"], + enable: OPENPI_TOOL_SURFACE.subagents.entry, }), false, ); @@ -176,13 +183,15 @@ test("catalog defines the compact parent entry surface and every managed name on OPENPI_TOOL_SURFACE_NAMES.length, new Set(OPENPI_TOOL_SURFACE_NAMES).size, ); - assert.deepEqual(OPENPI_TOOL_SURFACE.subagents.deferred, [ + assert.deepEqual(OPENPI_TOOL_SURFACE.subagents.entry, [ + "subagent_spawn", "subagent_wait", "subagent_cancel", "subagent_send", "subagent_check", "subagent_list", ]); + assert.deepEqual(OPENPI_TOOL_SURFACE.subagents.deferred, []); }); test("an unloaded capability remembers lifecycle state without exposing its tools", () => { diff --git a/extensions/shared/tool-surface.ts b/extensions/shared/tool-surface.ts index 06de273e..a9cf032c 100644 --- a/extensions/shared/tool-surface.ts +++ b/extensions/shared/tool-surface.ts @@ -18,14 +18,15 @@ export const OPENPI_TOOL_SURFACE = { deferred: [], }, subagents: { - entry: ["subagent_spawn"], - deferred: [ + entry: [ + "subagent_spawn", "subagent_wait", "subagent_cancel", "subagent_send", "subagent_check", "subagent_list", ], + deferred: [], }, workflows: { entry: ["workflow"], diff --git a/extensions/subagents/index.test.ts b/extensions/subagents/index.test.ts index 257dc896..5fd1f9be 100644 --- a/extensions/subagents/index.test.ts +++ b/extensions/subagents/index.test.ts @@ -132,7 +132,7 @@ test("the visible subagent result entry renders the completed report", () => { ); }); -test("session start keeps only the subagent entry tool active", () => { +test("session start preserves the complete registered subagent family", () => { let active = ["read", "third_party_tool"]; const registered: string[] = []; let sessionStart: @@ -177,7 +177,82 @@ test("session start keeps only the subagent entry tool active", () => { "subagent_list", ], ); - assert.deepEqual(active, ["read", "third_party_tool", "subagent_spawn"]); + assert.deepEqual( + new Set(active), + new Set(["read", "third_party_tool", ...registered]), + ); +}); + +test("the complete subagent family fails closed before the first spawn", async () => { + const handlers = new Map unknown>(); + const tools = new Map< + string, + { + execute: (...args: unknown[]) => Promise<{ + content: Array<{ type: string; text: string }>; + }>; + } + >(); + const pi = { + on(event: string, handler: (...args: unknown[]) => unknown) { + handlers.set(event, handler); + }, + events: { on() {} }, + registerTool(tool: { + name: string; + execute: (...args: unknown[]) => Promise<{ + content: Array<{ type: string; text: string }>; + }>; + }) { + tools.set(tool.name, tool); + }, + getActiveTools: () => [], + setActiveTools() {}, + registerMessageRenderer() {}, + registerEntryRenderer() {}, + registerCommand() {}, + } as unknown as ExtensionAPI; + const ctx = { + cwd: process.cwd(), + hasUI: false, + isIdle: () => true, + isProjectTrusted: () => false, + } as unknown as ExtensionContext; + + subagents(pi); + await handlers.get("session_start")?.({}, ctx); + + const invoke = (name: string, params: unknown) => + tools + .get(name)! + .execute( + `call-${name}`, + params, + new AbortController().signal, + undefined, + ctx, + ); + + try { + const listed = await invoke("subagent_list", {}); + assert.equal(listed.content[0]?.text, "No subagents."); + await assert.rejects( + invoke("subagent_check", { id: "sa-missing" }), + /Unknown subagent id "sa-missing"\. Known: none\./, + ); + await assert.rejects( + invoke("subagent_send", { id: "sa-missing", text: "hello" }), + /Unknown subagent id "sa-missing"\. Known: none\./, + ); + for (const name of ["subagent_wait", "subagent_cancel"]) { + await assert.rejects( + invoke(name, { ids: ["sa-missing"] }), + /Unknown subagent id\(s\): sa-missing\. Known: none\./, + ); + } + } finally { + await handlers.get("session_shutdown")?.(); + } }); async function withTempDir(run: (directory: string) => Promise) { diff --git a/extensions/subagents/index.ts b/extensions/subagents/index.ts index a5db7af8..f0378a54 100644 --- a/extensions/subagents/index.ts +++ b/extensions/subagents/index.ts @@ -329,13 +329,9 @@ export default function (pi: ExtensionAPI) { deliver: dispatchResults, }); pi.on("agent_settled", () => resultDelivery.parentSettled()); - const hideLifecycleTools = () => + const registerStableToolFamily = () => patchOwnedTools(pi, "subagents", { - disable: OPENPI_TOOL_SURFACE.subagents.deferred, - }); - const showLifecycleTools = () => - patchOwnedTools(pi, "subagents", { - enable: OPENPI_TOOL_SURFACE.subagents.deferred, + enable: OPENPI_TOOL_SURFACE.subagents.entry, }); const getRuntime = () => (runtime ??= createSubagentRuntime()); @@ -494,7 +490,7 @@ export default function (pi: ExtensionAPI) { pi.on("session_start", (_event, ctx) => { refreshAgentTypes(ctx.cwd, ctx.isProjectTrusted()); - hideLifecycleTools(); + registerStableToolFamily(); sessionContext = ctx; settledAcknowledgedAt = 0; if (ctx.hasUI) ui = ctx.ui; @@ -768,8 +764,6 @@ export default function (pi: ExtensionAPI) { throw error; } - showLifecycleTools(); - return { content: [ { From 858990ce228ec3ddb3b3691f3c0382010421a4be Mon Sep 17 00:00:00 2001 From: tt-a1i Date: Sat, 22 Aug 2026 10:29:32 +0800 Subject: [PATCH 3/3] fix(sessions): finish the border tone unification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session picker was the last place still painting frames with `border` while its own split-pane frame, the scrollbar's neighbours, and every other OpenPI panel had moved to `borderMuted` — so the compact picker's outer frame and the preview scrollbar read a shade louder than the pane they belong to. A token inventory found these four call sites were the only `border` uses left in the package. --- extensions/sessions/index.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/extensions/sessions/index.ts b/extensions/sessions/index.ts index aafb876b..4d6a4e11 100644 --- a/extensions/sessions/index.ts +++ b/extensions/sessions/index.ts @@ -83,7 +83,7 @@ const themeText = ( ): string => { if (kind === "title") return theme.fg("accent", theme.bold(text)); if (kind === "subtitle") return theme.fg("dim", text); - if (kind === "rule") return theme.fg("border", text); + if (kind === "rule") return theme.fg("borderMuted", text); if (kind === "user") return theme.fg("accent", theme.bold(text)); if (kind === "assistant") return theme.fg("warning", theme.bold(text)); if (kind === "tool") return theme.fg("muted", theme.bold(text)); @@ -428,10 +428,10 @@ const renderPreview = ( if (i >= thumbStart && i < thumbStart + thumbSize) { scrollChar = theme.fg("text", "█"); } else { - scrollChar = theme.fg("border", "│"); + scrollChar = theme.fg("borderMuted", "│"); } } else { - scrollChar = theme.fg("border", "│"); + scrollChar = theme.fg("borderMuted", "│"); } visible.push(padAnsiRight(line, width - 1) + scrollChar); @@ -464,7 +464,9 @@ async function listSessions( const sessions = await ctx.ui.custom( (tui, theme, _kb, done) => { const container = new Container(); - const borderColor = (text: string) => theme.fg("border", text); + // Same tone as the split-pane frame and every other OpenPI panel; this + // was the last `border` call left in the package. + const borderColor = (text: string) => theme.fg("borderMuted", text); const loader = new CancellableLoader( tui,