@oh-my-pi/pi-coding-agent 15.10.11 → 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.
- package/CHANGELOG.md +103 -2
- package/dist/cli.js +5790 -5731
- package/dist/types/async/index.d.ts +0 -1
- package/dist/types/cli/args.d.ts +1 -0
- package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
- package/dist/types/cli-commands.d.ts +12 -0
- package/dist/types/commands/launch.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +3 -0
- package/dist/types/config/keybindings.d.ts +6 -1
- package/dist/types/config/model-registry.d.ts +1 -0
- package/dist/types/config/model-resolver.d.ts +18 -0
- package/dist/types/config/settings-schema.d.ts +85 -34
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
- package/dist/types/eval/py/executor.d.ts +5 -0
- package/dist/types/eval/py/kernel.d.ts +6 -1
- package/dist/types/eval/py/runtime.d.ts +9 -0
- package/dist/types/exec/bash-executor.d.ts +2 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/runner.d.ts +3 -2
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/extensibility/shared-events.d.ts +2 -2
- package/dist/types/internal-urls/history-protocol.d.ts +14 -0
- package/dist/types/internal-urls/index.d.ts +1 -0
- package/dist/types/internal-urls/types.d.ts +1 -1
- package/dist/types/irc/bus.d.ts +66 -0
- package/dist/types/memory-backend/index.d.ts +1 -0
- package/dist/types/memory-backend/runtime.d.ts +4 -0
- package/dist/types/memory-backend/types.d.ts +66 -1
- package/dist/types/modes/components/agent-hub.d.ts +30 -0
- package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
- package/dist/types/modes/components/custom-editor.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +8 -0
- package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
- package/dist/types/modes/components/welcome.d.ts +3 -9
- package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
- package/dist/types/modes/index.d.ts +3 -3
- package/dist/types/modes/interactive-mode.d.ts +10 -4
- package/dist/types/modes/oauth-manual-input.d.ts +7 -0
- package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
- package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
- package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
- package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
- package/dist/types/modes/setup-wizard/index.d.ts +5 -1
- package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +5 -2
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
- package/dist/types/registry/agent-lifecycle.d.ts +51 -0
- package/dist/types/registry/agent-registry.d.ts +16 -5
- package/dist/types/secrets/index.d.ts +1 -1
- package/dist/types/secrets/obfuscator.d.ts +8 -2
- package/dist/types/session/agent-session.d.ts +49 -32
- package/dist/types/session/messages.d.ts +2 -4
- package/dist/types/session/session-history-format.d.ts +12 -0
- package/dist/types/session/session-manager.d.ts +21 -3
- package/dist/types/session/streaming-output.d.ts +46 -0
- package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
- package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
- package/dist/types/slash-commands/types.d.ts +1 -1
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +12 -2
- package/dist/types/task/index.d.ts +13 -6
- package/dist/types/task/output-manager.d.ts +0 -7
- package/dist/types/task/repair-args.d.ts +8 -7
- package/dist/types/task/types.d.ts +63 -51
- package/dist/types/thinking.d.ts +4 -0
- package/dist/types/tiny/title-client.d.ts +11 -0
- package/dist/types/tiny/title-protocol.d.ts +1 -0
- package/dist/types/tools/browser/tab-worker.d.ts +3 -1
- package/dist/types/tools/find.d.ts +0 -11
- package/dist/types/tools/grouped-file-output.d.ts +0 -49
- package/dist/types/tools/index.d.ts +7 -3
- package/dist/types/tools/irc.d.ts +76 -38
- package/dist/types/tools/job.d.ts +7 -1
- package/dist/types/utils/git.d.ts +15 -2
- package/dist/types/utils/title-generator.d.ts +3 -2
- package/examples/extensions/with-deps/package.json +1 -0
- package/package.json +11 -10
- package/scripts/bundle-dist.ts +28 -19
- package/src/async/index.ts +0 -1
- package/src/auto-thinking/classifier.ts +1 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/gallery-cli.ts +1 -1
- package/src/cli/gallery-fixtures/agentic.ts +230 -115
- package/src/cli/gallery-fixtures/types.ts +5 -0
- package/src/cli-commands.ts +29 -0
- package/src/cli.ts +28 -15
- package/src/commands/launch.ts +4 -0
- package/src/commit/agentic/tools/analyze-file.ts +38 -19
- package/src/commit/model-selection.ts +3 -2
- package/src/config/api-key-resolver.ts +8 -6
- package/src/config/keybindings.ts +6 -1
- package/src/config/model-registry.ts +97 -30
- package/src/config/model-resolver.ts +60 -0
- package/src/config/settings-schema.ts +99 -55
- package/src/config/settings.ts +68 -3
- package/src/edit/hashline/execute.ts +39 -2
- package/src/edit/hashline/noop-loop-guard.ts +99 -0
- package/src/eval/__tests__/agent-bridge.test.ts +5 -3
- package/src/eval/agent-bridge.ts +3 -16
- package/src/eval/completion-bridge.ts +1 -0
- package/src/eval/js/shared/prelude.txt +1 -1
- package/src/eval/py/executor.ts +29 -7
- package/src/eval/py/index.ts +6 -1
- package/src/eval/py/kernel.ts +31 -11
- package/src/eval/py/prelude.py +5 -6
- package/src/eval/py/runtime.ts +37 -0
- package/src/exec/bash-executor.ts +82 -3
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +38 -13
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/get-commands-handler.ts +2 -1
- package/src/extensibility/extensions/runner.ts +6 -1
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/shared-events.ts +2 -2
- package/src/hindsight/bank.ts +17 -2
- package/src/internal-urls/docs-index.generated.ts +11 -11
- package/src/internal-urls/history-protocol.ts +113 -0
- package/src/internal-urls/index.ts +1 -0
- package/src/internal-urls/router.ts +3 -1
- package/src/internal-urls/types.ts +1 -1
- package/src/irc/bus.ts +292 -0
- package/src/main.ts +26 -66
- package/src/memories/index.ts +2 -0
- package/src/memory-backend/index.ts +1 -0
- package/src/memory-backend/local-backend.ts +9 -0
- package/src/memory-backend/off-backend.ts +9 -0
- package/src/memory-backend/runtime.ts +66 -0
- package/src/memory-backend/types.ts +81 -1
- package/src/mnemopi/backend.ts +151 -4
- package/src/modes/acp/acp-agent.ts +119 -11
- package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
- package/src/modes/components/assistant-message.ts +19 -21
- package/src/modes/components/compaction-summary-message.ts +68 -32
- package/src/modes/components/custom-editor.ts +10 -0
- package/src/modes/components/footer.ts +3 -1
- package/src/modes/components/status-line/component.ts +118 -34
- package/src/modes/components/tool-execution.ts +31 -1
- package/src/modes/components/ttsr-notification.ts +72 -30
- package/src/modes/components/welcome.ts +9 -33
- package/src/modes/controllers/command-controller.ts +1 -1
- package/src/modes/controllers/event-controller.ts +65 -0
- package/src/modes/controllers/extension-ui-controller.ts +8 -8
- package/src/modes/controllers/input-controller.ts +19 -2
- package/src/modes/controllers/mcp-command-controller.ts +38 -3
- package/src/modes/controllers/selector-controller.ts +21 -17
- package/src/modes/index.ts +3 -21
- package/src/modes/interactive-mode.ts +47 -22
- package/src/modes/oauth-manual-input.ts +30 -3
- package/src/modes/rpc/rpc-client.ts +154 -3
- package/src/modes/rpc/rpc-mode.ts +97 -12
- package/src/modes/rpc/rpc-subagents.ts +265 -0
- package/src/modes/rpc/rpc-types.ts +81 -1
- package/src/modes/setup-wizard/index.ts +12 -2
- package/src/modes/setup-wizard/lazy.ts +16 -0
- package/src/modes/theme/theme.ts +18 -5
- package/src/modes/types.ts +5 -5
- package/src/modes/utils/hotkeys-markdown.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +51 -49
- package/src/prompts/system/irc-incoming.md +3 -4
- package/src/prompts/system/orchestrate-notice.md +2 -2
- package/src/prompts/system/subagent-system-prompt.md +0 -5
- package/src/prompts/system/system-prompt.md +1 -0
- package/src/prompts/system/workflow-notice.md +2 -2
- package/src/prompts/tools/eval.md +3 -3
- package/src/prompts/tools/irc.md +29 -19
- package/src/prompts/tools/read.md +2 -2
- package/src/prompts/tools/task-summary.md +5 -16
- package/src/prompts/tools/task.md +38 -29
- package/src/registry/agent-lifecycle.ts +218 -0
- package/src/registry/agent-registry.ts +16 -5
- package/src/sdk.ts +37 -10
- package/src/secrets/index.ts +8 -1
- package/src/secrets/obfuscator.ts +39 -18
- package/src/session/agent-session.ts +422 -291
- package/src/session/messages.ts +11 -78
- package/src/session/session-history-format.ts +246 -0
- package/src/session/session-manager.ts +59 -5
- package/src/session/streaming-output.ts +226 -10
- package/src/slash-commands/acp-builtins.ts +24 -0
- package/src/slash-commands/builtin-registry.ts +20 -0
- package/src/slash-commands/types.ts +1 -1
- package/src/system-prompt.ts +14 -0
- package/src/task/executor.ts +851 -461
- package/src/task/index.ts +721 -796
- package/src/task/output-manager.ts +0 -11
- package/src/task/render.ts +148 -63
- package/src/task/repair-args.ts +21 -9
- package/src/task/types.ts +82 -66
- package/src/thinking.ts +7 -0
- package/src/tiny/title-client.ts +34 -5
- package/src/tiny/title-protocol.ts +1 -1
- package/src/tiny/worker.ts +6 -4
- package/src/tools/ask.ts +4 -2
- package/src/tools/bash.ts +61 -10
- package/src/tools/browser/tab-worker.ts +26 -7
- package/src/tools/browser.ts +28 -1
- package/src/tools/find.ts +2 -27
- package/src/tools/grouped-file-output.ts +1 -118
- package/src/tools/image-gen.ts +11 -4
- package/src/tools/index.ts +17 -13
- package/src/tools/inspect-image.ts +1 -0
- package/src/tools/irc.ts +596 -171
- package/src/tools/job.ts +41 -7
- package/src/tools/read.ts +57 -1
- package/src/tools/renderers.ts +2 -0
- package/src/tools/resolve.ts +4 -1
- package/src/utils/commit-message-generator.ts +1 -0
- package/src/utils/git.ts +267 -13
- package/src/utils/title-generator.ts +24 -5
- package/dist/types/async/support.d.ts +0 -2
- package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
- package/dist/types/task/simple-mode.d.ts +0 -8
- package/src/async/support.ts +0 -5
- package/src/task/simple-mode.ts +0 -27
|
@@ -1,30 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Agent Hub overlay component.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* One overlay, two views:
|
|
5
|
+
* - Table view: every registered agent except Main (Main IS the ambient
|
|
6
|
+
* chat), live from the global AgentRegistry — status, 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
|
-
*
|
|
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 {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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(
|
|
135
|
+
constructor(deps: AgentHubDeps) {
|
|
95
136
|
super();
|
|
96
|
-
this.#registry = registry;
|
|
97
|
-
this.#
|
|
98
|
-
this.#
|
|
99
|
-
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
/**
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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.#
|
|
174
|
+
return this.#view === "table" ? this.#renderTable(width) : this.#renderChat(width);
|
|
123
175
|
}
|
|
124
176
|
|
|
125
|
-
|
|
126
|
-
|
|
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.#
|
|
132
|
-
|
|
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.#
|
|
136
|
-
this.#rebuildViewerContent();
|
|
209
|
+
this.#rebuildChatContent();
|
|
137
210
|
}
|
|
211
|
+
this.#requestRender();
|
|
138
212
|
}
|
|
139
213
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
this.#
|
|
162
|
-
|
|
163
|
-
this.#
|
|
164
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
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
|
|
228
|
-
lines.push(` ${
|
|
447
|
+
for (const headerLine of this.#viewerHeaderLines) {
|
|
448
|
+
lines.push(` ${headerLine}`);
|
|
229
449
|
}
|
|
230
450
|
lines.push(...new DynamicBorder().render(width));
|
|
231
451
|
|
|
232
|
-
|
|
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
|
-
|
|
243
|
-
for (const row of
|
|
461
|
+
scrollView.setScrollOffset(this.#scrollOffset);
|
|
462
|
+
for (const row of scrollView.render(Math.max(1, width - 1))) lines.push(` ${row}`);
|
|
244
463
|
|
|
245
|
-
|
|
246
|
-
lines.push(
|
|
247
|
-
lines.push(
|
|
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
|
-
#
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
return
|
|
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(
|
|
266
|
-
const 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
|
|
569
|
-
|
|
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
|
|
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("
|
|
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
|
|
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);
|