From 87e7f3cdfcb4230e59ebe3570c0689953c5ee258 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 25 Jun 2026 20:29:20 +0000 Subject: [PATCH 1/2] fix(tui): surfaced running subagents badge Forced the main status line to show a hub-key badge while subagents are running and labeled advisor rows in Agent Hub as read-only. Fixes #3499 --- packages/coding-agent/CHANGELOG.md | 2 ++ .../src/modes/components/agent-hub.ts | 3 +++ .../modes/components/status-line/component.ts | 20 ++++++++++++++++ .../src/modes/interactive-mode.ts | 24 ++++++++++++++++++- .../test/status-line-settings-cache.test.ts | 11 +++++++++ 5 files changed, 59 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dc459d21f..531ac9a56 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,6 +4,8 @@ ### Fixed +- Fixed the main TUI status line hiding live subagents unless users already enabled the `subagents` segment; running subagents now force a hub-key badge into the editor border, and advisor rows in Agent Hub are labeled read-only. ([#3499](https://github.com/can1357/oh-my-pi/issues/3499)) + - Fixed Ctrl+Z hanging the terminal after any tool call had run: the TUI tore down (`ui.stop()`) but the process kept running in `Sl+` state, leaving the user with a dead terminal recoverable only via `kill -9`. The embedded `brush-core` shell behind every bash tool call installs a tokio SIGTSTP listener on `Process::wait` (`crates/brush-core-vendored/src/sys/unix/signal.rs::tstp_signal_listener` → `tokio::signal::unix::signal(SIGTSTP)`); per tokio's contract, the first call for a SignalKind permanently replaces the kernel-default handler for the lifetime of the process. So the first bash invocation — even `/usr/bin/true` — silently overrode SIGTSTP's "stop" default, and `InputController.handleCtrlZ`'s subsequent `process.kill(0, "SIGTSTP")` was swallowed by tokio. The handler now sends `SIGSTOP` (uncatchable, unblockable, unignorable) to the foreground process group, so the kernel parks omp regardless of installed handlers and the shell sees the whole job stop even when omp runs behind a wrapper (`npx`, `pnpm exec`, `bunx`, …) or as one stage of a pipeline. MCP stdio servers now spawn detached into their own session — they're insulated both from terminal job-control signals (which used to stop their process trees and leave the JSONL read loop blocked on silent pipes) and from the new pgid=0 suspend itself ([#3461](https://github.com/can1357/oh-my-pi/issues/3461)). - Fixed image-only composer submissions while the agent is streaming being treated as empty input, which dropped the image or aborted the active turn when another message was queued. Pending pasted images now count as submit content for Enter and Ctrl+Enter follow-ups. ([#3467](https://github.com/can1357/oh-my-pi/issues/3467)) - Fixed `omp gallery --state` accepting lifecycle tokens that did not match displayed state labels and rendering unknown state values as `· undefined`; displayed labels now work as aliases, invalid values fail with a valid-token list, and failed gallery fixtures visibly render failures. ([#3473](https://github.com/can1357/oh-my-pi/issues/3473)) diff --git a/packages/coding-agent/src/modes/components/agent-hub.ts b/packages/coding-agent/src/modes/components/agent-hub.ts index cb8572ded..1f9d33571 100644 --- a/packages/coding-agent/src/modes/components/agent-hub.ts +++ b/packages/coding-agent/src/modes/components/agent-hub.ts @@ -425,6 +425,9 @@ export class AgentHubOverlayComponent extends Container { const parts: string[] = [statusBadge(ref.status), theme.bold(replaceTabs(ref.id))]; parts.push(theme.fg("dim", replaceTabs(ref.displayName))); parts.push(theme.fg("dim", ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind)); + if (ref.kind === "advisor") { + parts.push(theme.fg("warning", "read-only")); + } const observed = this.#observableFor(ref.id); const task = observed?.description ?? observed?.progress?.task; if (task) { diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index 65f8d5b2e..d4d7b7f4b 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -189,6 +189,7 @@ export class StatusLineComponent implements Component { #autoCompactEnabled: boolean = true; #hookStatuses: Map = new Map(); #subagentCount: number = 0; + #subagentHubHint: string | undefined; #sessionStartTime: number = Date.now(); #planModeStatus: { enabled: boolean; paused: boolean } | null = null; #loopModeStatus: { enabled: boolean } | null = null; @@ -279,6 +280,12 @@ export class StatusLineComponent implements Component { this.#subagentCount = count; } + /** Hub key label shown in the forced running-subagents badge. */ + setSubagentHubHint(hint: string | undefined): void { + const trimmed = hint?.trim(); + this.#subagentHubHint = trimmed ? trimmed : undefined; + } + /** Active subagent count as currently displayed (collab state mirroring). */ get subagentCount(): number { return this.#subagentCount; @@ -858,6 +865,13 @@ export class StatusLineComponent implements Component { }; } + #subagentBadgeText(): string | undefined { + if (this.#subagentCount === 0) return undefined; + const noun = this.#subagentCount === 1 ? "agent" : "agents"; + const hubHint = this.#subagentHubHint ? ` — ${this.#subagentHubHint} hub` : " — Agent Hub"; + return theme.fg("statusLineSubagents", `${theme.icon.agents} ${this.#subagentCount} ${noun} running${hubHint}`); + } + #buildStatusLine(width: number): string { const effectiveSettings = this.#resolveSettings(); const includeContext = @@ -888,11 +902,13 @@ export class StatusLineComponent implements Component { const transparentBg = bgAnsi === TRANSPARENT_BG_ANSI; const fgAnsi = theme.getFgAnsi("text"); const sepAnsi = theme.getFgAnsi("statusLineSep"); + const subagentBadge = this.#subagentBadgeText(); // Collect visible segment contents const leftParts: string[] = []; const leftSegIds: StatusLineSegmentId[] = []; for (const segId of effectiveSettings.leftSegments) { + if (subagentBadge && segId === "subagents") continue; const rendered = renderSegment(segId, ctx); if (rendered.visible && rendered.content) { leftParts.push(rendered.content); @@ -902,6 +918,7 @@ export class StatusLineComponent implements Component { const rightParts: string[] = []; for (const segId of effectiveSettings.rightSegments) { + if (subagentBadge && segId === "subagents") continue; const rendered = renderSegment(segId, ctx); if (rendered.visible && rendered.content) { rightParts.push(rendered.content); @@ -912,6 +929,9 @@ export class StatusLineComponent implements Component { if (runningBackgroundJobs > 0) { rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`)); } + if (subagentBadge) { + rightParts.unshift(subagentBadge); + } const topFillWidth = Math.max(0, width); const left = [...leftParts]; const right = [...rightParts]; diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index ab1fab100..ea845b4bc 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -86,6 +86,7 @@ import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" wit import planModeCompactInstructionsPrompt from "../prompts/system/plan-mode-compact-instructions.md" with { type: "text", }; +import { AgentRegistry } from "../registry/agent-registry"; import type { AgentSession, AgentSessionEvent, ResolvedRoleModel } from "../session/agent-session"; import type { CompactMode } from "../session/compact-modes"; import { HistoryStorage } from "../session/history-storage"; @@ -540,6 +541,7 @@ export class InteractiveMode implements InteractiveModeContext { #observerRegistry: SessionObserverRegistry; #eventBus?: EventBus; #eventBusUnsubscribers: Array<() => void> = []; + #agentRegistryUnsubscribe?: () => void; #mcpStatusOrder: string[] = []; #mcpPendingServers = new Set(); #mcpConnectedServers = new Set(); @@ -837,8 +839,18 @@ export class InteractiveMode implements InteractiveModeContext { this.#observerRegistry.subscribeToEventBus(this.#eventBus); } this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined); + this.statusLine.setSubagentHubHint( + this.keybindings.getDisplayString("app.agents.hub") || + this.keybindings.getDisplayString("app.session.observe") || + undefined, + ); + this.#syncRunningSubagentBadge(); + this.#agentRegistryUnsubscribe = AgentRegistry.global().onChange(() => { + this.#syncRunningSubagentBadge(); + this.ui.requestRender(); + }); this.#observerRegistry.onChange(() => { - this.statusLine.setSubagentCount(this.#observerRegistry.getActiveSubagentCount()); + this.#syncRunningSubagentBadge(); // Auto-checkmark todos whose matching subagent just succeeded, then // re-render so the running override (the static "live" glyph when a // subagent is doing the work for a still-pending todo) updates as @@ -1433,6 +1445,14 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(); } + #syncRunningSubagentBadge(): void { + const count = AgentRegistry.global() + .list() + .filter(ref => ref.kind === "sub" && ref.status === "running").length; + this.statusLine.setSubagentCount(count); + this.updateEditorTopBorder(); + } + updateEditorTopBorder(): void { const availableWidth = this.editor.getTopBorderAvailableWidth(this.ui.terminal.columns); const topBorder = this.statusLine.getTopBorder(availableWidth); @@ -3139,6 +3159,8 @@ export class InteractiveMode implements InteractiveModeContext { } this.#eventBusUnsubscribers = []; this.#observerRegistry.dispose(); + this.#agentRegistryUnsubscribe?.(); + this.#agentRegistryUnsubscribe = undefined; this.#eventController.dispose(); this.statusLine.dispose(); if (this.#resizeHandler) { diff --git a/packages/coding-agent/test/status-line-settings-cache.test.ts b/packages/coding-agent/test/status-line-settings-cache.test.ts index f784b626f..c86302a3d 100644 --- a/packages/coding-agent/test/status-line-settings-cache.test.ts +++ b/packages/coding-agent/test/status-line-settings-cache.test.ts @@ -156,6 +156,17 @@ describe("StatusLineComponent effective settings cache", () => { expect(customComponent.getTopBorder(120)).toEqual({ content: "", width: 0 }); }); + it("surfaces active subagents even when custom segments omit subagents", () => { + const component = makeComponent({ preset: "custom", leftSegments: [], rightSegments: [] }); + + component.setSubagentCount(2); + component.setSubagentHubHint("Alt+A"); + + const content = stripVTControlCharacters(component.getTopBorder(120).content); + expect(content).toContain("2 agents running"); + expect(content).toContain("Alt+A hub"); + }); + it("keeps plan and hook state dynamic without settings invalidation", () => { const component = makeComponent({ preset: "custom", leftSegments: ["mode"], rightSegments: [] }); const effective = component.getEffectiveSettingsForTest(); From b3833452a1a5d5904f68285b7149fb65562877bb Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 25 Jun 2026 20:39:24 +0000 Subject: [PATCH 2/2] fix(tui): used collab registry for subagent badge Switched the running-subagents status badge to follow the active collab guest registry when present. --- packages/coding-agent/src/collab/guest.ts | 4 +++ .../src/modes/interactive-mode.ts | 26 ++++++++++++------- packages/coding-agent/src/modes/types.ts | 2 ++ 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/coding-agent/src/collab/guest.ts b/packages/coding-agent/src/collab/guest.ts index affefff52..22a214ab6 100644 --- a/packages/coding-agent/src/collab/guest.ts +++ b/packages/coding-agent/src/collab/guest.ts @@ -276,6 +276,7 @@ export class CollabGuestLink { } this.#ctx.collabGuest = this; + this.#ctx.syncRunningSubagentBadge(); } /** User-initiated leave (or post-disconnect cleanup): restore the previous session. */ @@ -357,6 +358,7 @@ export class CollabGuestLink { this.#applyHostState(pending.state); this.#ctx.resetObserverRegistry(); this.#applyAgentSnapshots(pending.agents); + this.#ctx.syncRunningSubagentBadge(); this.#assistantStreamSynced = false; setSessionTerminalTitle(pending.state.sessionName ?? pending.header.title, pending.state.cwd); this.#ctx.chatContainer.clear(); @@ -440,6 +442,7 @@ export class CollabGuestLink { break; case "agents": this.#applyAgentSnapshots(frame.agents); + this.#ctx.syncRunningSubagentBadge(); break; case "transcript": { const resolve = this.#pendingTranscripts.get(frame.reqId); @@ -573,6 +576,7 @@ export class CollabGuestLink { this.#ctx.statusLine.setCollabStatus(null); this.#flushPendingTranscripts(); this.#clearAgentMirror(); + this.#ctx.syncRunningSubagentBadge(); this.#ctx.resetObserverRegistry(); this.#clearTransientUi(); // Replica file stays on disk: it is a valid session file outside the diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index ea845b4bc..77a28fa7d 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -542,6 +542,7 @@ export class InteractiveMode implements InteractiveModeContext { #eventBus?: EventBus; #eventBusUnsubscribers: Array<() => void> = []; #agentRegistryUnsubscribe?: () => void; + #agentRegistrySubscriptionTarget?: AgentRegistry; #mcpStatusOrder: string[] = []; #mcpPendingServers = new Set(); #mcpConnectedServers = new Set(); @@ -844,13 +845,9 @@ export class InteractiveMode implements InteractiveModeContext { this.keybindings.getDisplayString("app.session.observe") || undefined, ); - this.#syncRunningSubagentBadge(); - this.#agentRegistryUnsubscribe = AgentRegistry.global().onChange(() => { - this.#syncRunningSubagentBadge(); - this.ui.requestRender(); - }); + this.syncRunningSubagentBadge(); this.#observerRegistry.onChange(() => { - this.#syncRunningSubagentBadge(); + this.syncRunningSubagentBadge(); // Auto-checkmark todos whose matching subagent just succeeded, then // re-render so the running override (the static "live" glyph when a // subagent is doing the work for a still-pending todo) updates as @@ -1445,10 +1442,18 @@ export class InteractiveMode implements InteractiveModeContext { this.ui.requestRender(); } - #syncRunningSubagentBadge(): void { - const count = AgentRegistry.global() - .list() - .filter(ref => ref.kind === "sub" && ref.status === "running").length; + /** Refresh the running-subagents status badge from the active local or collab registry. */ + syncRunningSubagentBadge(): void { + const registry = this.collabGuest?.agentRegistry ?? AgentRegistry.global(); + if (this.#agentRegistrySubscriptionTarget !== registry) { + this.#agentRegistryUnsubscribe?.(); + this.#agentRegistrySubscriptionTarget = registry; + this.#agentRegistryUnsubscribe = registry.onChange(() => { + this.syncRunningSubagentBadge(); + this.ui.requestRender(); + }); + } + const count = registry.list().filter(ref => ref.kind === "sub" && ref.status === "running").length; this.statusLine.setSubagentCount(count); this.updateEditorTopBorder(); } @@ -3161,6 +3166,7 @@ export class InteractiveMode implements InteractiveModeContext { this.#observerRegistry.dispose(); this.#agentRegistryUnsubscribe?.(); this.#agentRegistryUnsubscribe = undefined; + this.#agentRegistrySubscriptionTarget = undefined; this.#eventController.dispose(); this.statusLine.dispose(); if (this.#resizeHandler) { diff --git a/packages/coding-agent/src/modes/types.ts b/packages/coding-agent/src/modes/types.ts index 731b64a84..789e5230b 100644 --- a/packages/coding-agent/src/modes/types.ts +++ b/packages/coding-agent/src/modes/types.ts @@ -288,6 +288,8 @@ export interface InteractiveModeContext { findLastAssistantMessage(): AssistantMessage | undefined; extractAssistantText(message: AssistantMessage): string; updateEditorTopBorder(): void; + /** Refresh the running-subagents status badge from the active local or collab registry. */ + syncRunningSubagentBadge(): void; updateEditorBorderColor(): void; rebuildChatFromMessages(): void; setTodos(todos: TodoItem[] | TodoPhase[]): void;