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.
This commit is contained in:
can1357
2026-06-12 15:37:56 +02:00
parent b3bcdf3c7c
commit c938fe4d27
34 changed files with 1449 additions and 539 deletions
+6
View File
@@ -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 (`<session>/<AgentId>.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/<id>#<key>`: 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 `<omp-tool-view>` 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/<version-key>` 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)).
File diff suppressed because it is too large Load Diff
@@ -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<unknown> }).fetchUsageReports;
const session = this.session;
const fetcher = (session as { fetchUsageReports?: () => Promise<unknown> }).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),
@@ -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 };
},
@@ -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: {
@@ -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<void> {
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<AgentSessionEvent, { type: "auto_retry_start" }>): Promise<void> {
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();
@@ -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<void> {
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<void> {
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);
@@ -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<void> {
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, {
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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();
}
}
@@ -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<void> {
return this.#focusController.focusAgent(id);
}
focusParentSession(): Promise<void> {
return this.#focusController.focusParent();
}
unfocusSession(): Promise<void> {
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();
@@ -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"],
+12
View File
@@ -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<void>;
/** Focus the focused agent's parent session, falling back to main (delegates to focusParent). */
focusParentSession(): Promise<void>;
/** Return the view to the main session (delegates to SessionFocusController.unfocus). */
unfocusSession(): Promise<void>;
/** Clear loader, status/pending containers, streaming state, and pending tools. */
clearTransientSessionUi(): void;
settings: Settings;
keybindings: KeybindingsManager;
agent: AgentSession["agent"];
@@ -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<unknown>, 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;
}
@@ -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;
@@ -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<void>) {
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<void>();
const renderRequested = Promise.withResolvers<void>();
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<void>();
const editorFocused = Promise.withResolvers<void>();
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();
});
});
@@ -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);
@@ -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);
@@ -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();
});
});
@@ -91,6 +91,9 @@ function createStubInputControllerContext(opts: { skillCommands: Map<string, str
prompt,
promptCustomMessage,
},
get viewSession() {
return (this as typeof ctx).session;
},
showError,
handleGoalModeCommand,
goalModeEnabled: false,
@@ -384,6 +387,7 @@ function createStubInteractiveModeContextForUiHelpers(session: AgentSession) {
ui: { requestRender },
pendingMessagesContainer,
session,
viewSession: session,
compactionQueuedMessages: [],
keybindings: {
getDisplayString: (_action: string) => "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);
@@ -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;
@@ -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 };
@@ -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",
@@ -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;
}
@@ -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;
@@ -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);
@@ -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 };
}
@@ -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;
@@ -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 {
@@ -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(() => []),
@@ -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<void>;
unsubscribeCalls: () => number;
setStreaming: (streaming: boolean) => void;
}
function makeSessionStub(opts: { isStreaming?: boolean } = {}): SessionStub {
let listener: ((event: AgentSessionEvent) => Promise<void> | void) | undefined;
let unsubscribeCalls = 0;
const stub = {
isStreaming: opts.isStreaming ?? false,
subscribe(fn: (event: AgentSessionEvent) => Promise<void> | 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<void> {
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],
]);
});
});
@@ -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: <message>"
expect(renderedText).toContain("Error: Connection timed out");
});
});
@@ -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 <paths>" 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", () => {
+4
View File
@@ -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
+1 -1
View File
@@ -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", () => {