@esso0428/pi-subagents 0.15.9 → 0.15.11
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 +15 -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 +8 -0
- package/dist/ui/conversation-timeline.d.ts.map +1 -1
- package/dist/ui/conversation-timeline.js +101 -20
- package/dist/ui/conversation-timeline.js.map +1 -1
- package/dist/ui/conversation-viewer.d.ts +5 -0
- package/dist/ui/conversation-viewer.d.ts.map +1 -1
- package/dist/ui/conversation-viewer.js +90 -17
- 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 +126 -18
- package/src/ui/conversation-viewer.ts +87 -17
- package/src/write-execution.ts +7 -1
- package/test/ui/conversation-diff.test.ts +13 -0
- package/test/ui/conversation-viewer.test.ts +74 -6
- 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
|
|
|
@@ -287,16 +354,60 @@ export class ConversationTimeline implements Component {
|
|
|
287
354
|
}
|
|
288
355
|
|
|
289
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
|
+
|
|
290
370
|
// The TUI sends a click only when a component claims the preceding press.
|
|
291
|
-
// Claim
|
|
292
|
-
//
|
|
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.
|
|
293
373
|
if (event.type !== "click" && event.type !== "press") return undefined;
|
|
294
374
|
if (event.button !== "left") return undefined;
|
|
295
|
-
const
|
|
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);
|
|
296
394
|
if (!span) return undefined;
|
|
297
395
|
if (event.type === "press") return { handled: true, capture: true, render: false };
|
|
298
396
|
|
|
397
|
+
const previousClickedToolCallId = this.lastClickedToolCallId;
|
|
299
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
|
+
|
|
300
411
|
const component = this.toolComponents.get(span.toolCallId);
|
|
301
412
|
try {
|
|
302
413
|
// Keep Pi's normalized mouse contract in the component tree. The local
|
|
@@ -306,9 +417,6 @@ export class ConversationTimeline implements Component {
|
|
|
306
417
|
} catch {
|
|
307
418
|
// A missing/older Pi mouse implementation must not break the viewer.
|
|
308
419
|
}
|
|
309
|
-
this.expandedTools.has(span.toolCallId)
|
|
310
|
-
? this.expandedTools.delete(span.toolCallId)
|
|
311
|
-
: this.expandedTools.add(span.toolCallId);
|
|
312
420
|
this.invalidate();
|
|
313
421
|
this.options.onChange?.();
|
|
314
422
|
return { handled: true, render: true };
|
|
@@ -62,6 +62,8 @@ export class ConversationViewer implements Component {
|
|
|
62
62
|
private lastInnerW = 0;
|
|
63
63
|
private closed = false;
|
|
64
64
|
private stopArmed = false;
|
|
65
|
+
private hoveredClose = false;
|
|
66
|
+
private scopedMouseMotion = false;
|
|
65
67
|
private keys: ViewerKeys;
|
|
66
68
|
private composer: Input | undefined;
|
|
67
69
|
private searchMode = false;
|
|
@@ -86,6 +88,7 @@ export class ConversationViewer implements Component {
|
|
|
86
88
|
/** Optional clipboard/nvim operation context, appended for compatibility. */
|
|
87
89
|
private operations?: ConversationViewerOperations,
|
|
88
90
|
) {
|
|
91
|
+
this.enableScopedMouseMotion();
|
|
89
92
|
this.keys = createViewerKeys(keybindings);
|
|
90
93
|
this.blocks = formatConversationMessages(session.messages);
|
|
91
94
|
this.timeline = new ConversationTimeline(tui, theme, {
|
|
@@ -135,11 +138,11 @@ export class ConversationViewer implements Component {
|
|
|
135
138
|
this.timeline.toggleFocusedTool();
|
|
136
139
|
return;
|
|
137
140
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
141
|
+
}
|
|
142
|
+
if (data === "e" && this.canSteer()) {
|
|
143
|
+
this.stopArmed = false;
|
|
144
|
+
this.openComposer();
|
|
145
|
+
return;
|
|
143
146
|
}
|
|
144
147
|
if (matchesKey(data, "x")) {
|
|
145
148
|
if (this.isStoppable()) {
|
|
@@ -210,9 +213,35 @@ export class ConversationViewer implements Component {
|
|
|
210
213
|
void maxScroll;
|
|
211
214
|
}
|
|
212
215
|
|
|
213
|
-
handleMouse(event: TuiMouseEvent): { handled?: boolean; render?: boolean } | undefined {
|
|
214
|
-
if (this.composer || this.searchMode) return undefined;
|
|
216
|
+
handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
|
|
215
217
|
const innerW = Math.max(1, this.lastInnerW || event.width - 4);
|
|
218
|
+
const closeHit = this.isCloseButtonHit(event.x, event.y, innerW);
|
|
219
|
+
if (event.type === "move") {
|
|
220
|
+
const closeChanged = closeHit !== this.hoveredClose;
|
|
221
|
+
this.hoveredClose = closeHit;
|
|
222
|
+
if (this.composer || this.searchMode) {
|
|
223
|
+
return closeChanged ? { handled: true, render: true } : undefined;
|
|
224
|
+
}
|
|
225
|
+
const contentLines = this.buildContentLines(innerW);
|
|
226
|
+
const viewportHeight = this.viewportHeight();
|
|
227
|
+
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
228
|
+
const visibleStart = Math.min(this.scrollOffset, maxScroll);
|
|
229
|
+
const contentOrigin = 3 + (this.invocationLine() ? 1 : 0);
|
|
230
|
+
const contentY = event.y - contentOrigin + visibleStart;
|
|
231
|
+
// Forward moves outside the content too so a previously hovered tool
|
|
232
|
+
// hint is cleared when the pointer returns to the header/footer.
|
|
233
|
+
const timelineResult = this.timeline.handleMouse({ ...event, y: contentY });
|
|
234
|
+
return timelineResult ?? (closeChanged ? { handled: true, render: true } : undefined);
|
|
235
|
+
}
|
|
236
|
+
if (closeHit && event.button === "left") {
|
|
237
|
+
if (event.type === "press") return { handled: true, capture: true, render: false };
|
|
238
|
+
if (event.type === "click") {
|
|
239
|
+
this.closed = true;
|
|
240
|
+
this.done(undefined);
|
|
241
|
+
return { handled: true, render: false };
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (this.composer || this.searchMode) return undefined;
|
|
216
245
|
const contentLines = this.buildContentLines(innerW);
|
|
217
246
|
const viewportHeight = this.viewportHeight();
|
|
218
247
|
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
|
|
@@ -231,13 +260,16 @@ export class ConversationViewer implements Component {
|
|
|
231
260
|
if (contentY < 0 || contentY >= contentLines.length) return undefined;
|
|
232
261
|
const result = this.timeline.handleMouse({ ...event, y: contentY });
|
|
233
262
|
if (result?.handled) {
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
263
|
+
if (event.type === "click") {
|
|
264
|
+
const focusedTool = this.timeline.getFocusedToolCallId();
|
|
265
|
+
const refreshed = this.timeline.getSnapshot();
|
|
266
|
+
const selected = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
|
|
267
|
+
if (selected >= 0) this.selectedMessageIdx = selected;
|
|
268
|
+
this.cacheVersion++;
|
|
269
|
+
this.invalidate();
|
|
270
|
+
this.tui.requestRender();
|
|
271
|
+
}
|
|
272
|
+
return result;
|
|
241
273
|
}
|
|
242
274
|
return result;
|
|
243
275
|
}
|
|
@@ -268,7 +300,12 @@ export class ConversationViewer implements Component {
|
|
|
268
300
|
const percent = getSessionContextPercent(this.activity?.session);
|
|
269
301
|
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
|
|
270
302
|
}
|
|
271
|
-
|
|
303
|
+
const headerText = `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`;
|
|
304
|
+
const closeLabel = this.hoveredClose ? th.fg("text", th.bold("[esc]")) : th.fg("dim", "[esc]");
|
|
305
|
+
const closeWidth = visibleWidth(closeLabel);
|
|
306
|
+
const headerLeft = truncateToWidth(headerText, Math.max(0, innerW - closeWidth - 1), "", true);
|
|
307
|
+
const headerGap = Math.max(1, innerW - visibleWidth(headerLeft) - closeWidth);
|
|
308
|
+
lines.push(row(headerLeft + " ".repeat(headerGap) + closeLabel));
|
|
272
309
|
const invocationLine = this.invocationLine();
|
|
273
310
|
if (invocationLine) lines.push(row(invocationLine));
|
|
274
311
|
lines.push(hrMid);
|
|
@@ -322,7 +359,7 @@ export class ConversationViewer implements Component {
|
|
|
322
359
|
lines.push(row(truncateToWidth(th.fg("accent", query) + th.fg("dim", matchCount) + hints, innerW, "...", true)));
|
|
323
360
|
} else {
|
|
324
361
|
const actions: string[] = [];
|
|
325
|
-
if (this.canSteer()) actions.push(th.fg("dim", "
|
|
362
|
+
if (this.canSteer()) actions.push(th.fg("dim", "e steer"));
|
|
326
363
|
if (this.isStoppable()) actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
|
|
327
364
|
const editHint = this.isLive() ? " O" : "";
|
|
328
365
|
const shortcutHints = [
|
|
@@ -354,6 +391,28 @@ export class ConversationViewer implements Component {
|
|
|
354
391
|
return lines;
|
|
355
392
|
}
|
|
356
393
|
|
|
394
|
+
private enableScopedMouseMotion(): void {
|
|
395
|
+
const terminal = this.tui.terminal as { write?: (data: string) => void } | undefined;
|
|
396
|
+
const isMultiplexed = Boolean(process.env.TMUX || process.env.ZELLIJ || process.env.STY);
|
|
397
|
+
if (!isMultiplexed || typeof terminal?.write !== "function") return;
|
|
398
|
+
try {
|
|
399
|
+
// Pi deliberately uses button-motion mode in multiplexers. The viewer's
|
|
400
|
+
// hover target needs passive motion too, so opt in only for this overlay;
|
|
401
|
+
// dispose restores the host's original button-motion mode.
|
|
402
|
+
terminal.write("\x1b[?1003h");
|
|
403
|
+
this.scopedMouseMotion = true;
|
|
404
|
+
} catch {
|
|
405
|
+
// Hover remains optional; clicks and keyboard controls still work.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
private isCloseButtonHit(x: number, y: number, innerW: number): boolean {
|
|
410
|
+
if (y !== 1) return false;
|
|
411
|
+
const labelWidth = visibleWidth("[esc]");
|
|
412
|
+
const labelStart = 2 + Math.max(0, innerW - labelWidth);
|
|
413
|
+
return x >= labelStart && x < 2 + innerW;
|
|
414
|
+
}
|
|
415
|
+
|
|
357
416
|
invalidate(): void {
|
|
358
417
|
this.cachedWidth = 0;
|
|
359
418
|
this.cachedLines = [];
|
|
@@ -366,6 +425,14 @@ export class ConversationViewer implements Component {
|
|
|
366
425
|
this.closed = true;
|
|
367
426
|
this.unsubscribe?.();
|
|
368
427
|
this.unsubscribe = undefined;
|
|
428
|
+
if (this.scopedMouseMotion) {
|
|
429
|
+
try {
|
|
430
|
+
this.tui.terminal.write("\x1b[?1003l");
|
|
431
|
+
} catch {
|
|
432
|
+
// The TUI may already be stopping; there is nothing else to clean up.
|
|
433
|
+
}
|
|
434
|
+
this.scopedMouseMotion = false;
|
|
435
|
+
}
|
|
369
436
|
}
|
|
370
437
|
|
|
371
438
|
private viewportHeight(): number {
|
|
@@ -429,7 +496,10 @@ export class ConversationViewer implements Component {
|
|
|
429
496
|
}
|
|
430
497
|
|
|
431
498
|
private activeHeaderLine(): number {
|
|
432
|
-
|
|
499
|
+
const block = this.cachedMessageBlocks[this.currentMessageIndex()];
|
|
500
|
+
return block?.role === "user" || block?.role === "assistant"
|
|
501
|
+
? this.cachedMessageStarts[this.currentMessageIndex()] ?? -1
|
|
502
|
+
: -1;
|
|
433
503
|
}
|
|
434
504
|
|
|
435
505
|
private currentBlock(): ConversationBlock | undefined {
|
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" }],
|
|
@@ -83,8 +83,13 @@ describe("ConversationViewer", () => {
|
|
|
83
83
|
);
|
|
84
84
|
const liveFooter = live.render(120).join("\n");
|
|
85
85
|
expect(liveFooter).toContain("j/k scroll · J/K messages · g/G t M o O /n N q");
|
|
86
|
-
expect(liveFooter).toContain("
|
|
86
|
+
expect(liveFooter).toContain("e steer");
|
|
87
87
|
expect(liveFooter).toContain("x stop");
|
|
88
|
+
live.handleInput("\r");
|
|
89
|
+
expect(live.render(120).join("\n")).not.toContain("Enter send");
|
|
90
|
+
live.handleInput("e");
|
|
91
|
+
expect(live.render(120).join("\n")).toContain("Enter send");
|
|
92
|
+
live.handleInput("\x1b");
|
|
88
93
|
|
|
89
94
|
const history = new ConversationViewer(
|
|
90
95
|
tui(),
|
|
@@ -147,7 +152,9 @@ describe("ConversationViewer", () => {
|
|
|
147
152
|
vi.fn(),
|
|
148
153
|
);
|
|
149
154
|
|
|
150
|
-
|
|
155
|
+
const initial = viewer.render(120).join("\n");
|
|
156
|
+
expect(initial).not.toContain("six");
|
|
157
|
+
expect(initial).not.toContain("READ");
|
|
151
158
|
viewer.handleInput("\r");
|
|
152
159
|
expect(viewer.render(120).join("\n")).toContain("six");
|
|
153
160
|
});
|
|
@@ -209,7 +216,7 @@ describe("ConversationViewer", () => {
|
|
|
209
216
|
expect((viewer as any).scrollOffset).toBeLessThan(before);
|
|
210
217
|
});
|
|
211
218
|
|
|
212
|
-
it("
|
|
219
|
+
it("expands on the first click and collapses only on the second click", () => {
|
|
213
220
|
const viewer = new ConversationViewer(
|
|
214
221
|
tui(),
|
|
215
222
|
createStaticConversationSource([
|
|
@@ -222,11 +229,72 @@ describe("ConversationViewer", () => {
|
|
|
222
229
|
vi.fn(),
|
|
223
230
|
);
|
|
224
231
|
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
225
|
-
const
|
|
226
|
-
expect(
|
|
232
|
+
const span = (viewer as any).timeline.getSnapshot().toolSpans[0];
|
|
233
|
+
expect(span.clickStartLine).toEqual(expect.any(Number));
|
|
234
|
+
const hintY = 4 + span.clickStartLine;
|
|
235
|
+
const mouse = { button: "left", x: 1, screenX: 1, screenY: hintY, width: 120, height: 30, shift: false, alt: false, ctrl: false };
|
|
236
|
+
expect(viewer.handleMouse({ ...mouse, type: "press", y: hintY } as any)?.handled).toBe(true);
|
|
227
237
|
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
228
|
-
viewer.handleMouse({ ...mouse, type: "click", y:
|
|
238
|
+
viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 1 } as any);
|
|
239
|
+
expect(viewer.render(120).join("\n")).toContain("six");
|
|
240
|
+
viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 1 } as any);
|
|
229
241
|
expect(viewer.render(120).join("\n")).toContain("six");
|
|
242
|
+
viewer.handleMouse({ ...mouse, type: "click", y: hintY, clickCount: 2 } as any);
|
|
243
|
+
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("enables passive mouse motion only while the viewer is open in a multiplexer", () => {
|
|
247
|
+
const originalTmux = process.env.TMUX;
|
|
248
|
+
process.env.TMUX = "test-multiplexer";
|
|
249
|
+
const ui = tui();
|
|
250
|
+
ui.terminal.write = vi.fn();
|
|
251
|
+
const viewer = new ConversationViewer(
|
|
252
|
+
ui,
|
|
253
|
+
createStaticConversationSource([{ role: "assistant", content: "history" }] as any),
|
|
254
|
+
record(),
|
|
255
|
+
undefined,
|
|
256
|
+
theme,
|
|
257
|
+
vi.fn(),
|
|
258
|
+
);
|
|
259
|
+
expect(ui.terminal.write).toHaveBeenCalledWith("\x1b[?1003h");
|
|
260
|
+
viewer.dispose();
|
|
261
|
+
expect(ui.terminal.write).toHaveBeenCalledWith("\x1b[?1003l");
|
|
262
|
+
if (originalTmux === undefined) delete process.env.TMUX;
|
|
263
|
+
else process.env.TMUX = originalTmux;
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("highlights the tool hint on hover and closes from the viewer [esc] button", () => {
|
|
267
|
+
const semanticTheme = {
|
|
268
|
+
fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
|
|
269
|
+
bold: (text: string) => `<bold>${text}</bold>`,
|
|
270
|
+
};
|
|
271
|
+
const done = vi.fn();
|
|
272
|
+
const viewer = new ConversationViewer(
|
|
273
|
+
tui(),
|
|
274
|
+
createStaticConversationSource([
|
|
275
|
+
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
|
|
276
|
+
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
|
|
277
|
+
] as any),
|
|
278
|
+
record(),
|
|
279
|
+
undefined,
|
|
280
|
+
semanticTheme,
|
|
281
|
+
done,
|
|
282
|
+
);
|
|
283
|
+
const initial = viewer.render(120).join("\n");
|
|
284
|
+
const span = (viewer as any).timeline.getSnapshot().toolSpans[0];
|
|
285
|
+
expect(span.clickStartLine).toEqual(expect.any(Number));
|
|
286
|
+
const hintY = 4 + span.clickStartLine;
|
|
287
|
+
expect(initial).not.toContain("<text>click to show more</text>");
|
|
288
|
+
viewer.handleMouse({ type: "move", button: "none", x: 2, y: hintY, screenX: 2, screenY: hintY, width: 120, height: 30, shift: false, alt: false, ctrl: false } as any);
|
|
289
|
+
expect(viewer.render(120).join("\n")).toContain("<text>click to show more</text>");
|
|
290
|
+
|
|
291
|
+
expect(viewer.render(120).join("\n")).toContain("[esc]");
|
|
292
|
+
viewer.handleMouse({ type: "move", button: "none", x: 114, y: 1, screenX: 114, screenY: 1, width: 120, height: 30, shift: false, alt: false, ctrl: false } as any);
|
|
293
|
+
expect(viewer.render(120).join("\n")).toContain("<text><bold>[esc]</bold></text>");
|
|
294
|
+
const close = { button: "left", x: 114, screenX: 114, screenY: 1, y: 1, width: 120, height: 30, shift: false, alt: false, ctrl: false };
|
|
295
|
+
expect(viewer.handleMouse({ ...close, type: "press" } as any)?.handled).toBe(true);
|
|
296
|
+
expect(viewer.handleMouse({ ...close, type: "click", clickCount: 1 } as any)?.handled).toBe(true);
|
|
297
|
+
expect(done).toHaveBeenCalledTimes(1);
|
|
230
298
|
});
|
|
231
299
|
|
|
232
300
|
it("keeps historical viewers read-only and closes on Escape", () => {
|
|
@@ -31,6 +31,7 @@ describe("executeWriteWithMetadata", () => {
|
|
|
31
31
|
|
|
32
32
|
expect(await readFile(join(cwd, "nested/file.txt"), "utf8")).toBe("new\n");
|
|
33
33
|
expect(result.content[0]?.text).toContain("Successfully wrote to nested/file.txt");
|
|
34
|
+
expect(result.details?.fileExistedBeforeWrite).toBe(false);
|
|
34
35
|
expect(result.details?.diff).toContain("+1 new");
|
|
35
36
|
expect(result.details?.patch).toContain("+new");
|
|
36
37
|
expect(store.get("call-new")?.diff).toBe(result.details?.diff);
|
|
@@ -48,6 +49,7 @@ describe("executeWriteWithMetadata", () => {
|
|
|
48
49
|
|
|
49
50
|
await writeFile(join(cwd, "file.txt"), "later\n", "utf8");
|
|
50
51
|
|
|
52
|
+
expect(persisted.fileExistedBeforeWrite).toBe(true);
|
|
51
53
|
expect(persisted.diff).toContain("-1 old");
|
|
52
54
|
expect(persisted.diff).toContain("+1 new");
|
|
53
55
|
expect(persisted.patch).toContain("-old");
|