@esso0428/pi-subagents 0.15.12 → 0.15.14

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.
@@ -25,13 +25,13 @@ function rawTextFromResult(result: any): string {
25
25
  : "";
26
26
  }
27
27
 
28
- export function textFromResult(result: any, expanded = false): string {
29
- const content = sanitizeToolResultText(rawTextFromResult(result), 16_384);
28
+ export function textFromResult(result: any, expanded = false, maxChars = 16_384): string {
29
+ const content = sanitizeToolResultText(rawTextFromResult(result), maxChars);
30
30
  const detail = result?.details;
31
31
  if (!expanded || detail === undefined) return content;
32
32
  const details = sanitizeToolResultText(
33
33
  typeof detail === "string" ? detail : safeJson(detail),
34
- 16_384,
34
+ maxChars,
35
35
  );
36
36
  if (!details || details === content) return content;
37
37
  return content ? `${content}\nDetails:\n${details}` : details;
@@ -51,56 +51,209 @@ export function outputLineCount(result: any): number {
51
51
  }
52
52
 
53
53
  export function countLines(text: string): number {
54
- return text.trim() ? text.trim().split("\n").length : 0;
54
+ return text.trim() ? text.trim().split("\n").filter((line) => line.trim().length > 0).length : 0;
55
55
  }
56
56
 
57
+ /** Format arguments as stable, readable `key: value` rows for the Input rail. */
57
58
  export function formatToolInputArgs(args: unknown, maxChars = 8_000): string {
58
59
  if (args === undefined || args === null) return "";
59
- const rendered =
60
- typeof args === "object" ? safeJson(args) : sanitizeToolResultText(String(args));
61
- const bounded = sanitizeToolResultText(rendered, maxChars);
62
- return rendered.length > maxChars ? `${bounded}…` : bounded;
60
+ if (typeof args !== "object") {
61
+ const text = sanitizeToolResultText(String(args));
62
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
63
+ }
64
+ if (Array.isArray(args)) {
65
+ const text = safeJson(args);
66
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
67
+ }
68
+
69
+ const preferred = [
70
+ "path", "file_path", "command", "query", "pattern", "url", "name",
71
+ "message", "content", "old_string", "new_string",
72
+ ];
73
+ const entries = Object.entries(args as Record<string, unknown>)
74
+ .filter(([, value]) => value !== undefined)
75
+ .sort(([left], [right]) => {
76
+ const li = preferred.indexOf(left);
77
+ const ri = preferred.indexOf(right);
78
+ if (li < 0 && ri < 0) return left.localeCompare(right);
79
+ if (li < 0) return 1;
80
+ if (ri < 0) return -1;
81
+ return li - ri;
82
+ });
83
+ const lines: string[] = [];
84
+ for (const [rawKey, value] of entries) {
85
+ const key = sanitizeToolResultText(rawKey);
86
+ if (typeof value === "string") {
87
+ const safeValue = sanitizeToolResultText(value);
88
+ if (safeValue.includes("\n")) {
89
+ lines.push(`${key}:`);
90
+ for (const line of safeValue.split("\n")) lines.push(` ${line}`);
91
+ } else {
92
+ lines.push(`${key}: ${safeValue}`);
93
+ }
94
+ continue;
95
+ }
96
+ if (typeof value === "number" || typeof value === "boolean" || value === null) {
97
+ lines.push(`${key}: ${String(value)}`);
98
+ continue;
99
+ }
100
+ const json = safeJson(value);
101
+ if (json.includes("\n")) {
102
+ lines.push(`${key}:`);
103
+ for (const line of json.split("\n")) lines.push(` ${line}`);
104
+ } else {
105
+ lines.push(`${key}: ${json}`);
106
+ }
107
+ }
108
+ const text = lines.join("\n");
109
+ return text.length > maxChars ? `${text.slice(0, maxChars)}…` : text;
63
110
  }
64
111
 
65
112
  function hasExpandableDetail(text: string, args: unknown): boolean {
66
113
  return countLines(text) > 1 || formatToolInputArgs(args).trim().length > 0;
67
114
  }
68
115
 
69
- /** Small Component used for the viewer-local Input/Output card. */
116
+ export type ToolIoSection = "input" | "output";
117
+
118
+ /**
119
+ * Viewer-local Input/Output card. The rail and show-more rows intentionally
120
+ * mirror ccstyle, but state is kept on this component so history remains
121
+ * deterministic and no global terminal mouse handler is needed.
122
+ */
70
123
  class ExpandedToolIoView implements Component {
71
- private readonly input: string;
72
- private readonly output: string;
73
124
  private readonly isError: boolean;
125
+ private input: string;
126
+ private output: string;
127
+ private maxOutputLines: number;
128
+ private maxInputLines: number;
129
+ private theme: any;
130
+ private cachedWidth: number | undefined;
131
+ private cachedLines: string[] | undefined;
132
+ private lineSections: Array<ToolIoSection | null> = [];
133
+ private hoveredSection: ToolIoSection | null = null;
134
+ private showMoreRows: Partial<Record<ToolIoSection, number>> = {};
74
135
 
75
- constructor(input: string, output: string, isError: boolean) {
136
+ constructor(
137
+ input: string,
138
+ output: string,
139
+ isError: boolean,
140
+ maxOutputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
141
+ maxInputLines = DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines,
142
+ theme?: any,
143
+ ) {
76
144
  this.input = input;
77
- this.output = output || "Done";
145
+ this.output = output;
78
146
  this.isError = isError;
147
+ this.maxOutputLines = Math.max(1, maxOutputLines);
148
+ this.maxInputLines = Math.max(1, maxInputLines);
149
+ this.theme = theme;
150
+ }
151
+
152
+ getInputBody(): string {
153
+ return this.input;
154
+ }
155
+
156
+ getOutputBody(): string {
157
+ return this.output.trim() ? this.output : "Done";
158
+ }
159
+
160
+ setHoveredSection(section: ToolIoSection | null): void {
161
+ if (this.hoveredSection === section) return;
162
+ this.hoveredSection = section;
163
+ this.invalidate();
164
+ }
165
+
166
+ sectionAtLine(line: number): ToolIoSection | null {
167
+ return this.lineSections[line] ?? null;
168
+ }
169
+
170
+ showMoreLine(section: ToolIoSection): number | undefined {
171
+ return this.showMoreRows[section];
79
172
  }
80
173
 
81
174
  render(width: number): string[] {
82
175
  const safeWidth = toolViewportWidth(width);
176
+ if (this.cachedLines && this.cachedWidth === safeWidth) return this.cachedLines;
177
+
178
+ const bodyColor = this.isError ? "error" : "toolOutput";
179
+ const rail = " │ ";
180
+ const contentWidth = Math.max(1, safeWidth - visibleWidth(rail));
83
181
  const rows: string[] = [];
84
- const addSection = (title: string, body: string, last: boolean) => {
85
- rows.push(truncateToWidth(`${last ? "└" : "├"} ${title}`, safeWidth, ""));
86
- const source = body.replace(/\t/g, " ").replace(/\n+$/, "") || "(empty)";
87
- for (const line of source.split("\n")) {
88
- for (const wrapped of wrapTextWithAnsi(line, Math.max(1, safeWidth - 3))) {
89
- const prefix = last ? " " : "│ ";
90
- rows.push(truncateToWidth(`${prefix}${wrapped}`, safeWidth, ""));
91
- }
182
+ this.lineSections = [];
183
+ this.showMoreRows = {};
184
+
185
+ const push = (line: string, section: ToolIoSection | null) => {
186
+ rows.push(truncateToWidth(line, safeWidth, ""));
187
+ this.lineSections.push(section);
188
+ };
189
+ // The theme is optional so the component remains safe in small/test hosts.
190
+ const theme = this.theme;
191
+ const color = (name: string, value: string): string => typeof theme?.fg === "function" ? theme.fg(name, value) : value;
192
+ const bold = (value: string): string => typeof theme?.bold === "function" ? theme.bold(value) : value;
193
+ const pushHeader = (corner: "├" | "└", title: string) => push(
194
+ color("dim", ` ${corner} `) + color("accent", bold(title)),
195
+ null,
196
+ );
197
+ const pushRail = (value: string, section: ToolIoSection, continued: boolean) => push(
198
+ color("dim", continued ? rail : " ") + value,
199
+ section,
200
+ );
201
+ const pushBlank = () => push(color("dim", " │"), null);
202
+ const styleInput = (line: string): string => {
203
+ const match = line.match(/^([A-Za-z_][\w.-]*)(:\s*)(.*)$/);
204
+ return match
205
+ ? color("dim", `${match[1]}${match[2]}`) + color("muted", match[3] ?? "")
206
+ : color("muted", line);
207
+ };
208
+ const pushBody = (body: string, section: ToolIoSection, limit: number, continued: boolean, input: boolean) => {
209
+ const raw = body.replace(/\t/g, " ").replace(/\n+$/, "");
210
+ if (!raw.trim()) {
211
+ pushRail(color("dim", "(empty)"), section, continued);
212
+ return;
213
+ }
214
+ const sources = raw.split("\n");
215
+ const wrapped: string[] = [];
216
+ for (const source of sources) {
217
+ const styled = input ? styleInput(source) : color(bodyColor, source);
218
+ const parts = wrapTextWithAnsi(styled, contentWidth);
219
+ wrapped.push(...(parts.length ? parts : [styled]));
220
+ }
221
+ const truncated = sources.length > limit || wrapped.length > limit;
222
+ const visible = truncated ? wrapped.slice(0, limit) : wrapped;
223
+ for (const line of visible) pushRail(line, section, continued);
224
+ if (truncated) {
225
+ const hidden = Math.max(1, wrapped.length - visible.length);
226
+ const hintColor = this.hoveredSection === section ? "text" : "dim";
227
+ pushRail(
228
+ color("dim", `… +${hidden} more lines`) +
229
+ color("dim", " •") + color(hintColor, ` ${showMoreHintText()}`),
230
+ section,
231
+ continued,
232
+ );
233
+ this.showMoreRows[section] = rows.length - 1;
92
234
  }
93
235
  };
94
- if (this.input.trim()) {
95
- addSection("Input", this.input, false);
96
- addSection("Output", this.output, true);
97
- } else {
98
- addSection("Output", this.output, true);
236
+
237
+ const hasInput = this.input.trim().length > 0;
238
+ const output = this.getOutputBody();
239
+ if (hasInput) {
240
+ pushHeader("├", "Input");
241
+ pushBody(this.input, "input", this.maxInputLines, true, true);
242
+ pushBlank();
99
243
  }
100
- return rows.map((row) => (this.isError ? row : row));
244
+ pushHeader("└", "Output");
245
+ pushBody(output, "output", this.maxOutputLines, false, false);
246
+
247
+ this.cachedWidth = safeWidth;
248
+ this.cachedLines = rows;
249
+ return rows;
250
+ }
251
+
252
+ invalidate(): void {
253
+ this.cachedWidth = undefined;
254
+ this.cachedLines = undefined;
101
255
  }
102
256
 
103
- invalidate(): void {}
104
257
  }
105
258
 
106
259
  function unavailableComponent(reason: string, theme: any): Component {
@@ -210,9 +363,20 @@ function settledIcon(context: any): string {
210
363
  function renderFallbackResult(result: any, options: any, theme: any, context: any): Component {
211
364
  const error = options?.isError || context?.isError;
212
365
  const expanded = options?.expanded === true || context?.expanded === true;
213
- const text = textFromResult(result, expanded);
214
- if (expanded && (text.trim() || formatToolInputArgs(context?.args).trim())) {
215
- return new ExpandedToolIoView(formatToolInputArgs(context?.args), text, Boolean(error));
366
+ const maxChars = Number.isFinite(context?.maxChars) ? Math.max(0, Math.floor(context.maxChars)) : 16_384;
367
+ const text = textFromResult(result, expanded, maxChars);
368
+ if (expanded && (text.trim() || formatToolInputArgs(context?.args, maxChars).trim())) {
369
+ const maxLines = Number.isFinite(context?.diffConfig?.expandedPreviewMaxLines)
370
+ ? Math.max(1, Math.floor(context.diffConfig.expandedPreviewMaxLines))
371
+ : DEFAULT_TOOL_DISPLAY_CONFIG.expandedPreviewMaxLines;
372
+ return new ExpandedToolIoView(
373
+ formatToolInputArgs(context?.args, maxChars),
374
+ text,
375
+ Boolean(error),
376
+ maxLines,
377
+ maxLines,
378
+ theme,
379
+ );
216
380
  }
217
381
  const lines = text ? text.split("\n") : [];
218
382
  const count = lines.length;
@@ -143,6 +143,8 @@ export class ConversationTimeline implements Component {
143
143
  private focusedToolCallId: string | undefined;
144
144
  private expandedTools = new Set<string>();
145
145
  private hoveredToolCallId: string | undefined;
146
+ private hoveredIoView: any;
147
+ private hoveredIoSection: "input" | "output" | null = null;
146
148
  /** The last tool clicked; used to make only clickCount=2 collapse it. */
147
149
  private lastClickedToolCallId: string | undefined;
148
150
  private lastClickedPoint: { x: number; y: number } | undefined;
@@ -167,6 +169,11 @@ export class ConversationTimeline implements Component {
167
169
  const ids = new Set(this.blocks.filter((block) => block.kind === "tool" && block.toolCallId).map((block) => block.toolCallId!));
168
170
  if (this.focusedToolCallId && !ids.has(this.focusedToolCallId)) this.focusedToolCallId = undefined;
169
171
  if (this.hoveredToolCallId && !ids.has(this.hoveredToolCallId)) this.hoveredToolCallId = undefined;
172
+ if (this.hoveredIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
173
+ this.hoveredIoView.setHoveredSection(null);
174
+ this.hoveredIoView = undefined;
175
+ this.hoveredIoSection = null;
176
+ }
170
177
  if (this.lastClickedToolCallId && !ids.has(this.lastClickedToolCallId)) {
171
178
  this.lastClickedToolCallId = undefined;
172
179
  this.lastClickedPoint = undefined;
@@ -182,6 +189,11 @@ export class ConversationTimeline implements Component {
182
189
  if (!show) {
183
190
  this.focusedToolCallId = undefined;
184
191
  this.hoveredToolCallId = undefined;
192
+ if (this.hoveredIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
193
+ this.hoveredIoView.setHoveredSection(null);
194
+ }
195
+ this.hoveredIoView = undefined;
196
+ this.hoveredIoSection = null;
185
197
  this.lastClickedToolCallId = undefined;
186
198
  this.lastClickedPoint = undefined;
187
199
  }
@@ -360,8 +372,33 @@ export class ConversationTimeline implements Component {
360
372
  event.y >= span.clickStartLine &&
361
373
  event.y < span.clickStartLine + (span.clickHeight ?? 1),
362
374
  )?.toolCallId;
363
- if (hovered === this.hoveredToolCallId) return undefined;
375
+ const fullSpan = this.snapshot.toolSpans.find((span) =>
376
+ event.y >= span.startLine && event.y < span.startLine + span.height,
377
+ );
378
+ let nextIoView: any;
379
+ let nextIoSection: "input" | "output" | null = null;
380
+ if (fullSpan) {
381
+ const component = this.toolComponents.get(fullSpan.toolCallId) as any;
382
+ const view = component?.resultRendererComponent;
383
+ const localRow = event.y - fullSpan.startLine;
384
+ if (view && typeof view.sectionAtLine === "function" && typeof view.showMoreLine === "function" && typeof view.setHoveredSection === "function") {
385
+ const section = view.sectionAtLine(localRow);
386
+ if (section && view.showMoreLine(section) === localRow) {
387
+ nextIoView = view;
388
+ nextIoSection = section;
389
+ }
390
+ }
391
+ }
392
+ const unchanged = hovered === this.hoveredToolCallId &&
393
+ nextIoView === this.hoveredIoView && nextIoSection === this.hoveredIoSection;
394
+ if (unchanged) return undefined;
395
+ if (this.hoveredIoView && this.hoveredIoView !== nextIoView && typeof this.hoveredIoView.setHoveredSection === "function") {
396
+ this.hoveredIoView.setHoveredSection(null);
397
+ }
364
398
  this.hoveredToolCallId = hovered;
399
+ this.hoveredIoView = nextIoView;
400
+ this.hoveredIoSection = nextIoSection;
401
+ if (nextIoView) nextIoView.setHoveredSection(nextIoSection);
365
402
  this.invalidate();
366
403
  this.options.onChange?.();
367
404
  return { handled: true, render: true };
@@ -18,9 +18,104 @@ import { renderConversationRoleHeader } from "./conversation-role.js";
18
18
  import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
19
19
  import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
20
20
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
21
+ import { createViewerCcstyleResult } from "./ccstyle/tool-result.js";
21
22
 
22
23
  /** Base lines consumed by the overlay: borders, header, separator and footer. */
23
24
  const CHROME_LINES_BASE = 6;
25
+ const FULL_TOOL_PREVIEW_MAX_CHARS = 500_000;
26
+
27
+ class FullToolPreview implements Component {
28
+ private readonly body: Component;
29
+ private scrollOffset = 0;
30
+ private pageSize = 1;
31
+ private totalLines = 1;
32
+ private hoveredClose = false;
33
+
34
+ constructor(
35
+ private readonly tui: TUI,
36
+ private readonly theme: Theme,
37
+ private readonly title: string,
38
+ body: Component,
39
+ private readonly done: (result: undefined) => void,
40
+ ) {
41
+ this.body = body;
42
+ }
43
+
44
+ handleInput(data: string): void {
45
+ if (matchesKey(data, Key.escape) || matchesKey(data, "q")) {
46
+ this.done(undefined);
47
+ return;
48
+ }
49
+ const delta = matchesKey(data, Key.up) ? -1 : matchesKey(data, Key.down) ? 1 :
50
+ matchesKey(data, "pageUp") ? -this.pageSize : matchesKey(data, "pageDown") ? this.pageSize :
51
+ matchesKey(data, Key.home) ? -this.totalLines : matchesKey(data, Key.end) ? this.totalLines : 0;
52
+ if (delta !== 0) {
53
+ this.scrollTo(this.scrollOffset + delta);
54
+ }
55
+ }
56
+
57
+ handleMouse(event: TuiMouseEvent): { handled?: boolean; capture?: boolean; render?: boolean } | undefined {
58
+ const closeHit = event.y === 1 && event.x >= Math.max(0, event.width - 7) && event.x < event.width;
59
+ if (event.type === "move") {
60
+ if (closeHit === this.hoveredClose) return undefined;
61
+ this.hoveredClose = closeHit;
62
+ return { handled: true, render: true };
63
+ }
64
+ if (closeHit && event.button === "left") {
65
+ if (event.type === "press") return { handled: true, capture: true, render: false };
66
+ if (event.type === "click") {
67
+ this.done(undefined);
68
+ return { handled: true, render: false };
69
+ }
70
+ }
71
+ if (event.type === "wheel") {
72
+ this.scrollTo(this.scrollOffset + Math.trunc(event.wheelDelta ?? 0));
73
+ return { handled: true, render: true };
74
+ }
75
+ return undefined;
76
+ }
77
+
78
+ render(width: number): string[] {
79
+ const safeWidth = Math.max(12, Math.floor(Number.isFinite(width) ? width : 12));
80
+ const inner = Math.max(1, safeWidth - 2);
81
+ const bodyWidth = Math.max(1, inner - 1);
82
+ const terminalRows = Math.max(1, this.tui.terminal.rows);
83
+ const viewport = Math.max(1, Math.min(30, Math.floor(terminalRows * 0.8), terminalRows - 6));
84
+ const wrapped = this.body.render(bodyWidth);
85
+ this.totalLines = wrapped.length;
86
+ this.pageSize = viewport;
87
+ this.scrollOffset = Math.min(this.scrollOffset, Math.max(0, this.totalLines - this.pageSize));
88
+ const visible = wrapped.slice(this.scrollOffset, this.scrollOffset + this.pageSize);
89
+ const border = (text: string) => this.theme.fg("border", text);
90
+ const pad = (text: string, rowWidth = inner): string => {
91
+ const clipped = truncateToWidth(text, rowWidth, "…", true);
92
+ return clipped + " ".repeat(Math.max(0, rowWidth - visibleWidth(clipped)));
93
+ };
94
+ const close = this.theme.fg(this.hoveredClose ? "text" : "dim", "[esc]");
95
+ const header = `${this.theme.bold(this.theme.fg("accent", this.title))}`;
96
+ const status = `${this.scrollOffset + 1}-${Math.min(this.totalLines, this.scrollOffset + this.pageSize)} / ${this.totalLines} lines · ↑↓ PgUp/PgDn Home/End · [esc] close`;
97
+ return [
98
+ border(`╭${"─".repeat(inner)}╮`),
99
+ `${border("│")}${pad(` ${header}`, inner - visibleWidth(close))}${close}${border("│")}`,
100
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
101
+ ...Array.from({ length: viewport }, (_, index) => `${border("│")}${pad(` ${visible[index] ?? ""}`, bodyWidth)}${border("│")}`),
102
+ `${border("├")}${border("─".repeat(inner))}${border("┤")}`,
103
+ `${border("│")}${pad(this.theme.fg("dim", ` ${status}`))}${border("│")}`,
104
+ border(`╰${"─".repeat(inner)}╯`),
105
+ ];
106
+ }
107
+
108
+ invalidate(): void {
109
+ this.body.invalidate();
110
+ }
111
+
112
+ private scrollTo(offset: number): void {
113
+ const next = Math.max(0, Math.min(Math.max(0, this.totalLines - this.pageSize), offset));
114
+ if (next === this.scrollOffset) return;
115
+ this.scrollOffset = next;
116
+ this.tui.requestRender();
117
+ }
118
+ }
24
119
  const MIN_VIEWPORT = 3;
25
120
  export const VIEWPORT_HEIGHT_PCT = 70;
26
121
  const MAX_COPY_CHARS = 500_000;
@@ -174,6 +269,12 @@ export class ConversationViewer implements Component {
174
269
  this.jumpToMessage(1);
175
270
  } else if (data === "K" || matchesKey(data, Key.shift("k"))) {
176
271
  this.jumpToMessage(-1);
272
+ } else if (data === "]") {
273
+ this.focusNearbyTool(1);
274
+ } else if (data === "[") {
275
+ this.focusNearbyTool(-1);
276
+ } else if (data === "w") {
277
+ this.openFocusedToolPreview();
177
278
  } else if (data === "g") {
178
279
  this.scrollToTop();
179
280
  } else if (data === "G" || matchesKey(data, Key.shift("g"))) {
@@ -361,9 +462,9 @@ export class ConversationViewer implements Component {
361
462
  if (this.isStoppable()) actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
362
463
  const editHint = this.isLive() ? " O" : "";
363
464
  const shortcutHints = [
364
- `j/k scroll · J/K messages · g/G t M o${editHint} /n N q`,
365
- `M o${editHint} /n N q`,
366
- `M o${editHint} /n N q`,
465
+ `j/k scroll · J/K messages · [/] tools · w preview · g/G t M o${editHint} /n N q`,
466
+ `[/] tools · w preview · M o${editHint} /n N q`,
467
+ `[/] tools · w · M o${editHint} /n N q`,
367
468
  ];
368
469
  const scrollPct = contentLines.length <= viewportHeight ? "100%" : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
369
470
  const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
@@ -521,12 +622,90 @@ export class ConversationViewer implements Component {
521
622
  const focused = this.timeline.getFocusedToolCallId();
522
623
  const refreshed = this.timeline.getSnapshot();
523
624
  const index = refreshed.messageBlocks.findIndex((block) => block.toolCallId === focused);
524
- if (index >= 0) this.selectedMessageIdx = index;
625
+ if (index >= 0) {
626
+ this.selectedMessageIdx = index;
627
+ this.buildContentLines(this.lastInnerW || 80);
628
+ this.autoScroll = false;
629
+ this.scrollTargetIntoView(index);
630
+ }
631
+ this.invalidate();
632
+ this.tui.requestRender();
633
+ }
634
+
635
+ private focusNearbyTool(direction: 1 | -1): void {
636
+ this.buildContentLines(this.lastInnerW || 80);
637
+ const toolIndices = this.cachedMessageBlocks
638
+ .map((block, index) => block.kind === "tool" && block.toolCallId ? index : -1)
639
+ .filter((index): index is number => index >= 0);
640
+ if (toolIndices.length === 0) return;
641
+
642
+ const focusedId = this.timeline.getFocusedToolCallId();
643
+ const focusedIndex = focusedId
644
+ ? this.cachedMessageBlocks.findIndex((block) => block.toolCallId === focusedId)
645
+ : -1;
646
+ const current = focusedIndex >= 0 ? focusedIndex : this.currentMessageIndex();
647
+ const target = focusedIndex < 0
648
+ ? direction > 0 ? toolIndices[0] : toolIndices[toolIndices.length - 1]
649
+ : direction > 0
650
+ ? toolIndices.find((index) => index > current)
651
+ : [...toolIndices].reverse().find((index) => index < current);
652
+ if (target === undefined) return;
653
+
654
+ const block = this.cachedMessageBlocks[target];
655
+ this.selectedMessageIdx = target;
525
656
  this.autoScroll = false;
657
+ this.scrollTargetIntoView(target);
658
+ this.timeline.setFocusedToolCallId(block.toolCallId);
526
659
  this.invalidate();
527
660
  this.tui.requestRender();
528
661
  }
529
662
 
663
+ private scrollTargetIntoView(index: number): void {
664
+ if (index < 0 || index >= this.cachedMessageStarts.length) return;
665
+ const targetLine = this.cachedMessageStarts[index];
666
+ const viewportHeight = this.viewportHeight();
667
+ const maxScroll = Math.max(0, this.cachedLines.length - viewportHeight);
668
+ if (targetLine < this.scrollOffset) this.scrollOffset = targetLine;
669
+ else if (targetLine >= this.scrollOffset + viewportHeight) this.scrollOffset = targetLine - viewportHeight + 1;
670
+ this.scrollOffset = Math.max(0, Math.min(maxScroll, this.scrollOffset));
671
+ }
672
+
673
+ private openFocusedToolPreview(): void {
674
+ this.buildContentLines(this.lastInnerW || 80);
675
+ const block = this.currentBlock();
676
+ const custom = this.operations?.ctx?.ui?.custom;
677
+ if (block?.kind !== "tool" || typeof custom !== "function") return;
678
+
679
+ const toolName = block.toolName || "tool";
680
+ const args = block.args ?? block.toolArguments;
681
+ const result = block.toolResult ?? block.result ?? { content: [] };
682
+ const title = `${toolName} · full preview`;
683
+
684
+ void custom.call(this.operations!.ctx.ui, (tui, overlayTheme, _keybindings, done) => {
685
+ const body = createViewerCcstyleResult(
686
+ toolName,
687
+ result,
688
+ { expanded: true, isError: result.isError === true },
689
+ overlayTheme,
690
+ {
691
+ args,
692
+ maxChars: FULL_TOOL_PREVIEW_MAX_CHARS,
693
+ diffConfig: { expandedPreviewMaxLines: Number.MAX_SAFE_INTEGER },
694
+ },
695
+ );
696
+ return new FullToolPreview(tui, overlayTheme as Theme, title, body, done);
697
+ }, {
698
+ overlay: true,
699
+ overlayOptions: {
700
+ anchor: "center",
701
+ width: "85%",
702
+ minWidth: 50,
703
+ maxHeight: "80%",
704
+ margin: 2,
705
+ },
706
+ }).catch(() => undefined);
707
+ }
708
+
530
709
  private scrollBy(delta: number): void {
531
710
  const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
532
711
  this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
@@ -66,6 +66,25 @@ describe("viewer-local ccstyle diff renderer", () => {
66
66
  expect(rendered).not.toContain("later");
67
67
  });
68
68
 
69
+ it("matches the pi-cc Input/Output tree style and hover affordance", () => {
70
+ const component = createViewerCcstyleResult("read", {
71
+ content: [{ type: "text", text: "one\ntwo\nthree" }],
72
+ }, { expanded: true }, theme, {
73
+ args: { path: "file.txt", offset: 2 },
74
+ diffConfig: { expandedPreviewMaxLines: 2 },
75
+ }) as any;
76
+
77
+ const initial = component.render(100).join("\n");
78
+ expect(initial).toContain("<accent><bold>Input</bold></accent>");
79
+ expect(initial).toContain("<dim>path: </dim><muted>file.txt</muted>");
80
+ expect(initial).toContain("<accent><bold>Output</bold></accent>");
81
+ expect(initial).toContain("click to show more");
82
+ const outputHintLine = component.showMoreLine("output");
83
+ expect(outputHintLine).toEqual(expect.any(Number));
84
+ component.setHoveredSection("output");
85
+ expect(component.render(100).join("\n")).toContain("<text> click to show more</text>");
86
+ });
87
+
69
88
  it("reads tool-hint hover state at render time", () => {
70
89
  let hovered = false;
71
90
  const component = createViewerCcstyleResult("read", {
@@ -82,7 +82,7 @@ describe("ConversationViewer", () => {
82
82
  { pi: {} as any, ctx: {} as any, readOnly: false },
83
83
  );
84
84
  const liveFooter = live.render(120).join("\n");
85
- expect(liveFooter).toContain("j/k scroll · J/K messages · g/G t M o O /n N q");
85
+ expect(liveFooter).toContain("j/k scroll · J/K messages · [/] tools · w preview · g/G t M o O /n N q");
86
86
  expect(liveFooter).toContain("e steer");
87
87
  expect(liveFooter).toContain("x stop");
88
88
  live.handleInput("\r");
@@ -104,7 +104,7 @@ describe("ConversationViewer", () => {
104
104
  { pi: {} as any, ctx: {} as any, readOnly: true },
105
105
  );
106
106
  const historyFooter = history.render(120).join("\n");
107
- expect(historyFooter).toContain("j/k scroll · J/K messages · g/G t M o /n N q");
107
+ expect(historyFooter).toContain("j/k scroll · J/K messages · [/] tools · w preview · g/G t M o /n N q");
108
108
  expect(historyFooter).not.toContain("M o O");
109
109
  });
110
110
 
@@ -180,6 +180,69 @@ describe("ConversationViewer", () => {
180
180
  expect((viewer as any).currentBlock().markdown).toBe("first");
181
181
  });
182
182
 
183
+ it("focuses nearby tools with [ and ] without changing line-scroll keys", () => {
184
+ const viewer = new ConversationViewer(
185
+ tui(),
186
+ createStaticConversationSource([
187
+ { role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "first" } }] },
188
+ { role: "toolResult", toolName: "read", content: [{ type: "text", text: "first output" }] },
189
+ { role: "assistant", content: [{ type: "text", text: "between" }, { type: "toolCall", name: "read", arguments: { path: "second" } }] },
190
+ { role: "toolResult", toolName: "read", content: [{ type: "text", text: "second output" }] },
191
+ ] as any),
192
+ record(),
193
+ undefined,
194
+ theme,
195
+ vi.fn(),
196
+ );
197
+ viewer.render(120);
198
+ const timeline = (viewer as any).timeline;
199
+ const ids = timeline.getSnapshot().interactiveToolIds;
200
+ expect(ids).toHaveLength(2);
201
+
202
+ viewer.handleInput("]");
203
+ expect(timeline.getFocusedToolCallId()).toBe(ids[0]);
204
+ viewer.render(120);
205
+ expect((viewer as any).currentBlock().toolCallId).toBe(ids[0]);
206
+ viewer.handleInput("]");
207
+ expect(timeline.getFocusedToolCallId()).toBe(ids[1]);
208
+ viewer.render(120);
209
+ expect((viewer as any).currentBlock().toolCallId).toBe(ids[1]);
210
+ viewer.handleInput("[");
211
+ expect(timeline.getFocusedToolCallId()).toBe(ids[0]);
212
+ });
213
+
214
+ it("opens a complete read-only tool preview with w for the focused tool", () => {
215
+ const ui = tui();
216
+ const custom = vi.fn((factory: any) => {
217
+ const preview = factory(ui, theme, undefined, vi.fn());
218
+ const rendered = preview.render(100).join("\\n");
219
+ expect(rendered).toContain("Input");
220
+ expect(rendered).toContain("Output");
221
+ expect(rendered).toContain("one");
222
+ expect(rendered).toContain("six");
223
+ return Promise.resolve(undefined);
224
+ });
225
+ const viewer = new ConversationViewer(
226
+ ui,
227
+ createStaticConversationSource([
228
+ { role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
229
+ { role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
230
+ ] as any),
231
+ record(),
232
+ undefined,
233
+ theme,
234
+ vi.fn(),
235
+ undefined,
236
+ undefined,
237
+ undefined,
238
+ { pi: {} as any, ctx: { ui: { custom } } as any, readOnly: true },
239
+ );
240
+ viewer.render(120);
241
+ viewer.handleInput("]");
242
+ viewer.handleInput("w");
243
+ expect(custom).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ overlay: true }));
244
+ });
245
+
183
246
  it("uses a distinct focus rail for tool cards", () => {
184
247
  const railTheme = {
185
248
  fg: (name: string, text: string) => `<${name}>${text}</${name}>`,