@esso0428/pi-subagents 0.15.8 → 0.15.10
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/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +53 -0
- package/.pi/workflows/index.json +5 -0
- package/CHANGELOG.md +17 -0
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/diff/diff-renderer.js +4 -2
- package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
- package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-renderer.js +12 -2
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
- package/dist/ui/ccstyle/tool-result.js +14 -3
- package/dist/ui/ccstyle/tool-result.js.map +1 -1
- package/dist/ui/conversation-timeline.d.ts +9 -0
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +107 -22
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +4 -0
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +116 -22
- package/dist/ui/conversation-viewer.js.map +1 -1
- package/dist/write-execution.d.ts +2 -0
- package/dist/write-execution.d.ts.map +1 -1
- package/dist/write-execution.js +5 -1
- package/dist/write-execution.js.map +1 -1
- package/package.json +1 -1
- package/src/ui/ccstyle/diff/diff-renderer.ts +7 -2
- package/src/ui/ccstyle/tool-renderer.ts +18 -2
- package/src/ui/ccstyle/tool-result.ts +14 -3
- package/src/ui/conversation-timeline.ts +132 -21
- package/src/ui/conversation-viewer.ts +113 -23
- package/src/write-execution.ts +7 -1
- package/test/ui/conversation-diff.test.ts +13 -0
- package/test/ui/conversation-viewer.test.ts +113 -9
- package/test/write-execution.test.ts +2 -0
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
createViewerCcstyleTool,
|
|
21
21
|
type ViewerToolRenderers,
|
|
22
22
|
} from "./ccstyle/tool-renderer.js";
|
|
23
|
+
import { showMoreHintText } from "./ccstyle/diff/render-utils.js";
|
|
23
24
|
|
|
24
25
|
export interface TimelineRenderLine {
|
|
25
26
|
text: string;
|
|
@@ -32,6 +33,9 @@ export interface TimelineToolSpan {
|
|
|
32
33
|
toolCallId: string;
|
|
33
34
|
startLine: number;
|
|
34
35
|
height: number;
|
|
36
|
+
/** The only row that opens a collapsed card with a single click. */
|
|
37
|
+
clickStartLine?: number;
|
|
38
|
+
clickHeight?: number;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
export interface ConversationTimelineSnapshot {
|
|
@@ -58,7 +62,7 @@ function resultContent(result: ConversationToolResultSnapshot): Array<Record<str
|
|
|
58
62
|
return [];
|
|
59
63
|
}
|
|
60
64
|
|
|
61
|
-
function fallbackToolLines(block: ConversationBlock, width: number, expanded = false): string[] {
|
|
65
|
+
function fallbackToolLines(block: ConversationBlock, width: number, expanded = false, hovered = false, theme?: Theme): string[] {
|
|
62
66
|
const summary = block.toolLine || block.fullText || block.toolName || "tool";
|
|
63
67
|
const output = block.toolResult
|
|
64
68
|
? block.toolResult.content
|
|
@@ -71,9 +75,17 @@ function fallbackToolLines(block: ConversationBlock, width: number, expanded = f
|
|
|
71
75
|
const outputLines = outputText.split(/\r?\n/);
|
|
72
76
|
const displayOutput = expanded || outputLines.length <= 4
|
|
73
77
|
? outputLines
|
|
74
|
-
: [...outputLines.slice(0, 4), `… (${outputLines.length - 4} more lines ·
|
|
78
|
+
: [...outputLines.slice(0, 4), `… (${outputLines.length - 4} more lines · ${showMoreHintText()})`];
|
|
75
79
|
const body = outputText ? [summary, ...displayOutput].join("\n") : summary;
|
|
76
|
-
return body.split(/\r?\n/).map((line) =>
|
|
80
|
+
return body.split(/\r?\n/).map((line) => {
|
|
81
|
+
const clipped = truncateToWidth(line, width, "");
|
|
82
|
+
if (!hovered || expanded || !theme) return clipped;
|
|
83
|
+
const plain = stripAnsi(clipped);
|
|
84
|
+
const marker = plain.includes(showMoreHintText()) ? showMoreHintText() : plain.includes("Enter to expand") ? "Enter to expand" : "";
|
|
85
|
+
if (!marker) return clipped;
|
|
86
|
+
const markerIndex = plain.indexOf(marker);
|
|
87
|
+
return theme.fg("dim", plain.slice(0, markerIndex)) + theme.fg("text", marker) + theme.fg("dim", plain.slice(markerIndex + marker.length));
|
|
88
|
+
});
|
|
77
89
|
}
|
|
78
90
|
|
|
79
91
|
function applyToolComponentState(component: any, block: ConversationBlock, expanded: boolean): void {
|
|
@@ -95,12 +107,13 @@ function createToolComponent(
|
|
|
95
107
|
tui: TUI,
|
|
96
108
|
cwd: string,
|
|
97
109
|
expanded: boolean,
|
|
110
|
+
isHovered: () => boolean,
|
|
98
111
|
): Component | undefined {
|
|
99
112
|
const Constructor = (CodingAgent as any).ToolExecutionComponent as (new (...args: any[]) => Component) | undefined;
|
|
100
113
|
if (typeof Constructor !== "function" || !block.toolName || !block.toolCallId) return undefined;
|
|
101
114
|
|
|
102
115
|
try {
|
|
103
|
-
const renderer = createViewerCcstyleTool(block.toolName) as ViewerToolRenderers;
|
|
116
|
+
const renderer = createViewerCcstyleTool(block.toolName, isHovered) as ViewerToolRenderers;
|
|
104
117
|
const component = new Constructor(
|
|
105
118
|
block.toolName,
|
|
106
119
|
block.toolCallId,
|
|
@@ -129,6 +142,10 @@ export class ConversationTimeline implements Component {
|
|
|
129
142
|
private showTools = true;
|
|
130
143
|
private focusedToolCallId: string | undefined;
|
|
131
144
|
private expandedTools = new Set<string>();
|
|
145
|
+
private hoveredToolCallId: string | undefined;
|
|
146
|
+
/** The last tool clicked; used to make only clickCount=2 collapse it. */
|
|
147
|
+
private lastClickedToolCallId: string | undefined;
|
|
148
|
+
private lastClickedPoint: { x: number; y: number } | undefined;
|
|
132
149
|
private toolComponents = new Map<string, Component & { handleMouse?: (event: TuiMouseEvent) => unknown }>();
|
|
133
150
|
private snapshot: ConversationTimelineSnapshot = {
|
|
134
151
|
lines: [],
|
|
@@ -149,6 +166,11 @@ export class ConversationTimeline implements Component {
|
|
|
149
166
|
this.blocks = [...blocks];
|
|
150
167
|
const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
|
|
151
168
|
if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
|
|
169
|
+
if (this.hoveredToolCallId && !ids.has(this.hoveredToolCallId)) this.hoveredToolCallId = undefined;
|
|
170
|
+
if (this.lastClickedToolCallId && !ids.has(this.lastClickedToolCallId)) {
|
|
171
|
+
this.lastClickedToolCallId = undefined;
|
|
172
|
+
this.lastClickedPoint = undefined;
|
|
173
|
+
}
|
|
152
174
|
for (const id of this.expandedTools) if (!ids.has(id)) this.expandedTools.delete(id);
|
|
153
175
|
for (const id of this.toolComponents.keys()) if (!ids.has(id)) this.toolComponents.delete(id);
|
|
154
176
|
this.invalidate();
|
|
@@ -157,7 +179,12 @@ export class ConversationTimeline implements Component {
|
|
|
157
179
|
setShowTools(show: boolean): void {
|
|
158
180
|
if (this.showTools === show) return;
|
|
159
181
|
this.showTools = show;
|
|
160
|
-
if (!show)
|
|
182
|
+
if (!show) {
|
|
183
|
+
this.focusedToolCallId = undefined;
|
|
184
|
+
this.hoveredToolCallId = undefined;
|
|
185
|
+
this.lastClickedToolCallId = undefined;
|
|
186
|
+
this.lastClickedPoint = undefined;
|
|
187
|
+
}
|
|
161
188
|
this.invalidate();
|
|
162
189
|
}
|
|
163
190
|
|
|
@@ -174,6 +201,10 @@ export class ConversationTimeline implements Component {
|
|
|
174
201
|
return this.focusedToolCallId;
|
|
175
202
|
}
|
|
176
203
|
|
|
204
|
+
getHoveredToolCallId(): string | undefined {
|
|
205
|
+
return this.hoveredToolCallId;
|
|
206
|
+
}
|
|
207
|
+
|
|
177
208
|
getSnapshot(): ConversationTimelineSnapshot {
|
|
178
209
|
if (this.lastWidth <= 0) this.render(80);
|
|
179
210
|
return this.snapshot;
|
|
@@ -227,10 +258,14 @@ export class ConversationTimeline implements Component {
|
|
|
227
258
|
lines.push({ text: truncateToWidth(separator, requestedWidth), plain: stripAnsi(separator), blockIndex });
|
|
228
259
|
}
|
|
229
260
|
|
|
230
|
-
const
|
|
231
|
-
|
|
261
|
+
const hasRoleHeader = block.role === "user" || block.role === "assistant";
|
|
262
|
+
const blockStartLine = lines.length;
|
|
263
|
+
messageStarts.push(blockStartLine);
|
|
232
264
|
messageBlocks.push(block);
|
|
233
|
-
|
|
265
|
+
if (hasRoleHeader) {
|
|
266
|
+
const headerLine = ` ${renderConversationRoleHeader(block, this.theme)}`;
|
|
267
|
+
lines.push({ text: truncateToWidth(headerLine, requestedWidth), plain: stripAnsi(headerLine), blockIndex, railable: true });
|
|
268
|
+
}
|
|
234
269
|
|
|
235
270
|
if (block.kind === "tool") {
|
|
236
271
|
const id = block.toolCallId;
|
|
@@ -242,22 +277,54 @@ export class ConversationTimeline implements Component {
|
|
|
242
277
|
applyToolComponentState(existing, block, this.expandedTools.has(id));
|
|
243
278
|
component = existing;
|
|
244
279
|
} else {
|
|
245
|
-
component = createToolComponent(
|
|
280
|
+
component = createToolComponent(
|
|
281
|
+
block,
|
|
282
|
+
this.tui,
|
|
283
|
+
this.options.cwd ?? process.cwd(),
|
|
284
|
+
this.expandedTools.has(id),
|
|
285
|
+
() => this.hoveredToolCallId === id,
|
|
286
|
+
) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
|
|
246
287
|
if (component) this.toolComponents.set(id, component);
|
|
247
288
|
}
|
|
248
289
|
}
|
|
249
290
|
let rendered: string[];
|
|
250
291
|
try {
|
|
251
|
-
rendered = component?.render(requestedWidth) ?? fallbackToolLines(
|
|
292
|
+
rendered = component?.render(requestedWidth) ?? fallbackToolLines(
|
|
293
|
+
block,
|
|
294
|
+
requestedWidth,
|
|
295
|
+
id ? this.expandedTools.has(id) : false,
|
|
296
|
+
id ? this.hoveredToolCallId === id : false,
|
|
297
|
+
this.theme,
|
|
298
|
+
);
|
|
252
299
|
} catch {
|
|
253
300
|
// Pi's interactive theme is a capability, not a viewer dependency;
|
|
254
301
|
// test hosts and older Pi versions can lack its initialized theme.
|
|
255
|
-
rendered = fallbackToolLines(
|
|
302
|
+
rendered = fallbackToolLines(
|
|
303
|
+
block,
|
|
304
|
+
requestedWidth,
|
|
305
|
+
id ? this.expandedTools.has(id) : false,
|
|
306
|
+
id ? this.hoveredToolCallId === id : false,
|
|
307
|
+
this.theme,
|
|
308
|
+
);
|
|
256
309
|
}
|
|
257
310
|
for (const line of rendered) {
|
|
258
311
|
lines.push({ text: truncateToWidth(line, requestedWidth), plain: stripAnsi(line), blockIndex, railable: true });
|
|
259
312
|
}
|
|
260
|
-
if (id)
|
|
313
|
+
if (id) {
|
|
314
|
+
const startLine = blockStartLine;
|
|
315
|
+
const hintLine = !this.expandedTools.has(id)
|
|
316
|
+
? rendered.findIndex((line) => {
|
|
317
|
+
const plain = stripAnsi(line);
|
|
318
|
+
return plain.includes(showMoreHintText()) || plain.includes("Enter to expand");
|
|
319
|
+
})
|
|
320
|
+
: -1;
|
|
321
|
+
toolSpans.push({
|
|
322
|
+
toolCallId: id,
|
|
323
|
+
startLine,
|
|
324
|
+
height: lines.length - startLine,
|
|
325
|
+
...(hintLine >= 0 ? { clickStartLine: startLine + (hasRoleHeader ? 1 : 0) + hintLine, clickHeight: 1 } : {}),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
261
328
|
continue;
|
|
262
329
|
}
|
|
263
330
|
|
|
@@ -286,14 +353,61 @@ export class ConversationTimeline implements Component {
|
|
|
286
353
|
return lines.map((line) => line.text);
|
|
287
354
|
}
|
|
288
355
|
|
|
289
|
-
handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
356
|
+
handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
|
|
357
|
+
if (event.type === "move") {
|
|
358
|
+
const hovered = this.snapshot.toolSpans.find((span) =>
|
|
359
|
+
span.clickStartLine !== undefined &&
|
|
360
|
+
event.y >= span.clickStartLine &&
|
|
361
|
+
event.y < span.clickStartLine + (span.clickHeight ?? 1),
|
|
362
|
+
)?.toolCallId;
|
|
363
|
+
if (hovered === this.hoveredToolCallId) return undefined;
|
|
364
|
+
this.hoveredToolCallId = hovered;
|
|
365
|
+
this.invalidate();
|
|
366
|
+
this.options.onChange?.();
|
|
367
|
+
return { handled: true, render: true };
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// The TUI sends a click only when a component claims the preceding press.
|
|
371
|
+
// Claim only the collapsed hint (or the last expanded card's hit area for
|
|
372
|
+
// a possible second click); never change expansion on press or release.
|
|
373
|
+
if (event.type !== "click" && event.type !== "press") return undefined;
|
|
374
|
+
if (event.button !== "left") return undefined;
|
|
375
|
+
const clickCount = event.clickCount ?? 1;
|
|
376
|
+
const hintSpan = this.snapshot.toolSpans.find((candidate) =>
|
|
377
|
+
candidate.clickStartLine !== undefined &&
|
|
378
|
+
event.y >= candidate.clickStartLine &&
|
|
379
|
+
event.y < candidate.clickStartLine + (candidate.clickHeight ?? 1),
|
|
380
|
+
);
|
|
381
|
+
const fullSpan = this.snapshot.toolSpans.find((candidate) =>
|
|
382
|
+
event.y >= candidate.startLine &&
|
|
383
|
+
event.y < candidate.startLine + candidate.height,
|
|
384
|
+
);
|
|
385
|
+
const lastSpan = this.lastClickedToolCallId
|
|
386
|
+
? this.snapshot.toolSpans.find((candidate) => candidate.toolCallId === this.lastClickedToolCallId)
|
|
387
|
+
: undefined;
|
|
388
|
+
const sameLastPoint = this.lastClickedPoint !== undefined
|
|
389
|
+
&& event.screenX === this.lastClickedPoint.x
|
|
390
|
+
&& event.screenY === this.lastClickedPoint.y;
|
|
391
|
+
const span = event.type === "click" && clickCount === 2 && lastSpan
|
|
392
|
+
? lastSpan
|
|
393
|
+
: hintSpan ?? (sameLastPoint ? lastSpan : undefined);
|
|
295
394
|
if (!span) return undefined;
|
|
395
|
+
if (event.type === "press") return { handled: true, capture: true, render: false };
|
|
396
|
+
|
|
397
|
+
const previousClickedToolCallId = this.lastClickedToolCallId;
|
|
296
398
|
this.focusedToolCallId = span.toolCallId;
|
|
399
|
+
this.lastClickedToolCallId = span.toolCallId;
|
|
400
|
+
this.lastClickedPoint = { x: event.screenX, y: event.screenY };
|
|
401
|
+
const isExpanded = this.expandedTools.has(span.toolCallId);
|
|
402
|
+
const shouldCollapse = isExpanded && clickCount === 2 && span.toolCallId === previousClickedToolCallId;
|
|
403
|
+
if (!isExpanded && hintSpan?.toolCallId === span.toolCallId) {
|
|
404
|
+
this.expandedTools.add(span.toolCallId);
|
|
405
|
+
} else if (shouldCollapse) {
|
|
406
|
+
this.expandedTools.delete(span.toolCallId);
|
|
407
|
+
} else if (!isExpanded) {
|
|
408
|
+
return { handled: true, render: false };
|
|
409
|
+
}
|
|
410
|
+
|
|
297
411
|
const component = this.toolComponents.get(span.toolCallId);
|
|
298
412
|
try {
|
|
299
413
|
// Keep Pi's normalized mouse contract in the component tree. The local
|
|
@@ -303,9 +417,6 @@ export class ConversationTimeline implements Component {
|
|
|
303
417
|
} catch {
|
|
304
418
|
// A missing/older Pi mouse implementation must not break the viewer.
|
|
305
419
|
}
|
|
306
|
-
this.expandedTools.has(span.toolCallId)
|
|
307
|
-
? this.expandedTools.delete(span.toolCallId)
|
|
308
|
-
: this.expandedTools.add(span.toolCallId);
|
|
309
420
|
this.invalidate();
|
|
310
421
|
this.options.onChange?.();
|
|
311
422
|
return { handled: true, render: true };
|
|
@@ -62,6 +62,7 @@ export class ConversationViewer implements Component {
|
|
|
62
62
|
private lastInnerW = 0;
|
|
63
63
|
private closed = false;
|
|
64
64
|
private stopArmed = false;
|
|
65
|
+
private hoveredClose = false;
|
|
65
66
|
private keys: ViewerKeys;
|
|
66
67
|
private composer: Input | undefined;
|
|
67
68
|
private searchMode = false;
|
|
@@ -125,16 +126,22 @@ export class ConversationViewer implements Component {
|
|
|
125
126
|
}
|
|
126
127
|
if (matchesKey(data, "enter")) {
|
|
127
128
|
this.buildContentLines(this.lastInnerW || 80);
|
|
128
|
-
|
|
129
|
+
const current = this.currentBlock();
|
|
130
|
+
if (current?.kind === "tool" && current.toolCallId) {
|
|
131
|
+
this.timeline.setFocusedToolCallId(current.toolCallId);
|
|
129
132
|
this.timeline.toggleFocusedTool();
|
|
130
133
|
return;
|
|
131
134
|
}
|
|
132
|
-
if (this.
|
|
133
|
-
this.
|
|
134
|
-
this.openComposer();
|
|
135
|
+
if (this.timeline.isToolFocused()) {
|
|
136
|
+
this.timeline.toggleFocusedTool();
|
|
135
137
|
return;
|
|
136
138
|
}
|
|
137
139
|
}
|
|
140
|
+
if (data === "e" && this.canSteer()) {
|
|
141
|
+
this.stopArmed = false;
|
|
142
|
+
this.openComposer();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
138
145
|
if (matchesKey(data, "x")) {
|
|
139
146
|
if (this.isStoppable()) {
|
|
140
147
|
if (this.stopArmed) {
|
|
@@ -160,13 +167,9 @@ export class ConversationViewer implements Component {
|
|
|
160
167
|
this.invalidate();
|
|
161
168
|
this.tui.requestRender();
|
|
162
169
|
} else if (data === "\t") {
|
|
163
|
-
this.
|
|
164
|
-
this.invalidate();
|
|
165
|
-
this.tui.requestRender();
|
|
170
|
+
this.moveToolFocus(1);
|
|
166
171
|
} else if (matchesKey(data, Key.shift("tab"))) {
|
|
167
|
-
this.
|
|
168
|
-
this.invalidate();
|
|
169
|
-
this.tui.requestRender();
|
|
172
|
+
this.moveToolFocus(-1);
|
|
170
173
|
} else if (data === "J" || matchesKey(data, Key.shift("j"))) {
|
|
171
174
|
this.jumpToMessage(1);
|
|
172
175
|
} else if (data === "K" || matchesKey(data, Key.shift("k"))) {
|
|
@@ -208,21 +211,63 @@ export class ConversationViewer implements Component {
|
|
|
208
211
|
void maxScroll;
|
|
209
212
|
}
|
|
210
213
|
|
|
211
|
-
handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
|
|
212
|
-
if (this.composer || this.searchMode) return undefined;
|
|
214
|
+
handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
|
|
213
215
|
const innerW = Math.max(1, this.lastInnerW || event.width - 4);
|
|
216
|
+
const closeHit = this.isCloseButtonHit(event.x, event.y, innerW);
|
|
217
|
+
if (event.type === "move") {
|
|
218
|
+
const closeChanged = closeHit !== this.hoveredClose;
|
|
219
|
+
this.hoveredClose = closeHit;
|
|
220
|
+
if (this.composer || this.searchMode) {
|
|
221
|
+
return closeChanged ? { handled: true, render: true } : undefined;
|
|
222
|
+
}
|
|
223
|
+
const contentLines = this.buildContentLines(innerW);
|
|
224
|
+
const viewportHeight = this.viewportHeight();
|
|
225
|
+
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
226
|
+
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
227
|
+
const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
|
|
228
|
+
const contentY = event.y - contentOrigin + visibleStart;
|
|
229
|
+
// Forward moves outside the content too so a previously hovered tool
|
|
230
|
+
// hint is cleared when the pointer returns to the header/footer.
|
|
231
|
+
const timelineResult = this.timeline.handleMouse({ ...event, y: contentY });
|
|
232
|
+
return timelineResult ?? (closeChanged ? { handled: true, render: true } : undefined);
|
|
233
|
+
}
|
|
234
|
+
if (closeHit && event.button === "left") {
|
|
235
|
+
if (event.type === "press") return { handled: true, capture: true, render: false };
|
|
236
|
+
if (event.type === "click") {
|
|
237
|
+
this.closed = true;
|
|
238
|
+
this.done(undefined);
|
|
239
|
+
return { handled: true, render: false };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (this.composer || this.searchMode) return undefined;
|
|
214
243
|
const contentLines = this.buildContentLines(innerW);
|
|
215
244
|
const viewportHeight = this.viewportHeight();
|
|
216
245
|
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
217
246
|
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
218
247
|
const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
|
|
248
|
+
|
|
249
|
+
if (event.type === "wheel") {
|
|
250
|
+
if (event.y < contentOrigin || event.y >= contentOrigin + viewportHeight) return undefined;
|
|
251
|
+
const delta = Math.trunc(event.wheelDelta ?? 0);
|
|
252
|
+
if (delta === 0) return { handled: true, render: false };
|
|
253
|
+
this.scrollBy(delta);
|
|
254
|
+
return { handled: true, render: true };
|
|
255
|
+
}
|
|
256
|
+
|
|
219
257
|
const contentY = event.y - contentOrigin + visibleStart;
|
|
220
258
|
if (contentY < 0 || contentY >= contentLines.length) return undefined;
|
|
221
259
|
const result = this.timeline.handleMouse({ ...event, y: contentY });
|
|
222
260
|
if (result?.handled) {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
261
|
+
if (event.type === "click") {
|
|
262
|
+
const focusedTool = this.timeline.getFocusedToolCallId();
|
|
263
|
+
const refreshed = this.timeline.getSnapshot();
|
|
264
|
+
const selected = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
|
|
265
|
+
if (selected >= 0) this.selectedMessageIdx = selected;
|
|
266
|
+
this.cacheVersion++;
|
|
267
|
+
this.invalidate();
|
|
268
|
+
this.tui.requestRender();
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
226
271
|
}
|
|
227
272
|
return result;
|
|
228
273
|
}
|
|
@@ -253,7 +298,12 @@ export class ConversationViewer implements Component {
|
|
|
253
298
|
const percent = getSessionContextPercent(this.activity?.session);
|
|
254
299
|
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
255
300
|
}
|
|
256
|
-
|
|
301
|
+
const headerText = `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`;
|
|
302
|
+
const closeLabel = this.hoveredClose ? th.fg("text", th.bold("[esc]")) : th.fg("dim", "[esc]");
|
|
303
|
+
const closeWidth = visibleWidth(closeLabel);
|
|
304
|
+
const headerLeft = truncateToWidth(headerText, Math.max(0, innerW - closeWidth - 1), "", true);
|
|
305
|
+
const headerGap = Math.max(1, innerW - visibleWidth(headerLeft) - closeWidth);
|
|
306
|
+
lines.push(row(headerLeft + " ".repeat(headerGap) + closeLabel));
|
|
257
307
|
const invocationLine = this.invocationLine();
|
|
258
308
|
if (invocationLine) lines.push(row(invocationLine));
|
|
259
309
|
lines.push(hrMid);
|
|
@@ -283,7 +333,10 @@ export class ConversationViewer implements Component {
|
|
|
283
333
|
if (hits.length > 0) text = highlightConversationLine(text, hits, MATCH_BG, MATCH_BG_CURRENT, BG_RESET);
|
|
284
334
|
}
|
|
285
335
|
if (line.railable && line.blockIndex === currentBlockIdx) {
|
|
286
|
-
|
|
336
|
+
const currentBlock = this.cachedMessageBlocks[this.currentMessageIndex()];
|
|
337
|
+
const railColor = currentBlock?.kind === "tool" ? "toolTitle" : "accent";
|
|
338
|
+
const railGlyph = currentBlock?.kind === "tool" ? "▌" : "▎";
|
|
339
|
+
text = text.startsWith(" ") ? th.fg(railColor, railGlyph) + text.slice(1) : th.fg(railColor, railGlyph) + text;
|
|
287
340
|
}
|
|
288
341
|
lines.push(row(text));
|
|
289
342
|
}
|
|
@@ -304,11 +357,11 @@ export class ConversationViewer implements Component {
|
|
|
304
357
|
lines.push(row(truncateToWidth(th.fg("accent", query) + th.fg("dim", matchCount) + hints, innerW, "...", true)));
|
|
305
358
|
} else {
|
|
306
359
|
const actions: string[] = [];
|
|
307
|
-
if (this.canSteer()) actions.push(th.fg("dim", "
|
|
360
|
+
if (this.canSteer()) actions.push(th.fg("dim", "e steer"));
|
|
308
361
|
if (this.isStoppable()) actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
|
|
309
362
|
const editHint = this.isLive() ? " O" : "";
|
|
310
363
|
const shortcutHints = [
|
|
311
|
-
`j/k · J/K · g/G t M o${editHint} /n N q`,
|
|
364
|
+
`j/k scroll · J/K messages · g/G t M o${editHint} /n N q`,
|
|
312
365
|
`M o${editHint} /n N q`,
|
|
313
366
|
`M o${editHint} /n N q`,
|
|
314
367
|
];
|
|
@@ -336,6 +389,13 @@ export class ConversationViewer implements Component {
|
|
|
336
389
|
return lines;
|
|
337
390
|
}
|
|
338
391
|
|
|
392
|
+
private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
|
|
393
|
+
if (y !== 1) return false;
|
|
394
|
+
const labelWidth = visibleWidth("[esc]");
|
|
395
|
+
const labelStart = 2 + Math.max(0, innerW - labelWidth);
|
|
396
|
+
return x >= labelStart && x < 2 + innerW;
|
|
397
|
+
}
|
|
398
|
+
|
|
339
399
|
invalidate(): void {
|
|
340
400
|
this.cachedWidth = 0;
|
|
341
401
|
this.cachedLines = [];
|
|
@@ -411,7 +471,10 @@ export class ConversationViewer implements Component {
|
|
|
411
471
|
}
|
|
412
472
|
|
|
413
473
|
private activeHeaderLine(): number {
|
|
414
|
-
|
|
474
|
+
const block = this.cachedMessageBlocks[this.currentMessageIndex()];
|
|
475
|
+
return block?.role === "user" || block?.role === "assistant"
|
|
476
|
+
? this.cachedMessageStarts[this.currentMessageIndex()] ?? -1
|
|
477
|
+
: -1;
|
|
415
478
|
}
|
|
416
479
|
|
|
417
480
|
private currentBlock(): ConversationBlock | undefined {
|
|
@@ -428,15 +491,42 @@ export class ConversationViewer implements Component {
|
|
|
428
491
|
}
|
|
429
492
|
|
|
430
493
|
private jumpToMessage(dir: 1 | -1): void {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
494
|
+
const messageIndices = this.cachedMessageBlocks
|
|
495
|
+
.map((block, index) => block.role === "user" || block.role === "assistant" ? index : -1)
|
|
496
|
+
.filter((index): index is number => index >= 0);
|
|
497
|
+
if (messageIndices.length === 0) return;
|
|
498
|
+
const current = this.currentMessageIndex();
|
|
499
|
+
const currentPosition = messageIndices.indexOf(current);
|
|
500
|
+
let precedingPosition = -1;
|
|
501
|
+
for (let index = 0; index < messageIndices.length; index++) {
|
|
502
|
+
if (messageIndices[index] < current) precedingPosition = index;
|
|
503
|
+
}
|
|
504
|
+
const basePosition = currentPosition >= 0
|
|
505
|
+
? currentPosition
|
|
506
|
+
: dir > 0
|
|
507
|
+
? messageIndices.findIndex((index) => index > current)
|
|
508
|
+
: precedingPosition;
|
|
509
|
+
const position = basePosition < 0 ? (dir > 0 ? 0 : messageIndices.length - 1) : basePosition + dir;
|
|
510
|
+
if (position < 0 || position >= messageIndices.length) return;
|
|
511
|
+
const target = messageIndices[position];
|
|
434
512
|
this.selectedMessageIdx = target;
|
|
513
|
+
this.timeline.setFocusedToolCallId(undefined);
|
|
435
514
|
this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
|
|
436
515
|
this.autoScroll = false;
|
|
437
516
|
this.tui.requestRender();
|
|
438
517
|
}
|
|
439
518
|
|
|
519
|
+
private moveToolFocus(direction: 1 | -1): void {
|
|
520
|
+
if (!this.timeline.moveFocus(direction)) return;
|
|
521
|
+
const focused = this.timeline.getFocusedToolCallId();
|
|
522
|
+
const refreshed = this.timeline.getSnapshot();
|
|
523
|
+
const index = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focused);
|
|
524
|
+
if (index >= 0) this.selectedMessageIdx = index;
|
|
525
|
+
this.autoScroll = false;
|
|
526
|
+
this.invalidate();
|
|
527
|
+
this.tui.requestRender();
|
|
528
|
+
}
|
|
529
|
+
|
|
440
530
|
private scrollBy(delta: number): void {
|
|
441
531
|
const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
|
|
442
532
|
this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
|
package/src/write-execution.ts
CHANGED
|
@@ -9,6 +9,8 @@ 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;
|
|
12
14
|
diff?: string;
|
|
13
15
|
patch?: string;
|
|
14
16
|
firstChangedLine?: number;
|
|
@@ -106,7 +108,10 @@ function durableDetails(
|
|
|
106
108
|
content: string,
|
|
107
109
|
): DurableWriteDetails {
|
|
108
110
|
if (metadata.diffUnavailableReason) {
|
|
109
|
-
return {
|
|
111
|
+
return {
|
|
112
|
+
fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
|
|
113
|
+
diffUnavailableReason: metadata.diffUnavailableReason,
|
|
114
|
+
};
|
|
110
115
|
}
|
|
111
116
|
const previousContent = metadata.previousContent ?? "";
|
|
112
117
|
const diffResult = generateDiffString(previousContent, content);
|
|
@@ -116,6 +121,7 @@ function durableDetails(
|
|
|
116
121
|
return { diffUnavailableReason: `durable diff exceeds ${MAX_DURABLE_DIFF_BYTES} bytes` };
|
|
117
122
|
}
|
|
118
123
|
return {
|
|
124
|
+
fileExistedBeforeWrite: metadata.fileExistedBeforeWrite,
|
|
119
125
|
diff: diffResult.diff,
|
|
120
126
|
patch,
|
|
121
127
|
firstChangedLine: diffResult.firstChangedLine,
|
|
@@ -66,6 +66,19 @@ describe("viewer-local ccstyle diff renderer", () => {
|
|
|
66
66
|
expect(rendered).not.toContain("later");
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
it("labels persisted overwrites correctly without reading the current file", () => {
|
|
70
|
+
const component = renderRichToolResult("write", {
|
|
71
|
+
content: [{ type: "text", text: "Successfully wrote" }],
|
|
72
|
+
details: {
|
|
73
|
+
diff: "-1 old\n+1 new",
|
|
74
|
+
patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
|
|
75
|
+
},
|
|
76
|
+
}, { expanded: false }, theme, { args: { path: "file.txt", content: "new\n" } });
|
|
77
|
+
|
|
78
|
+
expect(component?.render(100).join("\\n")).toContain("overwritten");
|
|
79
|
+
expect(component?.render(100).join("\\n")).toContain("click to show more");
|
|
80
|
+
});
|
|
81
|
+
|
|
69
82
|
it("returns an explicit unavailable row instead of an empty success card", () => {
|
|
70
83
|
const component = renderRichToolResult("write", {
|
|
71
84
|
content: [{ type: "text", text: "Successfully wrote" }],
|