@oh-my-pi/pi-coding-agent 14.4.3 → 14.4.4
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/package.json +7 -7
- package/src/config/settings-schema.ts +42 -0
- package/src/modes/components/session-observer-overlay.ts +635 -295
- package/src/modes/components/settings-defs.ts +1 -0
- package/src/modes/controllers/command-controller.ts +16 -5
- package/src/modes/controllers/selector-controller.ts +32 -19
- package/src/modes/interactive-mode.ts +10 -1
- package/src/modes/types.ts +1 -0
- package/src/session/agent-session.ts +9 -1
- package/src/session/session-manager.ts +13 -0
- package/src/session/session-storage.ts +4 -0
- package/src/slash-commands/builtin-registry.ts +8 -0
- package/src/web/search/index.ts +2 -2
- package/src/web/search/provider.ts +3 -0
- package/src/web/search/providers/searxng.ts +238 -0
- package/src/web/search/types.ts +3 -1
|
@@ -2,248 +2,276 @@
|
|
|
2
2
|
* Session observer overlay component.
|
|
3
3
|
*
|
|
4
4
|
* Picker mode: lists main + active subagent sessions with live status.
|
|
5
|
-
* Viewer mode: renders a
|
|
6
|
-
* by reading its JSONL session file — shows thinking, text, tool calls, results
|
|
5
|
+
* Viewer mode: renders a scrollable, interactive transcript of the selected subagent's session
|
|
6
|
+
* by reading its JSONL session file — shows thinking, text, tool calls, results
|
|
7
|
+
* with expand/collapse per entry and breadcrumb navigation for nested sub-agents.
|
|
7
8
|
*
|
|
8
9
|
* Lifecycle:
|
|
9
10
|
* - shortcut opens picker
|
|
10
11
|
* - Enter on a subagent -> viewer
|
|
11
|
-
* - shortcut while in viewer -> back to picker
|
|
12
|
-
* - Esc from viewer -> back to picker
|
|
12
|
+
* - shortcut while in viewer -> back to picker (or pop breadcrumb)
|
|
13
|
+
* - Esc from viewer -> back to picker (or pop breadcrumb)
|
|
13
14
|
* - Esc from picker -> close overlay
|
|
14
15
|
* - Enter on main session -> close overlay (jump back)
|
|
15
16
|
*/
|
|
16
|
-
import type {
|
|
17
|
-
import { Container, Markdown,
|
|
17
|
+
import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
|
|
18
|
+
import { Container, Markdown, type MarkdownTheme, matchesKey } from "@oh-my-pi/pi-tui";
|
|
18
19
|
import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
|
|
19
20
|
import type { KeyId } from "../../config/keybindings";
|
|
20
21
|
import type { SessionMessageEntry } from "../../session/session-manager";
|
|
21
22
|
import { parseSessionEntries } from "../../session/session-manager";
|
|
22
|
-
import { replaceTabs,
|
|
23
|
+
import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
|
|
23
24
|
import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
|
|
24
|
-
import { getMarkdownTheme,
|
|
25
|
+
import { getMarkdownTheme, theme } from "../theme/theme";
|
|
25
26
|
import { DynamicBorder } from "./dynamic-border";
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/** Max thinking characters
|
|
30
|
-
const
|
|
28
|
+
/** Max thinking characters in collapsed state */
|
|
29
|
+
const MAX_THINKING_CHARS_COLLAPSED = 200;
|
|
30
|
+
/** Max thinking characters in expanded state */
|
|
31
|
+
const MAX_THINKING_CHARS_EXPANDED = 4000;
|
|
31
32
|
/** Max tool args characters to display */
|
|
32
|
-
const MAX_TOOL_ARGS_CHARS =
|
|
33
|
-
/**
|
|
34
|
-
const
|
|
33
|
+
const MAX_TOOL_ARGS_CHARS = 500;
|
|
34
|
+
/** Lines per page for PageUp/PageDown */
|
|
35
|
+
const PAGE_SIZE = 15;
|
|
36
|
+
/** Left indent for content under entry headers */
|
|
37
|
+
const INDENT = " ";
|
|
38
|
+
|
|
39
|
+
/** Compute the max content width for the current terminal, accounting for indent and chrome. */
|
|
40
|
+
function contentWidth(indent = INDENT): number {
|
|
41
|
+
return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - indent.length - 2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
|
|
45
|
+
function sanitizeLine(text: string, maxWidth?: number): string {
|
|
46
|
+
return truncateToWidth(replaceTabs(text), maxWidth ?? contentWidth());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Represents a rendered entry in the viewer for selection/expand tracking */
|
|
50
|
+
interface ViewerEntry {
|
|
51
|
+
lineStart: number;
|
|
52
|
+
lineCount: number;
|
|
53
|
+
kind: "thinking" | "text" | "toolCall" | "user";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Breadcrumb item for nested session navigation */
|
|
57
|
+
interface BreadcrumbItem {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
label: string;
|
|
60
|
+
sessionFile: string;
|
|
61
|
+
}
|
|
35
62
|
|
|
36
63
|
export class SessionObserverOverlayComponent extends Container {
|
|
37
64
|
#registry: SessionObserverRegistry;
|
|
38
65
|
#onDone: () => void;
|
|
39
|
-
#mode: Mode = "picker";
|
|
40
|
-
#selectList: SelectList;
|
|
41
|
-
#viewerContainer: Container;
|
|
42
66
|
#selectedSessionId?: string;
|
|
43
67
|
#observeKeys: KeyId[];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
#
|
|
68
|
+
#transcriptCache?: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string };
|
|
69
|
+
|
|
70
|
+
// Scroll state
|
|
71
|
+
#scrollOffset = 0;
|
|
72
|
+
#renderedLines: string[] = [];
|
|
73
|
+
#viewportHeight = 20;
|
|
74
|
+
#wasAtBottom = true;
|
|
75
|
+
|
|
76
|
+
// Entry selection & expand/collapse
|
|
77
|
+
#viewerEntries: ViewerEntry[] = [];
|
|
78
|
+
#selectedEntryIndex = 0;
|
|
79
|
+
#expandedEntries = new Set<number>();
|
|
80
|
+
|
|
81
|
+
// Breadcrumb navigation
|
|
82
|
+
#navigationStack: BreadcrumbItem[] = [];
|
|
83
|
+
|
|
84
|
+
// Cached header/footer for viewer (rebuilt on refresh)
|
|
85
|
+
#viewerHeaderLines: string[] = [];
|
|
86
|
+
#viewerFooterLines: string[] = [];
|
|
87
|
+
// Markdown rendering
|
|
88
|
+
#mdTheme: MarkdownTheme = getMarkdownTheme();
|
|
48
89
|
|
|
49
90
|
constructor(registry: SessionObserverRegistry, onDone: () => void, observeKeys: KeyId[]) {
|
|
50
91
|
super();
|
|
51
92
|
this.#registry = registry;
|
|
52
93
|
this.#onDone = onDone;
|
|
53
94
|
this.#observeKeys = observeKeys;
|
|
54
|
-
this.#selectList = new SelectList([], 0, getSelectListTheme());
|
|
55
|
-
this.#viewerContainer = new Container();
|
|
56
|
-
|
|
57
|
-
this.#setupPicker();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
#setupPicker(): void {
|
|
61
|
-
this.#mode = "picker";
|
|
62
|
-
this.children = [];
|
|
63
95
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const items = this.#buildPickerItems();
|
|
69
|
-
this.#selectList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
|
|
70
|
-
|
|
71
|
-
this.#selectList.onSelect = item => {
|
|
72
|
-
if (item.value === "main") {
|
|
73
|
-
this.#onDone();
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
this.#selectedSessionId = item.value;
|
|
96
|
+
// Jump directly to the most recently active sub-agent
|
|
97
|
+
const mostRecent = this.#getMostRecentSubagent();
|
|
98
|
+
if (mostRecent) {
|
|
99
|
+
this.#selectedSessionId = mostRecent.id;
|
|
77
100
|
this.#setupViewer();
|
|
78
|
-
}
|
|
101
|
+
} else {
|
|
102
|
+
// No sub-agents — close immediately
|
|
103
|
+
queueMicrotask(() => this.#onDone());
|
|
104
|
+
}
|
|
105
|
+
}
|
|
79
106
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
107
|
+
/** Find the most recently updated sub-agent session (prefer active ones) */
|
|
108
|
+
#getMostRecentSubagent(): ObservableSession | undefined {
|
|
109
|
+
const sessions = this.#registry.getSessions().filter(s => s.kind === "subagent");
|
|
110
|
+
if (sessions.length === 0) return undefined;
|
|
111
|
+
// Prefer active sessions, then sort by lastUpdate descending
|
|
112
|
+
const active = sessions.filter(s => s.status === "active");
|
|
113
|
+
const pool = active.length > 0 ? active : sessions;
|
|
114
|
+
return pool.sort((a, b) => b.lastUpdate - a.lastUpdate)[0];
|
|
115
|
+
}
|
|
83
116
|
|
|
84
|
-
|
|
85
|
-
this
|
|
117
|
+
override render(width: number): string[] {
|
|
118
|
+
return this.#renderViewer(width);
|
|
86
119
|
}
|
|
87
120
|
|
|
88
121
|
#setupViewer(): void {
|
|
89
|
-
this.#mode = "viewer";
|
|
90
122
|
this.children = [];
|
|
91
|
-
this.#
|
|
92
|
-
this.#
|
|
93
|
-
this.#
|
|
94
|
-
|
|
95
|
-
this
|
|
96
|
-
|
|
97
|
-
this.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
123
|
+
this.#scrollOffset = 0;
|
|
124
|
+
this.#selectedEntryIndex = 0;
|
|
125
|
+
this.#expandedEntries.clear();
|
|
126
|
+
this.#wasAtBottom = true;
|
|
127
|
+
this.#rebuildViewerContent();
|
|
128
|
+
// Auto-scroll to bottom and select last entry on init
|
|
129
|
+
if (this.#viewerEntries.length > 0) {
|
|
130
|
+
this.#selectedEntryIndex = this.#viewerEntries.length - 1;
|
|
131
|
+
this.#wasAtBottom = true;
|
|
132
|
+
this.#rebuildViewerContent();
|
|
133
|
+
}
|
|
101
134
|
}
|
|
102
135
|
|
|
103
136
|
/** Rebuild content from live registry data */
|
|
104
137
|
refreshFromRegistry(): void {
|
|
105
|
-
if (this.#
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
this.#
|
|
138
|
+
if (this.#selectedSessionId) {
|
|
139
|
+
// Keep auto-scrolling to bottom unless the user navigated away from the last entry
|
|
140
|
+
this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
|
|
141
|
+
this.#rebuildViewerContent();
|
|
109
142
|
}
|
|
110
143
|
}
|
|
111
144
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
const items = this.#buildPickerItems();
|
|
117
|
-
const newList = new SelectList(items, Math.min(items.length, 12), getSelectListTheme());
|
|
118
|
-
newList.onSelect = this.#selectList.onSelect;
|
|
119
|
-
newList.onCancel = this.#selectList.onCancel;
|
|
145
|
+
/** Rebuild the transcript content lines (called on setup and refresh) */
|
|
146
|
+
#rebuildViewerContent(): void {
|
|
147
|
+
const sessions = this.#registry.getSessions();
|
|
148
|
+
const session = sessions.find(s => s.id === this.#selectedSessionId);
|
|
120
149
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
150
|
+
// Load transcript first so model info is available for header
|
|
151
|
+
let messageEntries: SessionMessageEntry[] | null = null;
|
|
152
|
+
if (session?.sessionFile) {
|
|
153
|
+
messageEntries = this.#loadTranscript(session.sessionFile);
|
|
124
154
|
}
|
|
125
155
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
156
|
+
// Header
|
|
157
|
+
this.#viewerHeaderLines = [];
|
|
158
|
+
const breadcrumb = this.#buildBreadcrumb(session);
|
|
159
|
+
this.#viewerHeaderLines.push(theme.fg("accent", breadcrumb));
|
|
160
|
+
if (session) {
|
|
161
|
+
const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
|
|
162
|
+
const statusText = theme.fg(statusColor, `[${session.status}]`);
|
|
163
|
+
const agentTag = session.agent ? theme.fg("dim", ` ${session.agent}`) : "";
|
|
164
|
+
const subagentIds = this.#getSubagentSessionIds();
|
|
165
|
+
const posIdx = subagentIds.indexOf(this.#selectedSessionId ?? "");
|
|
166
|
+
const posLabel =
|
|
167
|
+
subagentIds.length > 1 && posIdx >= 0 ? theme.fg("dim", ` (${posIdx + 1}/${subagentIds.length})`) : "";
|
|
168
|
+
const modelName = this.#transcriptCache?.model;
|
|
169
|
+
const modelLabel = modelName ? theme.fg("muted", ` · ${modelName}`) : "";
|
|
170
|
+
this.#viewerHeaderLines.push(`${theme.bold(session.label)} ${statusText}${agentTag}${posLabel}${modelLabel}`);
|
|
129
171
|
}
|
|
130
|
-
this.#selectList = newList;
|
|
131
|
-
}
|
|
132
172
|
|
|
133
|
-
|
|
134
|
-
|
|
173
|
+
// Content
|
|
174
|
+
const contentLines: string[] = [];
|
|
175
|
+
this.#viewerEntries = [];
|
|
135
176
|
|
|
136
|
-
const sessions = this.#registry.getSessions();
|
|
137
|
-
const session = sessions.find(s => s.id === this.#selectedSessionId);
|
|
138
177
|
if (!session) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
178
|
+
contentLines.push(theme.fg("dim", "Session no longer available."));
|
|
179
|
+
} else if (!session.sessionFile) {
|
|
180
|
+
contentLines.push(theme.fg("dim", "No session file available yet."));
|
|
181
|
+
} else if (!messageEntries) {
|
|
182
|
+
contentLines.push(theme.fg("dim", "Unable to read session file."));
|
|
183
|
+
} else if (messageEntries.length === 0) {
|
|
184
|
+
contentLines.push(theme.fg("dim", "No messages yet."));
|
|
185
|
+
} else {
|
|
186
|
+
this.#buildTranscriptLines(messageEntries, contentLines);
|
|
187
|
+
}
|
|
188
|
+
this.#renderedLines = contentLines;
|
|
189
|
+
|
|
190
|
+
// Footer
|
|
191
|
+
this.#viewerFooterLines = [];
|
|
192
|
+
const statsLine = this.#buildStatsLine(session);
|
|
193
|
+
if (statsLine) this.#viewerFooterLines.push(statsLine);
|
|
194
|
+
this.#viewerFooterLines.push(
|
|
195
|
+
theme.fg("dim", "j/k:scroll Enter:expand [/]/\u2190\u2192:cycle agents Esc/Ctrl+S:close g/G:top/bottom"),
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
// Auto-scroll to bottom if we were at bottom
|
|
199
|
+
if (this.#wasAtBottom) {
|
|
200
|
+
this.#scrollOffset = Math.max(0, contentLines.length - this.#viewportHeight);
|
|
142
201
|
}
|
|
143
|
-
|
|
144
|
-
this.#renderSessionHeader(session);
|
|
145
|
-
this.#renderSessionTranscript(session);
|
|
146
|
-
this.#updateStats(session);
|
|
147
202
|
}
|
|
148
203
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
// Header: label + status + [agent]
|
|
153
|
-
const statusColor = session.status === "active" ? "success" : session.status === "failed" ? "error" : "dim";
|
|
154
|
-
const statusText = theme.fg(statusColor, session.status);
|
|
155
|
-
const agentTag = session.agent ? theme.fg("dim", ` [${session.agent}]`) : "";
|
|
156
|
-
c.addChild(new Text(`${theme.bold(theme.fg("accent", session.label))} ${statusText}${agentTag}`, 1, 0));
|
|
204
|
+
/** Produce the final viewer output for the overlay system */
|
|
205
|
+
#renderViewer(width: number): string[] {
|
|
206
|
+
const termHeight = process.stdout.rows || 40;
|
|
157
207
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
208
|
+
// Compute viewport: total height minus header chrome and footer chrome
|
|
209
|
+
// Header: border(1) + headerLines + border(1) = headerLines.length + 2
|
|
210
|
+
// Footer: spacer(1) + scrollInfo(1) + footerLines + border(1) = footerLines.length + 2
|
|
211
|
+
const headerChrome = this.#viewerHeaderLines.length + 2;
|
|
212
|
+
const footerChrome = this.#viewerFooterLines.length + 2;
|
|
213
|
+
this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
|
|
161
214
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
215
|
+
// Clamp scroll offset
|
|
216
|
+
const maxScroll = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
|
|
217
|
+
this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
|
|
165
218
|
|
|
166
|
-
|
|
167
|
-
}
|
|
219
|
+
const lines: string[] = [];
|
|
168
220
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (!progress) {
|
|
174
|
-
this.#statsText.setText("");
|
|
175
|
-
return;
|
|
221
|
+
// --- Header ---
|
|
222
|
+
lines.push(...new DynamicBorder().render(width));
|
|
223
|
+
for (const hl of this.#viewerHeaderLines) {
|
|
224
|
+
lines.push(` ${hl}`);
|
|
176
225
|
}
|
|
177
|
-
|
|
178
|
-
if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`);
|
|
179
|
-
if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`);
|
|
180
|
-
if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
|
|
181
|
-
this.#statsText.setText(stats.length > 0 ? theme.fg("dim", stats.join(theme.sep.dot)) : "");
|
|
182
|
-
}
|
|
226
|
+
lines.push(...new DynamicBorder().render(width));
|
|
183
227
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
this.#transcriptCache = undefined;
|
|
228
|
+
// --- Scrolled content viewport ---
|
|
229
|
+
const visibleLines = this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight);
|
|
230
|
+
for (const vl of visibleLines) {
|
|
231
|
+
lines.push(` ${vl}`);
|
|
189
232
|
}
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
logger.debug("Session observer: failed to read session file", { path: sessionFile });
|
|
195
|
-
return this.#transcriptCache?.entries ?? null;
|
|
233
|
+
// Pad to fill viewport if content is shorter
|
|
234
|
+
const pad = this.#viewportHeight - visibleLines.length;
|
|
235
|
+
for (let i = 0; i < pad; i++) {
|
|
236
|
+
lines.push("");
|
|
196
237
|
}
|
|
197
238
|
|
|
198
|
-
//
|
|
199
|
-
|
|
200
|
-
this.#
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
239
|
+
// --- Footer ---
|
|
240
|
+
const scrollInfo =
|
|
241
|
+
this.#renderedLines.length > this.#viewportHeight
|
|
242
|
+
? ` ${theme.fg("dim", `[${this.#scrollOffset + 1}-${Math.min(this.#scrollOffset + this.#viewportHeight, this.#renderedLines.length)}/${this.#renderedLines.length}]`)}`
|
|
243
|
+
: "";
|
|
244
|
+
lines.push("");
|
|
245
|
+
lines.push(` ${this.#viewerFooterLines[0] ?? ""}${scrollInfo}`);
|
|
246
|
+
for (let i = 1; i < this.#viewerFooterLines.length; i++) {
|
|
247
|
+
lines.push(` ${this.#viewerFooterLines[i]}`);
|
|
206
248
|
}
|
|
249
|
+
lines.push(...new DynamicBorder().render(width));
|
|
207
250
|
|
|
208
|
-
|
|
209
|
-
// A partial trailing record (mid-write) must not be consumed —
|
|
210
|
-
// we leave those bytes for the next refresh.
|
|
211
|
-
if (result.text.length > 0) {
|
|
212
|
-
const lastNewline = result.text.lastIndexOf("\n");
|
|
213
|
-
if (lastNewline >= 0) {
|
|
214
|
-
const completeChunk = result.text.slice(0, lastNewline + 1);
|
|
215
|
-
const newEntries = parseSessionEntries(completeChunk);
|
|
216
|
-
for (const entry of newEntries) {
|
|
217
|
-
if (entry.type === "message") {
|
|
218
|
-
this.#transcriptCache.entries.push(entry as SessionMessageEntry);
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
222
|
-
}
|
|
223
|
-
// If no newline found, the entire chunk is partial — leave bytesRead unchanged
|
|
224
|
-
}
|
|
225
|
-
return this.#transcriptCache.entries;
|
|
251
|
+
return lines;
|
|
226
252
|
}
|
|
227
253
|
|
|
228
|
-
#
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
c.addChild(new Text(theme.fg("dim", "No session file available yet."), 1, 0));
|
|
233
|
-
return;
|
|
254
|
+
#buildBreadcrumb(session: ObservableSession | undefined): string {
|
|
255
|
+
const parts: string[] = ["Session Observer"];
|
|
256
|
+
for (const item of this.#navigationStack) {
|
|
257
|
+
parts.push(item.label);
|
|
234
258
|
}
|
|
259
|
+
if (session) parts.push(session.label);
|
|
260
|
+
return parts.join(" > ");
|
|
261
|
+
}
|
|
235
262
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
if (
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
263
|
+
#buildStatsLine(session: ObservableSession | undefined): string {
|
|
264
|
+
const progress = session?.progress;
|
|
265
|
+
if (!progress) return "";
|
|
266
|
+
const stats: string[] = [];
|
|
267
|
+
if (progress.toolCount > 0) stats.push(`${formatNumber(progress.toolCount)} tools`);
|
|
268
|
+
if (progress.tokens > 0) stats.push(`${formatNumber(progress.tokens)} tokens`);
|
|
269
|
+
if (progress.durationMs > 0) stats.push(formatDuration(progress.durationMs));
|
|
270
|
+
return stats.length > 0 ? theme.fg("dim", stats.join(theme.sep.dot)) : "";
|
|
271
|
+
}
|
|
245
272
|
|
|
246
|
-
|
|
273
|
+
#buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void {
|
|
274
|
+
// Build a tool call ID -> tool result map
|
|
247
275
|
const toolResults = new Map<string, ToolResultMessage>();
|
|
248
276
|
for (const entry of messageEntries) {
|
|
249
277
|
if (entry.message.role === "toolResult") {
|
|
@@ -251,13 +279,65 @@ export class SessionObserverOverlayComponent extends Container {
|
|
|
251
279
|
}
|
|
252
280
|
}
|
|
253
281
|
|
|
282
|
+
let entryIndex = 0;
|
|
254
283
|
for (const entry of messageEntries) {
|
|
255
284
|
const msg = entry.message;
|
|
256
285
|
|
|
257
286
|
if (msg.role === "assistant") {
|
|
258
|
-
|
|
287
|
+
// Handle error messages with empty content
|
|
288
|
+
if (msg.content.length === 0 && msg.errorMessage) {
|
|
289
|
+
const startLine = lines.length;
|
|
290
|
+
const isSelected = entryIndex === this.#selectedEntryIndex;
|
|
291
|
+
const cursor = isSelected ? theme.fg("accent", "▶") : " ";
|
|
292
|
+
lines.push("");
|
|
293
|
+
const errorLines = msg.errorMessage.split("\n");
|
|
294
|
+
const maxWidth = contentWidth();
|
|
295
|
+
lines.push(`${cursor} ${theme.fg("error", `✗ Error: ${sanitizeLine(errorLines[0], maxWidth)}`)}`);
|
|
296
|
+
for (let i = 1; i < errorLines.length; i++) {
|
|
297
|
+
lines.push(`${INDENT}${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
|
|
298
|
+
}
|
|
299
|
+
this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "text" });
|
|
300
|
+
entryIndex++;
|
|
301
|
+
} else {
|
|
302
|
+
for (const content of msg.content) {
|
|
303
|
+
if (content.type === "thinking" && content.thinking.trim()) {
|
|
304
|
+
const startLine = lines.length;
|
|
305
|
+
const isExpanded = this.#expandedEntries.has(entryIndex);
|
|
306
|
+
const isSelected = entryIndex === this.#selectedEntryIndex;
|
|
307
|
+
this.#renderThinkingLines(lines, content.thinking.trim(), isExpanded, isSelected);
|
|
308
|
+
this.#viewerEntries.push({
|
|
309
|
+
lineStart: startLine,
|
|
310
|
+
lineCount: lines.length - startLine,
|
|
311
|
+
kind: "thinking",
|
|
312
|
+
});
|
|
313
|
+
entryIndex++;
|
|
314
|
+
} else if (content.type === "text" && content.text.trim()) {
|
|
315
|
+
const startLine = lines.length;
|
|
316
|
+
const isExpanded = this.#expandedEntries.has(entryIndex);
|
|
317
|
+
const isSelected = entryIndex === this.#selectedEntryIndex;
|
|
318
|
+
this.#renderTextLines(lines, content.text.trim(), isExpanded, isSelected);
|
|
319
|
+
this.#viewerEntries.push({
|
|
320
|
+
lineStart: startLine,
|
|
321
|
+
lineCount: lines.length - startLine,
|
|
322
|
+
kind: "text",
|
|
323
|
+
});
|
|
324
|
+
entryIndex++;
|
|
325
|
+
} else if (content.type === "toolCall") {
|
|
326
|
+
const startLine = lines.length;
|
|
327
|
+
const isExpanded = this.#expandedEntries.has(entryIndex);
|
|
328
|
+
const isSelected = entryIndex === this.#selectedEntryIndex;
|
|
329
|
+
const result = toolResults.get(content.id);
|
|
330
|
+
this.#renderToolCallLines(lines, content, result, isExpanded, isSelected);
|
|
331
|
+
this.#viewerEntries.push({
|
|
332
|
+
lineStart: startLine,
|
|
333
|
+
lineCount: lines.length - startLine,
|
|
334
|
+
kind: "toolCall",
|
|
335
|
+
});
|
|
336
|
+
entryIndex++;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
259
340
|
} else if (msg.role === "user" || msg.role === "developer") {
|
|
260
|
-
// Show user/developer messages briefly
|
|
261
341
|
const text =
|
|
262
342
|
typeof msg.content === "string"
|
|
263
343
|
? msg.content
|
|
@@ -266,86 +346,176 @@ export class SessionObserverOverlayComponent extends Container {
|
|
|
266
346
|
.map(b => b.text)
|
|
267
347
|
.join("\n");
|
|
268
348
|
if (text.trim()) {
|
|
349
|
+
const startLine = lines.length;
|
|
350
|
+
const isSelected = entryIndex === this.#selectedEntryIndex;
|
|
351
|
+
const isExpanded = this.#expandedEntries.has(entryIndex);
|
|
269
352
|
const label = msg.role === "developer" ? "System" : "User";
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
353
|
+
const cursor = isSelected ? theme.fg("accent", "▶") : " ";
|
|
354
|
+
lines.push("");
|
|
355
|
+
if (isExpanded) {
|
|
356
|
+
lines.push(`${cursor} ${theme.fg("dim", `[${label}]`)}`);
|
|
357
|
+
const mdLines = this.#renderMarkdownToLines(text.trim());
|
|
358
|
+
for (const ml of mdLines) {
|
|
359
|
+
lines.push(ml);
|
|
360
|
+
}
|
|
361
|
+
} else {
|
|
362
|
+
const firstLine = text.trim().split("\n")[0];
|
|
363
|
+
const totalLines = text.trim().split("\n").length;
|
|
364
|
+
const hint = totalLines > 1 ? theme.fg("dim", ` (${totalLines} lines)`) : "";
|
|
365
|
+
lines.push(
|
|
366
|
+
`${cursor} ${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", sanitizeLine(firstLine, TRUNCATE_LENGTHS.TITLE))}${hint}`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "user" });
|
|
370
|
+
entryIndex++;
|
|
278
371
|
}
|
|
279
372
|
}
|
|
280
|
-
// toolResult entries are rendered inline with their tool calls above
|
|
281
373
|
}
|
|
282
374
|
}
|
|
283
375
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
376
|
+
/** Render markdown text into indented lines using the theme's markdown renderer */
|
|
377
|
+
#renderMarkdownToLines(text: string, indent: string = INDENT): string[] {
|
|
378
|
+
const width = Math.max(40, (process.stdout.columns || 80) - indent.length - 4);
|
|
379
|
+
const md = new Markdown(text, 0, 0, this.#mdTheme);
|
|
380
|
+
const rendered = md.render(width);
|
|
381
|
+
return rendered.map(line => `${indent}${line.trimEnd()}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#renderThinkingLines(lines: string[], thinking: string, expanded: boolean, selected: boolean): void {
|
|
385
|
+
const cursor = selected ? theme.fg("accent", "▶") : " ";
|
|
386
|
+
const maxChars = expanded ? MAX_THINKING_CHARS_EXPANDED : MAX_THINKING_CHARS_COLLAPSED;
|
|
387
|
+
const truncated = thinking.length > maxChars;
|
|
388
|
+
const expandLabel = !expanded && truncated ? theme.fg("dim", " ↵") : "";
|
|
389
|
+
|
|
390
|
+
lines.push("");
|
|
391
|
+
lines.push(`${cursor} ${theme.fg("dim", "💭 Thinking")}${expandLabel}`);
|
|
392
|
+
|
|
393
|
+
const displayText = truncated ? `${thinking.slice(0, maxChars)}...` : thinking;
|
|
394
|
+
if (expanded) {
|
|
395
|
+
// Expanded thinking: render as markdown for readable formatting
|
|
396
|
+
const mdLines = this.#renderMarkdownToLines(displayText);
|
|
397
|
+
const maxLines = 100;
|
|
398
|
+
for (let i = 0; i < Math.min(mdLines.length, maxLines); i++) {
|
|
399
|
+
lines.push(mdLines[i]);
|
|
400
|
+
}
|
|
401
|
+
if (mdLines.length > maxLines) {
|
|
402
|
+
lines.push(`${INDENT}${theme.fg("dim", `... ${mdLines.length - maxLines} more lines`)}`);
|
|
403
|
+
}
|
|
404
|
+
} else {
|
|
405
|
+
// Collapsed thinking: brief italic preview
|
|
406
|
+
const thinkingLines = displayText.split("\n");
|
|
407
|
+
const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
|
|
408
|
+
for (let i = 0; i < Math.min(thinkingLines.length, maxLines); i++) {
|
|
409
|
+
lines.push(`${INDENT}${theme.fg("thinkingText", sanitizeLine(thinkingLines[i]))}`);
|
|
410
|
+
}
|
|
411
|
+
if (thinkingLines.length > maxLines) {
|
|
412
|
+
lines.push(`${INDENT}${theme.fg("dim", `... ${thinkingLines.length - maxLines} more lines`)}`);
|
|
311
413
|
}
|
|
312
414
|
}
|
|
313
415
|
}
|
|
314
416
|
|
|
315
|
-
#
|
|
316
|
-
|
|
417
|
+
#renderTextLines(lines: string[], text: string, expanded: boolean, selected: boolean): void {
|
|
418
|
+
const cursor = selected ? theme.fg("accent", "▶") : " ";
|
|
419
|
+
|
|
420
|
+
lines.push("");
|
|
421
|
+
lines.push(`${cursor} ${theme.fg("muted", "Response")}`);
|
|
422
|
+
|
|
423
|
+
if (expanded) {
|
|
424
|
+
// Expanded: full markdown rendering
|
|
425
|
+
const mdLines = this.#renderMarkdownToLines(text);
|
|
426
|
+
for (const ml of mdLines) {
|
|
427
|
+
lines.push(ml);
|
|
428
|
+
}
|
|
429
|
+
} else {
|
|
430
|
+
// Collapsed: first few lines as plain text
|
|
431
|
+
const textLines = text.split("\n");
|
|
432
|
+
const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
|
|
433
|
+
const maxWidth = contentWidth();
|
|
434
|
+
for (let i = 0; i < Math.min(textLines.length, maxLines); i++) {
|
|
435
|
+
lines.push(`${INDENT}${sanitizeLine(textLines[i], maxWidth)}`);
|
|
436
|
+
}
|
|
437
|
+
if (textLines.length > maxLines) {
|
|
438
|
+
lines.push(`${INDENT}${theme.fg("dim", `... ${textLines.length - maxLines} more lines`)}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
#renderToolCallLines(
|
|
444
|
+
lines: string[],
|
|
317
445
|
call: { id: string; name: string; arguments: Record<string, unknown>; intent?: string },
|
|
318
|
-
|
|
446
|
+
result: ToolResultMessage | undefined,
|
|
447
|
+
expanded: boolean,
|
|
448
|
+
selected: boolean,
|
|
319
449
|
): void {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
c.addChild(new Text(` ${theme.fg("dim", argSummary)}`, 1, 0));
|
|
332
|
-
}
|
|
450
|
+
const cursor = selected ? theme.fg("accent", "▶") : " ";
|
|
451
|
+
lines.push("");
|
|
452
|
+
|
|
453
|
+
// Tool call header
|
|
454
|
+
const intentStr = call.intent ? theme.fg("dim", ` ${sanitizeLine(call.intent, TRUNCATE_LENGTHS.SHORT)}`) : "";
|
|
455
|
+
lines.push(`${cursor} ${theme.fg("accent", "\u25B8")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
|
|
456
|
+
|
|
457
|
+
// Key arguments
|
|
458
|
+
const argSummary = this.#formatToolArgs(call.name, call.arguments);
|
|
459
|
+
if (argSummary) {
|
|
460
|
+
lines.push(`${INDENT}${theme.fg("dim", sanitizeLine(argSummary, contentWidth()))}`);
|
|
333
461
|
}
|
|
334
462
|
|
|
335
|
-
//
|
|
336
|
-
const result = toolResults.get(call.id);
|
|
463
|
+
// Tool result
|
|
337
464
|
if (result) {
|
|
338
|
-
this.#
|
|
465
|
+
this.#renderToolResultLines(lines, result, expanded);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
#renderToolResultLines(lines: string[], result: ToolResultMessage, expanded: boolean): void {
|
|
470
|
+
const textParts = result.content
|
|
471
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
472
|
+
.map(p => p.text);
|
|
473
|
+
const text = textParts.join("\n").trim();
|
|
474
|
+
|
|
475
|
+
if (result.isError) {
|
|
476
|
+
const errorLines = text.split("\n");
|
|
477
|
+
const maxErrorLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
|
|
478
|
+
const maxWidth = contentWidth();
|
|
479
|
+
lines.push(`${INDENT}${theme.fg("error", `✗ ${sanitizeLine(errorLines[0] || "Error", maxWidth)}`)}`);
|
|
480
|
+
for (let i = 1; i < Math.min(errorLines.length, maxErrorLines); i++) {
|
|
481
|
+
lines.push(`${INDENT} ${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
|
|
482
|
+
}
|
|
483
|
+
if (errorLines.length > maxErrorLines) {
|
|
484
|
+
lines.push(`${INDENT} ${theme.fg("dim", `... ${errorLines.length - maxErrorLines} more lines`)}`);
|
|
485
|
+
}
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (!text) {
|
|
490
|
+
lines.push(`${INDENT}${theme.fg("dim", "✓ done")}`);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const resultLines = text.split("\n");
|
|
495
|
+
const maxLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.OUTPUT_COLLAPSED;
|
|
496
|
+
|
|
497
|
+
// Status line
|
|
498
|
+
const statusPrefix = `${INDENT}${theme.fg("success", "✓")}`;
|
|
499
|
+
|
|
500
|
+
if (resultLines.length === 1 && text.length < TRUNCATE_LENGTHS.LONG) {
|
|
501
|
+
lines.push(`${statusPrefix} ${theme.fg("dim", sanitizeLine(text))}`);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
lines.push(`${statusPrefix} ${theme.fg("dim", `${resultLines.length} lines`)}`);
|
|
506
|
+
const displayLines = resultLines.slice(0, maxLines);
|
|
507
|
+
for (const rl of displayLines) {
|
|
508
|
+
lines.push(`${INDENT} ${theme.fg("dim", sanitizeLine(rl))}`);
|
|
509
|
+
}
|
|
510
|
+
if (resultLines.length > maxLines) {
|
|
511
|
+
lines.push(`${INDENT} ${theme.fg("dim", `... ${resultLines.length - maxLines} more`)}`);
|
|
339
512
|
}
|
|
340
513
|
}
|
|
341
514
|
|
|
342
515
|
#formatToolArgs(toolName: string, args: Record<string, unknown>): string {
|
|
343
|
-
// Show the most relevant arg for common tools
|
|
344
516
|
switch (toolName) {
|
|
345
517
|
case "read":
|
|
346
|
-
return args.path ? `path: ${args.path}` : "";
|
|
347
518
|
case "write":
|
|
348
|
-
return args.path ? `path: ${args.path}` : "";
|
|
349
519
|
case "edit":
|
|
350
520
|
return args.path ? `path: ${args.path}` : "";
|
|
351
521
|
case "grep":
|
|
@@ -356,10 +526,7 @@ export class SessionObserverOverlayComponent extends Container {
|
|
|
356
526
|
return args.pattern ? `pattern: ${args.pattern}` : "";
|
|
357
527
|
case "bash": {
|
|
358
528
|
const cmd = args.command;
|
|
359
|
-
|
|
360
|
-
return truncateToWidth(replaceTabs(cmd), 70);
|
|
361
|
-
}
|
|
362
|
-
return "";
|
|
529
|
+
return typeof cmd === "string" ? replaceTabs(cmd) : "";
|
|
363
530
|
}
|
|
364
531
|
case "lsp":
|
|
365
532
|
return [args.action, args.file, args.symbol].filter(Boolean).join(" ");
|
|
@@ -368,19 +535,15 @@ export class SessionObserverOverlayComponent extends Container {
|
|
|
368
535
|
return args.path ? `path: ${args.path}` : "";
|
|
369
536
|
case "task": {
|
|
370
537
|
const tasks = args.tasks;
|
|
371
|
-
|
|
372
|
-
return `${tasks.length} task(s)`;
|
|
373
|
-
}
|
|
374
|
-
return "";
|
|
538
|
+
return Array.isArray(tasks) ? `${tasks.length} task(s)` : "";
|
|
375
539
|
}
|
|
376
540
|
default: {
|
|
377
|
-
// Generic: show first few args truncated
|
|
378
541
|
const parts: string[] = [];
|
|
379
542
|
let total = 0;
|
|
380
543
|
for (const [key, value] of Object.entries(args)) {
|
|
381
544
|
if (key.startsWith("_")) continue;
|
|
382
545
|
const v = typeof value === "string" ? value : JSON.stringify(value);
|
|
383
|
-
const entry = `${key}: ${
|
|
546
|
+
const entry = `${key}: ${replaceTabs(v ?? "")}`;
|
|
384
547
|
if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
|
|
385
548
|
parts.push(entry);
|
|
386
549
|
total += entry.length;
|
|
@@ -390,79 +553,256 @@ export class SessionObserverOverlayComponent extends Container {
|
|
|
390
553
|
}
|
|
391
554
|
}
|
|
392
555
|
|
|
393
|
-
#
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const text = textParts.join("\n").trim();
|
|
556
|
+
#loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
|
|
557
|
+
if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) {
|
|
558
|
+
this.#transcriptCache = undefined;
|
|
559
|
+
}
|
|
398
560
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
const lines = text.split("\n");
|
|
405
|
-
if (lines.length === 1 && text.length < MAX_TOOL_RESULT_CHARS) {
|
|
406
|
-
c.addChild(new Text(` ${theme.fg("dim", `✓ ${truncateToWidth(replaceTabs(text), 70)}`)}`, 1, 0));
|
|
407
|
-
} else {
|
|
408
|
-
c.addChild(new Text(` ${theme.fg("dim", `✓ ${lines.length} lines`)}`, 1, 0));
|
|
409
|
-
}
|
|
410
|
-
} else {
|
|
411
|
-
c.addChild(new Text(` ${theme.fg("dim", "✓ done")}`, 1, 0));
|
|
561
|
+
const fromByte = this.#transcriptCache?.bytesRead ?? 0;
|
|
562
|
+
const result = readFileIncremental(sessionFile, fromByte);
|
|
563
|
+
if (!result) {
|
|
564
|
+
logger.debug("Session observer: failed to read session file", { path: sessionFile });
|
|
565
|
+
return this.#transcriptCache?.entries ?? null;
|
|
412
566
|
}
|
|
413
|
-
}
|
|
414
567
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
568
|
+
if (result.newSize < fromByte) {
|
|
569
|
+
this.#transcriptCache = undefined;
|
|
570
|
+
return this.#loadTranscript(sessionFile);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (!this.#transcriptCache) {
|
|
574
|
+
this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (result.text.length > 0) {
|
|
578
|
+
const lastNewline = result.text.lastIndexOf("\n");
|
|
579
|
+
if (lastNewline >= 0) {
|
|
580
|
+
const completeChunk = result.text.slice(0, lastNewline + 1);
|
|
581
|
+
const newEntries = parseSessionEntries(completeChunk);
|
|
582
|
+
for (const entry of newEntries) {
|
|
583
|
+
if (entry.type === "message") {
|
|
584
|
+
this.#transcriptCache.entries.push(entry);
|
|
585
|
+
// Extract model from first assistant message
|
|
586
|
+
const msg = entry.message;
|
|
587
|
+
if (!this.#transcriptCache.model && msg.role === "assistant") {
|
|
588
|
+
this.#transcriptCache.model = msg.model;
|
|
589
|
+
}
|
|
590
|
+
} else if (entry.type === "model_change") {
|
|
591
|
+
this.#transcriptCache.model = entry.model;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
|
|
430
595
|
}
|
|
596
|
+
}
|
|
597
|
+
return this.#transcriptCache.entries;
|
|
598
|
+
}
|
|
431
599
|
|
|
432
|
-
|
|
433
|
-
|
|
600
|
+
#navigateBack(): boolean {
|
|
601
|
+
if (this.#navigationStack.length === 0) return false;
|
|
602
|
+
const prev = this.#navigationStack.pop()!;
|
|
603
|
+
this.#selectedSessionId = prev.sessionId;
|
|
604
|
+
this.#transcriptCache = undefined;
|
|
605
|
+
this.#scrollOffset = 0;
|
|
606
|
+
this.#selectedEntryIndex = 0;
|
|
607
|
+
this.#expandedEntries.clear();
|
|
608
|
+
this.#rebuildViewerContent();
|
|
609
|
+
return true;
|
|
434
610
|
}
|
|
435
611
|
|
|
436
612
|
handleInput(keyData: string): void {
|
|
613
|
+
// Ctrl+S (observe key) always closes the overlay
|
|
437
614
|
for (const key of this.#observeKeys) {
|
|
438
615
|
if (matchesKey(keyData, key)) {
|
|
439
|
-
if (this.#mode === "viewer") {
|
|
440
|
-
this.#setupPicker();
|
|
441
|
-
return;
|
|
442
|
-
}
|
|
443
616
|
this.#onDone();
|
|
444
617
|
return;
|
|
445
618
|
}
|
|
446
619
|
}
|
|
447
620
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
621
|
+
this.#handleViewerInput(keyData);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
#handleViewerInput(keyData: string): void {
|
|
625
|
+
const entryCount = this.#viewerEntries.length;
|
|
626
|
+
|
|
627
|
+
// Escape — pop breadcrumb navigation or close overlay
|
|
628
|
+
if (matchesKey(keyData, "escape")) {
|
|
629
|
+
if (!this.#navigateBack()) {
|
|
630
|
+
this.#onDone();
|
|
631
|
+
}
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// j / down — move selection down
|
|
636
|
+
if (keyData === "j" || matchesKey(keyData, "down")) {
|
|
637
|
+
if (entryCount > 0) {
|
|
638
|
+
this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
|
|
639
|
+
}
|
|
640
|
+
this.#rebuildAndScroll();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// k / up — move selection up
|
|
645
|
+
if (keyData === "k" || matchesKey(keyData, "up")) {
|
|
646
|
+
if (entryCount > 0) {
|
|
647
|
+
this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
|
|
648
|
+
}
|
|
649
|
+
this.#rebuildAndScroll();
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Page Down
|
|
654
|
+
if (matchesKey(keyData, "pageDown")) {
|
|
655
|
+
if (entryCount > 0) {
|
|
656
|
+
const prevIndex = this.#selectedEntryIndex;
|
|
657
|
+
this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
|
|
658
|
+
// If selection didn't move (bottom of list or single oversized entry), fall back to line scroll
|
|
659
|
+
if (this.#selectedEntryIndex === prevIndex) {
|
|
660
|
+
this.#scrollOffset = Math.min(
|
|
661
|
+
this.#scrollOffset + PAGE_SIZE,
|
|
662
|
+
Math.max(0, this.#renderedLines.length - this.#viewportHeight),
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
} else {
|
|
666
|
+
this.#scrollOffset = Math.min(
|
|
667
|
+
this.#scrollOffset + PAGE_SIZE,
|
|
668
|
+
Math.max(0, this.#renderedLines.length - this.#viewportHeight),
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
this.#rebuildAndScroll();
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Page Up
|
|
676
|
+
if (matchesKey(keyData, "pageUp")) {
|
|
677
|
+
if (entryCount > 0) {
|
|
678
|
+
const prevIndex = this.#selectedEntryIndex;
|
|
679
|
+
this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
|
|
680
|
+
// If selection didn't move (top of list or single oversized entry), fall back to line scroll
|
|
681
|
+
if (this.#selectedEntryIndex === prevIndex) {
|
|
682
|
+
this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
|
|
686
|
+
}
|
|
687
|
+
this.#rebuildAndScroll();
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Enter — toggle expand/collapse, or dive into nested session
|
|
692
|
+
if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
|
|
693
|
+
if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
|
|
694
|
+
// Toggle expand/collapse
|
|
695
|
+
if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
|
|
696
|
+
this.#expandedEntries.delete(this.#selectedEntryIndex);
|
|
697
|
+
} else {
|
|
698
|
+
this.#expandedEntries.add(this.#selectedEntryIndex);
|
|
699
|
+
}
|
|
700
|
+
this.#rebuildAndScroll();
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// G — jump to bottom
|
|
706
|
+
if (keyData === "G") {
|
|
707
|
+
if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
|
|
708
|
+
this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
|
|
709
|
+
this.#rebuildAndScroll();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// g — jump to top
|
|
714
|
+
if (keyData === "g") {
|
|
715
|
+
this.#selectedEntryIndex = 0;
|
|
716
|
+
this.#scrollOffset = 0;
|
|
717
|
+
this.#rebuildAndScroll();
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ] / → / Tab — next sub-agent session
|
|
722
|
+
if (keyData === "]" || matchesKey(keyData, "tab") || matchesKey(keyData, "right")) {
|
|
723
|
+
this.#cycleSession(1);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// [ / ← / Shift+Tab — previous sub-agent session
|
|
728
|
+
if (keyData === "[" || matchesKey(keyData, "shift+tab") || matchesKey(keyData, "left")) {
|
|
729
|
+
this.#cycleSession(-1);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** Get the ordered list of sub-agent session IDs (excludes main) */
|
|
735
|
+
#getSubagentSessionIds(): string[] {
|
|
736
|
+
return this.#registry
|
|
737
|
+
.getSessions()
|
|
738
|
+
.filter(s => s.kind === "subagent")
|
|
739
|
+
.map(s => s.id);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/** Cycle to next (+1) or previous (-1) sub-agent session */
|
|
743
|
+
#cycleSession(direction: 1 | -1): void {
|
|
744
|
+
const ids = this.#getSubagentSessionIds();
|
|
745
|
+
if (ids.length <= 1) return;
|
|
746
|
+
const currentIdx = ids.indexOf(this.#selectedSessionId ?? "");
|
|
747
|
+
if (currentIdx < 0) return;
|
|
748
|
+
const nextIdx = (currentIdx + direction + ids.length) % ids.length;
|
|
749
|
+
this.#selectedSessionId = ids[nextIdx];
|
|
750
|
+
this.#transcriptCache = undefined;
|
|
751
|
+
this.#scrollOffset = 0;
|
|
752
|
+
this.#selectedEntryIndex = 0;
|
|
753
|
+
this.#expandedEntries.clear();
|
|
754
|
+
this.#wasAtBottom = true;
|
|
755
|
+
this.#rebuildViewerContent();
|
|
756
|
+
// Auto-scroll to bottom: select last entry
|
|
757
|
+
if (this.#viewerEntries.length > 0) {
|
|
758
|
+
this.#selectedEntryIndex = this.#viewerEntries.length - 1;
|
|
759
|
+
this.#wasAtBottom = true;
|
|
760
|
+
this.#rebuildViewerContent();
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
|
|
765
|
+
#rebuildAndScroll(): void {
|
|
766
|
+
// Resume auto-scrolling once selection returns to the last entry
|
|
767
|
+
this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
|
|
768
|
+
this.#rebuildViewerContent();
|
|
769
|
+
this.#scrollToSelectedEntry();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
#scrollToSelectedEntry(): void {
|
|
773
|
+
if (this.#viewerEntries.length === 0) return;
|
|
774
|
+
const entry = this.#viewerEntries[this.#selectedEntryIndex];
|
|
775
|
+
if (!entry) return;
|
|
776
|
+
|
|
777
|
+
const entryTop = entry.lineStart;
|
|
778
|
+
const entryBottom = entry.lineStart + entry.lineCount;
|
|
779
|
+
|
|
780
|
+
if (entry.lineCount >= this.#viewportHeight) {
|
|
781
|
+
// Entry taller than viewport: only snap when it's completely out of view.
|
|
782
|
+
// If the viewport overlaps the entry at all, the user may be paging within it.
|
|
783
|
+
if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
|
|
784
|
+
// Viewport is entirely above the entry — snap to entry top
|
|
785
|
+
this.#scrollOffset = Math.max(0, entryTop - 1);
|
|
786
|
+
} else if (this.#scrollOffset >= entryBottom) {
|
|
787
|
+
// Viewport is entirely below the entry — snap to show entry bottom
|
|
788
|
+
this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
|
|
789
|
+
}
|
|
790
|
+
// Otherwise: viewport overlaps the entry — don't override manual scroll
|
|
791
|
+
} else {
|
|
792
|
+
// Entry fits in viewport: ensure it's fully visible
|
|
793
|
+
if (entryTop < this.#scrollOffset) {
|
|
794
|
+
this.#scrollOffset = Math.max(0, entryTop - 1);
|
|
795
|
+
}
|
|
796
|
+
if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
|
|
797
|
+
this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
|
|
454
798
|
}
|
|
455
799
|
}
|
|
456
800
|
}
|
|
457
801
|
}
|
|
458
802
|
|
|
459
|
-
// Sync helpers for render path
|
|
803
|
+
// Sync helpers for render path
|
|
460
804
|
import * as fs from "node:fs";
|
|
461
805
|
|
|
462
|
-
/**
|
|
463
|
-
* Read new bytes from a file starting at the given byte offset.
|
|
464
|
-
* Returns the new text and updated file size, or null on error.
|
|
465
|
-
*/
|
|
466
806
|
function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
|
|
467
807
|
try {
|
|
468
808
|
const stat = fs.statSync(filePath);
|