From c938fe4d27fd5309450121dc71eef3c294ea53c9 Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 12 Jun 2026 15:37:56 +0200 Subject: [PATCH] feat(coding-agent): added focused subagent session mode for main-agent hub navigation - Added a SessionFocusController to switch transcript and input context between main and subagent sessions. - Added agent-hub Enter activation and double-left return behavior for focused local agents. - Added view-session-based event and render logic to avoid stale focus-session state. - Added status-line focused agent display with ghost icon and focused-mode border dimming. --- packages/coding-agent/CHANGELOG.md | 6 + .../src/modes/components/agent-hub.ts | 979 ++++++++++-------- .../modes/components/status-line/component.ts | 43 +- .../modes/components/status-line/segments.ts | 6 +- .../src/modes/components/status-line/types.ts | 2 + .../src/modes/controllers/event-controller.ts | 61 +- .../src/modes/controllers/input-controller.ts | 77 +- .../modes/controllers/selector-controller.ts | 26 +- .../controllers/session-focus-controller.ts | 112 ++ .../src/modes/interactive-mode.ts | 39 +- .../coding-agent/src/modes/theme/theme.ts | 6 + packages/coding-agent/src/modes/types.ts | 12 + .../src/modes/utils/ui-helpers.ts | 29 +- .../coding-agent/src/session/agent-session.ts | 28 +- .../test/agent-hub-activate.test.ts | 172 +++ .../event-controller-abort-render.test.ts | 16 +- .../event-controller-error-banner.test.ts | 4 + .../test/input-controller-escape.test.ts | 30 +- .../test/input-controller-skill-queue.test.ts | 7 + .../test/interactive-mode-status.test.ts | 8 + .../test/job-poll-displacement.test.ts | 1 + .../components/user-message-keywords.test.ts | 22 +- .../event-controller-abort-guard.test.ts | 8 +- .../event-controller-args-reveal.test.ts | 1 + .../event-controller-idle-compaction.test.ts | 4 + .../event-controller-read-grouping.test.ts | 5 +- ...event-controller-toolcall-finalize.test.ts | 2 + ...selector-controller-session-delete.test.ts | 9 + .../utils/render-initial-messages.test.ts | 8 + .../test/session-focus-controller.test.ts | 209 ++++ .../test/silent-abort-overlay-render.test.ts | 26 +- .../test/tools/search-path-lists.test.ts | 24 +- packages/hashline/CHANGELOG.md | 4 + packages/hashline/test/block.test.ts | 2 +- 34 files changed, 1449 insertions(+), 539 deletions(-) create mode 100644 packages/coding-agent/src/modes/controllers/session-focus-controller.ts create mode 100644 packages/coding-agent/test/agent-hub-activate.test.ts create mode 100644 packages/coding-agent/test/session-focus-controller.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index bf1afe071..20f000715 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,15 +1,18 @@ # Changelog ## [Unreleased] + ### Added - Added `/collab view` command to create a read-only spectator join link - Added read-only hints and status text for guest-only participation in collab sessions - Added `share.serverUrl` and `share.redactSecrets` settings: the share server/viewer base for `/share` links (default `https://my.omp.sh/s`) and a toggle (default on) that runs the secret obfuscator over the shared snapshot before upload - HTML session exports now embed subagent transcripts: sub-session files stored next to the session (`/.jsonl`, recursively) ride along in the export payload, agent ids in task tool cards become drill-down links, and a breadcrumbed overlay renders each subagent's full transcript — including its own tool cards and deeper nested agents — with Esc/backdrop navigation +- Added Agent Hub focus mode: Enter on a local agent now retargets the main view to that live subagent session with regular transcript rendering, steering, Esc-to-main return, ←← parent navigation, and a ghost status-line badge. ### Changed +- Blocked cycling model and thinking presets while a focused subagent session is active and now prompts users to return to the main session first - Changed `codexResets.autoRedeem` from a boolean to `unset`/`yes`/`no`: unset runs the eligibility check and asks before spending a saved Codex reset, yes redeems without prompting, and no skips the check entirely - Changed collab links so full links with a write token grant mutation rights while links without a token now join as read-only - `/share` no longer uploads a plaintext HTML export to a gist for gistpreview. It now snapshots the session JSON, gzips and seals it with a fresh AES-256-GCM key, and pushes the blob to a secret gist (when `gh` is authenticated) or to the share server (capped at 1 MB; oversized sessions are trimmed — images first, then long strings, then oldest entries). The share link is `https://my.omp.sh/s/#`: the viewer fetches the blob (hex ids from the gist API, others from the relay store) and decrypts it in-browser, so the key never leaves the client. Configured secrets are additionally redacted from the snapshot unless `share.redactSecrets` is off. Custom `~/.omp/agent/share.{ts,js,mjs}` handlers keep the legacy HTML-file contract; `/share` also works for in-memory (`--no-session`) sessions now @@ -21,9 +24,12 @@ - Completed rows in the `job` tool output now show the standard done checkmark instead of the watch glyph that replaced the spinner - HTML session exports render tool calls through the same React tool renderers the collab web client uses: per-tool views for all built-in tools (bash, edit diffs, todo boards, eval cells, task batches, LSP, search, browser screenshots, …) are bundled as an `` web component into the export instead of the previous string-built dummy renderers - Modernized the HTML export page chrome to match the tool-card design language: hairline borders, dense mono typography, compact role-tinted message cards, refined tree sidebar and filter controls, themed scrollbars, collapsed-by-default thinking blocks, and mobile sidebar drawer — derived from the active theme's variables so light and dark themes both render correctly +- Agent Hub's embedded chat overlay is now reserved for collab guest viewing; local agents open in the main session renderer instead. ### Fixed +- Filtered silent abort markers from Agent Hub assistant output so failed turns now render as normal error text +- Reset focus-session state when switching transcript rendering between the main session and a focused subagent so streaming/progress context no longer leaks across sessions - Fixed read-only collab sessions so prompting, interrupts, and other write actions are blocked with a read-only warning instead of being applied - Fixed Mnemopi local embeddings in bundled and compiled installs failing with `Cannot find module '../bin/napi-v3/.../onnxruntime_binding.node'`: the Bun bundle inlined fastembed's loader so its relative native require resolved against `dist/cli.js`. `fastembed`/`onnxruntime-node` are no longer bundled; on first use Mnemopi `bun install`s the pinned pair into `~/.omp/cache/fastembed-runtime/` and loads the binding from there ([#2389](https://github.com/can1357/oh-my-pi/issues/2389)) - Fixed the interactive Model scope startup banner so models without an explicit thinking level do not show `:undefined`, and entries that were scoped without a `:level` are no longer rendered with the global default thinking level (which `applyRootSessionOptions` pre-fills on the cycling array for Ctrl+P) ([#2385](https://github.com/can1357/oh-my-pi/issues/2385)). diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index 6dadb32ba..048bdf4c5 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -14,43 +14,62 @@ * Replaces the old SessionObserverOverlayComponent (ctrl+s observer). */ import * as fs from "node:fs"; -import type { ToolResultMessage } from "@oh-my-pi/pi-ai"; -import { Container, Editor, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui"; -import { formatAge, formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils"; +import * as path from "node:path"; +import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core"; +import { Container, Editor, matchesKey, ScrollView, Text, type TUI } from "@oh-my-pi/pi-tui"; +import { formatAge, formatBytes, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils"; +import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol"; import type { KeyId } from "../../config/keybindings"; +import { settings } from "../../config/settings"; +import type { MessageRenderer } from "../../extensibility/extensions/types"; import { IrcBus } from "../../irc/bus"; import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry"; import type { AgentSession } from "../../session/agent-session"; -import { isSilentAbort, USER_INTERRUPT_LABEL } from "../../session/messages"; +import { + type CustomMessage, + isSilentAbort, + LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE, + resolveAbortLabel, + SKILL_PROMPT_MESSAGE_TYPE, + type SkillPromptDetails, + USER_INTERRUPT_LABEL, +} from "../../session/messages"; import type { SessionMessageEntry } from "../../session/session-manager"; import { parseSessionEntries } from "../../session/session-manager"; -import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; -import { toPathList } from "../../tools/search"; +import { createIrcMessageCard } from "../../tools/irc"; +import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils"; import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry"; -import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme"; +import { getEditorTheme, theme } from "../theme/theme"; import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers"; +import { AssistantMessageComponent } from "./assistant-message"; +import { BashExecutionComponent } from "./bash-execution"; +import { BranchSummaryMessageComponent } from "./branch-summary-message"; +import { CollabPromptMessageComponent } from "./collab-prompt-message"; +import { CompactionSummaryMessageComponent } from "./compaction-summary-message"; +import { CustomMessageComponent } from "./custom-message"; import { DynamicBorder } from "./dynamic-border"; +import { EvalExecutionComponent } from "./eval-execution"; +import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message"; +import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group"; +import { SkillMessageComponent } from "./skill-message"; import { formatContextUsage } from "./status-line/context-thresholds"; +import { ToolExecutionComponent } from "./tool-execution"; +import { TranscriptBlock, TranscriptContainer } from "./transcript-container"; +import { UserMessageComponent } from "./user-message"; -/** Max thinking characters in collapsed state */ -const MAX_THINKING_CHARS_COLLAPSED = 200; -/** Max thinking characters in expanded state */ -const MAX_THINKING_CHARS_EXPANDED = 4000; -/** Max tool args characters to display */ -const MAX_TOOL_ARGS_CHARS = 500; /** Lines per page for PageUp/PageDown */ const PAGE_SIZE = 15; -/** Left indent for content under entry headers */ -const INDENT = " "; /** Refresh cadence for the relative-time column */ const AGE_TICK_MS = 5_000; /** Debounce for live-session transcript refreshes */ const CHAT_REFRESH_DEBOUNCE_MS = 80; +/** Double-tap window for the left-left "go to parent" gesture (matches the editor's). */ +const LEFT_TAP_WINDOW_MS = 500; -/** Compute the max content width for the current terminal, accounting for indent and chrome. */ -function contentWidth(indent = INDENT): number { - return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - indent.length - 2); +/** Compute the max content width for the current terminal, accounting for chrome. */ +function contentWidth(): number { + return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6); } /** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */ @@ -58,13 +77,6 @@ function sanitizeLine(text: string, maxWidth?: number): string { return truncateToWidth(replaceTabs(text), maxWidth ?? contentWidth()); } -/** Represents a rendered entry in the viewer for selection/expand tracking */ -interface ViewerEntry { - lineStart: number; - lineCount: number; - kind: "thinking" | "text" | "toolCall" | "user"; -} - const STATUS_ORDER: Record = { running: 0, idle: 1, parked: 2, aborted: 3 }; /** Glyph + status word, colored per theme status conventions. */ @@ -81,6 +93,38 @@ function statusBadge(status: AgentStatus): string { } } +function registerPersistedSubagents(registry: AgentRegistry, sessionFile: string | null | undefined): void { + if (!sessionFile?.endsWith(".jsonl")) return; + const root = sessionFile.slice(0, -6); + registerPersistedSubagentsFromDir(registry, root, undefined); +} + +function registerPersistedSubagentsFromDir(registry: AgentRegistry, dir: string, parentId: string | undefined): void { + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue; + const id = entry.name.slice(0, -6); + const sessionFile = path.join(dir, entry.name); + if (!registry.get(id)) { + registry.register({ + id, + displayName: id, + kind: "sub", + parentId: parentId ?? MAIN_AGENT_ID, + session: null, + sessionFile, + status: "parked", + }); + } + registerPersistedSubagentsFromDir(registry, path.join(dir, id), id); + } +} + /** Guest-side proxy for hub actions executed on the collab host. */ export interface AgentHubRemote { chat(id: string, text: string): void; @@ -103,6 +147,22 @@ export interface AgentHubDeps { lifecycle?: AgentLifecycleManager; /** Injectable for tests; defaults to the process-global bus. */ irc?: IrcBus; + /** TUI handle for transcript components; tests omit it and get a render-only stub. */ + ui?: TUI; + /** Tool lookup for transcript renderers (labels, custom render functions). */ + getTool?: (name: string) => AgentTool | undefined; + /** Extension message renderers for custom messages in the transcript. */ + getMessageRenderer?: (customType: string) => MessageRenderer | undefined; + /** Cwd used by tool renderers for path shortening; defaults to the project dir. */ + cwd?: string; + /** Mirrors the main transcript's thinking-block visibility. */ + hideThinkingBlock?: () => boolean; + /** Keys toggling tool output expansion (app.tools.expand). */ + expandKeys?: KeyId[]; + /** Focus the main view on this agent's live session (ctx.focusAgentSession). When absent (collab guest, tests), Enter opens the in-hub chat view instead. */ + focusAgent?: (id: string) => Promise; + /** Current main session file; used to seed parked historical subagents after restart. */ + sessionFile?: string | null; /** Collab guest: route actions/transcripts to the host instead of local sessions. */ remote?: AgentHubRemote; } @@ -137,16 +197,33 @@ export class AgentHubOverlayComponent extends Container { #chatRefreshTimer: NodeJS.Timeout | undefined; #transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined; - // Transcript viewer state (absorbed from the session observer overlay) + // Chat transcript: the same component renderers as the main session + // transcript, assembled incrementally from the persisted JSONL entries. + #ui: TUI; + #getTool: ((name: string) => AgentTool | undefined) | undefined; + #getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined; + #cwd: string; + #hideThinkingBlock: (() => boolean) | undefined; + #expandKeys: KeyId[]; + #focusAgent: ((id: string) => Promise) | undefined; + #chatLog = new TranscriptContainer(); + #chatEntriesRef: SessionMessageEntry[] | undefined; + #chatBuiltCount = 0; + #chatPendingTools = new Map(); + #chatReadArgs = new Map>(); + #chatReadGroup: ReadToolGroupComponent | null = null; + #chatWaitingPoll: ToolExecutionComponent | null = null; + #chatExpandables: Array<{ setExpanded(expanded: boolean): void }> = []; + #chatExpanded = false; + #chatPlaceholder: string | undefined; + + // Viewport state #scrollOffset = 0; - #renderedLines: string[] = []; + #lastMaxScroll = 0; #viewportHeight = 20; #wasAtBottom = true; - #viewerEntries: ViewerEntry[] = []; - #selectedEntryIndex = 0; - #expandedEntries = new Set(); #viewerHeaderLines: string[] = []; - #mdTheme: MarkdownTheme = getMarkdownTheme(); + #lastLeftTap = 0; constructor(deps: AgentHubDeps) { super(); @@ -160,6 +237,18 @@ export class AgentHubOverlayComponent extends Container { this.#requestRender = deps.requestRender; this.#hubKeys = deps.hubKeys; this.#remote = deps.remote; + this.#ui = + deps.ui ?? + ({ + requestRender: () => deps.requestRender(), + requestComponentRender: () => deps.requestRender(), + } as unknown as TUI); + this.#getTool = deps.getTool; + this.#getMessageRenderer = deps.getMessageRenderer; + this.#cwd = deps.cwd ?? getProjectDir(); + this.#hideThinkingBlock = deps.hideThinkingBlock; + this.#expandKeys = deps.expandKeys ?? ["ctrl+o"]; + this.#focusAgent = deps.focusAgent; this.#editor = new Editor(getEditorTheme()); this.#editor.setMaxHeight(4); @@ -170,6 +259,7 @@ export class AgentHubOverlayComponent extends Container { this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS); this.#ageTimer.unref?.(); + if (!this.#remote) registerPersistedSubagents(this.#registry, deps.sessionFile); this.#refreshRows(); } @@ -185,6 +275,7 @@ export class AgentHubOverlayComponent extends Container { this.#chatRefreshTimer = undefined; } this.#detachLiveSession(); + this.#resetChatLog(); } override render(width: number): readonly string[] { @@ -216,18 +307,13 @@ export class AgentHubOverlayComponent extends Container { this.#remoteTranscriptUnavailable = false; this.#remoteFetchInFlight = false; this.#remoteFetchToken++; + this.#resetChatLog(); this.#scrollOffset = 0; - this.#selectedEntryIndex = 0; - this.#expandedEntries.clear(); this.#wasAtBottom = true; + this.#lastLeftTap = 0; this.#editor.setText(""); this.#attachLiveSession(); this.#rebuildChatContent(); - // Auto-scroll to bottom and select last entry on open - if (this.#viewerEntries.length > 0) { - this.#selectedEntryIndex = this.#viewerEntries.length - 1; - this.#rebuildChatContent(); - } this.#requestRender(); } @@ -283,12 +369,7 @@ export class AgentHubOverlayComponent extends Container { this.#chatRefreshTimer = setTimeout(() => { this.#chatRefreshTimer = undefined; if (this.#view !== "chat") return; - // Keep auto-scrolling to bottom unless the user navigated away - this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1; this.#rebuildChatContent(); - if (this.#wasAtBottom && this.#viewerEntries.length > 0) { - this.#selectedEntryIndex = this.#viewerEntries.length - 1; - } this.#requestRender(); }, CHAT_REFRESH_DEBOUNCE_MS); this.#chatRefreshTimer.unref?.(); @@ -335,7 +416,7 @@ export class AgentHubOverlayComponent extends Container { lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`); } lines.push(""); - lines.push(` ${theme.fg("dim", "j/k:select Enter:chat r:revive x:kill Esc:close")}`); + lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`); lines.push(...new DynamicBorder().render(width)); return lines; } @@ -375,6 +456,16 @@ export class AgentHubOverlayComponent extends Container { this.#onDone(); return; } + if (matchesKey(keyData, "left")) { + const now = Date.now(); + if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) { + this.#lastLeftTap = 0; + this.#onDone(); + } else { + this.#lastLeftTap = now; + } + return; + } if (keyData === "j" || matchesSelectDown(keyData)) { if (this.#rows.length > 0) { this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1); @@ -391,7 +482,7 @@ export class AgentHubOverlayComponent extends Container { } if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") { const selected = this.#rows[this.#selectedRow]; - if (selected) this.openChat(selected.id); + if (selected) this.#activateAgent(selected); return; } if (keyData === "r") { @@ -404,6 +495,30 @@ export class AgentHubOverlayComponent extends Container { } } + /** + * Enter on a row: focus the main view on the agent's live session and close + * the hub. The transcript then renders through the regular session pipeline — + * exact parity by construction. Collab guests (no local sessions) keep the + * in-hub chat view. + */ + #activateAgent(ref: AgentRef): void { + this.#notice = undefined; + const focusAgent = this.#focusAgent; + if (this.#remote || !focusAgent) { + this.openChat(ref.id); + return; + } + void (async () => { + try { + await focusAgent(ref.id); // ensureLive inside revives parked agents; no parking, no session files + this.#onDone(); + } catch (error) { + this.#notice = error instanceof Error ? error.message : String(error); + this.#requestRender(); + } + })(); + } + #reviveSelected(): void { const ref = this.#rows[this.#selectedRow]; if (!ref) return; @@ -471,7 +586,12 @@ export class AgentHubOverlayComponent extends Container { const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1; this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome); - const maxScroll = Math.max(0, this.#renderedLines.length - this.#viewportHeight); + const contentLines: readonly string[] = this.#chatPlaceholder + ? [theme.fg("dim", this.#chatPlaceholder)] + : this.#chatLog.render(innerWidth); + + const maxScroll = Math.max(0, contentLines.length - this.#viewportHeight); + this.#lastMaxScroll = maxScroll; if (this.#wasAtBottom) this.#scrollOffset = maxScroll; this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll)); @@ -483,11 +603,11 @@ export class AgentHubOverlayComponent extends Container { lines.push(...new DynamicBorder().render(width)); const scrollView = new ScrollView( - this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight), + contentLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight), { height: this.#viewportHeight, scrollbar: "auto", - totalRows: this.#renderedLines.length, + totalRows: contentLines.length, theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) }, }, ); @@ -506,7 +626,9 @@ export class AgentHubOverlayComponent extends Container { const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined; const statsLine = this.#buildStatsLine(observed); if (statsLine) lines.push(` ${statsLine}`); - lines.push(` ${theme.fg("dim", "Enter:send Esc:back empty input: j/k:scroll Enter:expand g/G:top/bottom")}`); + lines.push( + ` ${theme.fg("dim", `Enter:send Esc:back ←←:parent ${this.#expandKeys[0] ?? "ctrl+o"}:expand empty input: j/k:scroll g/G:top/bottom`)}`, + ); return lines; } @@ -538,7 +660,7 @@ export class AgentHubOverlayComponent extends Container { return parts.join(theme.sep.dot); } - /** Rebuild the chat header + transcript content lines */ + /** Rebuild the chat header and sync transcript components from new entries */ #rebuildChatContent(): void { const id = this.#chatAgentId; const ref = id ? this.#registry.get(id) : undefined; @@ -562,26 +684,24 @@ export class AgentHubOverlayComponent extends Container { this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`); } - const contentLines: string[] = []; - this.#viewerEntries = []; if (!ref) { - contentLines.push(theme.fg("dim", "Agent no longer registered.")); + this.#chatPlaceholder = "Agent no longer registered."; } else if (!this.#remote && !ref.sessionFile) { - contentLines.push(theme.fg("dim", "No session file available yet.")); + this.#chatPlaceholder = "No session file available yet."; } else if (!messageEntries) { - contentLines.push(theme.fg("dim", "Unable to read session file.")); + this.#chatPlaceholder = "Unable to read session file."; } else if (messageEntries.length === 0) { if (this.#remote && this.#remoteTranscriptUnavailable) { - contentLines.push(theme.fg("dim", "Transcript lives on the host — not available.")); + this.#chatPlaceholder = "Transcript lives on the host — not available."; } else if (this.#remote && !this.#transcriptCache) { - contentLines.push(theme.fg("dim", "Loading transcript from host…")); + this.#chatPlaceholder = "Loading transcript from host…"; } else { - contentLines.push(theme.fg("dim", "No messages yet.")); + this.#chatPlaceholder = "No messages yet."; } } else { - this.#buildTranscriptLines(messageEntries, contentLines); + this.#chatPlaceholder = undefined; + this.#syncChatComponents(messageEntries); } - this.#renderedLines = contentLines; } #handleChatInput(keyData: string): void { @@ -597,8 +717,31 @@ export class AgentHubOverlayComponent extends Container { return; } - // Navigation mirrors the old observer overlay while the input is empty; - // once the user starts typing, the editor owns every key. + // Tool output expansion mirrors the main transcript's app.tools.expand toggle. + for (const key of this.#expandKeys) { + if (matchesKey(keyData, key)) { + this.#chatExpanded = !this.#chatExpanded; + for (const component of this.#chatExpandables) component.setExpanded(this.#chatExpanded); + this.#requestRender(); + return; + } + } + + // Double-tap left on an empty editor hops to the parent session — + // the inverse of the main editor's double-left opening the hub. + if (editorEmpty && matchesKey(keyData, "left")) { + const now = Date.now(); + if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) { + this.#lastLeftTap = 0; + this.#openParent(); + } else { + this.#lastLeftTap = now; + } + return; + } + + // Scrolling works while the input is empty; once the user starts + // typing, the editor owns every key. if (editorEmpty && this.#handleViewerNavigation(keyData)) { return; } @@ -607,11 +750,23 @@ export class AgentHubOverlayComponent extends Container { this.#requestRender(); } + /** Open the chat for the agent's parent, or close the hub when the parent is the main session. */ + #openParent(): void { + const ref = this.#chatAgentId ? this.#registry.get(this.#chatAgentId) : undefined; + const parentId = ref?.parentId; + if (parentId && parentId !== MAIN_AGENT_ID && this.#registry.get(parentId)) { + this.openChat(parentId); + return; + } + this.#onDone(); + } + #closeChat(): void { this.#view = "table"; this.#chatAgentId = undefined; this.#notice = undefined; this.#detachLiveSession(); + this.#resetChatLog(); this.#refreshRows(); this.#requestRender(); } @@ -644,415 +799,391 @@ export class AgentHubOverlayComponent extends Container { this.#requestRender(); } - /** Viewer navigation (selection, paging, expand) for the chat transcript. Returns true when handled. */ + /** Viewport scrolling for the chat transcript. Returns true when handled. */ #handleViewerNavigation(keyData: string): boolean { - const entryCount = this.#viewerEntries.length; - + const maxScroll = this.#lastMaxScroll; + const scrollBy = (delta: number) => { + this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset + delta, maxScroll)); + this.#wasAtBottom = this.#scrollOffset >= maxScroll; + this.#requestRender(); + }; if (keyData === "j" || matchesSelectDown(keyData)) { - if (entryCount > 0) { - this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1); - } - this.#rebuildAndScroll(); + scrollBy(1); return true; } if (keyData === "k" || matchesSelectUp(keyData)) { - if (entryCount > 0) { - this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0); - } - this.#rebuildAndScroll(); + scrollBy(-1); return true; } if (matchesKey(keyData, "pageDown")) { - if (entryCount > 0) { - const prevIndex = this.#selectedEntryIndex; - this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1); - if (this.#selectedEntryIndex === prevIndex) { - this.#scrollOffset = Math.min( - this.#scrollOffset + PAGE_SIZE, - Math.max(0, this.#renderedLines.length - this.#viewportHeight), - ); - } - } else { - this.#scrollOffset = Math.min( - this.#scrollOffset + PAGE_SIZE, - Math.max(0, this.#renderedLines.length - this.#viewportHeight), - ); - } - this.#rebuildAndScroll(); + scrollBy(PAGE_SIZE); return true; } if (matchesKey(keyData, "pageUp")) { - if (entryCount > 0) { - const prevIndex = this.#selectedEntryIndex; - this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0); - if (this.#selectedEntryIndex === prevIndex) { - this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0); - } - } else { - this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0); - } - this.#rebuildAndScroll(); - return true; - } - if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") { - if (entryCount > 0 && this.#selectedEntryIndex < entryCount) { - if (this.#expandedEntries.has(this.#selectedEntryIndex)) { - this.#expandedEntries.delete(this.#selectedEntryIndex); - } else { - this.#expandedEntries.add(this.#selectedEntryIndex); - } - this.#rebuildAndScroll(); - } + scrollBy(-PAGE_SIZE); return true; } if (keyData === "G") { - if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1; - this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight); - this.#rebuildAndScroll(); + this.#scrollOffset = maxScroll; + this.#wasAtBottom = true; + this.#requestRender(); return true; } if (keyData === "g") { - this.#selectedEntryIndex = 0; this.#scrollOffset = 0; - this.#rebuildAndScroll(); + this.#wasAtBottom = maxScroll === 0; + this.#requestRender(); return true; } return false; } - /** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */ - #rebuildAndScroll(): void { - // Resume auto-scrolling once selection returns to the last entry - this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1; - this.#rebuildChatContent(); - this.#scrollToSelectedEntry(); - this.#requestRender(); - } - - #scrollToSelectedEntry(): void { - if (this.#viewerEntries.length === 0) return; - const entry = this.#viewerEntries[this.#selectedEntryIndex]; - if (!entry) return; - - const entryTop = entry.lineStart; - const entryBottom = entry.lineStart + entry.lineCount; - - if (entry.lineCount >= this.#viewportHeight) { - // Entry taller than viewport: only snap when it's completely out of view. - if (this.#scrollOffset + this.#viewportHeight <= entryTop) { - this.#scrollOffset = Math.max(0, entryTop - 1); - } else if (this.#scrollOffset >= entryBottom) { - this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight); - } - } else { - // Entry fits in viewport: ensure it's fully visible - if (entryTop < this.#scrollOffset) { - this.#scrollOffset = Math.max(0, entryTop - 1); - } - if (entryBottom > this.#scrollOffset + this.#viewportHeight) { - this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1); - } - } - } - // ======================================================================== - // Transcript rendering (absorbed from the session observer overlay) + // Transcript assembly — the same components as the main session transcript + // (mirrors UiHelpers.renderSessionContext / addMessageToChat). // ======================================================================== - #buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void { - // Build a tool call ID -> tool result map - const toolResults = new Map(); - for (const entry of messageEntries) { - if (entry.message.role === "toolResult") { - toolResults.set(entry.message.toolCallId, entry.message); - } + /** Tear down transcript components (sealing pending spinners) and reset build state. */ + #resetChatLog(): void { + for (const pending of this.#chatPendingTools.values()) pending.seal(); + this.#chatPendingTools.clear(); + this.#chatReadArgs.clear(); + this.#chatReadGroup = null; + this.#chatWaitingPoll = null; + this.#chatExpandables = []; + this.#chatLog.dispose(); + this.#chatLog.clear(); + this.#chatEntriesRef = undefined; + this.#chatBuiltCount = 0; + this.#chatPlaceholder = undefined; + } + + /** Append components for entries not yet materialized. Rebuilds from scratch when the cache was replaced (agent switch, file rotation). */ + #syncChatComponents(entries: SessionMessageEntry[]): void { + if (this.#chatEntriesRef !== entries) { + this.#resetChatLog(); + this.#chatEntriesRef = entries; } + for (let i = this.#chatBuiltCount; i < entries.length; i++) { + this.#appendChatMessage(entries[i].message); + } + this.#chatBuiltCount = entries.length; + } - let entryIndex = 0; - for (const entry of messageEntries) { - const msg = entry.message; + #trackExpandable(component: { setExpanded(expanded: boolean): void }): void { + component.setExpanded(this.#chatExpanded); + this.#chatExpandables.push(component); + } - if (msg.role === "assistant") { - // Handle error messages with empty content - if (msg.content.length === 0 && msg.errorMessage && !isSilentAbort(msg.errorMessage)) { - const startLine = lines.length; - const isSelected = entryIndex === this.#selectedEntryIndex; - const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " "; - lines.push(""); - const errorLines = msg.errorMessage.split("\n"); - const maxWidth = contentWidth(); - lines.push(`${cursor} ${theme.fg("error", `✗ Error: ${sanitizeLine(errorLines[0], maxWidth)}`)}`); - for (let i = 1; i < errorLines.length; i++) { - lines.push(`${INDENT}${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`); - } - this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "text" }); - entryIndex++; - } else { - for (const content of msg.content) { - if (content.type === "thinking" && content.thinking.trim()) { - const startLine = lines.length; - const isExpanded = this.#expandedEntries.has(entryIndex); - const isSelected = entryIndex === this.#selectedEntryIndex; - this.#renderThinkingLines(lines, content.thinking.trim(), isExpanded, isSelected); - this.#viewerEntries.push({ - lineStart: startLine, - lineCount: lines.length - startLine, - kind: "thinking", - }); - entryIndex++; - } else if (content.type === "text" && content.text.trim()) { - const startLine = lines.length; - const isExpanded = this.#expandedEntries.has(entryIndex); - const isSelected = entryIndex === this.#selectedEntryIndex; - this.#renderTextLines(lines, content.text.trim(), isExpanded, isSelected); - this.#viewerEntries.push({ - lineStart: startLine, - lineCount: lines.length - startLine, - kind: "text", - }); - entryIndex++; - } else if (content.type === "toolCall") { - const startLine = lines.length; - const isExpanded = this.#expandedEntries.has(entryIndex); - const isSelected = entryIndex === this.#selectedEntryIndex; - const result = toolResults.get(content.id); - this.#renderToolCallLines(lines, content, result, isExpanded, isSelected); - this.#viewerEntries.push({ - lineStart: startLine, - lineCount: lines.length - startLine, - kind: "toolCall", - }); - entryIndex++; - } - } + /** A `job` poll showing all-running is displaced by the next `job` call (mirrors the rebuild path). */ + #resolveWaitingPoll(nextToolName?: string): void { + const previous = this.#chatWaitingPoll; + if (!previous) return; + this.#chatWaitingPoll = null; + if (nextToolName === "job" && previous.isDisplaceableBlock()) { + this.#chatLog.removeChild(previous); + } + previous.seal(); + } + + #ensureReadGroup(): ReadToolGroupComponent { + if (!this.#chatReadGroup) { + this.#chatReadGroup = new ReadToolGroupComponent({ + showContentPreview: settings.get("read.toolResultPreview"), + }); + this.#trackExpandable(this.#chatReadGroup); + this.#chatLog.addChild(this.#chatReadGroup); + } + return this.#chatReadGroup; + } + + #appendChatMessage(message: AgentMessage): void { + switch (message.role) { + case "assistant": + this.#appendAssistantMessage(message); + break; + case "toolResult": + this.#appendToolResult(message); + break; + case "user": + case "developer": { + // A user prompt closes the poll-displacement window, same as the live path. + if (message.role === "user") this.#resolveWaitingPoll(); + const textContent = + message.role !== "user" + ? "" + : typeof message.content === "string" + ? message.content + : message.content + .filter((block): block is { type: "text"; text: string } => block.type === "text") + .map(block => block.text) + .join(""); + if (textContent) { + const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); + this.#chatLog.addChild(new UserMessageComponent(textContent, isSynthetic)); } - } else if (msg.role === "user" || msg.role === "developer") { - const text = - typeof msg.content === "string" - ? msg.content - : msg.content - .filter((b): b is { type: "text"; text: string } => b.type === "text") - .map(b => b.text) - .join("\n"); - if (text.trim()) { - const startLine = lines.length; - const isSelected = entryIndex === this.#selectedEntryIndex; - const isExpanded = this.#expandedEntries.has(entryIndex); - const label = msg.role === "developer" ? "System" : "User"; - const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " "; - lines.push(""); - if (isExpanded) { - lines.push(`${cursor} ${theme.fg("dim", `[${label}]`)}`); - const mdLines = this.#renderMarkdownToLines(text.trim()); - for (const ml of mdLines) { - lines.push(ml); - } + break; + } + case "bashExecution": { + const component = new BashExecutionComponent(message.command, this.#ui, message.excludeFromContext); + if (message.output) component.appendOutput(message.output); + component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); + this.#chatLog.addChild(component); + break; + } + case "pythonExecution": { + const component = new EvalExecutionComponent(message.code, this.#ui, message.excludeFromContext); + if (message.output) component.appendOutput(message.output); + component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation }); + this.#chatLog.addChild(component); + break; + } + case "hookMessage": + case "custom": + this.#appendCustomMessage(message); + break; + case "compactionSummary": { + const component = new CompactionSummaryMessageComponent(message); + this.#trackExpandable(component); + this.#chatLog.addChild(component); + break; + } + case "branchSummary": { + const component = new BranchSummaryMessageComponent(message); + this.#trackExpandable(component); + this.#chatLog.addChild(component); + break; + } + case "fileMention": { + const block = new TranscriptBlock(); + for (const file of message.files) { + let suffix: string; + if (file.skippedReason === "tooLarge") { + const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size"; + suffix = `(skipped: ${size})`; } else { - const firstLine = text.trim().split("\n")[0]; - const totalLines = text.trim().split("\n").length; - const hint = totalLines > 1 ? theme.fg("dim", ` (${totalLines} lines)`) : ""; - lines.push( - `${cursor} ${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", sanitizeLine(firstLine, TRUNCATE_LENGTHS.TITLE))}${hint}`, - ); + suffix = file.image + ? "(image)" + : file.lineCount === undefined + ? "(unknown lines)" + : `(${file.lineCount} lines)`; } - this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "user" }); - entryIndex++; + const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg( + "accent", + file.path, + )} ${theme.fg("dim", suffix)}`; + block.addChild(new Text(text, 0, 0)); } + if (block.children.length > 0) this.#chatLog.addChild(block); + break; + } + default: + message satisfies never; + } + } + + #appendAssistantMessage(message: Extract): void { + const assistantComponent = new AssistantMessageComponent(message, this.#hideThinkingBlock?.() ?? false, () => + this.#requestRender(), + ); + assistantComponent.setUsageInfo(message.usage); + this.#chatLog.addChild(assistantComponent); + + const hasVisibleAssistantContent = message.content.some( + content => + (content.type === "text" && content.text.trim().length > 0) || + (content.type === "thinking" && content.thinking.trim().length > 0), + ); + if (hasVisibleAssistantContent) { + // New visible turn content closes the current read run (mirrors rebuild). + this.#chatReadGroup?.seal(); + this.#chatReadGroup = null; + } + + const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage); + const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error"); + const errorMessage = hasErrorStop + ? message.stopReason === "aborted" + ? resolveAbortLabel(message.errorMessage) + : message.errorMessage || "Error" + : null; + + for (const content of message.content) { + if (content.type !== "toolCall") continue; + this.#resolveWaitingPoll(content.name); + + if ( + content.name === "read" && + readArgsHaveTarget(content.arguments) && + !readArgsTargetInternalUrl(content.arguments) + ) { + if (hasErrorStop && errorMessage) { + const group = this.#ensureReadGroup(); + group.updateArgs(content.arguments, content.id); + group.updateResult( + { content: [{ type: "text", text: errorMessage }], isError: true }, + false, + content.id, + ); + } else { + const normalizedArgs = + content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments) + ? (content.arguments as Record) + : {}; + this.#chatReadArgs.set(content.id, normalizedArgs); + } + continue; + } + + this.#chatReadGroup?.seal(); + this.#chatReadGroup = null; + const component = new ToolExecutionComponent( + content.name, + content.arguments, + { + // Images can't be sliced through the scroll viewport; keep them off. + showImages: false, + editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), + editAllowFuzzy: settings.get("edit.fuzzyMatch"), + liveRegion: this.#chatLog, + }, + this.#getTool?.(content.name), + this.#ui, + this.#cwd, + content.id, + ); + this.#trackExpandable(component); + this.#chatLog.addChild(component); + + if (hasErrorStop && errorMessage) { + component.updateResult( + { content: [{ type: "text", text: errorMessage }], isError: true }, + false, + content.id, + ); + } else { + this.#chatPendingTools.set(content.id, component); } } } - /** Render markdown text into indented lines using the theme's markdown renderer */ - #renderMarkdownToLines(text: string, indent: string = INDENT): string[] { - const width = Math.max(40, (process.stdout.columns || 80) - indent.length - 4); - const md = new Markdown(text, 0, 0, this.#mdTheme); - const rendered = md.render(width); - return rendered.map(line => `${indent}${line.trimEnd()}`); - } - - #renderThinkingLines(lines: string[], thinking: string, expanded: boolean, selected: boolean): void { - const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; - const maxChars = expanded ? MAX_THINKING_CHARS_EXPANDED : MAX_THINKING_CHARS_COLLAPSED; - const truncated = thinking.length > maxChars; - const expandLabel = !expanded && truncated ? theme.fg("dim", " ↵") : ""; - - lines.push(""); - lines.push(`${cursor} ${theme.fg("dim", "💭 Thinking")}${expandLabel}`); - - const displayText = truncated ? `${thinking.slice(0, maxChars)}...` : thinking; - if (expanded) { - // Expanded thinking: render as markdown for readable formatting - const mdLines = this.#renderMarkdownToLines(displayText); - const maxLines = 100; - for (let i = 0; i < Math.min(mdLines.length, maxLines); i++) { - lines.push(mdLines[i]); - } - if (mdLines.length > maxLines) { - lines.push(`${INDENT}${theme.fg("dim", `... ${mdLines.length - maxLines} more lines`)}`); - } - } else { - // Collapsed thinking: brief italic preview - const thinkingLines = displayText.split("\n"); - const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES; - for (let i = 0; i < Math.min(thinkingLines.length, maxLines); i++) { - lines.push(`${INDENT}${theme.fg("thinkingText", sanitizeLine(thinkingLines[i]))}`); - } - if (thinkingLines.length > maxLines) { - lines.push(`${INDENT}${theme.fg("dim", `... ${thinkingLines.length - maxLines} more lines`)}`); - } - } - } - - #renderTextLines(lines: string[], text: string, expanded: boolean, selected: boolean): void { - const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; - - lines.push(""); - lines.push(`${cursor} ${theme.fg("muted", "Response")}`); - - if (expanded) { - // Expanded: full markdown rendering - const mdLines = this.#renderMarkdownToLines(text); - for (const ml of mdLines) { - lines.push(ml); - } - } else { - // Collapsed: first few lines as plain text - const textLines = text.split("\n"); - const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES; - const maxWidth = contentWidth(); - for (let i = 0; i < Math.min(textLines.length, maxLines); i++) { - lines.push(`${INDENT}${sanitizeLine(textLines[i], maxWidth)}`); - } - if (textLines.length > maxLines) { - lines.push(`${INDENT}${theme.fg("dim", `... ${textLines.length - maxLines} more lines`)}`); - } - } - } - - #renderToolCallLines( - lines: string[], - call: { id: string; name: string; arguments: Record; intent?: string }, - result: ToolResultMessage | undefined, - expanded: boolean, - selected: boolean, - ): void { - const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " "; - lines.push(""); - - // Tool call header - const intentStr = call.intent ? theme.fg("dim", ` ${sanitizeLine(call.intent, TRUNCATE_LENGTHS.SHORT)}`) : ""; - lines.push(`${cursor} ${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`); - - // Key arguments - const argSummary = this.#formatToolArgs(call.name, call.arguments); - if (argSummary) { - lines.push(`${INDENT}${theme.fg("dim", sanitizeLine(argSummary, contentWidth()))}`); - } - - // Tool result - if (result) { - this.#renderToolResultLines(lines, result, expanded); - } - } - - #renderToolResultLines(lines: string[], result: ToolResultMessage, expanded: boolean): void { - const textParts = result.content - .filter((p): p is { type: "text"; text: string } => p.type === "text") - .map(p => p.text); - const text = textParts.join("\n").trim(); - - if (result.isError) { - const errorLines = text.split("\n"); - const maxErrorLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES; - const maxWidth = contentWidth(); - lines.push(`${INDENT}${theme.fg("error", `✗ ${sanitizeLine(errorLines[0] || "Error", maxWidth)}`)}`); - for (let i = 1; i < Math.min(errorLines.length, maxErrorLines); i++) { - lines.push(`${INDENT} ${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`); - } - if (errorLines.length > maxErrorLines) { - lines.push(`${INDENT} ${theme.fg("dim", `... ${errorLines.length - maxErrorLines} more lines`)}`); + #appendToolResult(message: Extract): void { + const pending = this.#chatPendingTools.get(message.toolCallId); + const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent); + if (isReadGroupResult) { + let component = pending; + if (!component) { + const group = this.#ensureReadGroup(); + const args = this.#chatReadArgs.get(message.toolCallId); + if (args) group.updateArgs(args, message.toolCallId); + component = group; } + component.updateResult(message, false, message.toolCallId); + this.#chatPendingTools.delete(message.toolCallId); + this.#chatReadArgs.delete(message.toolCallId); return; } - - if (!text) { - lines.push(`${INDENT}${theme.fg("dim", "✓ done")}`); - return; - } - - const resultLines = text.split("\n"); - const maxLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.OUTPUT_COLLAPSED; - - // Status line - const statusPrefix = `${INDENT}${theme.fg("success", "✓")}`; - - if (resultLines.length === 1 && text.length < TRUNCATE_LENGTHS.LONG) { - lines.push(`${statusPrefix} ${theme.fg("dim", sanitizeLine(text))}`); - return; - } - - lines.push(`${statusPrefix} ${theme.fg("dim", `${resultLines.length} lines`)}`); - const displayLines = resultLines.slice(0, maxLines); - for (const rl of displayLines) { - lines.push(`${INDENT} ${theme.fg("dim", sanitizeLine(rl))}`); - } - if (resultLines.length > maxLines) { - lines.push(`${INDENT} ${theme.fg("dim", `... ${resultLines.length - maxLines} more`)}`); + if (!pending) return; + pending.updateResult(message, false, message.toolCallId); + this.#chatPendingTools.delete(message.toolCallId); + if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) { + this.#chatWaitingPoll = pending; } } - #formatToolArgs(toolName: string, args: Record): string { - switch (toolName) { - case "read": - case "write": - case "edit": - return args.path ? `path: ${args.path}` : ""; - case "search": { - const searchPathsInput = - typeof args.paths === "string" || Array.isArray(args.paths) - ? args.paths - : typeof args.path === "string" - ? args.path - : undefined; - const searchPaths = toPathList(searchPathsInput); - return [ - args.pattern ? `pattern: ${args.pattern}` : "", - searchPaths.length > 0 ? `paths: ${searchPaths.join(", ")}` : "", + #appendCustomMessage(message: Extract): void { + if (!message.display) return; + if (message.customType === "async-result") { + const details = ( + message as CustomMessage<{ + jobId?: string; + type?: "bash" | "task"; + label?: string; + durationMs?: number; + jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>; + }> + ).details; + const jobs = + details?.jobs && details.jobs.length > 0 + ? details.jobs + : [ + { + jobId: details?.jobId, + type: details?.type, + label: details?.label, + durationMs: details?.durationMs, + }, + ]; + const block = new TranscriptBlock(); + for (const job of jobs) { + const jobId = job.jobId ?? "unknown"; + const typeLabel = job.type ? `[${job.type}]` : "[job]"; + const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined; + const line = [ + theme.fg("success", `${theme.status.done} Background job completed`), + theme.fg("dim", typeLabel), + theme.fg("accent", jobId), + duration ? theme.fg("dim", `(${duration})`) : undefined, ] .filter(Boolean) - .join(", "); - } - case "find": - return Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : ""; - case "bash": { - const cmd = args.command; - return typeof cmd === "string" ? replaceTabs(cmd) : ""; - } - case "lsp": - return [args.action, args.file, args.symbol].filter(Boolean).join(" "); - case "ast_grep": - case "ast_edit": - return args.path ? `path: ${args.path}` : ""; - case "task": { - const target = typeof args.agent === "string" ? args.agent : ""; - const id = typeof args.id === "string" && args.id ? ` ${args.id}` : ""; - return `${target}${id}`.trim(); - } - default: { - const parts: string[] = []; - let total = 0; - for (const key in args) { - if (key.startsWith("_")) continue; - const value = args[key]; - const v = typeof value === "string" ? value : JSON.stringify(value); - const entry = `${key}: ${replaceTabs(v ?? "")}`; - if (total + entry.length > MAX_TOOL_ARGS_CHARS) break; - parts.push(entry); - total += entry.length; - } - return parts.join(", "); + .join(" "); + block.addChild(new Text(line, 1, 0)); } + this.#chatLog.addChild(block); + return; } + if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) { + const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details; + const component = new LateDiagnosticsMessageComponent(details?.files ?? []); + this.#trackExpandable(component); + this.#chatLog.addChild(component); + return; + } + if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) { + this.#chatLog.addChild(new CollabPromptMessageComponent(message as CustomMessage)); + return; + } + if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) { + const component = new SkillMessageComponent(message as CustomMessage); + this.#trackExpandable(component); + this.#chatLog.addChild(component); + return; + } + if ( + message.customType === "irc:incoming" || + message.customType === "irc:autoreply" || + message.customType === "irc:relay" + ) { + const details = ( + message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }> + ).details; + const kind = + message.customType === "irc:incoming" + ? ("incoming" as const) + : message.customType === "irc:autoreply" + ? ("autoreply" as const) + : ("relay" as const); + const card = createIrcMessageCard( + { + kind, + from: details?.from, + to: details?.to, + body: kind === "incoming" ? details?.message : details?.body, + replyTo: details?.replyTo, + timestamp: message.timestamp, + }, + () => this.#chatExpanded, + theme, + ); + this.#chatLog.addChild(card); + return; + } + const component = new CustomMessageComponent( + message as CustomMessage, + this.#getMessageRenderer?.(message.customType), + ); + this.#trackExpandable(component); + this.#chatLog.addChild(component); } #loadTranscript(sessionFile: string): SessionMessageEntry[] | null { diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 74ba39b25..6851cf7d4 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -154,6 +154,7 @@ export class StatusLineComponent implements Component { #loopModeStatus: { enabled: boolean } | null = null; #goalModeStatus: { enabled: boolean; paused: boolean } | null = null; #collabStatus: CollabStatus | null = null; + #focusedAgentId: string | undefined; // Git status caching (1s TTL) #cachedGitStatus: { staged: number; unstaged: number; untracked: number } | null = null; @@ -189,7 +190,7 @@ export class StatusLineComponent implements Component { #nonMessageInputsKey: string | undefined; #messageTokenTotalsCache: MessageTokenTotalsCache | undefined; - constructor(private readonly session: AgentSession) { + constructor(private session: AgentSession) { this.#settings = { preset: settings.get("statusLine.preset"), leftSegments: settings.get("statusLine.leftSegments"), @@ -202,6 +203,19 @@ export class StatusLineComponent implements Component { }; } + /** + * Re-point the status line at another session (focus proxy). Invalidate: model/context/usage all derive + * from it. `focusedAgentId` is the focused subagent id while the view is proxied, undefined for main. + */ + setSession(session: AgentSession, focusedAgentId?: string): void { + const sessionChanged = this.session !== session; + if (!sessionChanged && this.#focusedAgentId === focusedAgentId) return; + this.session = session; + this.#focusedAgentId = focusedAgentId; + if (sessionChanged) this.#invalidateSessionCaches(); + this.invalidate(); + } + updateSettings(settings: StatusLineSettings): void { this.#settings = settings; this.#effectiveSettings = undefined; @@ -292,6 +306,16 @@ export class StatusLineComponent implements Component { invalidate(): void { this.#invalidateGitCaches(); } + #invalidateSessionCaches(): void { + this.#cachedUsage = null; + this.#usageFetchedAt = 0; + this.#usageInFlight = false; + this.#nonMessageTokensCache = undefined; + this.#nonMessageInputsKey = undefined; + this.#messageTokenTotalsCache = undefined; + this.#lastTokensPerSecond = null; + this.#lastTokensPerSecondTimestamp = null; + } #invalidateGitCaches(): void { this.#cachedBranch = undefined; @@ -452,16 +476,19 @@ export class StatusLineComponent implements Component { const now = Date.now(); if (this.#usageInFlight) return; if (this.#usageFetchedAt > 0 && now - this.#usageFetchedAt < 5 * 60_000) return; - const fetcher = (this.session as { fetchUsageReports?: () => Promise }).fetchUsageReports; + const session = this.session; + const fetcher = (session as { fetchUsageReports?: () => Promise }).fetchUsageReports; if (typeof fetcher !== "function") return; this.#usageInFlight = true; void fetcher - .call(this.session) + .call(session) .then(reports => { + if (this.session !== session) return; this.#cachedUsage = this.#normalizeUsageReports(reports); this.#usageFetchedAt = Date.now(); }) .catch(() => { + if (this.session !== session) return; // Backoff on error: stamp the fetch time so the 5-min TTL guard // also acts as an error budget. Without this, every render // kicks off another fetch (gated only by #usageInFlight), @@ -469,7 +496,7 @@ export class StatusLineComponent implements Component { this.#usageFetchedAt = Date.now(); }) .finally(() => { - this.#usageInFlight = false; + if (this.session === session) this.#usageInFlight = false; }); } @@ -665,6 +692,7 @@ export class StatusLineComponent implements Component { return { session: this.session, + focusedAgentId: this.#focusedAgentId, width, options: segmentOptions ?? {}, planMode: this.#planModeStatus, @@ -878,7 +906,12 @@ export class StatusLineComponent implements Component { } getTopBorder(width: number): { content: string; width: number } { - const content = this.#buildStatusLine(width); + let content = this.#buildStatusLine(width); + if (this.#focusedAgentId && content) { + // Dim the whole bar while focus-proxied. Group/cap terminators emit full + // `\x1b[0m` resets that would cancel faint mid-bar, so re-open it after each. + content = `\x1b[2m${content.replaceAll("\x1b[0m", "\x1b[0m\x1b[2m")}\x1b[22m`; + } return { content, width: visibleWidth(content), diff --git a/packages/coding-agent/src/modes/components/status-line/segments.ts b/packages/coding-agent/src/modes/components/status-line/segments.ts index 708e646b7..9dd0120cb 100644 --- a/packages/coding-agent/src/modes/components/status-line/segments.ts +++ b/packages/coding-agent/src/modes/components/status-line/segments.ts @@ -65,7 +65,11 @@ function classifyProjectDir(pwd: string): { scratch: boolean; relative: string | const piSegment: StatusLineSegment = { id: "pi", - render(_ctx) { + render(ctx) { + if (ctx.focusedAgentId) { + const icon = theme.icon.ghost ? `${theme.icon.ghost} ` : ""; + return { content: theme.fg("warning", `${icon}${ctx.focusedAgentId} `), visible: true }; + } const content = theme.icon.pi ? `${theme.icon.pi} ` : ""; return { content: theme.fg("accent", content), visible: true }; }, diff --git a/packages/coding-agent/src/modes/components/status-line/types.ts b/packages/coding-agent/src/modes/components/status-line/types.ts index 953fbf571..8587a62fe 100644 --- a/packages/coding-agent/src/modes/components/status-line/types.ts +++ b/packages/coding-agent/src/modes/components/status-line/types.ts @@ -45,6 +45,8 @@ export type RGB = readonly [number, number, number]; export interface SegmentContext { session: AgentSession; + /** Focused subagent id while the view is proxied at its session, undefined otherwise. */ + focusedAgentId?: string | undefined; width: number; options: StatusLineSegmentOptions; planMode: { diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index 86ae5126b..e13aee886 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -221,6 +221,35 @@ export class EventController { await this.handleEvent(event); }); } + /** + * Clear every transcript-anchored/turn-scoped piece of state. Used by the + * session focus proxy when re-pointing the transcript at another session: + * components, timers, and stream-reveal state all reference the previous + * session's transcript and must not bleed into the new one. + */ + resetTranscriptAnchors(): void { + this.#resetReadGroup(); + this.#lastVisibleBlockCount = 0; + this.#renderedCustomMessages.clear(); + this.#lastIntent = undefined; + this.#backgroundToolCallIds.clear(); + this.#agentTurnActive = false; + this.#interrupting = false; + this.#readToolCallArgs.clear(); + this.#readToolCallAssistantComponents.clear(); + this.#lastAssistantComponent = undefined; + this.#pinnedErrorComponent = undefined; + this.#cancelIdleCompaction(); + for (const timer of this.#ircExpiryTimers.values()) { + clearTimeout(timer); + } + this.#ircExpiryTimers.clear(); + this.#liveIrcCards.clear(); + this.#displaceablePollComponent = undefined; + this.#lastTtsrNotification = undefined; + this.#streamingReveal.stop(); + this.#toolArgsReveal.stop(); + } async handleEvent(event: AgentSessionEvent): Promise { if (!this.ctx.isInitialized) { @@ -335,7 +364,7 @@ export class EventController { undefined, this.ctx.hideThinkingBlock, () => this.ctx.ui.requestRender(), - this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(), + this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(), this.ctx.ui.imageBudget, ); this.ctx.streamingMessage = event.message; @@ -519,12 +548,12 @@ export class EventController { if (!this.ctx.pendingTools.has(content.id)) { this.#resolveDisplaceablePoll(content.name); this.#resetReadGroup(); - const tool = this.ctx.session.getToolByName(content.name); + const tool = this.ctx.viewSession.getToolByName(content.name); const component = new ToolExecutionComponent( content.name, renderArgs, { - snapshots: getFileSnapshotStore(this.ctx.session), + snapshots: getFileSnapshotStore(this.ctx.viewSession), showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), @@ -556,7 +585,7 @@ export class EventController { this.#updateWorkingMessageFromIntent(args[INTENT_FIELD]); continue; } - const tool = this.ctx.session.getToolByName(content.name); + const tool = this.ctx.viewSession.getToolByName(content.name); if (typeof tool?.intent !== "function") continue; try { const derived = tool.intent(args as never)?.trim(); @@ -581,7 +610,7 @@ export class EventController { let errorMessage: string | undefined; const aborted = this.ctx.streamingMessage.stopReason === "aborted"; const silentlyAborted = aborted && isSilentAbort(this.ctx.streamingMessage.errorMessage); - const ttsrSilenced = aborted && this.ctx.session.isTtsrAbortPending; + const ttsrSilenced = aborted && this.ctx.viewSession.isTtsrAbortPending; if (aborted && !silentlyAborted && !ttsrSilenced) { // Resolve the operator-facing label: a user-interrupt (Esc) abort // carries USER_INTERRUPT_LABEL on errorMessage (threaded through the @@ -590,7 +619,7 @@ export class EventController { // AgentSession.#handleAgentEvent already stamped SILENT_ABORT_MARKER for // the plan-compact transition before this controller ran, so reaching // this branch implies the abort was NOT a silent internal transition. - errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.session.retryAttempt); + errorMessage = resolveAbortLabel(this.ctx.streamingMessage.errorMessage, this.ctx.viewSession.retryAttempt); this.ctx.streamingMessage.errorMessage = errorMessage; } if (silentlyAborted || ttsrSilenced) { @@ -664,12 +693,12 @@ export class EventController { } this.#resetReadGroup(); - const tool = this.ctx.session.getToolByName(event.toolName); + const tool = this.ctx.viewSession.getToolByName(event.toolName); const component = new ToolExecutionComponent( event.toolName, event.args, { - snapshots: getFileSnapshotStore(this.ctx.session), + snapshots: getFileSnapshotStore(this.ctx.viewSession), showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), @@ -844,7 +873,7 @@ export class EventController { this.#cancelIdleCompaction(); this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape; this.ctx.editor.onEscape = () => { - this.ctx.session.abortCompaction(); + this.ctx.viewSession.abortCompaction(); }; this.ctx.statusContainer.clear(); const reasonText = @@ -937,7 +966,7 @@ export class EventController { async #handleAutoRetryStart(event: Extract): Promise { this.ctx.retryEscapeHandler = this.ctx.editor.onEscape; this.ctx.editor.onEscape = () => { - this.ctx.session.abortRetry(); + this.ctx.viewSession.abortRetry(); }; this.ctx.statusContainer.clear(); const delaySeconds = Math.round(event.delayMs / 1000); @@ -1023,7 +1052,7 @@ export class EventController { this.#cancelIdleCompaction(); // Don't schedule idle work while context maintenance is already running; the // maintenance flow may reset the session before this timer fires. - if (this.ctx.session.isCompacting) return; + if (this.ctx.viewSession.isCompacting) return; const idleSettings = settings.getGroup("compaction"); if (!idleSettings.idleEnabled) return; @@ -1040,17 +1069,17 @@ export class EventController { this.#idleCompactionTimer = undefined; // Re-check conditions before firing. Pruning may have run between arming // the timer and now, dropping usage back below the idle threshold. - if (this.ctx.session.isStreaming) return; - if (this.ctx.session.isCompacting) return; + if (this.ctx.viewSession.isStreaming) return; + if (this.ctx.viewSession.isCompacting) return; if (this.ctx.editor.getText().trim()) return; if (this.#currentContextTokens() < threshold) return; - void this.ctx.session.runIdleCompaction(); + void this.ctx.viewSession.runIdleCompaction(); }, timeoutMs); this.#idleCompactionTimer.unref?.(); } #currentContextTokens(): number { - const lastAssistant = this.ctx.session.agent.state.messages + const lastAssistant = this.ctx.viewSession.agent.state.messages .slice() .reverse() .find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted"); @@ -1065,7 +1094,7 @@ export class EventController { // errored — those are not "Task complete" events. Mirrors the gate // already used by #currentContextTokens, #handleMessageEnd, and the // retry / TTSR / compaction skip paths across agent-session.ts. - const last = this.ctx.session.getLastAssistantMessage?.(); + const last = this.ctx.viewSession.getLastAssistantMessage?.(); if (last?.stopReason === "aborted" || last?.stopReason === "error") return; const sessionName = this.ctx.sessionManager.getSessionName(); diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index be0a27dca..ff7f83254 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -125,6 +125,18 @@ export class InputController { if (this.ctx.hasActiveOmfg() && this.ctx.handleOmfgEscape()) { return; } + if (this.ctx.focusedAgentId) { + // Esc never interrupts the focused agent's turn: clear typed text, + // else return the view to the main session. Interrupt via empty + // steer-flush submit if needed. + if (this.ctx.editor.getText().trim()) { + this.ctx.editor.setText(""); + this.ctx.ui.requestRender(); + } else { + void this.ctx.unfocusSession(); + } + return; // double-escape backtrack (/tree, /branch) stays main-only + } if (this.ctx.collabGuest) { // Guest Esc: ask the host to interrupt its agent; the local replica // session is never streaming, so the native abort path below would @@ -261,13 +273,15 @@ export class InputController { this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showAgentHub()); } - // Double-tap left arrow on an empty editor opens the agent hub — same - // 500ms window as the double-escape state machine above. + // Double-tap left arrow on an empty editor: opens the agent hub from the + // main session, or returns the focused subagent view to the main session. + // Focused ←← intentionally matches Esc. this.ctx.editor.onLeftAtStart = () => { const now = Date.now(); if (now - this.ctx.lastLeftTapTime < 500) { this.ctx.lastLeftTapTime = 0; - this.ctx.showAgentHub(); + if (this.ctx.focusedAgentId) void this.ctx.unfocusSession(); + else this.ctx.showAgentHub(); } else { this.ctx.lastLeftTapTime = now; } @@ -324,6 +338,14 @@ export class InputController { text = text.trim(); if ((!isSettingsInitialized() || settings.get("emojiAutocomplete")) && text) text = expandEmoticons(text); + // Focused subagent session: the editor is a plain chat box for it. + // Everything below (continue shortcuts, slash/bash/python, loop, + // compaction queueing) is main-session-only. + if (this.ctx.focusedAgentId) { + await this.#submitToFocusedSession(text, "steer"); + return; + } + // Empty submit while streaming with queued steering: interrupt now and // immediately resume so the visible `Steer:` entry is sent without // waiting for the current tool/model boundary. @@ -607,6 +629,41 @@ export class InputController { }; } + /** Submit editor text to the focused subagent session (chat-only focus policy). */ + async #submitToFocusedSession(text: string, streamingBehavior: "steer" | "followUp"): Promise { + const target = this.ctx.viewSession; + if (!text) { + // Mirror the empty-submit steer flush against the focused session. + if (target.isStreaming && target.getQueuedMessages().steering.length > 0) { + await target.interruptAndFlushQueuedMessages({ reason: USER_INTERRUPT_LABEL }); + this.ctx.updatePendingMessagesDisplay(); + this.ctx.ui.requestRender(); + } + return; + } + if (text.startsWith("/") || text.startsWith("!") || text.startsWith("$")) { + this.ctx.showStatus("Commands run in the main session — press ←← to return first"); + return; // editor text not cleared: Editor does not auto-clear on submit + } + const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined; + this.ctx.editor.addToHistory(text); + this.ctx.editor.setText(""); + this.ctx.editor.imageLinks = undefined; + this.ctx.pendingImages = []; + this.ctx.pendingImageLinks = []; + try { + // prompt() handles idle (new turn) and streaming (queues per streamingBehavior). + await this.ctx.withLocalSubmission(text, () => target.prompt(text, { streamingBehavior, images }), { + imageCount: images?.length ?? 0, + }); + } catch (error) { + this.ctx.editor.setText(text); // hand the message back, mirroring the main submit error path + this.ctx.showError(error instanceof Error ? error.message : String(error)); + } + this.ctx.updatePendingMessagesDisplay(); + this.ctx.ui.requestRender(); + } + handleCtrlC(): void { const now = Date.now(); if (now - this.ctx.lastSigintTime < 500) { @@ -745,6 +802,12 @@ export class InputController { let text = this.ctx.editor.getText().trim(); if (!text) return; + // Focused subagent session: follow-ups go to it; non-chat input is gated. + if (this.ctx.focusedAgentId) { + await this.#submitToFocusedSession(text, "followUp"); + return; + } + // Compaction first: while compacting, free text gets queued via // `queueCompactionMessage`, and `/skill:*` rides the same queue so a // skill typed during compaction is not lost or short-circuited through @@ -1054,6 +1117,10 @@ export class InputController { } cycleThinkingLevel(): void { + if (this.ctx.focusedAgentId) { + this.ctx.showStatus("Model/thinking apply to the main session — press ←← to return first"); + return; + } const newLevel = this.ctx.session.cycleThinkingLevel(); if (newLevel === undefined) { this.ctx.showStatus("Current model does not support thinking"); @@ -1064,6 +1131,10 @@ export class InputController { } async cycleRoleModel(direction: "forward" | "backward" = "forward"): Promise { + if (this.ctx.focusedAgentId) { + this.ctx.showStatus("Model/thinking apply to the main session — press ←← to return first"); + return; + } try { const cycleOrder = settings.get("cycleOrder"); const result = await this.ctx.session.cycleRoleModels(cycleOrder, direction); diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 5a9270fed..08e374436 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -841,19 +841,6 @@ export class SelectorController { }); } - #clearTransientSessionUi(): void { - if (this.ctx.loadingAnimation) { - this.ctx.loadingAnimation.stop(); - this.ctx.loadingAnimation = undefined; - } - this.ctx.statusContainer.clear(); - this.ctx.pendingMessagesContainer.clear(); - this.ctx.compactionQueuedMessages = []; - this.ctx.streamingComponent = undefined; - this.ctx.streamingMessage = undefined; - this.ctx.pendingTools.clear(); - } - #refreshSessionTerminalTitle(): void { const sessionManager = this.ctx.sessionManager as { getSessionName?: () => string | undefined; @@ -875,7 +862,7 @@ export class SelectorController { } this.#refreshSessionTerminalTitle(); - this.#clearTransientSessionUi(); + this.ctx.clearTransientSessionUi(); this.ctx.statusLine.invalidate(); this.ctx.statusLine.setSessionStartTime(Date.now()); this.ctx.updateEditorTopBorder(); @@ -887,7 +874,7 @@ export class SelectorController { } async handleResumeSession(sessionPath: string): Promise { - this.#clearTransientSessionUi(); + this.ctx.clearTransientSessionUi(); const previousCwd = this.ctx.sessionManager.getCwd(); // Switch session via AgentSession (emits hook and tool session events). The @@ -1180,16 +1167,25 @@ export class SelectorController { const done = () => { hub?.dispose(); overlayHandle?.hide(); + this.ctx.ui.setFocus(this.ctx.editor); this.ctx.ui.requestRender(); }; hub = new AgentHubOverlayComponent({ observers, hubKeys, + expandKeys: this.ctx.keybindings.getKeys("app.tools.expand"), onDone: done, requestRender: () => this.ctx.ui.requestRender(), registry: this.ctx.collabGuest?.agentRegistry, remote: this.ctx.collabGuest?.hubRemote, + ui: this.ctx.ui, + getTool: name => this.ctx.session.getToolByName(name), + getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type), + cwd: this.ctx.sessionManager.getCwd(), + hideThinkingBlock: () => this.ctx.hideThinkingBlock, + focusAgent: id => this.ctx.focusAgentSession(id), + sessionFile: this.ctx.sessionManager.getSessionFile() ?? null, }); overlayHandle = this.ctx.ui.showOverlay(hub, { diff --git a/packages/coding-agent/src/modes/controllers/session-focus-controller.ts b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts new file mode 100644 index 000000000..90c2fa6f5 --- /dev/null +++ b/packages/coding-agent/src/modes/controllers/session-focus-controller.ts @@ -0,0 +1,112 @@ +/** + * SessionFocusController - Weak retargeting primitive between the rendering/ + * input layer and the AgentSession it displays. + * + * Focusing re-points the transcript, streaming event subscription, status + * line, and editor prompt/interrupt at a subagent's live AgentSession (from + * AgentRegistry) without touching the main session underneath; unfocusing + * re-attaches the main session and rebuilds the transcript from its + * authoritative state. + */ + +import { AgentLifecycleManager } from "../../registry/agent-lifecycle"; +import { AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "../../registry/agent-registry"; +import type { AgentSession } from "../../session/agent-session"; +import type { InteractiveModeContext } from "../types"; + +export class SessionFocusController { + #focusedAgentId: string | undefined; + /** Session currently attached while focused; undefined when unfocused. */ + #attachedSession: AgentSession | undefined; + #registryUnsubscribe: (() => void) | undefined; + + constructor( + private ctx: InteractiveModeContext, + private registry: AgentRegistry = AgentRegistry.global(), + private lifecycle: () => AgentLifecycleManager = () => AgentLifecycleManager.global(), + ) {} + + get focusedAgentId(): string | undefined { + return this.#focusedAgentId; + } + + /** Focused live session, undefined when unfocused. */ + get target(): AgentSession | undefined { + return this.#attachedSession; + } + + /** Focus the main view on an agent's live session. Throws an Error with a user-displayable message. */ + async focusAgent(id: string): Promise { + if (this.ctx.collabGuest) throw new Error("Viewing agents is unavailable in a collab session."); + if (id === MAIN_AGENT_ID) return this.unfocus(); + const session = await this.lifecycle().ensureLive(id); + if (id === this.#focusedAgentId && session === this.#attachedSession) return; + this.#focusedAgentId = id; + this.#attachedSession = session; + this.#registryUnsubscribe ??= this.registry.onChange(e => this.#onRegistryEvent(e)); + await this.#attach(session); + this.ctx.showStatus(`Viewing agent ${id} — Esc returns to main, ←← hops to parent`); + } + + /** Focus the focused agent's parent agent, falling back to the main session. No-op when unfocused. */ + async focusParent(): Promise { + if (!this.#focusedAgentId) return; + const parentId = this.registry.get(this.#focusedAgentId)?.parentId; + if (parentId && parentId !== MAIN_AGENT_ID && this.registry.get(parentId)) { + return this.focusAgent(parentId); + } + return this.unfocus(); + } + + /** Return to the main session. No-op when unfocused. */ + async unfocus(): Promise { + if (!this.#focusedAgentId) return; + this.#focusedAgentId = undefined; + this.#attachedSession = undefined; + await this.#attach(this.ctx.session); + this.ctx.showStatus("Returned to main session"); + } + + dispose(): void { + this.#registryUnsubscribe?.(); + this.#registryUnsubscribe = undefined; + } + + #onRegistryEvent(event: RegistryEvent): void { + if (event.ref.id !== this.#focusedAgentId) return; + const gone = event.type === "removed"; + const dead = event.type === "status_changed" && (event.ref.status === "parked" || event.ref.status === "aborted"); + if (!gone && !dead) return; + void this.unfocus().then(() => { + this.ctx.showStatus(`Agent ${event.ref.id} is ${gone ? "gone" : event.ref.status}; returned to main session`); + }); + } + + /** Retarget core, both directions: swap subscription, transcript, and status line onto `target`. */ + async #attach(target: AgentSession): Promise { + this.ctx.unsubscribe?.(); + this.ctx.clearTransientSessionUi(); + this.ctx.eventController.resetTranscriptAnchors(); + // Orphan-delta guard: when attaching mid-turn the message_start for the + // in-flight assistant message predates the attach. message_update carries + // the full accumulating message, so synthesize the missing start before + // the first orphaned update; every other handler is tolerant of unknown + // anchors (guarded by streamingComponent/pendingTools lookups). + let assistantStreamSynced = false; + this.ctx.unsubscribe = target.subscribe(async event => { + if (event.type === "message_start" && event.message.role === "assistant") { + assistantStreamSynced = true; + } else if (event.type === "message_update" && event.message.role === "assistant" && !assistantStreamSynced) { + assistantStreamSynced = true; + await this.ctx.eventController.handleEvent({ type: "message_start", message: event.message }); + } + await this.ctx.eventController.handleEvent(event); + }); + this.ctx.statusLine.setSession(target, this.#focusedAgentId); + this.ctx.renderInitialMessages({ clearTerminalHistory: true }); + // Mid-turn attach: no agent_start will arrive; arm the loader/turn state manually. + if (target.isStreaming) await this.ctx.eventController.handleEvent({ type: "agent_start" }); + this.ctx.updateEditorBorderColor(); + this.ctx.ui.requestRender(); + } +} diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index d44656aba..f2143429a 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -122,6 +122,7 @@ import { InputController } from "./controllers/input-controller"; import { MCPCommandController } from "./controllers/mcp-command-controller"; import { OmfgController } from "./controllers/omfg-controller"; import { SelectorController } from "./controllers/selector-controller"; +import { SessionFocusController } from "./controllers/session-focus-controller"; import { SSHCommandController } from "./controllers/ssh-command-controller"; import { TanCommandController } from "./controllers/tan-command-controller"; import { TodoCommandController } from "./controllers/todo-command-controller"; @@ -440,6 +441,34 @@ export class InteractiveMode implements InteractiveModeContext { readonly #extensionUiController: ExtensionUiController; readonly #inputController: InputController; readonly #selectorController: SelectorController; + readonly #focusController: SessionFocusController; + get viewSession(): AgentSession { + return this.#focusController.target ?? this.session; + } + get focusedAgentId(): string | undefined { + return this.#focusController.focusedAgentId; + } + focusAgentSession(id: string): Promise { + return this.#focusController.focusAgent(id); + } + focusParentSession(): Promise { + return this.#focusController.focusParent(); + } + unfocusSession(): Promise { + return this.#focusController.unfocus(); + } + clearTransientSessionUi(): void { + if (this.loadingAnimation) { + this.loadingAnimation.stop(); + this.loadingAnimation = undefined; + } + this.statusContainer.clear(); + this.pendingMessagesContainer.clear(); + this.compactionQueuedMessages = []; + this.streamingComponent = undefined; + this.streamingMessage = undefined; + this.pendingTools.clear(); + } readonly #uiHelpers: UiHelpers; #sttController: STTController | undefined; #voiceAnimationInterval: NodeJS.Timeout | undefined; @@ -565,6 +594,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#commandController = new CommandController(this); this.#todoCommandController = new TodoCommandController(this); this.#selectorController = new SelectorController(this); + this.#focusController = new SessionFocusController(this); this.#inputController = new InputController(this); this.#observerRegistry = new SessionObserverRegistry(); } @@ -1160,6 +1190,12 @@ export class InteractiveMode implements InteractiveModeContext { this.editor.borderColor = theme.getThinkingBorderColor(level); } } + if (this.focusedAgentId) { + // Focused subagent view: faint the outline so the borrowed session is + // visually distinct from the main one. + const base = this.editor.borderColor; + this.editor.borderColor = (str: string) => `\x1b[2m${base(str)}\x1b[22m`; + } this.updateEditorTopBorder(); this.ui.requestRender(); } @@ -1174,7 +1210,7 @@ export class InteractiveMode implements InteractiveModeContext { this.chatContainer.clear(); // Full-history transcript: compactions render as inline dividers instead // of restarting the visible conversation (the LLM context still resets). - const context = this.session.buildTranscriptSessionContext(); + const context = this.viewSession.buildTranscriptSessionContext(); this.renderSessionContext(context); // During the pre-streaming window — after `startPendingSubmission` has // optimistically rendered the user's message but before the user @@ -2700,6 +2736,7 @@ export class InteractiveMode implements InteractiveModeContext { } this.#btwController.dispose(); this.#omfgController.dispose(); + this.#focusController.dispose(); // Emit shutdown event to hooks await this.session.dispose(); diff --git a/packages/coding-agent/src/modes/theme/theme.ts b/packages/coding-agent/src/modes/theme/theme.ts index 3258e705a..94b0061fd 100644 --- a/packages/coding-agent/src/modes/theme/theme.ts +++ b/packages/coding-agent/src/modes/theme/theme.ts @@ -107,6 +107,7 @@ export type SymbolKey = | "icon.cost" | "icon.time" | "icon.pi" + | "icon.ghost" | "icon.agents" | "icon.job" | "icon.cache" @@ -305,6 +306,7 @@ const UNICODE_SYMBOLS: SymbolMap = { "icon.cost": "💲", "icon.time": "⏱", "icon.pi": "π", + "icon.ghost": "👻", "icon.agents": "👥", "icon.job": "⚙", "icon.cache": "💾", @@ -569,6 +571,8 @@ const NERD_SYMBOLS: SymbolMap = { "icon.time": "\uf017", // pick:  | alt: π ∏ ∑ "icon.pi": "\ue22c", + // pick: 󰊠 (nf-md-ghost) | alt: 👻 + "icon.ghost": "\u{f02a0}", // pick:  | alt:  "icon.agents": "\uf0c0", // pick: (nf-fa-gear) | alt: ⚙ @@ -802,6 +806,7 @@ const ASCII_SYMBOLS: SymbolMap = { "icon.cost": "$", "icon.time": "t:", "icon.pi": "pi", + "icon.ghost": "@", "icon.agents": "AG", "icon.job": "bg", "icon.cache": "cache", @@ -1769,6 +1774,7 @@ export class Theme { cost: this.#symbols["icon.cost"], time: this.#symbols["icon.time"], pi: this.#symbols["icon.pi"], + ghost: this.#symbols["icon.ghost"], agents: this.#symbols["icon.agents"], job: this.#symbols["icon.job"], cache: this.#symbols["icon.cache"], diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 2f25f9e6e..cc3dea50a 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -98,6 +98,18 @@ export interface InteractiveModeContext { // Session access session: AgentSession; sessionManager: SessionManager; + /** Session the transcript/editor/status are attached to: the focused agent's, else `session`. */ + readonly viewSession: AgentSession; + /** Id of the focused agent, undefined when the main session is attached. */ + readonly focusedAgentId: string | undefined; + /** Focus the main view on an agent's live session (delegates to SessionFocusController.focusAgent). */ + focusAgentSession(id: string): Promise; + /** Focus the focused agent's parent session, falling back to main (delegates to focusParent). */ + focusParentSession(): Promise; + /** Return the view to the main session (delegates to SessionFocusController.unfocus). */ + unfocusSession(): Promise; + /** Clear loader, status/pending containers, streaming state, and pending tools. */ + clearTransientSessionUi(): void; settings: Settings; keybindings: KeybindingsManager; agent: AgentSession["agent"]; diff --git a/packages/coding-agent/src/modes/utils/ui-helpers.ts b/packages/coding-agent/src/modes/utils/ui-helpers.ts index 3bbeeb870..3ee8d52f6 100644 --- a/packages/coding-agent/src/modes/utils/ui-helpers.ts +++ b/packages/coding-agent/src/modes/utils/ui-helpers.ts @@ -233,7 +233,7 @@ export class UiHelpers { this.ctx.chatContainer.addChild(card); return [card]; } - const renderer = this.ctx.session.extensionRunner?.getMessageRenderer(message.customType); + const renderer = this.ctx.viewSession.extensionRunner?.getMessageRenderer(message.customType); // Both HookMessage and CustomMessage have the same structure, cast for compatibility const component = new CustomMessageComponent(message as CustomMessage, renderer); component.setExpanded(this.ctx.toolOutputExpanded); @@ -284,7 +284,10 @@ export class UiHelpers { const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false); const imageLinks = options?.imageLinks ?? - imageLinksForMessage(message, this.ctx.sessionManager.putBlobSync.bind(this.ctx.sessionManager)); + imageLinksForMessage( + message, + this.ctx.viewSession.sessionManager.putBlobSync.bind(this.ctx.viewSession.sessionManager), + ); const userComponent = new UserMessageComponent(textContent, isSynthetic, imageLinks); this.ctx.chatContainer.addChild(userComponent); if (options?.populateHistory && message.role === "user" && !isSynthetic) { @@ -298,7 +301,7 @@ export class UiHelpers { message, this.ctx.hideThinkingBlock, () => this.ctx.ui.requestRender(), - this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(), + this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(), this.ctx.ui.imageBudget, ); this.ctx.chatContainer.addChild(assistantComponent); @@ -379,7 +382,7 @@ export class UiHelpers { !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error"); const errorMessage = hasErrorStop ? message.stopReason === "aborted" - ? resolveAbortLabel(message.errorMessage, this.ctx.session.retryAttempt) + ? resolveAbortLabel(message.errorMessage, this.ctx.viewSession.retryAttempt) : message.errorMessage || "Error" : null; @@ -424,7 +427,7 @@ export class UiHelpers { readGroup?.seal(); readGroup = null; - const tool = this.ctx.session.getToolByName(content.name); + const tool = this.ctx.viewSession.getToolByName(content.name); const renderArgs = "partialJson" in content ? { ...content.arguments, __partialJson: content.partialJson } @@ -433,7 +436,7 @@ export class UiHelpers { content.name, renderArgs, { - snapshots: getFileSnapshotStore(this.ctx.session), + snapshots: getFileSnapshotStore(this.ctx.viewSession), showImages: settings.get("terminal.showImages"), editFuzzyThreshold: settings.get("edit.fuzzyThreshold"), editAllowFuzzy: settings.get("edit.fuzzyMatch"), @@ -441,7 +444,7 @@ export class UiHelpers { }, tool, this.ctx.ui, - this.ctx.sessionManager.getCwd(), + this.ctx.viewSession.sessionManager.getCwd(), content.id, ); component.setExpanded(this.ctx.toolOutputExpanded); @@ -550,14 +553,14 @@ export class UiHelpers { // Display always uses the full-history transcript: compactions show as // inline dividers instead of restarting the visible conversation. - const context = this.ctx.session.buildTranscriptSessionContext(); + const context = this.ctx.viewSession.buildTranscriptSessionContext(); this.ctx.renderSessionContext(context, { updateFooter: true, - populateHistory: true, + populateHistory: !this.ctx.focusedAgentId, }); // Show compaction info if session was compacted - const allEntries = this.ctx.sessionManager.getEntries(); + const allEntries = this.ctx.viewSession.sessionManager.getEntries(); let compactionCount = 0; for (const entry of allEntries) { if (entry.type === "compaction") { @@ -614,7 +617,7 @@ export class UiHelpers { updatePendingMessagesDisplay(): void { this.ctx.pendingMessagesContainer.clear(); - const queuedMessages = this.ctx.session.getQueuedMessages() as QueuedMessages; + const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages; const steeringMessages: Array<{ message: string; label: string }> = []; for (const message of queuedMessages.steering) { @@ -800,8 +803,8 @@ export class UiHelpers { } findLastAssistantMessage(): AssistantMessage | undefined { - for (let i = this.ctx.session.messages.length - 1; i >= 0; i--) { - const message = this.ctx.session.messages[i]; + for (let i = this.ctx.viewSession.messages.length - 1; i >= 0; i--) { + const message = this.ctx.viewSession.messages[i]; if (message?.role === "assistant") { return message as AssistantMessage; } diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index 6c86d9aed..c0390966d 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -10219,19 +10219,21 @@ export class AgentSession { const who = decision.target.email ?? decision.target.accountId ?? "the active account"; const resetLabel = decision.availableCount === 1 ? "reset" : "resets"; try { - const choice = await runner.getUIContext().select( - `Do you wanna redeem your reset?\n${who} is blocked by the weekly Codex limit for about ${formatDuration(decision.remainingMs)}. Spend 1 of ${decision.availableCount} saved ${resetLabel}?`, - [ - { - label: "Yes", - description: "Redeem now and remember yes for future eligible Codex weekly blocks.", - }, - { - label: "No", - description: "Do not auto-redeem saved Codex resets.", - }, - ], - ); + const choice = await runner + .getUIContext() + .select( + `Do you wanna redeem your reset?\n${who} is blocked by the weekly Codex limit for about ${formatDuration(decision.remainingMs)}. Spend 1 of ${decision.availableCount} saved ${resetLabel}?`, + [ + { + label: "Yes", + description: "Redeem now and remember yes for future eligible Codex weekly blocks.", + }, + { + label: "No", + description: "Do not auto-redeem saved Codex resets.", + }, + ], + ); if (choice === "Yes") { this.settings.set("codexResets.autoRedeem", "yes"); return true; diff --git a/packages/coding-agent/test/agent-hub-activate.test.ts b/packages/coding-agent/test/agent-hub-activate.test.ts new file mode 100644 index 000000000..a721cbf31 --- /dev/null +++ b/packages/coding-agent/test/agent-hub-activate.test.ts @@ -0,0 +1,172 @@ +/** + * Hub Enter contract: activating a non-remote agent row delegates to the + * `focusAgent` dep (session focus proxy) and closes the hub on success; a + * focus failure keeps the hub open and surfaces the error as a notice. + */ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus"; +import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; +import { SelectorController } from "@oh-my-pi/pi-coding-agent/modes/controllers/selector-controller"; +import { SessionObserverRegistry } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; +import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import type { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session"; +import { TempDir } from "@oh-my-pi/pi-utils"; + +const AGENT_ID = "Worker"; + +function makeHub(focusAgent: (id: string) => Promise) { + const agents = new AgentRegistry(); + agents.register({ + id: AGENT_ID, + displayName: AGENT_ID, + kind: "sub", + parentId: "Main", + session: { subscribe: () => () => {} } as unknown as AgentSession, + sessionFile: null, + status: "running", + }); + let doneCalls = 0; + const done = Promise.withResolvers(); + const renderRequested = Promise.withResolvers(); + const hub = new AgentHubOverlayComponent({ + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => { + doneCalls++; + done.resolve(); + }, + requestRender: () => renderRequested.resolve(), + registry: agents, + irc: new IrcBus(agents), + focusAgent, + }); + return { hub, doneCalls: () => doneCalls, done: done.promise, renderRequested: renderRequested.promise }; +} + +describe("Agent hub Enter activation", () => { + beforeAll(() => { + initTheme(); + }); + + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); + }); + + afterEach(() => { + resetSettingsForTest(); + }); + + it("Enter focuses the selected agent and closes the hub", async () => { + const focusedIds: string[] = []; + const { hub, doneCalls, done } = makeHub(async id => { + focusedIds.push(id); + }); + + hub.handleInput("\r"); + await done; // activation is fire-and-forget async; onDone signals completion + + expect(focusedIds).toEqual([AGENT_ID]); + expect(doneCalls()).toBe(1); + hub.dispose(); + }); + + it("a focus failure keeps the hub open and shows the error as a notice", async () => { + const message = 'Agent "X" is aborted and cannot be revived'; + const { hub, doneCalls, renderRequested } = makeHub(() => Promise.reject(new Error(message))); + + hub.handleInput("\r"); + await renderRequested; // the rejection path requests a render after setting the notice + + expect(doneCalls()).toBe(0); + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain(message); + hub.dispose(); + }); + + it("lists persisted subagent session files after restart", async () => { + using tempDir = TempDir.createSync("@omp-agent-hub-persisted-"); + const sessionFile = `${tempDir.path()}/main.jsonl`; + await Bun.write(sessionFile, ""); + await Bun.write(`${tempDir.path()}/main/Worker.jsonl`, ""); + const agents = new AgentRegistry(); + const hub = new AgentHubOverlayComponent({ + observers: new SessionObserverRegistry(), + hubKeys: [], + onDone: () => {}, + requestRender: () => {}, + registry: agents, + irc: new IrcBus(agents), + focusAgent: async () => {}, + sessionFile, + }); + + const rendered = Bun.stripANSI(hub.render(120).join("\n")); + expect(rendered).toContain("Worker"); + expect(rendered).toContain("parked"); + expect(agents.get("Worker")?.sessionFile).toBe(`${tempDir.path()}/main/Worker.jsonl`); + hub.dispose(); + }); + + it("selector controller restores focus to the editor after Enter focuses an agent", async () => { + const agents = new AgentRegistry(); + agents.register({ + id: AGENT_ID, + displayName: AGENT_ID, + kind: "sub", + parentId: "Main", + session: { subscribe: () => () => {} } as unknown as AgentSession, + sessionFile: null, + status: "running", + }); + + const editor = {}; + let capturedHub: AgentHubOverlayComponent | undefined; + let hideCalls = 0; + const focusedIds: string[] = []; + const focusResolved = Promise.withResolvers(); + const editorFocused = Promise.withResolvers(); + const focusTargets: unknown[] = []; + const ctx = { + keybindings: { getKeys: () => [] }, + ui: { + showOverlay: (component: AgentHubOverlayComponent) => { + capturedHub = component; + return { hide: () => hideCalls++ }; + }, + setFocus: (target: unknown) => { + focusTargets.push(target); + if (target === editor) editorFocused.resolve(); + }, + requestRender: () => {}, + }, + editor, + collabGuest: { agentRegistry: agents, hubRemote: undefined }, + focusAgentSession: async (id: string) => { + focusedIds.push(id); + focusResolved.resolve(); + }, + session: { getToolByName: () => undefined, extensionRunner: undefined }, + sessionManager: { getCwd: () => "/tmp", getSessionFile: () => null }, + hideThinkingBlock: false, + }; + const controller = new SelectorController(ctx as unknown as InteractiveModeContext); + + controller.showAgentHub(new SessionObserverRegistry()); + + expect(capturedHub).toBeDefined(); + expect(focusTargets[0]).toBe(capturedHub); + + capturedHub!.handleInput("\r"); + await focusResolved.promise; + await editorFocused.promise; + + expect(focusedIds).toEqual([AGENT_ID]); + expect(hideCalls).toBe(1); + expect(focusTargets.at(-1)).toBe(editor); + capturedHub!.dispose(); + }); +}); diff --git a/packages/coding-agent/test/event-controller-abort-render.test.ts b/packages/coding-agent/test/event-controller-abort-render.test.ts index 9b5a44042..228bbf760 100644 --- a/packages/coding-agent/test/event-controller-abort-render.test.ts +++ b/packages/coding-agent/test/event-controller-abort-render.test.ts @@ -56,7 +56,7 @@ function createFixture(opts: { const streamingComponent = { updateContent, setUsageInfo, setComplete, markTranscriptBlockFinalized }; const requestRender = vi.fn(); - const ctx = { + const ctxBase = { isInitialized: true, init: vi.fn(async () => {}), ui: { requestRender }, @@ -65,10 +65,16 @@ function createFixture(opts: { streamingComponent, streamingMessage: opts.streamingMessage, pendingTools: new Map(), - session: { - isTtsrAbortPending: opts.isTtsrAbortPending ?? false, - retryAttempt: opts.retryAttempt ?? 0, - }, + }; + const sessionMock = { + isTtsrAbortPending: opts.isTtsrAbortPending ?? false, + retryAttempt: opts.retryAttempt ?? 0, + }; + const ctx = { + ...ctxBase, + session: sessionMock, + viewSession: sessionMock, + clearTransientSessionUi: () => {}, } as unknown as InteractiveModeContext; const controller = new EventController(ctx); diff --git a/packages/coding-agent/test/event-controller-error-banner.test.ts b/packages/coding-agent/test/event-controller-error-banner.test.ts index dcd2102dd..33f6b4578 100644 --- a/packages/coding-agent/test/event-controller-error-banner.test.ts +++ b/packages/coding-agent/test/event-controller-error-banner.test.ts @@ -76,6 +76,10 @@ function createFixture(streamingMessage?: AssistantMessage) { showPinnedError, clearPinnedError, session: { isTtsrAbortPending: false, retryAttempt: 0 }, + get viewSession() { + return this.session; + }, + clearTransientSessionUi: () => {}, } as unknown as InteractiveModeContext; const controller = new EventController(ctx); diff --git a/packages/coding-agent/test/input-controller-escape.test.ts b/packages/coding-agent/test/input-controller-escape.test.ts index eebe70619..dc7260ae2 100644 --- a/packages/coding-agent/test/input-controller-escape.test.ts +++ b/packages/coding-agent/test/input-controller-escape.test.ts @@ -16,6 +16,7 @@ type FakeEditor = { onCycleModelBackward?: () => void; onSelectModelTemporary?: () => void; onSelectModel?: () => void; + onLeftAtStart?: () => void; onHistorySearch?: () => void; onPasteImage?: () => void; onCopyPrompt?: () => void; @@ -163,7 +164,9 @@ function createContext(): { updateEditorBorderColor: vi.fn(), showDebugSelector: vi.fn(), toggleTodoExpansion: vi.fn(), - handleHotkeysCommand: vi.fn(), + showAgentHub: vi.fn(), + unfocusSession: vi.fn(async () => {}), + focusParentSession: vi.fn(async () => {}), handleSTTToggle: vi.fn(), handleBtwEscape, handleBtwCommand, @@ -336,4 +339,29 @@ describe("InputController escape behavior", () => { expect(spies.clearQueue).not.toHaveBeenCalled(); expect(spies.abort).toHaveBeenCalledTimes(1); }); + + it("returns focused subagent view to main on Esc instead of aborting", () => { + const { ctx, editor, spies } = createContext(); + Object.defineProperty(ctx, "focusedAgentId", { value: "Worker", configurable: true }); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onEscape?.(); + + expect(ctx.unfocusSession).toHaveBeenCalledTimes(1); + expect(spies.abort).not.toHaveBeenCalled(); + }); + + it("routes focused left-left through the same main-return path as Esc", () => { + const { ctx, editor } = createContext(); + Object.defineProperty(ctx, "focusedAgentId", { value: "Worker", configurable: true }); + ctx.lastLeftTapTime = Date.now(); + const controller = new InputController(ctx); + + controller.setupKeyHandlers(); + editor.onLeftAtStart?.(); + + expect(ctx.unfocusSession).toHaveBeenCalledTimes(1); + expect(ctx.focusParentSession).not.toHaveBeenCalled(); + }); }); diff --git a/packages/coding-agent/test/input-controller-skill-queue.test.ts b/packages/coding-agent/test/input-controller-skill-queue.test.ts index cce5e04c1..e536290da 100644 --- a/packages/coding-agent/test/input-controller-skill-queue.test.ts +++ b/packages/coding-agent/test/input-controller-skill-queue.test.ts @@ -91,6 +91,9 @@ function createStubInputControllerContext(opts: { skillCommands: Map "Alt+Up", @@ -490,6 +494,9 @@ function createEventControllerFixtureForE10() { updatePendingMessagesDisplay, pendingTools: new Map(), session: {}, + get viewSession() { + return (this as typeof ctx).session; + }, } as unknown as InteractiveModeContext; const controller = new EventController(ctx); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index fa82aa3a7..9dba3163f 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -46,7 +46,15 @@ function createInitialRenderHarness(): { ctx: InteractiveModeContext; helpers: U retryAttempt: 0, getToolByName: () => undefined, buildTranscriptSessionContext: () => buildSessionContext([]), + sessionManager: { + buildSessionContext: () => buildSessionContext([]), + getEntries: () => [], + }, }, + get viewSession() { + return (this as typeof ctx).session; + }, + clearTransientSessionUi: () => {}, toolOutputExpanded: false, hideThinkingBlock: false, } as unknown as InteractiveModeContext; diff --git a/packages/coding-agent/test/job-poll-displacement.test.ts b/packages/coding-agent/test/job-poll-displacement.test.ts index 81fa7d08d..a9ed774b5 100644 --- a/packages/coding-agent/test/job-poll-displacement.test.ts +++ b/packages/coding-agent/test/job-poll-displacement.test.ts @@ -142,6 +142,7 @@ describe("EventController displaces consecutive waiting polls", () => { }, }, session: { getToolByName: () => undefined }, + viewSession: { getToolByName: () => undefined }, sessionManager: { getCwd: () => process.cwd() }, } as unknown as InteractiveModeContext; return { controller: new EventController(ctx), children }; diff --git a/packages/coding-agent/test/modes/components/user-message-keywords.test.ts b/packages/coding-agent/test/modes/components/user-message-keywords.test.ts index d55276c77..4e49b3611 100644 --- a/packages/coding-agent/test/modes/components/user-message-keywords.test.ts +++ b/packages/coding-agent/test/modes/components/user-message-keywords.test.ts @@ -75,19 +75,21 @@ describe("UserMessageComponent magic-keyword highlighting", () => { it("rebuilds user messages with image hyperlinks when image links are not precomputed", () => { const chatContainer = new Container(); + const sessionManagerMock = { + putBlobSync: () => ({ + hash: "abc123", + path: "/tmp/abc123", + displayPath: "/tmp/abc123.png", + get ref() { + return "blob:sha256:abc123"; + }, + }), + }; const helpers = new UiHelpers({ chatContainer, getUserMessageText: () => "please inspect [Image #1]", - sessionManager: { - putBlobSync: () => ({ - hash: "abc123", - path: "/tmp/abc123", - displayPath: "/tmp/abc123.png", - get ref() { - return "blob:sha256:abc123"; - }, - }), - }, + sessionManager: sessionManagerMock, + viewSession: { sessionManager: sessionManagerMock }, } as unknown as InteractiveModeContext); const message: AgentMessage = { role: "user", diff --git a/packages/coding-agent/test/modes/controllers/event-controller-abort-guard.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-abort-guard.test.ts index e25eab0a4..5e26dfa75 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-abort-guard.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-abort-guard.test.ts @@ -48,13 +48,15 @@ function makeAssistantMessage(stopReason: StopReason): AssistantMessage { } function makeContext(lastMessage: AssistantMessage | undefined): InteractiveModeContext { + const sessionMock = { + getLastAssistantMessage: () => lastMessage, + }; return { sessionManager: { getSessionName: () => "test-session", }, - session: { - getLastAssistantMessage: () => lastMessage, - }, + session: sessionMock, + viewSession: sessionMock, } as unknown as InteractiveModeContext; } diff --git a/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts index 4a0d2d07e..83569b063 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-args-reveal.test.ts @@ -54,6 +54,7 @@ function createFixture(streamingMessage: AssistantMessage) { chatContainer: { addChild: vi.fn() }, toolOutputExpanded: false, session: { getToolByName: () => undefined }, + viewSession: { getToolByName: () => undefined }, sessionManager: { getCwd: () => process.cwd() }, } as unknown as InteractiveModeContext; diff --git a/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts index 9fea85655..7856e65bc 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-idle-compaction.test.ts @@ -66,6 +66,10 @@ describe("EventController idle compaction teardown", () => { runIdleCompaction, agent: { state: { messages: [createAssistantMessage()] } }, }, + get viewSession() { + return (this as typeof context).session; + }, + clearTransientSessionUi: () => {}, } as unknown as InteractiveModeContext; const controller = new EventController(context); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts index 3ea3122d4..57ca09120 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-read-grouping.test.ts @@ -68,6 +68,7 @@ function assistantMessage(content: Block[]): AssistantMessage { function createFixture() { const chatContainer = new Container(); + const sessionMock = { getToolByName: () => undefined, extensionRunner: undefined }; const ctx = { isInitialized: true, init: vi.fn(async () => {}), @@ -80,7 +81,9 @@ function createFixture() { toolOutputExpanded: false, hideThinkingBlock: false, setWorkingMessage: vi.fn(), - session: { getToolByName: () => undefined, extensionRunner: undefined }, + clearTransientSessionUi: () => {}, + session: sessionMock, + viewSession: sessionMock, } as unknown as InteractiveModeContext; return { controller: new EventController(ctx), chatContainer }; } diff --git a/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts index 31e908679..5dfa06b5e 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-toolcall-finalize.test.ts @@ -58,6 +58,8 @@ function createFixture(streamingMessage: AssistantMessage) { chatContainer: { addChild: vi.fn() }, toolOutputExpanded: false, session: { getToolByName: () => undefined }, + viewSession: { getToolByName: () => undefined }, + clearTransientSessionUi: () => {}, sessionManager: { getCwd: () => process.cwd() }, } as unknown as InteractiveModeContext; diff --git a/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts b/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts index 90990791e..bac440ee7 100644 --- a/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts +++ b/packages/coding-agent/test/modes/controllers/selector-controller-session-delete.test.ts @@ -70,6 +70,9 @@ function createContext(currentSessionFile: string): { newSession, switchSession: vi.fn(async () => true), }, + get viewSession() { + return this.session; + }, sessionManager: { getCwd: () => "/tmp/project", getSessionDir: () => "/tmp/project/sessions", @@ -124,6 +127,12 @@ function createContext(currentSessionFile: string): { showError: vi.fn(), showHookConfirm, shutdown: vi.fn(async () => undefined), + clearTransientSessionUi() { + ctx.loadingAnimation.stop(); + ctx.statusContainer.clear(); + ctx.pendingMessagesContainer.clear(); + ctx.pendingTools.clear(); + }, } as unknown as TestContext; return { diff --git a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts index 14f02fa88..02e29bfc5 100644 --- a/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts +++ b/packages/coding-agent/test/modes/utils/render-initial-messages.test.ts @@ -52,6 +52,14 @@ function makeCtx(): { pendingBashComponents: [], pendingPythonComponents: [], session: { buildTranscriptSessionContext: transcriptSpy }, + viewSession: { + buildTranscriptSessionContext: transcriptSpy, + sessionManager: { + buildSessionContext: llmContextSpy, + getEntries: vi.fn(() => []), + getCwd: vi.fn(() => "/tmp"), + }, + }, sessionManager: { buildSessionContext: llmContextSpy, getEntries: vi.fn(() => []), diff --git a/packages/coding-agent/test/session-focus-controller.test.ts b/packages/coding-agent/test/session-focus-controller.test.ts new file mode 100644 index 000000000..f98165b90 --- /dev/null +++ b/packages/coding-agent/test/session-focus-controller.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from "bun:test"; +import { SessionFocusController } from "@oh-my-pi/pi-coding-agent/modes/controllers/session-focus-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; +import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; +import { AgentRegistry, MAIN_AGENT_ID } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import type { AgentSession, AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; + +interface SessionStub { + session: AgentSession; + /** Emit an event through the listener captured by the last subscribe(). */ + emit: (event: unknown) => Promise; + unsubscribeCalls: () => number; + setStreaming: (streaming: boolean) => void; +} + +function makeSessionStub(opts: { isStreaming?: boolean } = {}): SessionStub { + let listener: ((event: AgentSessionEvent) => Promise | void) | undefined; + let unsubscribeCalls = 0; + const stub = { + isStreaming: opts.isStreaming ?? false, + subscribe(fn: (event: AgentSessionEvent) => Promise | void) { + listener = fn; + return () => { + unsubscribeCalls++; + }; + }, + }; + return { + session: stub as unknown as AgentSession, + emit: async event => { + if (!listener) throw new Error("no listener captured: subscribe() was never called"); + await listener(event as AgentSessionEvent); + }, + unsubscribeCalls: () => unsubscribeCalls, + setStreaming: streaming => { + stub.isStreaming = streaming; + }, + }; +} + +interface Harness { + ctx: InteractiveModeContext; + controller: SessionFocusController; + registry: AgentRegistry; + main: SessionStub; + handledEvents: unknown[]; + setSessionCalls: Array<[AgentSession, string | undefined]>; + counts: { + clearTransientSessionUi: () => number; + resetTranscriptAnchors: () => number; + renderInitialMessages: () => number; + mainUnsubscribe: () => number; + }; +} + +function makeHarness(): Harness { + const main = makeSessionStub(); + const handledEvents: unknown[] = []; + const setSessionCalls: Array<[AgentSession, string | undefined]> = []; + let clearTransientSessionUi = 0; + let resetTranscriptAnchors = 0; + let renderInitialMessages = 0; + let mainUnsubscribe = 0; + + const ctx = { + session: main.session, + unsubscribe: () => { + mainUnsubscribe++; + }, + eventController: { + handleEvent: async (event: unknown) => { + handledEvents.push(event); + }, + resetTranscriptAnchors: () => { + resetTranscriptAnchors++; + }, + }, + statusLine: { + setSession: (session: AgentSession, focusedAgentId?: string) => { + setSessionCalls.push([session, focusedAgentId]); + }, + invalidate() {}, + }, + clearTransientSessionUi: () => { + clearTransientSessionUi++; + }, + renderInitialMessages: () => { + renderInitialMessages++; + }, + updateEditorBorderColor() {}, + ui: { requestRender() {} }, + showStatus() {}, + collabGuest: undefined, + } as unknown as InteractiveModeContext; + + const registry = new AgentRegistry(); + const lifecycle = new AgentLifecycleManager(registry); + const controller = new SessionFocusController(ctx, registry, () => lifecycle); + + return { + ctx, + controller, + registry, + main, + handledEvents, + setSessionCalls, + counts: { + clearTransientSessionUi: () => clearTransientSessionUi, + resetTranscriptAnchors: () => resetTranscriptAnchors, + renderInitialMessages: () => renderInitialMessages, + mainUnsubscribe: () => mainUnsubscribe, + }, + }; +} + +function registerSub(registry: AgentRegistry, id: string, session: AgentSession, parentId?: string) { + return registry.register({ id, displayName: id, kind: "sub", parentId, session, status: "running" }); +} + +/** Settle the async unfocus chain (registry event → void unfocus() → #attach). */ +async function flushAsync(): Promise { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + +describe("SessionFocusController", () => { + it("focusAgent retargets subscription, transcript anchors, and status line onto the worker session", async () => { + const h = makeHarness(); + const worker = makeSessionStub(); + registerSub(h.registry, "Worker", worker.session, MAIN_AGENT_ID); + + await h.controller.focusAgent("Worker"); + + expect(h.controller.focusedAgentId).toBe("Worker"); + expect(h.controller.target).toBe(worker.session); + expect(h.counts.mainUnsubscribe()).toBe(1); + expect(h.counts.clearTransientSessionUi()).toBe(1); + expect(h.counts.resetTranscriptAnchors()).toBe(1); + expect(h.counts.renderInitialMessages()).toBe(1); + expect(h.setSessionCalls).toEqual([[worker.session, "Worker"]]); + + const event = { type: "message_start", message: { role: "user" } }; + await worker.emit(event); + expect(h.handledEvents).toEqual([event]); + }); + + it("mid-turn attach synthesizes agent_start, and an orphaned assistant message_update gets a synthesized message_start", async () => { + const h = makeHarness(); + const worker = makeSessionStub({ isStreaming: true }); + registerSub(h.registry, "Worker", worker.session, MAIN_AGENT_ID); + + await h.controller.focusAgent("Worker"); + expect(h.handledEvents).toEqual([{ type: "agent_start" }]); + + const message = { role: "assistant", content: "partial" }; + await worker.emit({ type: "message_update", message }); + expect(h.handledEvents.slice(1)).toEqual([ + { type: "message_start", message }, + { type: "message_update", message }, + ]); + + // Guard fires once: subsequent updates pass through unsynthesized. + await worker.emit({ type: "message_update", message }); + expect(h.handledEvents.slice(3)).toEqual([{ type: "message_update", message }]); + }); + + it("focusParent walks parentId to a registered non-main agent, then re-attaches the main session", async () => { + const h = makeHarness(); + const parent = makeSessionStub(); + const worker = makeSessionStub(); + registerSub(h.registry, "Parent", parent.session, MAIN_AGENT_ID); + registerSub(h.registry, "Worker", worker.session, "Parent"); + + await h.controller.focusAgent("Worker"); + await h.controller.focusParent(); + expect(h.controller.focusedAgentId).toBe("Parent"); + expect(h.setSessionCalls).toEqual([ + [worker.session, "Worker"], + [parent.session, "Parent"], + ]); + + // Parent's parent is Main → unfocus back to ctx.session. + await h.controller.focusParent(); + expect(h.controller.focusedAgentId).toBeUndefined(); + expect(h.controller.target).toBeUndefined(); + expect(h.setSessionCalls).toEqual([ + [worker.session, "Worker"], + [parent.session, "Parent"], + [h.main.session, undefined], + ]); + }); + + it("parking the focused agent auto-unfocuses back to the main session", async () => { + const h = makeHarness(); + const worker = makeSessionStub(); + registerSub(h.registry, "Worker", worker.session, MAIN_AGENT_ID); + + await h.controller.focusAgent("Worker"); + expect(h.controller.focusedAgentId).toBe("Worker"); + + h.registry.setStatus("Worker", "parked"); + await flushAsync(); + + expect(h.controller.focusedAgentId).toBeUndefined(); + expect(h.setSessionCalls).toEqual([ + [worker.session, "Worker"], + [h.main.session, undefined], + ]); + }); +}); diff --git a/packages/coding-agent/test/silent-abort-overlay-render.test.ts b/packages/coding-agent/test/silent-abort-overlay-render.test.ts index d002df540..e99a6ccf2 100644 --- a/packages/coding-agent/test/silent-abort-overlay-render.test.ts +++ b/packages/coding-agent/test/silent-abort-overlay-render.test.ts @@ -2,14 +2,16 @@ * Regression: the agent-hub chat transcript must not render SILENT_ABORT_MARKER verbatim. * * Codex review flagged that the old observer overlay rendered `errorMessage` - * without filtering the silent-abort sentinel; the renderer now lives in - * `agent-hub.ts`. This test exercises the full `#buildTranscriptLines` path - * through a real JSONL session file and an isolated agent registry. + * without filtering the silent-abort sentinel; the hub chat view now renders + * assistant messages through AssistantMessageComponent. This test exercises the + * full chat-rebuild path through a real JSONL session file and an isolated + * agent registry. */ import { afterEach, beforeAll, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; import type { ObservableSession } from "@oh-my-pi/pi-coding-agent/modes/session-observer-registry"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; @@ -63,15 +65,18 @@ describe("Agent hub silent-abort regression", () => { initTheme(); }); - beforeEach(() => { + beforeEach(async () => { + resetSettingsForTest(); + await Settings.init({ inMemory: true }); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-overlay-test-")); }); afterEach(() => { + resetSettingsForTest(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); - it("does not render ✗ Error: for silent-abort assistant messages with empty content", () => { + it("renders no error line for silent-abort assistant messages with empty content", () => { const sessionFile = makeJsonlSessionFile(tmpDir, [ { type: "session", version: 3, id: SESSION_ID, timestamp: new Date().toISOString() }, { @@ -126,11 +131,11 @@ describe("Agent hub silent-abort regression", () => { // The sentinel MUST NOT appear verbatim in any rendered line expect(renderedText).not.toContain(SILENT_ABORT_MARKER); - // The error prefix MUST NOT appear for a silent-abort message - expect(renderedText).not.toContain("✗ Error:"); + // No error line at all for a silent abort + expect(renderedText).not.toContain("Error:"); }); - it("renders normal error messages with ✗ Error: prefix", () => { + it("renders normal error messages with an Error: line", () => { const sessionFile = makeJsonlSessionFile(tmpDir, [ { type: "session", version: 3, id: SESSION_ID, timestamp: new Date().toISOString() }, { @@ -181,8 +186,7 @@ describe("Agent hub silent-abort regression", () => { hub.dispose(); const renderedText = rendered.join("\n"); - // A real error message SHOULD be rendered with the ✗ Error: prefix - expect(renderedText).toContain("✗ Error:"); - expect(renderedText).toContain("Connection timed out"); + // AssistantMessageComponent renders the error as "Error: " + expect(renderedText).toContain("Error: Connection timed out"); }); }); diff --git a/packages/coding-agent/test/tools/search-path-lists.test.ts b/packages/coding-agent/test/tools/search-path-lists.test.ts index 9eb52f7cb..9483f247e 100644 --- a/packages/coding-agent/test/tools/search-path-lists.test.ts +++ b/packages/coding-agent/test/tools/search-path-lists.test.ts @@ -4,7 +4,7 @@ import * as os from "node:os"; import * as path from "node:path"; import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation"; -import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { canonicalSnapshotKey } from "@oh-my-pi/pi-coding-agent/edit/file-snapshot-store"; import type { RenderResultOptions } from "@oh-my-pi/pi-coding-agent/extensibility/custom-tools/types"; import { AgentHubOverlayComponent } from "@oh-my-pi/pi-coding-agent/modes/components/agent-hub"; @@ -129,12 +129,15 @@ describe("tool path arrays", () => { }); beforeEach(async () => { treeEntryCounter = 0; + resetSettingsForTest(); + await Settings.init({ inMemory: true }); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "search-path-lists-")); await createSearchFixture(tempDir); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); + resetSettingsForTest(); }); it("search accepts explicit path arrays", async () => { @@ -325,20 +328,6 @@ describe("tool path arrays", () => { timestamp: 2, }, }, - { - type: "message", - id: "msg-tool-1", - parentId: "msg-assistant-1", - timestamp: new Date().toISOString(), - message: { - role: "toolResult", - toolName: "search", - toolCallId: "search-call-1", - content: [{ type: "text", text: "note.txt" }], - isError: false, - timestamp: 3, - }, - }, ]); const observers = makeSubagentRegistry([ { @@ -372,7 +361,10 @@ describe("tool path arrays", () => { const rendered = Bun.stripANSI(hub.render(120).join("\n")); hub.dispose(); - expect(rendered).toContain("paths: folder with spaces/"); + // The hub chat now renders through searchToolRenderer.renderCall; the + // single-string `paths` arg shows up as the "in " scope meta on the + // pending call line (a completed result merges the call line away). + expect(rendered).toContain("in folder with spaces/"); }); it("tree selector renders a single-string search path summary", () => { diff --git a/packages/hashline/CHANGELOG.md b/packages/hashline/CHANGELOG.md index e26075cb8..5a386aa93 100644 --- a/packages/hashline/CHANGELOG.md +++ b/packages/hashline/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- Condensed all parser/applier/patcher error and warning messages: shorter wording, same diagnostic anchors (op names, line numbers, suggested fallback forms) + ## [15.11.4] - 2026-06-12 ### Added diff --git a/packages/hashline/test/block.test.ts b/packages/hashline/test/block.test.ts index fb3d2ce46..b11f8f018 100644 --- a/packages/hashline/test/block.test.ts +++ b/packages/hashline/test/block.test.ts @@ -158,7 +158,7 @@ describe("PatchSection.applyTo / applyPartialTo with block edits", () => { it("applyTo throws when a block edit has no resolver", () => { const section = Patch.parseSingle(`[${PATH}#1A2B]\nreplace block 2:\n+X`); - expect(() => section.applyTo(text)).toThrow("replace block"); + expect(() => section.applyTo(text)).toThrow("Block-anchored ops are not available"); }); it("applyPartialTo drops an unresolvable block edit instead of throwing", () => {