@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.
Files changed (35) hide show
  1. package/.pi/tasks/tasks-01a09263-0ac6-77be-97f5-664b3ffea76b.json +53 -0
  2. package/.pi/workflows/index.json +5 -0
  3. package/CHANGELOG.md +15 -0
  4. package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -1
  5. package/dist/ui/ccstyle/diff/diff-renderer.js +4 -2
  6. package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -1
  7. package/dist/ui/ccstyle/tool-renderer.d.ts +1 -1
  8. package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -1
  9. package/dist/ui/ccstyle/tool-renderer.js +12 -2
  10. package/dist/ui/ccstyle/tool-renderer.js.map +1 -1
  11. package/dist/ui/ccstyle/tool-result.d.ts.map +1 -1
  12. package/dist/ui/ccstyle/tool-result.js +14 -3
  13. package/dist/ui/ccstyle/tool-result.js.map +1 -1
  14. package/dist/ui/conversation-timeline.d.ts +8 -0
  15. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  16. package/dist/ui/conversation-timeline.js +101 -20
  17. package/dist/ui/conversation-timeline.js.map +1 -1
  18. package/dist/ui/conversation-viewer.d.ts +5 -0
  19. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  20. package/dist/ui/conversation-viewer.js +90 -17
  21. package/dist/ui/conversation-viewer.js.map +1 -1
  22. package/dist/write-execution.d.ts +2 -0
  23. package/dist/write-execution.d.ts.map +1 -1
  24. package/dist/write-execution.js +5 -1
  25. package/dist/write-execution.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/ui/ccstyle/diff/diff-renderer.ts +7 -2
  28. package/src/ui/ccstyle/tool-renderer.ts +18 -2
  29. package/src/ui/ccstyle/tool-result.ts +14 -3
  30. package/src/ui/conversation-timeline.ts +126 -18
  31. package/src/ui/conversation-viewer.ts +87 -17
  32. package/src/write-execution.ts +7 -1
  33. package/test/ui/conversation-diff.test.ts +13 -0
  34. package/test/ui/conversation-viewer.test.ts +74 -6
  35. 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 · Enter to expand)`];
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) => truncateToWidth(line, width, ""));
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) this.focusedToolCallId = undefined;
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 headerLine = ` ${renderConversationRoleHeader(block, this.theme)}`;
231
- messageStarts.push(lines.length);
261
+ const hasRoleHeader = block.role === "user" || block.role === "assistant";
262
+ const blockStartLine = lines.length;
263
+ messageStarts.push(blockStartLine);
232
264
  messageBlocks.push(block);
233
- lines.push({ text: truncateToWidth(headerLine, requestedWidth), plain: stripAnsi(headerLine), blockIndex, railable: true });
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(block, this.tui, this.options.cwd ?? process.cwd(), this.expandedTools.has(id)) as (Component & { handleMouse?: (event: TuiMouseEvent) => unknown }) | undefined;
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(block, requestedWidth, id ? this.expandedTools.has(id) : false);
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(block, requestedWidth, id ? this.expandedTools.has(id) : false);
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) toolSpans.push({ toolCallId: id, startLine: messageStarts[messageStarts.length - 1], height: lines.length - messageStarts[messageStarts.length - 1] });
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 tool presses without changing expansion state; the actual toggle
292
- // must happen exactly once on the terminal click event.
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 span = this.snapshot.toolSpans.find((candidate) => event.y >= candidate.startLine && event.y < candidate.startLine + candidate.height);
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
- if (this.canSteer()) {
139
- this.stopArmed = false;
140
- this.openComposer();
141
- return;
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
- const focusedTool = this.timeline.getFocusedToolCallId();
235
- const refreshed = this.timeline.getSnapshot();
236
- const selected = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focusedTool);
237
- if (selected >= 0) this.selectedMessageIdx = selected;
238
- this.cacheVersion++;
239
- this.invalidate();
240
- this.tui.requestRender();
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
- lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`));
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", "Enter steer"));
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
- return this.cachedMessageStarts[this.currentMessageIndex()] ?? -1;
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 {
@@ -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 { diffUnavailableReason: metadata.diffUnavailableReason };
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("Enter steer");
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
- expect(viewer.render(120).join("\n")).not.toContain("six");
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("toggles mouse expansion only on the normalized click event", () => {
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 mouse = { button: "left", x: 1, screenX: 1, screenY: 5, width: 120, height: 30, shift: false, alt: false, ctrl: false };
226
- expect(viewer.handleMouse({ ...mouse, type: "press", y: 5 } as any)?.handled).toBe(true);
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: 5 } as any);
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");