@esso0428/pi-subagents 0.15.15 → 0.15.17
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 +14 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/index.ts +47 -5
- package/src/ui/agent-widget.ts +85 -17
- package/src/ui/conversation-blocks.ts +13 -11
- package/src/ui/conversation-timeline.ts +141 -56
- package/src/ui/conversation-viewer.ts +52 -22
- package/src/ui/deferred-ui-refresh.ts +72 -0
- package/src/ui/fleet-list.ts +215 -46
- package/test/ui/agent-widget.test.ts +50 -1
- package/test/ui/deferred-ui-refresh.test.ts +55 -0
- package/test/ui/fleet-list.test.ts +264 -6
|
@@ -59,10 +59,21 @@ export interface ConversationTimelineMouseResult {
|
|
|
59
59
|
focusedBlockIndex?: number;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
export interface ConversationTimelineLineRange {
|
|
63
|
+
startLine: number;
|
|
64
|
+
height: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface ConversationTimelineChange {
|
|
68
|
+
kind: "content" | "interaction";
|
|
69
|
+
/** Lines patched in-place for an interaction-only change such as hover. */
|
|
70
|
+
changedRanges?: readonly ConversationTimelineLineRange[];
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
export interface ConversationTimelineOptions {
|
|
63
74
|
cwd?: string;
|
|
64
75
|
record?: AgentRecord;
|
|
65
|
-
onChange?: () => void;
|
|
76
|
+
onChange?: (change: ConversationTimelineChange) => void;
|
|
66
77
|
}
|
|
67
78
|
|
|
68
79
|
function safeWidth(width: number): number {
|
|
@@ -157,6 +168,7 @@ export class ConversationTimeline implements Component {
|
|
|
157
168
|
private expandedTools = new Set<string>();
|
|
158
169
|
private hoveredToolCallId: string | undefined;
|
|
159
170
|
private hoveredIoView: any;
|
|
171
|
+
private hoveredIoToolCallId: string | undefined;
|
|
160
172
|
private hoveredIoSection: "input" | "output" | null = null;
|
|
161
173
|
private lastClickedToolCallId: string | undefined;
|
|
162
174
|
private lastClickedPoint: { x: number; y: number } | undefined;
|
|
@@ -182,6 +194,12 @@ export class ConversationTimeline implements Component {
|
|
|
182
194
|
const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
|
|
183
195
|
if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
|
|
184
196
|
if (this.hoveredToolCallId && !ids.has(this.hoveredToolCallId)) this.hoveredToolCallId = undefined;
|
|
197
|
+
if (this.hoveredIoToolCallId && !ids.has(this.hoveredIoToolCallId)) {
|
|
198
|
+
this.hoveredIoView?.setHoveredSection?.(null);
|
|
199
|
+
this.hoveredIoView = undefined;
|
|
200
|
+
this.hoveredIoToolCallId = undefined;
|
|
201
|
+
this.hoveredIoSection = null;
|
|
202
|
+
}
|
|
185
203
|
for (const id of this.expandedTools) if (!ids.has(id)) this.expandedTools.delete(id);
|
|
186
204
|
for (const id of this.toolComponents.keys()) if (!ids.has(id)) this.toolComponents.delete(id);
|
|
187
205
|
this.invalidate();
|
|
@@ -193,6 +211,10 @@ export class ConversationTimeline implements Component {
|
|
|
193
211
|
if (!show) {
|
|
194
212
|
this.focusedToolCallId = undefined;
|
|
195
213
|
this.hoveredToolCallId = undefined;
|
|
214
|
+
this.hoveredIoView?.setHoveredSection?.(null);
|
|
215
|
+
this.hoveredIoView = undefined;
|
|
216
|
+
this.hoveredIoToolCallId = undefined;
|
|
217
|
+
this.hoveredIoSection = null;
|
|
196
218
|
}
|
|
197
219
|
this.invalidate();
|
|
198
220
|
}
|
|
@@ -202,8 +224,10 @@ export class ConversationTimeline implements Component {
|
|
|
202
224
|
}
|
|
203
225
|
|
|
204
226
|
setFocusedToolCallId(id: string | undefined): void {
|
|
227
|
+
// Focus changes are view state. The rendered rows do not change, so keep
|
|
228
|
+
// the snapshot and its Markdown/tool output cache intact.
|
|
229
|
+
if (this.focusedToolCallId === id) return;
|
|
205
230
|
this.focusedToolCallId = id;
|
|
206
|
-
this.invalidate();
|
|
207
231
|
}
|
|
208
232
|
|
|
209
233
|
getFocusedToolCallId(): string | undefined {
|
|
@@ -223,8 +247,7 @@ export class ConversationTimeline implements Component {
|
|
|
223
247
|
? direction > 0 ? 0 : ids.length - 1
|
|
224
248
|
: (current + direction + ids.length) % ids.length;
|
|
225
249
|
this.focusedToolCallId = ids[next];
|
|
226
|
-
this.
|
|
227
|
-
this.options.onChange?.();
|
|
250
|
+
this.options.onChange?.({ kind: "interaction" });
|
|
228
251
|
return true;
|
|
229
252
|
}
|
|
230
253
|
|
|
@@ -234,7 +257,7 @@ export class ConversationTimeline implements Component {
|
|
|
234
257
|
if (this.expandedTools.has(id)) this.expandedTools.delete(id);
|
|
235
258
|
else this.expandedTools.add(id);
|
|
236
259
|
this.invalidate();
|
|
237
|
-
this.options.onChange?.();
|
|
260
|
+
this.options.onChange?.({ kind: "content" });
|
|
238
261
|
return true;
|
|
239
262
|
}
|
|
240
263
|
|
|
@@ -244,21 +267,24 @@ export class ConversationTimeline implements Component {
|
|
|
244
267
|
|
|
245
268
|
render(width: number): string[] {
|
|
246
269
|
const requestedWidth = safeWidth(width);
|
|
270
|
+
if (this.lastWidth === requestedWidth) return this.snapshot.lines.map((line) => line.text);
|
|
271
|
+
|
|
247
272
|
const lines: TimelineRenderLine[] = [];
|
|
248
273
|
const messageStarts: number[] = [];
|
|
249
274
|
const messageBlocks: ConversationBlock[] = [];
|
|
250
275
|
const interactiveToolIds: string[] = [];
|
|
251
276
|
const blockSpans: TimelineBlockSpan[] = [];
|
|
252
277
|
const toolSpans: TimelineToolSpan[] = [];
|
|
253
|
-
const visibleBlocks = this.blocks.filter((block) => this.showTools || block.kind !== "tool");
|
|
254
278
|
|
|
255
|
-
if (
|
|
279
|
+
if (this.blocks.length === 0 || (!this.showTools && this.blocks.every((block) => block.kind === "tool"))) {
|
|
256
280
|
const text = this.blocks.length === 0 ? "(waiting for first message...)" : "(tools hidden)";
|
|
257
281
|
lines.push({ text: this.theme.fg("dim", text), plain: text, blockIndex: -1 });
|
|
258
282
|
}
|
|
259
283
|
|
|
260
|
-
for (
|
|
261
|
-
const
|
|
284
|
+
for (let sourceBlockIndex = 0; sourceBlockIndex < this.blocks.length; sourceBlockIndex++) {
|
|
285
|
+
const block = this.blocks[sourceBlockIndex]!;
|
|
286
|
+
if (!this.showTools && block.kind === "tool") continue;
|
|
287
|
+
const blockIndex = sourceBlockIndex;
|
|
262
288
|
if (lines.length > 0) {
|
|
263
289
|
const separator = this.theme.fg("border", " " + "─ ".repeat(Math.max(1, Math.floor((requestedWidth - 6) / 2))).trimEnd());
|
|
264
290
|
lines.push({ text: truncateToWidth(separator, requestedWidth), plain: stripAnsi(separator), blockIndex });
|
|
@@ -277,51 +303,11 @@ export class ConversationTimeline implements Component {
|
|
|
277
303
|
if (block.kind === "tool") {
|
|
278
304
|
const id = block.toolCallId;
|
|
279
305
|
if (id) interactiveToolIds.push(id);
|
|
280
|
-
|
|
281
|
-
if (id) {
|
|
282
|
-
const existing = this.toolComponents.get(id);
|
|
283
|
-
if (existing) {
|
|
284
|
-
applyToolComponentState(existing, block, this.expandedTools.has(id));
|
|
285
|
-
component = existing;
|
|
286
|
-
} else {
|
|
287
|
-
component = createToolComponent(
|
|
288
|
-
block,
|
|
289
|
-
this.tui,
|
|
290
|
-
this.options.cwd ?? process.cwd(),
|
|
291
|
-
this.expandedTools.has(id),
|
|
292
|
-
() => this.hoveredToolCallId === id,
|
|
293
|
-
) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
|
|
294
|
-
if (component) this.toolComponents.set(id, component);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
let rendered: string[];
|
|
298
|
-
try {
|
|
299
|
-
rendered = component?.render(requestedWidth) ?? fallbackToolLines(
|
|
300
|
-
block,
|
|
301
|
-
requestedWidth,
|
|
302
|
-
id ? this.expandedTools.has(id) : false,
|
|
303
|
-
id ? this.hoveredToolCallId === id : false,
|
|
304
|
-
this.theme,
|
|
305
|
-
);
|
|
306
|
-
} catch {
|
|
307
|
-
rendered = fallbackToolLines(
|
|
308
|
-
block,
|
|
309
|
-
requestedWidth,
|
|
310
|
-
id ? this.expandedTools.has(id) : false,
|
|
311
|
-
id ? this.hoveredToolCallId === id : false,
|
|
312
|
-
this.theme,
|
|
313
|
-
);
|
|
314
|
-
}
|
|
306
|
+
const { rendered, hintLine } = this.renderToolLines(block, requestedWidth);
|
|
315
307
|
for (const line of rendered) {
|
|
316
308
|
lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
|
|
317
309
|
}
|
|
318
310
|
if (id) {
|
|
319
|
-
const hintLine = !this.expandedTools.has(id)
|
|
320
|
-
? rendered.findIndex((line) => {
|
|
321
|
-
const plain = stripAnsi(line);
|
|
322
|
-
return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
|
|
323
|
-
})
|
|
324
|
-
: -1;
|
|
325
311
|
const blockHeight = lines.length - blockStartLine;
|
|
326
312
|
blockSpans.push({ blockIndex: snapshotBlockIndex, startLine: blockStartLine, height: blockHeight });
|
|
327
313
|
toolSpans.push({
|
|
@@ -363,6 +349,79 @@ export class ConversationTimeline implements Component {
|
|
|
363
349
|
return lines.map((line) => line.text);
|
|
364
350
|
}
|
|
365
351
|
|
|
352
|
+
private renderToolLines(block: ConversationBlock, width: number): { rendered: string[]; hintLine: number } {
|
|
353
|
+
const id = block.toolCallId;
|
|
354
|
+
let component: (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
|
|
355
|
+
if (id) {
|
|
356
|
+
const existing = this.toolComponents.get(id);
|
|
357
|
+
if (existing) {
|
|
358
|
+
applyToolComponentState(existing, block, this.expandedTools.has(id));
|
|
359
|
+
component = existing;
|
|
360
|
+
} else {
|
|
361
|
+
component = createToolComponent(
|
|
362
|
+
block,
|
|
363
|
+
this.tui,
|
|
364
|
+
this.options.cwd ?? process.cwd(),
|
|
365
|
+
this.expandedTools.has(id),
|
|
366
|
+
() => this.hoveredToolCallId === id,
|
|
367
|
+
) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
|
|
368
|
+
if (component) this.toolComponents.set(id, component);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let rendered: string[];
|
|
373
|
+
try {
|
|
374
|
+
rendered = component?.render(width) ?? fallbackToolLines(
|
|
375
|
+
block,
|
|
376
|
+
width,
|
|
377
|
+
id ? this.expandedTools.has(id) : false,
|
|
378
|
+
id ? this.hoveredToolCallId === id : false,
|
|
379
|
+
this.theme,
|
|
380
|
+
);
|
|
381
|
+
} catch {
|
|
382
|
+
rendered = fallbackToolLines(
|
|
383
|
+
block,
|
|
384
|
+
width,
|
|
385
|
+
id ? this.expandedTools.has(id) : false,
|
|
386
|
+
id ? this.hoveredToolCallId === id : false,
|
|
387
|
+
this.theme,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
const hintLine = id && !this.expandedTools.has(id)
|
|
391
|
+
? rendered.findIndex((line) => {
|
|
392
|
+
const plain = stripAnsi(line);
|
|
393
|
+
return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
|
|
394
|
+
})
|
|
395
|
+
: -1;
|
|
396
|
+
return { rendered, hintLine };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Hover only changes styling in a tool card. Re-render those rows in place
|
|
401
|
+
* and leave the Markdown rows and their spans untouched.
|
|
402
|
+
*/
|
|
403
|
+
private refreshToolSpan(span: TimelineToolSpan): boolean {
|
|
404
|
+
const block = this.snapshot.messageBlocks[span.blockIndex];
|
|
405
|
+
if (!block || block.kind !== "tool") return false;
|
|
406
|
+
const { rendered, hintLine } = this.renderToolLines(block, this.lastWidth);
|
|
407
|
+
if (rendered.length + (block.role === "user" || block.role === "assistant" ? 1 : 0) !== span.height) return false;
|
|
408
|
+
|
|
409
|
+
const headerOffset = block.role === "user" || block.role === "assistant" ? 1 : 0;
|
|
410
|
+
const sourceBlockIndex = this.snapshot.lines[span.startLine]?.blockIndex ?? -1;
|
|
411
|
+
for (let index = 0; index < rendered.length; index++) {
|
|
412
|
+
const line = rendered[index];
|
|
413
|
+
this.snapshot.lines[span.startLine + headerOffset + index] = {
|
|
414
|
+
text: truncateToWidth(line, this.lastWidth),
|
|
415
|
+
plain: stripAnsi(line),
|
|
416
|
+
blockIndex: sourceBlockIndex,
|
|
417
|
+
railable: true,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
span.clickStartLine = hintLine >= 0 ? span.startLine + headerOffset + hintLine : undefined;
|
|
421
|
+
span.clickHeight = hintLine >= 0 ? 1 : undefined;
|
|
422
|
+
return true;
|
|
423
|
+
}
|
|
424
|
+
|
|
366
425
|
handleMouse(event: TuiMouseEvent): ConversationTimelineMouseResult | undefined {
|
|
367
426
|
if (event.type === "move") {
|
|
368
427
|
const hovered = this.snapshot.toolSpans.find((span) =>
|
|
@@ -392,12 +451,35 @@ export class ConversationTimeline implements Component {
|
|
|
392
451
|
if (this.hoveredIoView && this.hoveredIoView !== nextIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
|
|
393
452
|
this.hoveredIoView.setHoveredSection(null);
|
|
394
453
|
}
|
|
454
|
+
const previousHoveredToolCallId = this.hoveredToolCallId;
|
|
455
|
+
const previousIoToolCallId = this.hoveredIoToolCallId;
|
|
456
|
+
const nextIoToolCallId = nextIoView ? fullSpan?.toolCallId : undefined;
|
|
395
457
|
this.hoveredToolCallId = hovered;
|
|
396
458
|
this.hoveredIoView = nextIoView;
|
|
459
|
+
this.hoveredIoToolCallId = nextIoToolCallId;
|
|
397
460
|
this.hoveredIoSection = nextIoSection;
|
|
398
461
|
if (nextIoView) nextIoView.setHoveredSection(nextIoSection);
|
|
399
|
-
|
|
400
|
-
|
|
462
|
+
|
|
463
|
+
const changedIds = new Set(
|
|
464
|
+
[previousHoveredToolCallId, hovered, previousIoToolCallId, nextIoToolCallId]
|
|
465
|
+
.filter((id): id is string => id !== undefined),
|
|
466
|
+
);
|
|
467
|
+
const changedRanges: ConversationTimelineLineRange[] = [];
|
|
468
|
+
let patched = true;
|
|
469
|
+
for (const id of changedIds) {
|
|
470
|
+
const span = this.snapshot.toolSpans.find((candidate) => candidate.toolCallId === id);
|
|
471
|
+
if (!span || !this.refreshToolSpan(span)) {
|
|
472
|
+
patched = false;
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
changedRanges.push({ startLine: span.startLine, height: span.height });
|
|
476
|
+
}
|
|
477
|
+
if (!patched) {
|
|
478
|
+
this.invalidate();
|
|
479
|
+
this.options.onChange?.({ kind: "content" });
|
|
480
|
+
} else {
|
|
481
|
+
this.options.onChange?.({ kind: "interaction", changedRanges });
|
|
482
|
+
}
|
|
401
483
|
return { handled: true, render: true };
|
|
402
484
|
}
|
|
403
485
|
|
|
@@ -448,8 +530,12 @@ export class ConversationTimeline implements Component {
|
|
|
448
530
|
// A missing/older Pi mouse implementation must not break the viewer.
|
|
449
531
|
}
|
|
450
532
|
}
|
|
451
|
-
|
|
452
|
-
|
|
533
|
+
if (shouldExpand || shouldCollapse) {
|
|
534
|
+
this.invalidate();
|
|
535
|
+
this.options.onChange?.({ kind: "content" });
|
|
536
|
+
} else {
|
|
537
|
+
this.options.onChange?.({ kind: "interaction" });
|
|
538
|
+
}
|
|
453
539
|
return { handled: true, render: shouldExpand || shouldCollapse, focusedBlockIndex: span.blockIndex };
|
|
454
540
|
}
|
|
455
541
|
|
|
@@ -458,8 +544,7 @@ export class ConversationTimeline implements Component {
|
|
|
458
544
|
this.focusedToolCallId = undefined;
|
|
459
545
|
this.lastClickedToolCallId = undefined;
|
|
460
546
|
this.lastClickedPoint = undefined;
|
|
461
|
-
this.
|
|
462
|
-
this.options.onChange?.();
|
|
547
|
+
this.options.onChange?.({ kind: "interaction" });
|
|
463
548
|
return { handled: true, render: true, focusedBlockIndex: target.blockIndex };
|
|
464
549
|
}
|
|
465
550
|
|
|
@@ -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";
|
|
@@ -156,6 +156,8 @@ export class ConversationViewer implements Component {
|
|
|
156
156
|
private cachedMessageBlocks: ConversationBlock[] = [];
|
|
157
157
|
private cacheVersion = 0;
|
|
158
158
|
private cachedVersion = -1;
|
|
159
|
+
private sessionRefreshQueued = false;
|
|
160
|
+
private selectedBlockId: string | undefined;
|
|
159
161
|
private scrollOffset = 0;
|
|
160
162
|
private autoScroll = true;
|
|
161
163
|
private unsubscribe: (() => void) | undefined;
|
|
@@ -192,21 +194,10 @@ export class ConversationViewer implements Component {
|
|
|
192
194
|
this.timeline = new ConversationTimeline(tui, theme, {
|
|
193
195
|
cwd: operations?.ctx.cwd,
|
|
194
196
|
record,
|
|
195
|
-
onChange: () =>
|
|
196
|
-
this.cacheVersion++;
|
|
197
|
-
this.invalidate();
|
|
198
|
-
this.tui.requestRender();
|
|
199
|
-
},
|
|
197
|
+
onChange: (change) => this.handleTimelineChange(change),
|
|
200
198
|
});
|
|
201
199
|
this.timeline.setBlocks(this.blocks);
|
|
202
|
-
this.unsubscribe = session.subscribe(() =>
|
|
203
|
-
if (this.closed) return;
|
|
204
|
-
this.blocks = formatConversationMessages(this.session.messages);
|
|
205
|
-
this.timeline.setBlocks(this.blocks);
|
|
206
|
-
this.cacheVersion++;
|
|
207
|
-
this.invalidate();
|
|
208
|
-
this.tui.requestRender();
|
|
209
|
-
});
|
|
200
|
+
this.unsubscribe = session.subscribe(() => this.scheduleSessionRefresh());
|
|
210
201
|
}
|
|
211
202
|
|
|
212
203
|
handleInput(data: string): void {
|
|
@@ -365,7 +356,6 @@ export class ConversationViewer implements Component {
|
|
|
365
356
|
this.setFocusFromMessageIndex(selected);
|
|
366
357
|
this.autoScroll = false;
|
|
367
358
|
this.scrollTargetIntoView(selected);
|
|
368
|
-
this.invalidate();
|
|
369
359
|
this.tui.requestRender();
|
|
370
360
|
}
|
|
371
361
|
}
|
|
@@ -504,6 +494,39 @@ export class ConversationViewer implements Component {
|
|
|
504
494
|
this.cachedVersion = -1;
|
|
505
495
|
}
|
|
506
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
|
+
|
|
507
530
|
dispose(): void {
|
|
508
531
|
this.closed = true;
|
|
509
532
|
this.unsubscribe?.();
|
|
@@ -549,6 +572,19 @@ export class ConversationViewer implements Component {
|
|
|
549
572
|
const lines: RenderLine[] = timelineSnapshot.lines.map((line) => ({ ...line }));
|
|
550
573
|
this.cachedMessageStarts = [...timelineSnapshot.messageStarts];
|
|
551
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
|
+
}
|
|
552
588
|
if (this.record.status === "running" && this.activity) {
|
|
553
589
|
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
554
590
|
const text = `▍ ${act}`;
|
|
@@ -571,6 +607,7 @@ export class ConversationViewer implements Component {
|
|
|
571
607
|
this.selectedMessageIdx = index;
|
|
572
608
|
this.hasFocusedBlock = true;
|
|
573
609
|
const block = this.cachedMessageBlocks[index];
|
|
610
|
+
this.selectedBlockId = block.id;
|
|
574
611
|
this.timeline.setFocusedToolCallId(block.kind === "tool" ? block.toolCallId : undefined);
|
|
575
612
|
return true;
|
|
576
613
|
}
|
|
@@ -617,7 +654,6 @@ export class ConversationViewer implements Component {
|
|
|
617
654
|
this.setFocusFromMessageIndex(target);
|
|
618
655
|
this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
|
|
619
656
|
this.autoScroll = false;
|
|
620
|
-
this.invalidate();
|
|
621
657
|
this.tui.requestRender();
|
|
622
658
|
}
|
|
623
659
|
|
|
@@ -631,7 +667,6 @@ export class ConversationViewer implements Component {
|
|
|
631
667
|
this.autoScroll = false;
|
|
632
668
|
this.scrollTargetIntoView(index);
|
|
633
669
|
}
|
|
634
|
-
this.invalidate();
|
|
635
670
|
this.tui.requestRender();
|
|
636
671
|
}
|
|
637
672
|
|
|
@@ -653,7 +688,6 @@ export class ConversationViewer implements Component {
|
|
|
653
688
|
this.setFocusFromMessageIndex(resolvedTarget);
|
|
654
689
|
this.autoScroll = false;
|
|
655
690
|
this.scrollTargetIntoView(resolvedTarget);
|
|
656
|
-
this.invalidate();
|
|
657
691
|
this.tui.requestRender();
|
|
658
692
|
}
|
|
659
693
|
|
|
@@ -701,7 +735,6 @@ export class ConversationViewer implements Component {
|
|
|
701
735
|
const selected = this.messageIndexForOffset(this.scrollOffset);
|
|
702
736
|
this.setFocusFromMessageIndex(selected);
|
|
703
737
|
this.autoScroll = this.scrollOffset >= max;
|
|
704
|
-
this.invalidate();
|
|
705
738
|
this.tui.requestRender();
|
|
706
739
|
}
|
|
707
740
|
|
|
@@ -709,7 +742,6 @@ export class ConversationViewer implements Component {
|
|
|
709
742
|
this.scrollOffset = 0;
|
|
710
743
|
this.setFocusFromMessageIndex(0);
|
|
711
744
|
this.autoScroll = false;
|
|
712
|
-
this.invalidate();
|
|
713
745
|
this.tui.requestRender();
|
|
714
746
|
}
|
|
715
747
|
|
|
@@ -717,7 +749,6 @@ export class ConversationViewer implements Component {
|
|
|
717
749
|
this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
718
750
|
this.setFocusFromMessageIndex(Math.max(0, this.cachedMessageStarts.length - 1));
|
|
719
751
|
this.autoScroll = true;
|
|
720
|
-
this.invalidate();
|
|
721
752
|
this.tui.requestRender();
|
|
722
753
|
}
|
|
723
754
|
|
|
@@ -769,7 +800,6 @@ export class ConversationViewer implements Component {
|
|
|
769
800
|
this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
|
|
770
801
|
this.setFocusFromMessageIndex(this.messageIndexForOffset(this.scrollOffset));
|
|
771
802
|
this.autoScroll = false;
|
|
772
|
-
this.invalidate();
|
|
773
803
|
}
|
|
774
804
|
|
|
775
805
|
private async copyCurrentMessage(): Promise<void> {
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Coalesced, deferred render requests for structural UI changes. */
|
|
2
|
+
|
|
3
|
+
const DEFAULT_DELAY_MS = 50;
|
|
4
|
+
const DEFAULT_MAX_WAIT_MS = 200;
|
|
5
|
+
|
|
6
|
+
export interface DeferredUiRefresh {
|
|
7
|
+
/** Schedule one render request after the debounce/max-wait window. */
|
|
8
|
+
schedule(): void;
|
|
9
|
+
/** Cancel the current pending render request, if any. */
|
|
10
|
+
cancel(): void;
|
|
11
|
+
/** Cancel and permanently disable this scheduler. */
|
|
12
|
+
dispose(): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createDeferredUiRefresh(
|
|
16
|
+
requestRender: () => void,
|
|
17
|
+
options: {
|
|
18
|
+
delayMs?: number;
|
|
19
|
+
maxWaitMs?: number;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
} = {},
|
|
22
|
+
): DeferredUiRefresh {
|
|
23
|
+
const delayMs = Math.max(0, options.delayMs ?? DEFAULT_DELAY_MS);
|
|
24
|
+
const maxWaitMs = Math.max(0, options.maxWaitMs ?? DEFAULT_MAX_WAIT_MS);
|
|
25
|
+
const now = options.now ?? Date.now;
|
|
26
|
+
|
|
27
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
|
28
|
+
let firstScheduledAt: number | undefined;
|
|
29
|
+
let disposed = false;
|
|
30
|
+
|
|
31
|
+
const clearPendingTimer = () => {
|
|
32
|
+
if (debounceTimer !== undefined) {
|
|
33
|
+
clearTimeout(debounceTimer);
|
|
34
|
+
debounceTimer = undefined;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const flush = () => {
|
|
39
|
+
// Clear all state before invoking user code. A render callback may schedule
|
|
40
|
+
// another refresh, which should begin a fresh debounce window.
|
|
41
|
+
clearPendingTimer();
|
|
42
|
+
firstScheduledAt = undefined;
|
|
43
|
+
if (disposed) return;
|
|
44
|
+
requestRender();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const schedule = () => {
|
|
48
|
+
if (disposed) return;
|
|
49
|
+
|
|
50
|
+
const current = now();
|
|
51
|
+
if (firstScheduledAt === undefined) firstScheduledAt = current;
|
|
52
|
+
|
|
53
|
+
clearPendingTimer();
|
|
54
|
+
const maxDeadline = firstScheduledAt + maxWaitMs;
|
|
55
|
+
const remainingMaxWait = Math.max(0, maxDeadline - current);
|
|
56
|
+
const wait = Math.min(delayMs, remainingMaxWait);
|
|
57
|
+
debounceTimer = setTimeout(flush, wait);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const cancel = () => {
|
|
61
|
+
clearPendingTimer();
|
|
62
|
+
firstScheduledAt = undefined;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const dispose = () => {
|
|
66
|
+
if (disposed) return;
|
|
67
|
+
cancel();
|
|
68
|
+
disposed = true;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
return { schedule, cancel, dispose };
|
|
72
|
+
}
|