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:
@@ -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"],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
|
||||
+9
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user