Merge PR #3501: fix(tui): surface running subagents in status line (@roboomp)

This commit is contained in:
can1357
2026-06-27 01:40:12 +02:00
7 changed files with 72 additions and 1 deletions
+3
View File
@@ -90,6 +90,9 @@
### Fixed
- 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/vendor/brush-core/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 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))
- Fixed the bash tool's snapshotted `mise()` shell function dying with `command: command not found:` because `$__MISE_EXE` was empty in the replay shell. `generateSnapshotScript` captured the function via `declare -f`/`typeset -f` but only ever re-exported `PATH`, so every other env var the rc file set (notably the `*_EXE` sidecar `mise activate` exports) was lost; the function body then expanded `command "$__MISE_EXE" "$@"` to `command "" …` and died with exit 127. The snapshot script now scans captured function bodies for `$VAR` / `${VAR…}` references and re-emits `export NAME='value'` for each referenced var that is currently set (with a denylist for shell-internal names like `PATH`/`HOME`/`BASH_*`/`LC_*` plus a likely-secret denylist for `*TOKEN*`/`*SECRET*`/`*API_KEY*`/`*PASSWORD*`/`*PRIVATE_KEY*`/`*ACCESS_KEY*`/`*CREDENTIAL*`/`*SESSION_KEY*`), the snapshot script `umask 077`s itself and the JS caller chmods the snapshot file/dir to `0600`/`0700` so the new export pass can't leak secrets into a shared tmp dir. Fixes mise, asdf shims, direnv-style helpers, and other activation idioms that pair a function with a helper env var. `getShellConfigFile` now also honours `env.HOME` (falling back to `os.homedir()`) so sandboxed callers can target a non-default rc. ([#3470](https://github.com/can1357/oh-my-pi/issues/3470))
@@ -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
@@ -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) {
@@ -189,6 +189,7 @@ export class StatusLineComponent implements Component {
#autoCompactEnabled: boolean = true;
#hookStatuses: Map<string, string> = 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];
@@ -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";
@@ -555,6 +556,8 @@ export class InteractiveMode implements InteractiveModeContext {
#observerRegistry: SessionObserverRegistry;
#eventBus?: EventBus;
#eventBusUnsubscribers: Array<() => void> = [];
#agentRegistryUnsubscribe?: () => void;
#agentRegistrySubscriptionTarget?: AgentRegistry;
#mcpStatusOrder: string[] = [];
#mcpPendingServers = new Set<string>();
#mcpConnectedServers = new Set<string>();
@@ -853,8 +856,14 @@ 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.#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
@@ -1449,6 +1458,22 @@ export class InteractiveMode implements InteractiveModeContext {
this.ui.requestRender();
}
/** 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();
}
updateEditorTopBorder(): void {
const availableWidth = this.editor.getTopBorderAvailableWidth(this.ui.terminal.columns);
const topBorder = this.statusLine.getTopBorder(availableWidth);
@@ -3185,6 +3210,9 @@ export class InteractiveMode implements InteractiveModeContext {
}
this.#eventBusUnsubscribers = [];
this.#observerRegistry.dispose();
this.#agentRegistryUnsubscribe?.();
this.#agentRegistryUnsubscribe = undefined;
this.#agentRegistrySubscriptionTarget = undefined;
this.#eventController.dispose();
this.statusLine.dispose();
if (this.#resizeHandler) {
+2
View File
@@ -294,6 +294,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;
@@ -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();