55630876f5
Reset the run-state title to idle when focusing an idle session (was inheriting the previous session's stuck spinner); drive the title to attention while a tool blocks on an approval prompt (not just ask), returning to working at its end; let an extension setTitle() own the terminal verbatim so neither the run-state prefix nor the spinner tick clobbers it, cleared when the app sets an authoritative session title; use NodeJS.Timeout for the spinner timer field.
118 lines
5.1 KiB
TypeScript
118 lines
5.1 KiB
TypeScript
/**
|
|
* 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 { setTerminalTitleState } from "../../utils/title-generator";
|
|
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 });
|
|
// Sync the run-state title to the attached target: a streaming target has no
|
|
// agent_start incoming, so arm the loader/working title manually; an idle
|
|
// target would otherwise inherit the previous session's stuck spinner, so
|
|
// reset it to idle (agent_end teardown already ran via clearTransientSessionUi).
|
|
if (target.isStreaming) await this.ctx.eventController.handleEvent({ type: "agent_start" });
|
|
else setTerminalTitleState("idle");
|
|
this.ctx.updateEditorBorderColor();
|
|
this.ctx.ui.requestRender();
|
|
}
|
|
}
|