@esso0428/pi-subagents 0.15.14 → 0.15.16
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 +9 -32
- package/dist/ui/agent-widget.d.ts +8 -0
- package/dist/ui/agent-widget.d.ts.map +1 -1
- package/dist/ui/agent-widget.js +40 -12
- package/dist/ui/agent-widget.js.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.js +2 -4
- package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.js +4 -10
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-result.d.ts +9 -16
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-result.js +71 -121
- package/dist/ui/ccstyle/tool-result.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +14 -9
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +60 -72
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +4 -1
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +95 -94
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/dist/write-execution.d.ts +0 -2
- package/dist/write-execution.d.ts.map +1 -1
- package/dist/write-execution.js +1 -5
- package/dist/write-execution.js.map +1 -1
- package/package.json +1 -1
- package/src/ui/agent-widget.ts +106 -20
- package/src/ui/ccstyle/diff/diff-renderer.ts +2 -7
- package/src/ui/ccstyle/tool-renderer.ts +9 -22
- package/src/ui/ccstyle/tool-result.ts +82 -186
- package/src/ui/conversation-blocks.ts +13 -11
- package/src/ui/conversation-timeline.ts +211 -122
- package/src/ui/conversation-viewer.ts +149 -125
- package/src/ui/fleet-list.ts +34 -1
- package/src/write-execution.ts +1 -7
- package/test/ui/agent-widget.test.ts +161 -0
- package/test/ui/conversation-diff.test.ts +1 -47
- package/test/ui/conversation-viewer.test.ts +181 -112
- package/test/ui/fleet-list.test.ts +5 -1
- package/test/write-execution.test.ts +0 -2
- package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +0 -53
- package/.pi/workflows/index.json +0 -5
|
@@ -13,7 +13,7 @@ import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
|
|
|
13
13
|
import type { Theme } from "./agent-widget.js";
|
|
14
14
|
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
|
|
15
15
|
import { formatConversationMessages, type ConversationBlock } from "./conversation-blocks.js";
|
|
16
|
-
import { ConversationTimeline, type TimelineRenderLine } from "./conversation-timeline.js";
|
|
16
|
+
import { ConversationTimeline, type ConversationTimelineChange, type TimelineRenderLine } from "./conversation-timeline.js";
|
|
17
17
|
import { renderConversationRoleHeader } from "./conversation-role.js";
|
|
18
18
|
import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
|
|
19
19
|
import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
|
|
@@ -22,40 +22,61 @@ import { createViewerCcstyleResult } from "./ccstyle/tool-result.js";
|
|
|
22
22
|
|
|
23
23
|
/** Base lines consumed by the overlay: borders, header, separator and footer. */
|
|
24
24
|
const CHROME_LINES_BASE = 6;
|
|
25
|
+
const MIN_VIEWPORT = 3;
|
|
26
|
+
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
27
|
+
const MAX_COPY_CHARS = 500_000;
|
|
28
|
+
const MATCH_BG = "\x1b[48;5;238m";
|
|
29
|
+
const MATCH_BG_CURRENT = "\x1b[48;5;220m\x1b[30m";
|
|
30
|
+
const BG_RESET = "\x1b[49m\x1b[39m";
|
|
31
|
+
|
|
32
|
+
/** The live fields needed by the viewer; historical viewers use a static source. */
|
|
33
|
+
export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
|
|
34
|
+
|
|
35
|
+
export function createStaticConversationSource(
|
|
36
|
+
messages: AgentSession["messages"],
|
|
37
|
+
): ConversationSource {
|
|
38
|
+
return { messages, subscribe: () => () => {} };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type RenderLine = TimelineRenderLine;
|
|
42
|
+
|
|
25
43
|
const FULL_TOOL_PREVIEW_MAX_CHARS = 500_000;
|
|
26
44
|
|
|
27
45
|
class FullToolPreview implements Component {
|
|
28
|
-
private readonly body: Component;
|
|
29
46
|
private scrollOffset = 0;
|
|
30
47
|
private pageSize = 1;
|
|
31
48
|
private totalLines = 1;
|
|
49
|
+
private pendingTop = false;
|
|
32
50
|
private hoveredClose = false;
|
|
33
51
|
|
|
34
52
|
constructor(
|
|
35
53
|
private readonly tui: TUI,
|
|
36
54
|
private readonly theme: Theme,
|
|
37
55
|
private readonly title: string,
|
|
38
|
-
body: Component,
|
|
56
|
+
private readonly body: Component,
|
|
39
57
|
private readonly done: (result: undefined) => void,
|
|
40
|
-
) {
|
|
41
|
-
this.body = body;
|
|
42
|
-
}
|
|
58
|
+
) {}
|
|
43
59
|
|
|
44
60
|
handleInput(data: string): void {
|
|
45
61
|
if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
|
|
46
62
|
this.done(undefined);
|
|
47
63
|
return;
|
|
48
64
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this.scrollTo(this.scrollOffset + delta);
|
|
65
|
+
if (data === "g") {
|
|
66
|
+
if (this.pendingTop) this.scrollTo(0);
|
|
67
|
+
this.pendingTop = true;
|
|
68
|
+
return;
|
|
54
69
|
}
|
|
70
|
+
const delta = data === "j" || matchesKey(data, Key.down) ? 1 :
|
|
71
|
+
data === "k" || matchesKey(data, Key.up) ? -1 :
|
|
72
|
+
matchesKey(data, "pageUp") ? -this.pageSize : matchesKey(data, "pageDown") ? this.pageSize :
|
|
73
|
+
matchesKey(data, Key.home) ? -this.totalLines : data === "G" || matchesKey(data, Key.end) ? this.totalLines : 0;
|
|
74
|
+
this.pendingTop = false;
|
|
75
|
+
if (delta !== 0) this.scrollTo(this.scrollOffset + delta);
|
|
55
76
|
}
|
|
56
77
|
|
|
57
78
|
handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
|
|
58
|
-
const closeHit = event.y === 1 && event.x >= Math.max(0, event.width - 7) && event.x < event.width;
|
|
79
|
+
const closeHit = event.y === 1 && event.x >= Math.max(0, event.width - 7) && event.x < Math.max(0, event.width - 1);
|
|
59
80
|
if (event.type === "move") {
|
|
60
81
|
if (closeHit === this.hoveredClose) return undefined;
|
|
61
82
|
this.hoveredClose = closeHit;
|
|
@@ -92,8 +113,8 @@ class FullToolPreview implements Component {
|
|
|
92
113
|
return clipped + " ".repeat(Math.max(0, rowWidth - visibleWidth(clipped)));
|
|
93
114
|
};
|
|
94
115
|
const close = this.theme.fg(this.hoveredClose ? "text" : "dim", "[esc]");
|
|
95
|
-
const header =
|
|
96
|
-
const status = `${this.scrollOffset + 1}-${Math.min(this.totalLines, this.scrollOffset + this.pageSize)} / ${this.totalLines} lines · ↑↓ PgUp/PgDn
|
|
116
|
+
const header = this.theme.bold(this.theme.fg("accent", this.title));
|
|
117
|
+
const status = `${this.scrollOffset + 1}-${Math.min(this.totalLines, this.scrollOffset + this.pageSize)} / ${this.totalLines} lines · j/k gg/G ↑↓ PgUp/PgDn · [esc] close`;
|
|
97
118
|
return [
|
|
98
119
|
border(`╭${"─".repeat(inner)}╮`),
|
|
99
120
|
`${border("│")}${pad(` ${header}`, inner - visibleWidth(close))}${close}${border("│")}`,
|
|
@@ -116,23 +137,6 @@ class FullToolPreview implements Component {
|
|
|
116
137
|
this.tui.requestRender();
|
|
117
138
|
}
|
|
118
139
|
}
|
|
119
|
-
const MIN_VIEWPORT = 3;
|
|
120
|
-
export const VIEWPORT_HEIGHT_PCT = 70;
|
|
121
|
-
const MAX_COPY_CHARS = 500_000;
|
|
122
|
-
const MATCH_BG = "\x1b[48;5;238m";
|
|
123
|
-
const MATCH_BG_CURRENT = "\x1b[48;5;220m\x1b[30m";
|
|
124
|
-
const BG_RESET = "\x1b[49m\x1b[39m";
|
|
125
|
-
|
|
126
|
-
/** The live fields needed by the viewer; historical viewers use a static source. */
|
|
127
|
-
export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
|
|
128
|
-
|
|
129
|
-
export function createStaticConversationSource(
|
|
130
|
-
messages: AgentSession["messages"],
|
|
131
|
-
): ConversationSource {
|
|
132
|
-
return { messages, subscribe: () => () => {} };
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
type RenderLine = TimelineRenderLine;
|
|
136
140
|
|
|
137
141
|
/** Optional operations are appended so all existing constructor call sites stay valid. */
|
|
138
142
|
export interface ConversationViewerOperations {
|
|
@@ -145,12 +149,15 @@ export class ConversationViewer implements Component {
|
|
|
145
149
|
private blocks: ConversationBlock[] = [];
|
|
146
150
|
private showTools = true;
|
|
147
151
|
private selectedMessageIdx = 0;
|
|
152
|
+
private hasFocusedBlock = false;
|
|
148
153
|
private cachedWidth = 0;
|
|
149
154
|
private cachedLines: RenderLine[] = [];
|
|
150
155
|
private cachedMessageStarts: number[] = [];
|
|
151
156
|
private cachedMessageBlocks: ConversationBlock[] = [];
|
|
152
157
|
private cacheVersion = 0;
|
|
153
158
|
private cachedVersion = -1;
|
|
159
|
+
private sessionRefreshQueued = false;
|
|
160
|
+
private selectedBlockId: string | undefined;
|
|
154
161
|
private scrollOffset = 0;
|
|
155
162
|
private autoScroll = true;
|
|
156
163
|
private unsubscribe: (() => void) | undefined;
|
|
@@ -187,21 +194,10 @@ export class ConversationViewer implements Component {
|
|
|
187
194
|
this.timeline = new ConversationTimeline(tui, theme, {
|
|
188
195
|
cwd: operations?.ctx.cwd,
|
|
189
196
|
record,
|
|
190
|
-
onChange: () =>
|
|
191
|
-
this.cacheVersion++;
|
|
192
|
-
this.invalidate();
|
|
193
|
-
this.tui.requestRender();
|
|
194
|
-
},
|
|
197
|
+
onChange: (change) => this.handleTimelineChange(change),
|
|
195
198
|
});
|
|
196
199
|
this.timeline.setBlocks(this.blocks);
|
|
197
|
-
this.unsubscribe = session.subscribe(() =>
|
|
198
|
-
if (this.closed) return;
|
|
199
|
-
this.blocks = formatConversationMessages(this.session.messages);
|
|
200
|
-
this.timeline.setBlocks(this.blocks);
|
|
201
|
-
this.cacheVersion++;
|
|
202
|
-
this.invalidate();
|
|
203
|
-
this.tui.requestRender();
|
|
204
|
-
});
|
|
200
|
+
this.unsubscribe = session.subscribe(() => this.scheduleSessionRefresh());
|
|
205
201
|
}
|
|
206
202
|
|
|
207
203
|
handleInput(data: string): void {
|
|
@@ -221,12 +217,6 @@ export class ConversationViewer implements Component {
|
|
|
221
217
|
}
|
|
222
218
|
if (matchesKey(data, "enter")) {
|
|
223
219
|
this.buildContentLines(this.lastInnerW || 80);
|
|
224
|
-
const current = this.currentBlock();
|
|
225
|
-
if (current?.kind === "tool" && current.toolCallId) {
|
|
226
|
-
this.timeline.setFocusedToolCallId(current.toolCallId);
|
|
227
|
-
this.timeline.toggleFocusedTool();
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
220
|
if (this.timeline.isToolFocused()) {
|
|
231
221
|
this.timeline.toggleFocusedTool();
|
|
232
222
|
return;
|
|
@@ -301,8 +291,9 @@ export class ConversationViewer implements Component {
|
|
|
301
291
|
this.scrollBy(viewportHeight);
|
|
302
292
|
} else if (matchesKey(data, "home")) {
|
|
303
293
|
this.scrollOffset = 0;
|
|
304
|
-
this.
|
|
294
|
+
this.setFocusFromMessageIndex(0);
|
|
305
295
|
this.autoScroll = false;
|
|
296
|
+
this.invalidate();
|
|
306
297
|
this.tui.requestRender();
|
|
307
298
|
} else if (matchesKey(data, "end")) {
|
|
308
299
|
this.scrollToBottom();
|
|
@@ -318,17 +309,14 @@ export class ConversationViewer implements Component {
|
|
|
318
309
|
if (event.type === "move") {
|
|
319
310
|
const closeChanged = closeHit !== this.hoveredClose;
|
|
320
311
|
this.hoveredClose = closeHit;
|
|
321
|
-
if (this.composer || this.searchMode) {
|
|
322
|
-
return closeChanged ? { handled: true, render: true } : undefined;
|
|
323
|
-
}
|
|
312
|
+
if (this.composer || this.searchMode) return closeChanged ? { handled: true, render: true } : undefined;
|
|
324
313
|
const contentLines = this.buildContentLines(innerW);
|
|
325
314
|
const viewportHeight = this.viewportHeight();
|
|
326
315
|
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
316
|
+
if (this.autoScroll) this.scrollOffset = maxScroll;
|
|
327
317
|
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
328
318
|
const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
|
|
329
319
|
const contentY = event.y - contentOrigin + visibleStart;
|
|
330
|
-
// Forward moves outside the content too so a previously hovered tool
|
|
331
|
-
// hint is cleared when the pointer returns to the header/footer.
|
|
332
320
|
const timelineResult = this.timeline.handleMouse({ ...event, y: contentY });
|
|
333
321
|
return timelineResult ?? (closeChanged ? { handled: true, render: true } : undefined);
|
|
334
322
|
}
|
|
@@ -346,33 +334,41 @@ export class ConversationViewer implements Component {
|
|
|
346
334
|
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
347
335
|
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
348
336
|
const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
|
|
349
|
-
|
|
350
337
|
if (event.type === "wheel") {
|
|
351
338
|
if (event.y < contentOrigin || event.y >= contentOrigin + viewportHeight) return undefined;
|
|
352
|
-
|
|
353
|
-
if (delta === 0) return { handled: true, render: false };
|
|
354
|
-
this.scrollBy(delta);
|
|
339
|
+
this.scrollBy(Math.trunc(event.wheelDelta ?? 0));
|
|
355
340
|
return { handled: true, render: true };
|
|
356
341
|
}
|
|
357
|
-
|
|
358
342
|
const contentY = event.y - contentOrigin + visibleStart;
|
|
359
343
|
if (contentY < 0 || contentY >= contentLines.length) return undefined;
|
|
344
|
+
// Rows have one leading space and one trailing space inside the frame.
|
|
345
|
+
// Neither padding nor the frame itself is a focus target.
|
|
346
|
+
if ((event.type === "click" || event.type === "press") && (event.x < 2 || event.x >= 2 + innerW)) return undefined;
|
|
360
347
|
const result = this.timeline.handleMouse({ ...event, y: contentY });
|
|
361
|
-
if (result?.handled) {
|
|
362
|
-
|
|
348
|
+
if (result?.handled && event.type === "click") {
|
|
349
|
+
let selected = result.focusedBlockIndex;
|
|
350
|
+
if (selected === undefined) {
|
|
363
351
|
const focusedTool = this.timeline.getFocusedToolCallId();
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
this.
|
|
368
|
-
this.
|
|
352
|
+
selected = this.timeline.getSnapshot().messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
|
|
353
|
+
}
|
|
354
|
+
if (selected !== undefined && selected >= 0) {
|
|
355
|
+
this.buildContentLines(innerW);
|
|
356
|
+
this.setFocusFromMessageIndex(selected);
|
|
357
|
+
this.autoScroll = false;
|
|
358
|
+
this.scrollTargetIntoView(selected);
|
|
369
359
|
this.tui.requestRender();
|
|
370
360
|
}
|
|
371
|
-
return result;
|
|
372
361
|
}
|
|
373
362
|
return result;
|
|
374
363
|
}
|
|
375
364
|
|
|
365
|
+
private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
|
|
366
|
+
if (y !== 1) return false;
|
|
367
|
+
const labelWidth = visibleWidth("[esc]");
|
|
368
|
+
const labelStart = 2 + Math.max(0, innerW - labelWidth);
|
|
369
|
+
return x >= labelStart && x < 2 + innerW;
|
|
370
|
+
}
|
|
371
|
+
|
|
376
372
|
render(width: number): string[] {
|
|
377
373
|
if (width < 6) return [];
|
|
378
374
|
const th = this.theme;
|
|
@@ -490,13 +486,6 @@ export class ConversationViewer implements Component {
|
|
|
490
486
|
return lines;
|
|
491
487
|
}
|
|
492
488
|
|
|
493
|
-
private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
|
|
494
|
-
if (y !== 1) return false;
|
|
495
|
-
const labelWidth = visibleWidth("[esc]");
|
|
496
|
-
const labelStart = 2 + Math.max(0, innerW - labelWidth);
|
|
497
|
-
return x >= labelStart && x < 2 + innerW;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
489
|
invalidate(): void {
|
|
501
490
|
this.cachedWidth = 0;
|
|
502
491
|
this.cachedLines = [];
|
|
@@ -505,6 +494,39 @@ export class ConversationViewer implements Component {
|
|
|
505
494
|
this.cachedVersion = -1;
|
|
506
495
|
}
|
|
507
496
|
|
|
497
|
+
private scheduleSessionRefresh(): void {
|
|
498
|
+
if (this.closed || this.sessionRefreshQueued) return;
|
|
499
|
+
this.sessionRefreshQueued = true;
|
|
500
|
+
queueMicrotask(() => {
|
|
501
|
+
this.sessionRefreshQueued = false;
|
|
502
|
+
if (this.closed) return;
|
|
503
|
+
if (this.hasFocusedBlock) this.selectedBlockId = this.currentBlock()?.id;
|
|
504
|
+
this.blocks = formatConversationMessages(this.session.messages);
|
|
505
|
+
this.timeline.setBlocks(this.blocks);
|
|
506
|
+
this.cacheVersion++;
|
|
507
|
+
this.invalidate();
|
|
508
|
+
this.tui.requestRender();
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
private handleTimelineChange(change: ConversationTimelineChange): void {
|
|
513
|
+
if (change.kind === "content") {
|
|
514
|
+
this.cacheVersion++;
|
|
515
|
+
this.invalidate();
|
|
516
|
+
} else if (change.changedRanges && this.cachedWidth > 0 && this.cachedVersion === this.cacheVersion) {
|
|
517
|
+
const snapshotLines = this.timeline.getSnapshot().lines;
|
|
518
|
+
for (const range of change.changedRanges) {
|
|
519
|
+
const start = Math.max(0, range.startLine);
|
|
520
|
+
const end = Math.min(this.cachedLines.length, start + Math.max(0, range.height));
|
|
521
|
+
for (let index = start; index < end; index++) {
|
|
522
|
+
const line = snapshotLines[index];
|
|
523
|
+
if (line) this.cachedLines[index] = { ...line };
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
this.tui.requestRender();
|
|
528
|
+
}
|
|
529
|
+
|
|
508
530
|
dispose(): void {
|
|
509
531
|
this.closed = true;
|
|
510
532
|
this.unsubscribe?.();
|
|
@@ -550,6 +572,19 @@ export class ConversationViewer implements Component {
|
|
|
550
572
|
const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
|
|
551
573
|
this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
|
|
552
574
|
this.cachedMessageBlocks = [...timelineSnapshot.messageBlocks];
|
|
575
|
+
if (this.hasFocusedBlock) {
|
|
576
|
+
const selectedIndex = this.selectedBlockId
|
|
577
|
+
? this.cachedMessageBlocks.findIndex((block) => block.id === this.selectedBlockId)
|
|
578
|
+
: -1;
|
|
579
|
+
const focusedToolIndex = this.timeline.getFocusedToolCallId()
|
|
580
|
+
? this.cachedMessageBlocks.findIndex((block) => block.toolCallId === this.timeline.getFocusedToolCallId())
|
|
581
|
+
: -1;
|
|
582
|
+
const restoredIndex = selectedIndex >= 0 ? selectedIndex : focusedToolIndex;
|
|
583
|
+
if (restoredIndex >= 0) {
|
|
584
|
+
this.selectedMessageIdx = restoredIndex;
|
|
585
|
+
this.selectedBlockId = this.cachedMessageBlocks[restoredIndex]?.id;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
553
588
|
if (this.record.status === "running" && this.activity) {
|
|
554
589
|
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
555
590
|
const text = `▍ ${act}`;
|
|
@@ -566,6 +601,17 @@ export class ConversationViewer implements Component {
|
|
|
566
601
|
return this.cachedMessageStarts.length === 0 ? 0 : Math.max(0, Math.min(this.selectedMessageIdx, this.cachedMessageStarts.length - 1));
|
|
567
602
|
}
|
|
568
603
|
|
|
604
|
+
/** Keep the selected transcript block and the tool focus as one target. */
|
|
605
|
+
private setFocusFromMessageIndex(index: number): boolean {
|
|
606
|
+
if (index < 0 || index >= this.cachedMessageBlocks.length) return false;
|
|
607
|
+
this.selectedMessageIdx = index;
|
|
608
|
+
this.hasFocusedBlock = true;
|
|
609
|
+
const block = this.cachedMessageBlocks[index];
|
|
610
|
+
this.selectedBlockId = block.id;
|
|
611
|
+
this.timeline.setFocusedToolCallId(block.kind === "tool" ? block.toolCallId : undefined);
|
|
612
|
+
return true;
|
|
613
|
+
}
|
|
614
|
+
|
|
569
615
|
private currentBlockGlobalIndex(): number {
|
|
570
616
|
const block = this.cachedMessageBlocks[this.currentMessageIndex()];
|
|
571
617
|
return block ? this.blocks.indexOf(block) : -1;
|
|
@@ -598,20 +644,14 @@ export class ConversationViewer implements Component {
|
|
|
598
644
|
if (messageIndices.length === 0) return;
|
|
599
645
|
const current = this.currentMessageIndex();
|
|
600
646
|
const currentPosition = messageIndices.indexOf(current);
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
? currentPosition
|
|
607
|
-
: dir > 0
|
|
608
|
-
? messageIndices.findIndex((index) => index > current)
|
|
609
|
-
: precedingPosition;
|
|
610
|
-
const position = basePosition < 0 ? (dir > 0 ? 0 : messageIndices.length - 1) : basePosition + dir;
|
|
647
|
+
const precedingPosition = messageIndices.reduce((last, index, position) => index < current ? position : last, -1);
|
|
648
|
+
const nextPosition = messageIndices.findIndex((index) => index > current);
|
|
649
|
+
const position = currentPosition >= 0
|
|
650
|
+
? currentPosition + dir
|
|
651
|
+
: dir > 0 ? nextPosition : precedingPosition;
|
|
611
652
|
if (position < 0 || position >= messageIndices.length) return;
|
|
612
653
|
const target = messageIndices[position];
|
|
613
|
-
this.
|
|
614
|
-
this.timeline.setFocusedToolCallId(undefined);
|
|
654
|
+
this.setFocusFromMessageIndex(target);
|
|
615
655
|
this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
|
|
616
656
|
this.autoScroll = false;
|
|
617
657
|
this.tui.requestRender();
|
|
@@ -620,15 +660,13 @@ export class ConversationViewer implements Component {
|
|
|
620
660
|
private moveToolFocus(direction: 1 | -1): void {
|
|
621
661
|
if (!this.timeline.moveFocus(direction)) return;
|
|
622
662
|
const focused = this.timeline.getFocusedToolCallId();
|
|
623
|
-
|
|
624
|
-
const index =
|
|
663
|
+
this.buildContentLines(this.lastInnerW || 80);
|
|
664
|
+
const index = this.cachedMessageBlocks.findIndex((block) => block.toolCallId === focused);
|
|
625
665
|
if (index >= 0) {
|
|
626
|
-
this.
|
|
627
|
-
this.buildContentLines(this.lastInnerW || 80);
|
|
666
|
+
this.setFocusFromMessageIndex(index);
|
|
628
667
|
this.autoScroll = false;
|
|
629
668
|
this.scrollTargetIntoView(index);
|
|
630
669
|
}
|
|
631
|
-
this.invalidate();
|
|
632
670
|
this.tui.requestRender();
|
|
633
671
|
}
|
|
634
672
|
|
|
@@ -639,24 +677,17 @@ export class ConversationViewer implements Component {
|
|
|
639
677
|
.filter((index): index is number => index >= 0);
|
|
640
678
|
if (toolIndices.length === 0) return;
|
|
641
679
|
|
|
642
|
-
const
|
|
643
|
-
const
|
|
644
|
-
? this.cachedMessageBlocks.findIndex((block) => block.toolCallId === focusedId)
|
|
645
|
-
: -1;
|
|
646
|
-
const current = focusedIndex >= 0 ? focusedIndex : this.currentMessageIndex();
|
|
647
|
-
const target = focusedIndex < 0
|
|
680
|
+
const current = this.currentMessageIndex();
|
|
681
|
+
const resolvedTarget = !this.hasFocusedBlock
|
|
648
682
|
? direction > 0 ? toolIndices[0] : toolIndices[toolIndices.length - 1]
|
|
649
683
|
: direction > 0
|
|
650
684
|
? toolIndices.find((index) => index > current)
|
|
651
685
|
: [...toolIndices].reverse().find((index) => index < current);
|
|
652
|
-
if (
|
|
686
|
+
if (resolvedTarget === undefined) return;
|
|
653
687
|
|
|
654
|
-
|
|
655
|
-
this.selectedMessageIdx = target;
|
|
688
|
+
this.setFocusFromMessageIndex(resolvedTarget);
|
|
656
689
|
this.autoScroll = false;
|
|
657
|
-
this.scrollTargetIntoView(
|
|
658
|
-
this.timeline.setFocusedToolCallId(block.toolCallId);
|
|
659
|
-
this.invalidate();
|
|
690
|
+
this.scrollTargetIntoView(resolvedTarget);
|
|
660
691
|
this.tui.requestRender();
|
|
661
692
|
}
|
|
662
693
|
|
|
@@ -672,7 +703,10 @@ export class ConversationViewer implements Component {
|
|
|
672
703
|
|
|
673
704
|
private openFocusedToolPreview(): void {
|
|
674
705
|
this.buildContentLines(this.lastInnerW || 80);
|
|
675
|
-
const
|
|
706
|
+
const focusedToolCallId = this.timeline.getFocusedToolCallId();
|
|
707
|
+
const block = focusedToolCallId
|
|
708
|
+
? this.cachedMessageBlocks.find((candidate) => candidate.toolCallId === focusedToolCallId)
|
|
709
|
+
: this.currentBlock();
|
|
676
710
|
const custom = this.operations?.ctx?.ui?.custom;
|
|
677
711
|
if (block?.kind !== "tool" || typeof custom !== "function") return;
|
|
678
712
|
|
|
@@ -680,50 +714,40 @@ export class ConversationViewer implements Component {
|
|
|
680
714
|
const args = block.args ?? block.toolArguments;
|
|
681
715
|
const result = block.toolResult ?? block.result ?? { content: [] };
|
|
682
716
|
const title = `${toolName} · full preview`;
|
|
683
|
-
|
|
684
717
|
void custom.call(this.operations!.ctx.ui, (tui, overlayTheme, _keybindings, done) => {
|
|
685
718
|
const body = createViewerCcstyleResult(
|
|
686
719
|
toolName,
|
|
687
720
|
result,
|
|
688
721
|
{ expanded: true, isError: result.isError === true },
|
|
689
722
|
overlayTheme,
|
|
690
|
-
{
|
|
691
|
-
args,
|
|
692
|
-
maxChars: FULL_TOOL_PREVIEW_MAX_CHARS,
|
|
693
|
-
diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER },
|
|
694
|
-
},
|
|
723
|
+
{ args, maxChars: FULL_TOOL_PREVIEW_MAX_CHARS, diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER } },
|
|
695
724
|
);
|
|
696
725
|
return new FullToolPreview(tui, overlayTheme as Theme, title, body, done);
|
|
697
726
|
}, {
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
width: "85%",
|
|
702
|
-
minWidth: 50,
|
|
703
|
-
maxHeight: "80%",
|
|
704
|
-
margin: 2,
|
|
705
|
-
},
|
|
706
|
-
}).catch(() => undefined);
|
|
727
|
+
overlay: true,
|
|
728
|
+
overlayOptions: { anchor: "center", width: "85%", minWidth: 50, maxHeight: "80%", margin: 2 },
|
|
729
|
+
}).catch(() => undefined);
|
|
707
730
|
}
|
|
708
731
|
|
|
709
732
|
private scrollBy(delta: number): void {
|
|
710
733
|
const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
711
734
|
this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
|
|
712
|
-
|
|
735
|
+
const selected = this.messageIndexForOffset(this.scrollOffset);
|
|
736
|
+
this.setFocusFromMessageIndex(selected);
|
|
713
737
|
this.autoScroll = this.scrollOffset >= max;
|
|
714
738
|
this.tui.requestRender();
|
|
715
739
|
}
|
|
716
740
|
|
|
717
741
|
private scrollToTop(): void {
|
|
718
742
|
this.scrollOffset = 0;
|
|
719
|
-
this.
|
|
743
|
+
this.setFocusFromMessageIndex(0);
|
|
720
744
|
this.autoScroll = false;
|
|
721
745
|
this.tui.requestRender();
|
|
722
746
|
}
|
|
723
747
|
|
|
724
748
|
private scrollToBottom(): void {
|
|
725
749
|
this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
726
|
-
this.
|
|
750
|
+
this.setFocusFromMessageIndex(Math.max(0, this.cachedMessageStarts.length - 1));
|
|
727
751
|
this.autoScroll = true;
|
|
728
752
|
this.tui.requestRender();
|
|
729
753
|
}
|
|
@@ -774,7 +798,7 @@ export class ConversationViewer implements Component {
|
|
|
774
798
|
if (!match) return;
|
|
775
799
|
const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
776
800
|
this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
|
|
777
|
-
this.
|
|
801
|
+
this.setFocusFromMessageIndex(this.messageIndexForOffset(this.scrollOffset));
|
|
778
802
|
this.autoScroll = false;
|
|
779
803
|
}
|
|
780
804
|
|
package/src/ui/fleet-list.ts
CHANGED
|
@@ -107,6 +107,7 @@ export class FleetList {
|
|
|
107
107
|
private inputUnsub: (() => void) | undefined;
|
|
108
108
|
private widgetRegistered = false;
|
|
109
109
|
private lastRosterKey: string | undefined;
|
|
110
|
+
private lastRenderKey: string | undefined;
|
|
110
111
|
private timer: ReturnType<typeof setInterval> | undefined;
|
|
111
112
|
|
|
112
113
|
private enabled = true;
|
|
@@ -142,6 +143,7 @@ export class FleetList {
|
|
|
142
143
|
this.ui = ui;
|
|
143
144
|
this.widgetRegistered = false;
|
|
144
145
|
this.lastRosterKey = undefined;
|
|
146
|
+
this.lastRenderKey = undefined;
|
|
145
147
|
this.tui = undefined;
|
|
146
148
|
this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data));
|
|
147
149
|
}
|
|
@@ -168,6 +170,7 @@ export class FleetList {
|
|
|
168
170
|
if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined);
|
|
169
171
|
this.widgetRegistered = false;
|
|
170
172
|
this.lastRosterKey = undefined;
|
|
173
|
+
this.lastRenderKey = undefined;
|
|
171
174
|
this.tui = undefined;
|
|
172
175
|
this.active = false;
|
|
173
176
|
// Null last so a `viewerClose()` microtask above can't re-register the widget.
|
|
@@ -193,11 +196,13 @@ export class FleetList {
|
|
|
193
196
|
this.active = false;
|
|
194
197
|
this.selectedIndex = 0;
|
|
195
198
|
this.lastRosterKey = undefined;
|
|
199
|
+
this.lastRenderKey = undefined;
|
|
196
200
|
return;
|
|
197
201
|
}
|
|
198
202
|
|
|
199
203
|
this.clampSelection();
|
|
200
204
|
this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable)
|
|
205
|
+
const renderKey = this.renderKey(records);
|
|
201
206
|
|
|
202
207
|
if (!this.widgetRegistered) {
|
|
203
208
|
this.ui.setWidget(FLEET_KEY, (tui, theme) => {
|
|
@@ -208,9 +213,37 @@ export class FleetList {
|
|
|
208
213
|
};
|
|
209
214
|
}, { placement: "belowEditor" });
|
|
210
215
|
this.widgetRegistered = true;
|
|
211
|
-
} else {
|
|
216
|
+
} else if (renderKey !== this.lastRenderKey) {
|
|
217
|
+
// The timer runs more frequently than elapsed time is displayed. Avoid
|
|
218
|
+
// waking the TUI when the visible FleetView snapshot is unchanged, but
|
|
219
|
+
// preserve a forced redraw when rows were added/removed.
|
|
212
220
|
this.tui?.requestRender(rosterChanged);
|
|
213
221
|
}
|
|
222
|
+
this.lastRenderKey = renderKey;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Return only values that affect `renderBar()`. Elapsed time is rendered in
|
|
227
|
+
* whole seconds, so the 200ms timer can skip four out of five updates while
|
|
228
|
+
* still refreshing the row at the visible cadence.
|
|
229
|
+
*/
|
|
230
|
+
private renderKey(records: AgentRecord[]): string {
|
|
231
|
+
const now = Date.now();
|
|
232
|
+
const agentState = records.map(record => {
|
|
233
|
+
const activity = this.agentActivity.get(record.id);
|
|
234
|
+
const usage = activity?.lifetimeUsage ?? record.lifetimeUsage;
|
|
235
|
+
return [
|
|
236
|
+
record.id,
|
|
237
|
+
record.type,
|
|
238
|
+
record.description,
|
|
239
|
+
record.status,
|
|
240
|
+
record.startedAt,
|
|
241
|
+
record.completedAt ?? "",
|
|
242
|
+
getLifetimeTotal(usage),
|
|
243
|
+
Math.round(((record.completedAt ?? now) - record.startedAt) / 1000),
|
|
244
|
+
].join("\u001f");
|
|
245
|
+
});
|
|
246
|
+
return [this.enabled ? "1" : "0", this.active ? "1" : "0", String(this.selectedIndex), ...agentState].join("\u001e");
|
|
214
247
|
}
|
|
215
248
|
|
|
216
249
|
// ---- Roster ----
|
package/src/write-execution.ts
CHANGED
|
@@ -9,8 +9,6 @@ export const MAX_COMPARABLE_WRITE_BYTES = 512_000;
|
|
|
9
9
|
export const MAX_WRITE_METADATA_ENTRIES = 100;
|
|
10
10
|
|
|
11
11
|
export interface DurableWriteDetails {
|
|
12
|
-
/** Persisted so history can distinguish create vs overwrite without file access. */
|
|
13
|
-
fileExistedBeforeWrite?: boolean;
|
|
14
12
|
diff?: string;
|
|
15
13
|
patch?: string;
|
|
16
14
|
firstChangedLine?: number;
|
|
@@ -108,10 +106,7 @@ function durableDetails(
|
|
|
108
106
|
content: string,
|
|
109
107
|
): DurableWriteDetails {
|
|
110
108
|
if (metadata.diffUnavailableReason) {
|
|
111
|
-
return {
|
|
112
|
-
fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
|
|
113
|
-
diffUnavailableReason: metadata.diffUnavailableReason,
|
|
114
|
-
};
|
|
109
|
+
return { diffUnavailableReason: metadata.diffUnavailableReason };
|
|
115
110
|
}
|
|
116
111
|
const previousContent = metadata.previousContent ?? "";
|
|
117
112
|
const diffResult = generateDiffString(previousContent, content);
|
|
@@ -121,7 +116,6 @@ function durableDetails(
|
|
|
121
116
|
return { diffUnavailableReason: `durable diff exceeds ${MAX_DURABLE_DIFF_BYTES} bytes` };
|
|
122
117
|
}
|
|
123
118
|
return {
|
|
124
|
-
fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
|
|
125
119
|
diff: diffResult.diff,
|
|
126
120
|
patch,
|
|
127
121
|
firstChangedLine: diffResult.firstChangedLine,
|