@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.
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 +17 -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 +9 -0
  15. package/dist/ui/conversation-timeline.d.ts.map +1 -1
  16. package/dist/ui/conversation-timeline.js +107 -22
  17. package/dist/ui/conversation-timeline.js.map +1 -1
  18. package/dist/ui/conversation-viewer.d.ts +4 -0
  19. package/dist/ui/conversation-viewer.d.ts.map +1 -1
  20. package/dist/ui/conversation-viewer.js +116 -22
  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 +132 -21
  31. package/src/ui/conversation-viewer.ts +113 -23
  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 +113 -9
  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
 
@@ -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
- // A normalized mouse interaction emits press/release/click. Toggle only
291
- // on the terminal click; handling press as well causes one user click to
292
- // expand and immediately collapse the same card.
293
- if (event.type !== "click" || event.button !== "left") return undefined;
294
- const span = this.snapshot.toolSpans.find((candidate) => event.y >= candidate.startLine && event.y < candidate.startLine + candidate.height);
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
- if (this.timeline.isToolFocused()) {
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.canSteer()) {
133
- this.stopArmed = false;
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.timeline.moveFocus(1);
164
- this.invalidate();
165
- this.tui.requestRender();
170
+ this.moveToolFocus(1);
166
171
  } else if (matchesKey(data, Key.shift("tab"))) {
167
- this.timeline.moveFocus(-1);
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
- this.cacheVersion++;
224
- this.invalidate();
225
- this.tui.requestRender();
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
- lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`));
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
- text = text.startsWith(" ") ? th.fg("accent", "▎") + text.slice(1) : th.fg("accent", "▎") + text;
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", "Enter steer"));
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
- return this.cachedMessageStarts[this.currentMessageIndex()] ?? -1;
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
- if (this.cachedMessageStarts.length === 0) return;
432
- const target = this.currentMessageIndex() + dir;
433
- if (target < 0 || target >= this.cachedMessageStarts.length) return;
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));
@@ -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" }],