@oh-my-pi/pi-coding-agent 15.10.12 → 15.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +60 -3
  2. package/dist/cli.js +841 -803
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  5. package/dist/types/config/keybindings.d.ts +6 -1
  6. package/dist/types/config/settings-schema.d.ts +56 -33
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/types.d.ts +1 -1
  13. package/dist/types/irc/bus.d.ts +66 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  15. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  16. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  17. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  18. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  19. package/dist/types/modes/components/welcome.d.ts +3 -9
  20. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  21. package/dist/types/modes/interactive-mode.d.ts +3 -2
  22. package/dist/types/modes/theme/theme.d.ts +2 -1
  23. package/dist/types/modes/types.d.ts +3 -2
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  25. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  26. package/dist/types/registry/agent-registry.d.ts +16 -5
  27. package/dist/types/session/agent-session.d.ts +35 -30
  28. package/dist/types/session/messages.d.ts +2 -4
  29. package/dist/types/session/session-history-format.d.ts +12 -0
  30. package/dist/types/session/session-manager.d.ts +21 -3
  31. package/dist/types/session/streaming-output.d.ts +23 -0
  32. package/dist/types/task/executor.d.ts +11 -2
  33. package/dist/types/task/index.d.ts +11 -4
  34. package/dist/types/task/output-manager.d.ts +0 -7
  35. package/dist/types/task/repair-args.d.ts +8 -7
  36. package/dist/types/task/types.d.ts +55 -51
  37. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  38. package/dist/types/tools/find.d.ts +0 -11
  39. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  40. package/dist/types/tools/index.d.ts +1 -3
  41. package/dist/types/tools/irc.d.ts +76 -38
  42. package/dist/types/tools/job.d.ts +7 -1
  43. package/examples/extensions/with-deps/package.json +1 -0
  44. package/package.json +11 -10
  45. package/scripts/bundle-dist.ts +28 -19
  46. package/src/async/index.ts +0 -1
  47. package/src/cli/gallery-cli.ts +1 -1
  48. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  49. package/src/cli/gallery-fixtures/types.ts +5 -0
  50. package/src/cli.ts +20 -6
  51. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  52. package/src/config/keybindings.ts +6 -1
  53. package/src/config/settings-schema.ts +56 -40
  54. package/src/config/settings.ts +7 -0
  55. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  56. package/src/eval/agent-bridge.ts +3 -16
  57. package/src/eval/js/shared/prelude.txt +1 -1
  58. package/src/eval/py/prelude.py +5 -6
  59. package/src/export/html/template.generated.ts +1 -1
  60. package/src/export/html/template.js +38 -13
  61. package/src/extensibility/custom-tools/types.ts +2 -2
  62. package/src/extensibility/shared-events.ts +2 -2
  63. package/src/internal-urls/docs-index.generated.ts +8 -8
  64. package/src/internal-urls/history-protocol.ts +113 -0
  65. package/src/internal-urls/index.ts +1 -0
  66. package/src/internal-urls/router.ts +3 -1
  67. package/src/internal-urls/types.ts +1 -1
  68. package/src/irc/bus.ts +292 -0
  69. package/src/main.ts +8 -60
  70. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  71. package/src/modes/components/compaction-summary-message.ts +68 -32
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/tool-execution.ts +31 -1
  74. package/src/modes/components/ttsr-notification.ts +72 -30
  75. package/src/modes/components/welcome.ts +9 -33
  76. package/src/modes/controllers/event-controller.ts +65 -0
  77. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  78. package/src/modes/controllers/input-controller.ts +18 -2
  79. package/src/modes/controllers/selector-controller.ts +21 -17
  80. package/src/modes/interactive-mode.ts +8 -13
  81. package/src/modes/theme/theme.ts +18 -5
  82. package/src/modes/types.ts +3 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/modes/utils/ui-helpers.ts +51 -49
  85. package/src/prompts/system/irc-incoming.md +3 -4
  86. package/src/prompts/system/orchestrate-notice.md +2 -2
  87. package/src/prompts/system/subagent-system-prompt.md +0 -5
  88. package/src/prompts/system/system-prompt.md +1 -0
  89. package/src/prompts/system/workflow-notice.md +2 -2
  90. package/src/prompts/tools/eval.md +3 -3
  91. package/src/prompts/tools/irc.md +29 -19
  92. package/src/prompts/tools/read.md +2 -2
  93. package/src/prompts/tools/task-summary.md +5 -16
  94. package/src/prompts/tools/task.md +38 -29
  95. package/src/registry/agent-lifecycle.ts +218 -0
  96. package/src/registry/agent-registry.ts +16 -5
  97. package/src/sdk.ts +29 -9
  98. package/src/session/agent-session.ts +243 -237
  99. package/src/session/messages.ts +11 -78
  100. package/src/session/session-history-format.ts +246 -0
  101. package/src/session/session-manager.ts +59 -5
  102. package/src/session/streaming-output.ts +60 -0
  103. package/src/task/executor.ts +855 -466
  104. package/src/task/index.ts +718 -794
  105. package/src/task/output-manager.ts +0 -11
  106. package/src/task/render.ts +133 -63
  107. package/src/task/repair-args.ts +21 -9
  108. package/src/task/types.ts +73 -66
  109. package/src/tools/ask.ts +4 -2
  110. package/src/tools/bash.ts +15 -5
  111. package/src/tools/browser/tab-worker.ts +26 -7
  112. package/src/tools/browser.ts +28 -1
  113. package/src/tools/find.ts +2 -27
  114. package/src/tools/grouped-file-output.ts +1 -118
  115. package/src/tools/index.ts +4 -12
  116. package/src/tools/irc.ts +596 -171
  117. package/src/tools/job.ts +41 -7
  118. package/src/tools/read.ts +57 -1
  119. package/src/tools/renderers.ts +2 -0
  120. package/src/tools/resolve.ts +4 -1
  121. package/dist/types/async/support.d.ts +0 -2
  122. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  123. package/dist/types/task/simple-mode.d.ts +0 -8
  124. package/src/async/support.ts +0 -5
  125. package/src/task/simple-mode.ts +0 -27
@@ -1,30 +1,34 @@
1
1
  /**
2
- * Session observer overlay component.
2
+ * Agent Hub overlay component.
3
3
  *
4
- * Picker mode: lists main + active subagent sessions with live status.
5
- * Viewer mode: renders a scrollable, interactive transcript of the selected subagent's session
6
- * by reading its JSONL session fileshows thinking, text, tool calls, results
7
- * with expand/collapse per entry and breadcrumb navigation for nested sub-agents.
4
+ * One overlay, two views:
5
+ * - Table view: every registered agent except Main (Main IS the ambient
6
+ * chat), live from the global AgentRegistrystatus, unread irc count,
7
+ * current/last task, last activity. Select with j/k, Enter opens a chat,
8
+ * `r` revives a parked agent, `x` aborts + releases one.
9
+ * - Chat view: per-agent transcript (incremental session-file tail, absorbed
10
+ * from the old session observer overlay) plus an input line. Submitting
11
+ * revives a parked agent, then prompts/steers it; the message lands in the
12
+ * agent's persisted history via the normal prompt path.
8
13
  *
9
- * Lifecycle:
10
- * - shortcut opens picker
11
- * - Enter on a subagent -> viewer
12
- * - shortcut while in viewer -> back to picker (or pop breadcrumb)
13
- * - Esc from viewer -> back to picker (or pop breadcrumb)
14
- * - Esc from picker -> close overlay
15
- * - Enter on main session -> close overlay (jump back)
14
+ * Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
16
15
  */
16
+ import * as fs from "node:fs";
17
17
  import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
18
- import { Container, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
19
- import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
18
+ import { Container, Editor, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
19
+ import { formatAge, formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
20
20
  import type { KeyId } from "../../config/keybindings";
21
- import { isSilentAbort } from "../../session/messages";
21
+ import { IrcBus } from "../../irc/bus";
22
+ import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
23
+ import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
24
+ import type { AgentSession } from "../../session/agent-session";
25
+ import { isSilentAbort, USER_INTERRUPT_LABEL } from "../../session/messages";
22
26
  import type { SessionMessageEntry } from "../../session/session-manager";
23
27
  import { parseSessionEntries } from "../../session/session-manager";
24
28
  import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
25
29
  import { toPathList } from "../../tools/search";
26
30
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
27
- import { getMarkdownTheme, theme } from "../theme/theme";
31
+ import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme";
28
32
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
29
33
  import { DynamicBorder } from "./dynamic-border";
30
34
  import { formatContextUsage } from "./status-line/context-thresholds";
@@ -39,6 +43,10 @@ const MAX_TOOL_ARGS_CHARS = 500;
39
43
  const PAGE_SIZE = 15;
40
44
  /** Left indent for content under entry headers */
41
45
  const INDENT = " ";
46
+ /** Refresh cadence for the relative-time column */
47
+ const AGE_TICK_MS = 5_000;
48
+ /** Debounce for live-session transcript refreshes */
49
+ const CHAT_REFRESH_DEBOUNCE_MS = 80;
42
50
 
43
51
  /** Compute the max content width for the current terminal, accounting for indent and chrome. */
44
52
  function contentWidth(indent = INDENT): number {
@@ -57,180 +65,391 @@ interface ViewerEntry {
57
65
  kind: "thinking" | "text" | "toolCall" | "user";
58
66
  }
59
67
 
60
- /** Breadcrumb item for nested session navigation */
61
- interface BreadcrumbItem {
62
- sessionId: string;
63
- label: string;
64
- sessionFile: string;
68
+ const STATUS_ORDER: Record<AgentStatus, number> = { running: 0, idle: 1, parked: 2, aborted: 3 };
69
+
70
+ /** Glyph + status word, colored per theme status conventions. */
71
+ function statusBadge(status: AgentStatus): string {
72
+ switch (status) {
73
+ case "running":
74
+ return theme.fg("accent", `${theme.status.running} running`);
75
+ case "idle":
76
+ return theme.fg("success", `${theme.status.enabled} idle`);
77
+ case "parked":
78
+ return theme.fg("muted", `${theme.status.shadowed} parked`);
79
+ case "aborted":
80
+ return theme.fg("error", `${theme.status.aborted} aborted`);
81
+ }
65
82
  }
66
83
 
67
- export class SessionObserverOverlayComponent extends Container {
68
- #registry: SessionObserverRegistry;
69
- #onDone: () => void;
70
- #selectedSessionId?: string;
71
- #observeKeys: KeyId[];
72
- #transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string };
84
+ export interface AgentHubDeps {
85
+ /** Progress/status snapshot source (task lifecycle + progress channels). */
86
+ observers: SessionObserverRegistry;
87
+ /** Keys that toggle the hub closed from inside (app.agents.hub + app.session.observe). */
88
+ hubKeys: KeyId[];
89
+ onDone: () => void;
90
+ requestRender: () => void;
91
+ /** Injectable for tests; defaults to the process-global registry. */
92
+ registry?: AgentRegistry;
93
+ /** Injectable for tests; defaults to the process-global lifecycle manager. */
94
+ lifecycle?: AgentLifecycleManager;
95
+ /** Injectable for tests; defaults to the process-global bus. */
96
+ irc?: IrcBus;
97
+ }
73
98
 
74
- // Scroll state
99
+ export class AgentHubOverlayComponent extends Container {
100
+ #registry: AgentRegistry;
101
+ #observers: SessionObserverRegistry;
102
+ #irc: IrcBus;
103
+ #lifecycle: () => AgentLifecycleManager;
104
+ #onDone: () => void;
105
+ #requestRender: () => void;
106
+ #hubKeys: KeyId[];
107
+ #unsubscribers: Array<() => void> = [];
108
+ #ageTimer: NodeJS.Timeout | undefined;
109
+
110
+ // Table state
111
+ #view: "table" | "chat" = "table";
112
+ #rows: AgentRef[] = [];
113
+ #selectedRow = 0;
114
+ #notice: string | undefined;
115
+
116
+ // Chat state
117
+ #chatAgentId: string | undefined;
118
+ #editor: Editor;
119
+ #sessionUnsubscribe: (() => void) | undefined;
120
+ #attachedSession: AgentSession | undefined;
121
+ #chatRefreshTimer: NodeJS.Timeout | undefined;
122
+ #transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined;
123
+
124
+ // Transcript viewer state (absorbed from the session observer overlay)
75
125
  #scrollOffset = 0;
76
126
  #renderedLines: string[] = [];
77
127
  #viewportHeight = 20;
78
128
  #wasAtBottom = true;
79
-
80
- // Entry selection & expand/collapse
81
129
  #viewerEntries: ViewerEntry[] = [];
82
130
  #selectedEntryIndex = 0;
83
131
  #expandedEntries = new Set<number>();
84
-
85
- // Breadcrumb navigation
86
- #navigationStack: BreadcrumbItem[] = [];
87
-
88
- // Cached header/footer for viewer (rebuilt on refresh)
89
132
  #viewerHeaderLines: string[] = [];
90
- #viewerFooterLines: string[] = [];
91
- // Markdown rendering
92
133
  #mdTheme: MarkdownTheme = getMarkdownTheme();
93
134
 
94
- constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) {
135
+ constructor(deps: AgentHubDeps) {
95
136
  super();
96
- this.#registry = registry;
97
- this.#onDone = onDone;
98
- this.#observeKeys = observeKeys;
99
-
100
- // Jump directly to the most recently active sub-agent
101
- const mostRecent = this.#getMostRecentSubagent();
102
- if (mostRecent) {
103
- this.#selectedSessionId = mostRecent.id;
104
- this.#setupViewer();
105
- } else {
106
- // No sub-agents — close immediately
107
- queueMicrotask(() => this.#onDone());
108
- }
137
+ this.#registry = deps.registry ?? AgentRegistry.global();
138
+ this.#observers = deps.observers;
139
+ this.#irc = deps.irc ?? IrcBus.global();
140
+ // Lazy: the lifecycle global self-constructs against the global
141
+ // registry, so only touch it when revive/kill actually needs it.
142
+ this.#lifecycle = () => deps.lifecycle ?? AgentLifecycleManager.global();
143
+ this.#onDone = deps.onDone;
144
+ this.#requestRender = deps.requestRender;
145
+ this.#hubKeys = deps.hubKeys;
146
+
147
+ this.#editor = new Editor(getEditorTheme());
148
+ this.#editor.setMaxHeight(4);
149
+ this.#editor.onSubmit = text => this.#submitChatMessage(text);
150
+
151
+ this.#unsubscribers.push(this.#registry.onChange(() => this.#onDataChange()));
152
+ this.#unsubscribers.push(this.#observers.onChange(() => this.#onDataChange()));
153
+ this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS);
154
+ this.#ageTimer.unref?.();
155
+
156
+ this.#refreshRows();
109
157
  }
110
158
 
111
- /** Find the most recently updated sub-agent session (prefer active ones) */
112
- #getMostRecentSubagent(): ObservableSession | undefined {
113
- const sessions = this.#registry.getSessions().filter(s => s.kind === "subagent");
114
- if (sessions.length === 0) return undefined;
115
- // Prefer active sessions, then sort by lastUpdate descending
116
- const active = sessions.filter(s => s.status === "active");
117
- const pool = active.length > 0 ? active : sessions;
118
- return pool.sort((a, b) => b.lastUpdate - a.lastUpdate)[0];
159
+ /** Tear down every subscription and timer. Called by the overlay owner on close. */
160
+ dispose(): void {
161
+ for (const unsubscribe of this.#unsubscribers.splice(0)) unsubscribe();
162
+ if (this.#ageTimer) {
163
+ clearInterval(this.#ageTimer);
164
+ this.#ageTimer = undefined;
165
+ }
166
+ if (this.#chatRefreshTimer) {
167
+ clearTimeout(this.#chatRefreshTimer);
168
+ this.#chatRefreshTimer = undefined;
169
+ }
170
+ this.#detachLiveSession();
119
171
  }
120
172
 
121
173
  override render(width: number): readonly string[] {
122
- return this.#renderViewer(width);
174
+ return this.#view === "table" ? this.#renderTable(width) : this.#renderChat(width);
123
175
  }
124
176
 
125
- #setupViewer(): void {
126
- this.clear();
177
+ handleInput(keyData: string): void {
178
+ // The hub/observe keys always close the overlay (toggle semantics)
179
+ for (const key of this.#hubKeys) {
180
+ if (matchesKey(keyData, key)) {
181
+ this.#onDone();
182
+ return;
183
+ }
184
+ }
185
+ if (this.#view === "table") {
186
+ this.#handleTableInput(keyData);
187
+ } else {
188
+ this.#handleChatInput(keyData);
189
+ }
190
+ }
191
+
192
+ /** Open the chat view for an agent id (public for table Enter and tests). */
193
+ openChat(id: string): void {
194
+ if (!this.#registry.get(id)) return;
195
+ this.#view = "chat";
196
+ this.#chatAgentId = id;
197
+ this.#notice = undefined;
198
+ this.#transcriptCache = undefined;
127
199
  this.#scrollOffset = 0;
128
200
  this.#selectedEntryIndex = 0;
129
201
  this.#expandedEntries.clear();
130
202
  this.#wasAtBottom = true;
131
- this.#rebuildViewerContent();
132
- // Auto-scroll to bottom and select last entry on init
203
+ this.#editor.setText("");
204
+ this.#attachLiveSession();
205
+ this.#rebuildChatContent();
206
+ // Auto-scroll to bottom and select last entry on open
133
207
  if (this.#viewerEntries.length > 0) {
134
208
  this.#selectedEntryIndex = this.#viewerEntries.length - 1;
135
- this.#wasAtBottom = true;
136
- this.#rebuildViewerContent();
209
+ this.#rebuildChatContent();
137
210
  }
211
+ this.#requestRender();
138
212
  }
139
213
 
140
- /** Rebuild content from live registry data */
141
- refreshFromRegistry(): void {
142
- if (this.#selectedSessionId) {
143
- // Keep auto-scrolling to bottom unless the user navigated away from the last entry
144
- this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
145
- this.#rebuildViewerContent();
214
+ // ========================================================================
215
+ // Live data plumbing
216
+ // ========================================================================
217
+
218
+ #onDataChange(): void {
219
+ this.#refreshRows();
220
+ if (this.#view === "chat") {
221
+ // A revive/park swaps the live session out from under the chat view.
222
+ this.#attachLiveSession();
223
+ this.#scheduleChatRefresh();
224
+ return;
146
225
  }
226
+ this.#requestRender();
147
227
  }
148
228
 
149
- /** Rebuild the transcript content lines (called on setup and refresh) */
150
- #rebuildViewerContent(): void {
151
- const sessions = this.#registry.getSessions();
152
- const session = sessions.find(s => s.id === this.#selectedSessionId);
229
+ #refreshRows(): void {
230
+ const selectedId = this.#rows[this.#selectedRow]?.id;
231
+ this.#rows = this.#registry
232
+ .list()
233
+ .filter(ref => ref.id !== MAIN_AGENT_ID)
234
+ .sort((a, b) => STATUS_ORDER[a.status] - STATUS_ORDER[b.status] || b.lastActivity - a.lastActivity);
235
+ const keptIndex = selectedId ? this.#rows.findIndex(ref => ref.id === selectedId) : -1;
236
+ this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1));
237
+ }
153
238
 
154
- // Load transcript first so model info is available for header
155
- let messageEntries: SessionMessageEntry[] | null = null;
156
- if (session?.sessionFile) {
157
- messageEntries = this.#loadTranscript(session.sessionFile);
158
- }
239
+ /** Subscribe to the chat agent's live session (if any) for transcript refreshes. Idempotent per session. */
240
+ #attachLiveSession(): void {
241
+ const session = this.#chatAgentId ? (this.#registry.get(this.#chatAgentId)?.session ?? undefined) : undefined;
242
+ if (session === this.#attachedSession) return;
243
+ this.#detachLiveSession();
244
+ if (!session) return;
245
+ this.#attachedSession = session;
246
+ this.#sessionUnsubscribe = session.subscribe(event => {
247
+ if (event.type === "message_end" || event.type === "tool_execution_end" || event.type === "agent_end") {
248
+ this.#scheduleChatRefresh();
249
+ }
250
+ });
251
+ }
159
252
 
160
- // Header
161
- this.#viewerHeaderLines = [];
162
- const breadcrumb = this.#buildBreadcrumb(session);
163
- this.#viewerHeaderLines.push(theme.fg("accent", breadcrumb));
164
- if (session) {
165
- const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
166
- const statusText = theme.fg(statusColor, `[${session.status}]`);
167
- const agentTag = session.agent ? theme.fg("dim", ` ${session.agent}`) : "";
168
- const subagentIds = this.#getSubagentSessionIds();
169
- const posIdx = subagentIds.indexOf(this.#selectedSessionId ?? "");
170
- const posLabel =
171
- subagentIds.length > 1 && posIdx >= 0 ? theme.fg("dim", ` (${posIdx + 1}/${subagentIds.length})`) : "";
172
- const modelName = this.#transcriptCache?.model;
173
- const modelLabel = modelName ? theme.fg("muted", ` · ${modelName}`) : "";
174
- this.#viewerHeaderLines.push(`${theme.bold(session.label)} ${statusText}${agentTag}${posLabel}${modelLabel}`);
175
- }
176
-
177
- // Content
178
- const contentLines: string[] = [];
179
- this.#viewerEntries = [];
253
+ #detachLiveSession(): void {
254
+ this.#sessionUnsubscribe?.();
255
+ this.#sessionUnsubscribe = undefined;
256
+ this.#attachedSession = undefined;
257
+ }
180
258
 
181
- if (!session) {
182
- contentLines.push(theme.fg("dim", "Session no longer available."));
183
- } else if (!session.sessionFile) {
184
- contentLines.push(theme.fg("dim", "No session file available yet."));
185
- } else if (!messageEntries) {
186
- contentLines.push(theme.fg("dim", "Unable to read session file."));
187
- } else if (messageEntries.length === 0) {
188
- contentLines.push(theme.fg("dim", "No messages yet."));
259
+ #scheduleChatRefresh(): void {
260
+ if (this.#chatRefreshTimer) return;
261
+ this.#chatRefreshTimer = setTimeout(() => {
262
+ this.#chatRefreshTimer = undefined;
263
+ if (this.#view !== "chat") return;
264
+ // Keep auto-scrolling to bottom unless the user navigated away
265
+ this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
266
+ this.#rebuildChatContent();
267
+ if (this.#wasAtBottom && this.#viewerEntries.length > 0) {
268
+ this.#selectedEntryIndex = this.#viewerEntries.length - 1;
269
+ }
270
+ this.#requestRender();
271
+ }, CHAT_REFRESH_DEBOUNCE_MS);
272
+ this.#chatRefreshTimer.unref?.();
273
+ }
274
+
275
+ #observableFor(id: string): ObservableSession | undefined {
276
+ return this.#observers.getSessions().find(s => s.id === id);
277
+ }
278
+
279
+ // ========================================================================
280
+ // Table view
281
+ // ========================================================================
282
+
283
+ #renderTable(width: number): string[] {
284
+ const lines: string[] = [];
285
+ lines.push(...new DynamicBorder().render(width));
286
+ const counts = this.#statusSummary();
287
+ lines.push(` ${theme.fg("accent", "Agent Hub")}${counts ? theme.fg("dim", `${theme.sep.dot}${counts}`) : ""}`);
288
+ lines.push(...new DynamicBorder().render(width));
289
+
290
+ if (this.#rows.length === 0) {
291
+ lines.push(` ${theme.fg("dim", "no subagents yet — task spawns appear here")}`);
189
292
  } else {
190
- this.#buildTranscriptLines(messageEntries, contentLines);
293
+ const termHeight = process.stdout.rows || 40;
294
+ // Chrome: 2 borders + title + notice? + blank + hints + border
295
+ const maxVisible = Math.max(3, termHeight - 7 - (this.#notice ? 1 : 0));
296
+ let start = 0;
297
+ if (this.#rows.length > maxVisible) {
298
+ start = Math.min(
299
+ Math.max(0, this.#selectedRow - Math.floor(maxVisible / 2)),
300
+ this.#rows.length - maxVisible,
301
+ );
302
+ }
303
+ const end = Math.min(start + maxVisible, this.#rows.length);
304
+ for (let i = start; i < end; i++) {
305
+ lines.push(this.#renderRow(this.#rows[i], i === this.#selectedRow, width));
306
+ }
307
+ if (end < this.#rows.length) {
308
+ lines.push(` ${theme.fg("dim", `… ${this.#rows.length - end} more`)}`);
309
+ }
191
310
  }
192
- this.#renderedLines = contentLines;
193
311
 
194
- // Footer
195
- this.#viewerFooterLines = [];
196
- const statsLine = this.#buildStatsLine(session);
197
- if (statsLine) this.#viewerFooterLines.push(statsLine);
198
- this.#viewerFooterLines.push(
199
- theme.fg("dim", "j/k:scroll Enter:expand [/]/←→:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
200
- );
312
+ if (this.#notice) {
313
+ lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`);
314
+ }
315
+ lines.push("");
316
+ lines.push(` ${theme.fg("dim", "j/k:select Enter:chat r:revive x:kill Esc:close")}`);
317
+ lines.push(...new DynamicBorder().render(width));
318
+ return lines;
319
+ }
201
320
 
202
- // Auto-scroll to bottom if we were at bottom
203
- if (this.#wasAtBottom) {
204
- this.#scrollOffset = Math.max(0, contentLines.length - this.#viewportHeight);
321
+ #statusSummary(): string {
322
+ const counts: Record<AgentStatus, number> = { running: 0, idle: 0, parked: 0, aborted: 0 };
323
+ for (const ref of this.#rows) {
324
+ counts[ref.status]++;
205
325
  }
326
+ const parts: string[] = [];
327
+ for (const status of ["running", "idle", "parked", "aborted"] as const) {
328
+ const count = counts[status];
329
+ if (count > 0) parts.push(`${count} ${status}`);
330
+ }
331
+ return parts.join(theme.sep.dot);
206
332
  }
207
333
 
208
- /** Produce the final viewer output for the overlay system */
209
- #renderViewer(width: number): string[] {
210
- const termHeight = process.stdout.rows || 40;
334
+ #renderRow(ref: AgentRef, selected: boolean, width: number): string {
335
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
336
+ const parts: string[] = [statusBadge(ref.status), theme.bold(replaceTabs(ref.id))];
337
+ parts.push(theme.fg("dim", ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind));
338
+ const observed = this.#observableFor(ref.id);
339
+ const task = observed?.description ?? observed?.progress?.task;
340
+ if (task) {
341
+ parts.push(theme.fg("muted", sanitizeLine(task, TRUNCATE_LENGTHS.TITLE)));
342
+ }
343
+ const unread = this.#irc.unreadCount(ref.id);
344
+ if (unread > 0) {
345
+ parts.push(theme.fg("warning", `⧉ ${unread}`));
346
+ }
347
+ parts.push(theme.fg("dim", formatAge(Math.max(1, Math.round((Date.now() - ref.lastActivity) / 1000)))));
348
+ return truncateToWidth(` ${cursor} ${parts.join(theme.sep.dot)}`, Math.max(10, width - 1));
349
+ }
350
+
351
+ #handleTableInput(keyData: string): void {
352
+ if (matchesKey(keyData, "escape")) {
353
+ this.#onDone();
354
+ return;
355
+ }
356
+ if (keyData === "j" || matchesSelectDown(keyData)) {
357
+ if (this.#rows.length > 0) {
358
+ this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1);
359
+ }
360
+ this.#requestRender();
361
+ return;
362
+ }
363
+ if (keyData === "k" || matchesSelectUp(keyData)) {
364
+ if (this.#rows.length > 0) {
365
+ this.#selectedRow = Math.max(this.#selectedRow - 1, 0);
366
+ }
367
+ this.#requestRender();
368
+ return;
369
+ }
370
+ if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
371
+ const selected = this.#rows[this.#selectedRow];
372
+ if (selected) this.openChat(selected.id);
373
+ return;
374
+ }
375
+ if (keyData === "r") {
376
+ this.#reviveSelected();
377
+ return;
378
+ }
379
+ if (keyData === "x") {
380
+ this.#killSelected();
381
+ return;
382
+ }
383
+ }
384
+
385
+ #reviveSelected(): void {
386
+ const ref = this.#rows[this.#selectedRow];
387
+ if (!ref) return;
388
+ if (ref.status !== "parked") {
389
+ this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`;
390
+ this.#requestRender();
391
+ return;
392
+ }
393
+ this.#notice = undefined;
394
+ // Fire-and-forget; failures surface as an inline notice
395
+ this.#lifecycle()
396
+ .ensureLive(ref.id)
397
+ .catch((error: unknown) => {
398
+ this.#notice = error instanceof Error ? error.message : String(error);
399
+ this.#requestRender();
400
+ });
401
+ this.#requestRender();
402
+ }
211
403
 
212
- // Compute viewport: total height minus header chrome and footer chrome
213
- // Header: border(1) + headerLines + border(1) = headerLines.length + 2
214
- // Footer: spacer(1) + scrollInfo(1) + footerLines + border(1) = footerLines.length + 2
404
+ #killSelected(): void {
405
+ const ref = this.#rows[this.#selectedRow];
406
+ if (!ref) return;
407
+ this.#notice = undefined;
408
+ void (async () => {
409
+ try {
410
+ if (ref.status === "running" && ref.session) {
411
+ await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
412
+ }
413
+ await this.#lifecycle().release(ref.id);
414
+ } catch (error) {
415
+ logger.warn("Agent hub: kill failed", { id: ref.id, error: String(error) });
416
+ this.#notice = error instanceof Error ? error.message : String(error);
417
+ }
418
+ this.#refreshRows();
419
+ this.#requestRender();
420
+ })();
421
+ }
422
+
423
+ // ========================================================================
424
+ // Chat view
425
+ // ========================================================================
426
+
427
+ #renderChat(width: number): string[] {
428
+ const termHeight = process.stdout.rows || 40;
429
+ const innerWidth = Math.max(20, width - 2);
430
+ const editorLines = this.#editor.render(innerWidth);
431
+ const noticeLine = this.#notice
432
+ ? ` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`
433
+ : undefined;
434
+ const footerLines = this.#buildChatFooterLines();
435
+
436
+ // Header: border + headerLines + border; footer: notice? + editor + footer + border
215
437
  const headerChrome = this.#viewerHeaderLines.length + 2;
216
- const footerChrome = this.#viewerFooterLines.length + 2;
438
+ const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1;
217
439
  this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
218
440
 
219
- // Clamp scroll offset
220
441
  const maxScroll = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
442
+ if (this.#wasAtBottom) this.#scrollOffset = maxScroll;
221
443
  this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
222
444
 
223
445
  const lines: string[] = [];
224
-
225
- // --- Header ---
226
446
  lines.push(...new DynamicBorder().render(width));
227
- for (const hl of this.#viewerHeaderLines) {
228
- lines.push(` ${hl}`);
447
+ for (const headerLine of this.#viewerHeaderLines) {
448
+ lines.push(` ${headerLine}`);
229
449
  }
230
450
  lines.push(...new DynamicBorder().render(width));
231
451
 
232
- // --- Scrolled content viewport ---
233
- const sv = new ScrollView(
452
+ const scrollView = new ScrollView(
234
453
  this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
235
454
  {
236
455
  height: this.#viewportHeight,
@@ -239,31 +458,27 @@ export class SessionObserverOverlayComponent extends Container {
239
458
  theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
240
459
  },
241
460
  );
242
- sv.setScrollOffset(this.#scrollOffset);
243
- for (const row of sv.render(Math.max(1, width - 1))) lines.push(` ${row}`);
461
+ scrollView.setScrollOffset(this.#scrollOffset);
462
+ for (const row of scrollView.render(Math.max(1, width - 1))) lines.push(` ${row}`);
244
463
 
245
- // --- Footer ---
246
- lines.push("");
247
- lines.push(` ${this.#viewerFooterLines[0] ?? ""}`);
248
- for (let i = 1; i < this.#viewerFooterLines.length; i++) {
249
- lines.push(` ${this.#viewerFooterLines[i]}`);
250
- }
464
+ if (noticeLine) lines.push(noticeLine);
465
+ for (const editorLine of editorLines) lines.push(` ${editorLine}`);
466
+ lines.push(...footerLines);
251
467
  lines.push(...new DynamicBorder().render(width));
252
-
253
468
  return lines;
254
469
  }
255
470
 
256
- #buildBreadcrumb(session: ObservableSession | undefined): string {
257
- const parts: string[] = ["Session Observer"];
258
- for (const item of this.#navigationStack) {
259
- parts.push(item.label);
260
- }
261
- if (session) parts.push(session.label);
262
- return parts.join(" > ");
471
+ #buildChatFooterLines(): string[] {
472
+ const lines: string[] = [];
473
+ const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined;
474
+ const statsLine = this.#buildStatsLine(observed);
475
+ if (statsLine) lines.push(` ${statsLine}`);
476
+ lines.push(` ${theme.fg("dim", "Enter:send Esc:back empty input: j/k:scroll Enter:expand g/G:top/bottom")}`);
477
+ return lines;
263
478
  }
264
479
 
265
- #buildStatsLine(session: ObservableSession | undefined): string {
266
- const progress = session?.progress;
480
+ #buildStatsLine(observed: ObservableSession | undefined): string {
481
+ const progress = observed?.progress;
267
482
  if (!progress) return "";
268
483
  const stats: string[] = [];
269
484
  // Current per-turn context — match the status line's `<pct>%/<window>` gauge (e.g. `5.1%/1M`).
@@ -290,6 +505,212 @@ export class SessionObserverOverlayComponent extends Container {
290
505
  return parts.join(theme.sep.dot);
291
506
  }
292
507
 
508
+ /** Rebuild the chat header + transcript content lines */
509
+ #rebuildChatContent(): void {
510
+ const id = this.#chatAgentId;
511
+ const ref = id ? this.#registry.get(id) : undefined;
512
+
513
+ // Load transcript first so model info is available for the header
514
+ let messageEntries: SessionMessageEntry[] | null = null;
515
+ if (ref?.sessionFile) {
516
+ messageEntries = this.#loadTranscript(ref.sessionFile);
517
+ }
518
+
519
+ this.#viewerHeaderLines = [];
520
+ this.#viewerHeaderLines.push(theme.fg("accent", `Agent Hub > ${id ?? "?"}`));
521
+ if (ref) {
522
+ const observed = this.#observableFor(ref.id);
523
+ const model = observed?.progress?.resolvedModel ?? this.#transcriptCache?.model;
524
+ const kindTag = theme.fg("dim", ` ${ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind}`);
525
+ const modelLabel = model ? theme.fg("muted", `${theme.sep.dot}${model}`) : "";
526
+ this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`);
527
+ }
528
+
529
+ const contentLines: string[] = [];
530
+ this.#viewerEntries = [];
531
+ if (!ref) {
532
+ contentLines.push(theme.fg("dim", "Agent no longer registered."));
533
+ } else if (!ref.sessionFile) {
534
+ contentLines.push(theme.fg("dim", "No session file available yet."));
535
+ } else if (!messageEntries) {
536
+ contentLines.push(theme.fg("dim", "Unable to read session file."));
537
+ } else if (messageEntries.length === 0) {
538
+ contentLines.push(theme.fg("dim", "No messages yet."));
539
+ } else {
540
+ this.#buildTranscriptLines(messageEntries, contentLines);
541
+ }
542
+ this.#renderedLines = contentLines;
543
+ }
544
+
545
+ #handleChatInput(keyData: string): void {
546
+ const editorEmpty = this.#editor.getText().trim() === "";
547
+
548
+ if (matchesKey(keyData, "escape")) {
549
+ if (!editorEmpty) {
550
+ this.#editor.setText("");
551
+ this.#requestRender();
552
+ return;
553
+ }
554
+ this.#closeChat();
555
+ return;
556
+ }
557
+
558
+ // Navigation mirrors the old observer overlay while the input is empty;
559
+ // once the user starts typing, the editor owns every key.
560
+ if (editorEmpty && this.#handleViewerNavigation(keyData)) {
561
+ return;
562
+ }
563
+
564
+ this.#editor.handleInput(keyData);
565
+ this.#requestRender();
566
+ }
567
+
568
+ #closeChat(): void {
569
+ this.#view = "table";
570
+ this.#chatAgentId = undefined;
571
+ this.#notice = undefined;
572
+ this.#detachLiveSession();
573
+ this.#refreshRows();
574
+ this.#requestRender();
575
+ }
576
+
577
+ #submitChatMessage(text: string): void {
578
+ const id = this.#chatAgentId;
579
+ const trimmed = text.trim();
580
+ if (!id || !trimmed) return;
581
+ this.#editor.setText("");
582
+ this.#notice = undefined;
583
+ void (async () => {
584
+ try {
585
+ // Revives a parked agent; returns the live session for running/idle.
586
+ const session = await this.#lifecycle().ensureLive(id);
587
+ this.#attachLiveSession();
588
+ // Steers a mid-turn agent; sends a normal prompt to an idle one.
589
+ await session.prompt(trimmed, { streamingBehavior: "steer" });
590
+ } catch (error) {
591
+ this.#notice = error instanceof Error ? error.message : String(error);
592
+ }
593
+ this.#scheduleChatRefresh();
594
+ this.#requestRender();
595
+ })();
596
+ this.#requestRender();
597
+ }
598
+
599
+ /** Viewer navigation (selection, paging, expand) for the chat transcript. Returns true when handled. */
600
+ #handleViewerNavigation(keyData: string): boolean {
601
+ const entryCount = this.#viewerEntries.length;
602
+
603
+ if (keyData === "j" || matchesSelectDown(keyData)) {
604
+ if (entryCount > 0) {
605
+ this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
606
+ }
607
+ this.#rebuildAndScroll();
608
+ return true;
609
+ }
610
+ if (keyData === "k" || matchesSelectUp(keyData)) {
611
+ if (entryCount > 0) {
612
+ this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
613
+ }
614
+ this.#rebuildAndScroll();
615
+ return true;
616
+ }
617
+ if (matchesKey(keyData, "pageDown")) {
618
+ if (entryCount > 0) {
619
+ const prevIndex = this.#selectedEntryIndex;
620
+ this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
621
+ if (this.#selectedEntryIndex === prevIndex) {
622
+ this.#scrollOffset = Math.min(
623
+ this.#scrollOffset + PAGE_SIZE,
624
+ Math.max(0, this.#renderedLines.length - this.#viewportHeight),
625
+ );
626
+ }
627
+ } else {
628
+ this.#scrollOffset = Math.min(
629
+ this.#scrollOffset + PAGE_SIZE,
630
+ Math.max(0, this.#renderedLines.length - this.#viewportHeight),
631
+ );
632
+ }
633
+ this.#rebuildAndScroll();
634
+ return true;
635
+ }
636
+ if (matchesKey(keyData, "pageUp")) {
637
+ if (entryCount > 0) {
638
+ const prevIndex = this.#selectedEntryIndex;
639
+ this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
640
+ if (this.#selectedEntryIndex === prevIndex) {
641
+ this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
642
+ }
643
+ } else {
644
+ this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
645
+ }
646
+ this.#rebuildAndScroll();
647
+ return true;
648
+ }
649
+ if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
650
+ if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
651
+ if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
652
+ this.#expandedEntries.delete(this.#selectedEntryIndex);
653
+ } else {
654
+ this.#expandedEntries.add(this.#selectedEntryIndex);
655
+ }
656
+ this.#rebuildAndScroll();
657
+ }
658
+ return true;
659
+ }
660
+ if (keyData === "G") {
661
+ if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
662
+ this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
663
+ this.#rebuildAndScroll();
664
+ return true;
665
+ }
666
+ if (keyData === "g") {
667
+ this.#selectedEntryIndex = 0;
668
+ this.#scrollOffset = 0;
669
+ this.#rebuildAndScroll();
670
+ return true;
671
+ }
672
+ return false;
673
+ }
674
+
675
+ /** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
676
+ #rebuildAndScroll(): void {
677
+ // Resume auto-scrolling once selection returns to the last entry
678
+ this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
679
+ this.#rebuildChatContent();
680
+ this.#scrollToSelectedEntry();
681
+ this.#requestRender();
682
+ }
683
+
684
+ #scrollToSelectedEntry(): void {
685
+ if (this.#viewerEntries.length === 0) return;
686
+ const entry = this.#viewerEntries[this.#selectedEntryIndex];
687
+ if (!entry) return;
688
+
689
+ const entryTop = entry.lineStart;
690
+ const entryBottom = entry.lineStart + entry.lineCount;
691
+
692
+ if (entry.lineCount >= this.#viewportHeight) {
693
+ // Entry taller than viewport: only snap when it's completely out of view.
694
+ if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
695
+ this.#scrollOffset = Math.max(0, entryTop - 1);
696
+ } else if (this.#scrollOffset >= entryBottom) {
697
+ this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
698
+ }
699
+ } else {
700
+ // Entry fits in viewport: ensure it's fully visible
701
+ if (entryTop < this.#scrollOffset) {
702
+ this.#scrollOffset = Math.max(0, entryTop - 1);
703
+ }
704
+ if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
705
+ this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
706
+ }
707
+ }
708
+ }
709
+
710
+ // ========================================================================
711
+ // Transcript rendering (absorbed from the session observer overlay)
712
+ // ========================================================================
713
+
293
714
  #buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void {
294
715
  // Build a tool call ID -> tool result map
295
716
  const toolResults = new Map<string, ToolResultMessage>();
@@ -308,7 +729,7 @@ export class SessionObserverOverlayComponent extends Container {
308
729
  if (msg.content.length === 0 && msg.errorMessage && !isSilentAbort(msg.errorMessage)) {
309
730
  const startLine = lines.length;
310
731
  const isSelected = entryIndex === this.#selectedEntryIndex;
311
- const cursor = isSelected ? theme.fg("accent", "▶") : " ";
732
+ const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " ";
312
733
  lines.push("");
313
734
  const errorLines = msg.errorMessage.split("\n");
314
735
  const maxWidth = contentWidth();
@@ -370,7 +791,7 @@ export class SessionObserverOverlayComponent extends Container {
370
791
  const isSelected = entryIndex === this.#selectedEntryIndex;
371
792
  const isExpanded = this.#expandedEntries.has(entryIndex);
372
793
  const label = msg.role === "developer" ? "System" : "User";
373
- const cursor = isSelected ? theme.fg("accent", "▶") : " ";
794
+ const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " ";
374
795
  lines.push("");
375
796
  if (isExpanded) {
376
797
  lines.push(`${cursor} ${theme.fg("dim", `[${label}]`)}`);
@@ -402,7 +823,7 @@ export class SessionObserverOverlayComponent extends Container {
402
823
  }
403
824
 
404
825
  #renderThinkingLines(lines: string[], thinking: string, expanded: boolean, selected: boolean): void {
405
- const cursor = selected ? theme.fg("accent", "▶") : " ";
826
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
406
827
  const maxChars = expanded ? MAX_THINKING_CHARS_EXPANDED : MAX_THINKING_CHARS_COLLAPSED;
407
828
  const truncated = thinking.length > maxChars;
408
829
  const expandLabel = !expanded && truncated ? theme.fg("dim", " ↵") : "";
@@ -435,7 +856,7 @@ export class SessionObserverOverlayComponent extends Container {
435
856
  }
436
857
 
437
858
  #renderTextLines(lines: string[], text: string, expanded: boolean, selected: boolean): void {
438
- const cursor = selected ? theme.fg("accent", "▶") : " ";
859
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
439
860
 
440
861
  lines.push("");
441
862
  lines.push(`${cursor} ${theme.fg("muted", "Response")}`);
@@ -467,7 +888,7 @@ export class SessionObserverOverlayComponent extends Container {
467
888
  expanded: boolean,
468
889
  selected: boolean,
469
890
  ): void {
470
- const cursor = selected ? theme.fg("accent", "▶") : " ";
891
+ const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
471
892
  lines.push("");
472
893
 
473
894
  // Tool call header
@@ -565,14 +986,16 @@ export class SessionObserverOverlayComponent extends Container {
565
986
  case "ast_edit":
566
987
  return args.path ? `path: ${args.path}` : "";
567
988
  case "task": {
568
- const tasks = args.tasks;
569
- return Array.isArray(tasks) ? `${tasks.length} task(s)` : "";
989
+ const target = typeof args.agent === "string" ? args.agent : "";
990
+ const id = typeof args.id === "string" && args.id ? ` ${args.id}` : "";
991
+ return `${target}${id}`.trim();
570
992
  }
571
993
  default: {
572
994
  const parts: string[] = [];
573
995
  let total = 0;
574
- for (const [key, value] of Object.entries(args)) {
996
+ for (const key in args) {
575
997
  if (key.startsWith("_")) continue;
998
+ const value = args[key];
576
999
  const v = typeof value === "string" ? value : JSON.stringify(value);
577
1000
  const entry = `${key}: ${replaceTabs(v ?? "")}`;
578
1001
  if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
@@ -592,7 +1015,7 @@ export class SessionObserverOverlayComponent extends Container {
592
1015
  const fromByte = this.#transcriptCache?.bytesRead ?? 0;
593
1016
  const result = readFileIncremental(sessionFile, fromByte);
594
1017
  if (!result) {
595
- logger.debug("Session observer: failed to read session file", { path: sessionFile });
1018
+ logger.debug("Agent hub: failed to read session file", { path: sessionFile });
596
1019
  return this.#transcriptCache?.entries ?? null;
597
1020
  }
598
1021
 
@@ -627,213 +1050,9 @@ export class SessionObserverOverlayComponent extends Container {
627
1050
  }
628
1051
  return this.#transcriptCache.entries;
629
1052
  }
630
-
631
- #navigateBack(): boolean {
632
- if (this.#navigationStack.length === 0) return false;
633
- const prev = this.#navigationStack.pop()!;
634
- this.#selectedSessionId = prev.sessionId;
635
- this.#transcriptCache = undefined;
636
- this.#scrollOffset = 0;
637
- this.#selectedEntryIndex = 0;
638
- this.#expandedEntries.clear();
639
- this.#rebuildViewerContent();
640
- return true;
641
- }
642
-
643
- handleInput(keyData: string): void {
644
- // Ctrl+S (observe key) always closes the overlay
645
- for (const key of this.#observeKeys) {
646
- if (matchesKey(keyData, key)) {
647
- this.#onDone();
648
- return;
649
- }
650
- }
651
-
652
- this.#handleViewerInput(keyData);
653
- }
654
-
655
- #handleViewerInput(keyData: string): void {
656
- const entryCount = this.#viewerEntries.length;
657
-
658
- // Escape — pop breadcrumb navigation or close overlay
659
- if (matchesKey(keyData, "escape")) {
660
- if (!this.#navigateBack()) {
661
- this.#onDone();
662
- }
663
- return;
664
- }
665
-
666
- // j / down — move selection down
667
- if (keyData === "j" || matchesSelectDown(keyData)) {
668
- if (entryCount > 0) {
669
- this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
670
- }
671
- this.#rebuildAndScroll();
672
- return;
673
- }
674
-
675
- // k / up — move selection up
676
- if (keyData === "k" || matchesSelectUp(keyData)) {
677
- if (entryCount > 0) {
678
- this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
679
- }
680
- this.#rebuildAndScroll();
681
- return;
682
- }
683
-
684
- // Page Down
685
- if (matchesKey(keyData, "pageDown")) {
686
- if (entryCount > 0) {
687
- const prevIndex = this.#selectedEntryIndex;
688
- this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
689
- // If selection didn't move (bottom of list or single oversized entry), fall back to line scroll
690
- if (this.#selectedEntryIndex === prevIndex) {
691
- this.#scrollOffset = Math.min(
692
- this.#scrollOffset + PAGE_SIZE,
693
- Math.max(0, this.#renderedLines.length - this.#viewportHeight),
694
- );
695
- }
696
- } else {
697
- this.#scrollOffset = Math.min(
698
- this.#scrollOffset + PAGE_SIZE,
699
- Math.max(0, this.#renderedLines.length - this.#viewportHeight),
700
- );
701
- }
702
- this.#rebuildAndScroll();
703
- return;
704
- }
705
-
706
- // Page Up
707
- if (matchesKey(keyData, "pageUp")) {
708
- if (entryCount > 0) {
709
- const prevIndex = this.#selectedEntryIndex;
710
- this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
711
- // If selection didn't move (top of list or single oversized entry), fall back to line scroll
712
- if (this.#selectedEntryIndex === prevIndex) {
713
- this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
714
- }
715
- } else {
716
- this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
717
- }
718
- this.#rebuildAndScroll();
719
- return;
720
- }
721
-
722
- // Enter — toggle expand/collapse, or dive into nested session
723
- if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
724
- if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
725
- // Toggle expand/collapse
726
- if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
727
- this.#expandedEntries.delete(this.#selectedEntryIndex);
728
- } else {
729
- this.#expandedEntries.add(this.#selectedEntryIndex);
730
- }
731
- this.#rebuildAndScroll();
732
- }
733
- return;
734
- }
735
-
736
- // G — jump to bottom
737
- if (keyData === "G") {
738
- if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
739
- this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
740
- this.#rebuildAndScroll();
741
- return;
742
- }
743
-
744
- // g — jump to top
745
- if (keyData === "g") {
746
- this.#selectedEntryIndex = 0;
747
- this.#scrollOffset = 0;
748
- this.#rebuildAndScroll();
749
- return;
750
- }
751
-
752
- // ] / → / Tab — next sub-agent session
753
- if (keyData === "]" || matchesKey(keyData, "tab") || matchesKey(keyData, "right")) {
754
- this.#cycleSession(1);
755
- return;
756
- }
757
-
758
- // [ / ← / Shift+Tab — previous sub-agent session
759
- if (keyData === "[" || matchesKey(keyData, "shift+tab") || matchesKey(keyData, "left")) {
760
- this.#cycleSession(-1);
761
- return;
762
- }
763
- }
764
-
765
- /** Get the ordered list of sub-agent session IDs (excludes main) */
766
- #getSubagentSessionIds(): string[] {
767
- return this.#registry
768
- .getSessions()
769
- .filter(s => s.kind === "subagent")
770
- .map(s => s.id);
771
- }
772
-
773
- /** Cycle to next (+1) or previous (-1) sub-agent session */
774
- #cycleSession(direction: 1 | -1): void {
775
- const ids = this.#getSubagentSessionIds();
776
- if (ids.length <= 1) return;
777
- const currentIdx = ids.indexOf(this.#selectedSessionId ?? "");
778
- if (currentIdx < 0) return;
779
- const nextIdx = (currentIdx + direction + ids.length) % ids.length;
780
- this.#selectedSessionId = ids[nextIdx];
781
- this.#transcriptCache = undefined;
782
- this.#scrollOffset = 0;
783
- this.#selectedEntryIndex = 0;
784
- this.#expandedEntries.clear();
785
- this.#wasAtBottom = true;
786
- this.#rebuildViewerContent();
787
- // Auto-scroll to bottom: select last entry
788
- if (this.#viewerEntries.length > 0) {
789
- this.#selectedEntryIndex = this.#viewerEntries.length - 1;
790
- this.#wasAtBottom = true;
791
- this.#rebuildViewerContent();
792
- }
793
- }
794
-
795
- /** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
796
- #rebuildAndScroll(): void {
797
- // Resume auto-scrolling once selection returns to the last entry
798
- this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
799
- this.#rebuildViewerContent();
800
- this.#scrollToSelectedEntry();
801
- }
802
-
803
- #scrollToSelectedEntry(): void {
804
- if (this.#viewerEntries.length === 0) return;
805
- const entry = this.#viewerEntries[this.#selectedEntryIndex];
806
- if (!entry) return;
807
-
808
- const entryTop = entry.lineStart;
809
- const entryBottom = entry.lineStart + entry.lineCount;
810
-
811
- if (entry.lineCount >= this.#viewportHeight) {
812
- // Entry taller than viewport: only snap when it's completely out of view.
813
- // If the viewport overlaps the entry at all, the user may be paging within it.
814
- if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
815
- // Viewport is entirely above the entry — snap to entry top
816
- this.#scrollOffset = Math.max(0, entryTop - 1);
817
- } else if (this.#scrollOffset >= entryBottom) {
818
- // Viewport is entirely below the entry — snap to show entry bottom
819
- this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
820
- }
821
- // Otherwise: viewport overlaps the entry — don't override manual scroll
822
- } else {
823
- // Entry fits in viewport: ensure it's fully visible
824
- if (entryTop < this.#scrollOffset) {
825
- this.#scrollOffset = Math.max(0, entryTop - 1);
826
- }
827
- if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
828
- this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
829
- }
830
- }
831
- }
832
1053
  }
833
1054
 
834
- // Sync helpers for render path
835
- import * as fs from "node:fs";
836
-
1055
+ // Sync helper for the render path
837
1056
  function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
838
1057
  try {
839
1058
  const stat = fs.statSync(filePath);