@esso0428/pi-subagents 0.15.5 → 0.15.6

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 (73) hide show
  1. package/CHANGELOG.md +4 -22
  2. package/README.md +5 -5
  3. package/dist/agent-history-list.d.ts +18 -0
  4. package/dist/agent-history-list.js +68 -0
  5. package/dist/agent-history.d.ts +1 -1
  6. package/dist/agent-history.js +31 -34
  7. package/dist/agent-manager.d.ts +22 -8
  8. package/dist/agent-manager.js +100 -93
  9. package/dist/agent-runner.js +67 -86
  10. package/dist/agent-types.js +27 -45
  11. package/dist/context.js +2 -6
  12. package/dist/cross-extension-rpc.js +5 -9
  13. package/dist/custom-agents.js +15 -18
  14. package/dist/default-agents.js +1 -4
  15. package/dist/enabled-models.js +11 -16
  16. package/dist/env.js +1 -4
  17. package/dist/group-join.js +1 -5
  18. package/dist/index.js +273 -286
  19. package/dist/invocation-config.js +2 -6
  20. package/dist/memory.js +24 -34
  21. package/dist/model-resolver.js +1 -4
  22. package/dist/nico-overrides.js +14 -20
  23. package/dist/output-file.js +16 -22
  24. package/dist/prompts.js +1 -4
  25. package/dist/schedule-store.js +16 -21
  26. package/dist/schedule.js +8 -12
  27. package/dist/settings.js +15 -23
  28. package/dist/skill-loader.js +20 -23
  29. package/dist/status-note.js +1 -4
  30. package/dist/types.d.ts +0 -1
  31. package/dist/types.js +1 -4
  32. package/dist/ui/agent-widget.d.ts +1 -5
  33. package/dist/ui/agent-widget.js +25 -49
  34. package/dist/ui/conversation-blocks.d.ts +18 -0
  35. package/dist/ui/conversation-blocks.js +214 -0
  36. package/dist/ui/conversation-nvim.d.ts +7 -0
  37. package/dist/ui/conversation-nvim.js +116 -0
  38. package/dist/ui/conversation-search.d.ts +38 -0
  39. package/dist/ui/conversation-search.js +123 -0
  40. package/dist/ui/conversation-viewer.d.ts +55 -19
  41. package/dist/ui/conversation-viewer.js +398 -215
  42. package/dist/ui/fleet-list.d.ts +23 -11
  43. package/dist/ui/fleet-list.js +85 -78
  44. package/dist/ui/schedule-menu.js +1 -4
  45. package/dist/ui/viewer-keys.js +7 -10
  46. package/dist/usage.js +4 -10
  47. package/dist/worktree.js +26 -31
  48. package/package.json +1 -1
  49. package/src/agent-history-list.ts +91 -0
  50. package/src/agent-history.ts +12 -17
  51. package/src/agent-manager.ts +108 -81
  52. package/src/agent-runner.ts +5 -13
  53. package/src/index.ts +84 -89
  54. package/src/output-file.ts +1 -1
  55. package/src/types.ts +0 -1
  56. package/src/ui/agent-widget.ts +1 -8
  57. package/src/ui/conversation-blocks.ts +270 -0
  58. package/src/ui/conversation-nvim.ts +127 -0
  59. package/src/ui/conversation-search.ts +161 -0
  60. package/src/ui/conversation-viewer.ts +376 -205
  61. package/src/ui/fleet-list.ts +73 -52
  62. package/test/agent-history-list.test.ts +121 -0
  63. package/test/agent-manager-history.test.ts +59 -58
  64. package/test/agent-menu-history-navigation.test.ts +86 -0
  65. package/test/ui/conversation-blocks.test.ts +72 -0
  66. package/test/ui/conversation-search.test.ts +55 -0
  67. package/test/ui/conversation-viewer.test.ts +132 -0
  68. package/test/ui/fleet-list.test.ts +131 -0
  69. package/dist/ui/markdown-result.d.ts +0 -3
  70. package/dist/ui/markdown-result.js +0 -53
  71. package/src/ui/markdown-result.ts +0 -56
  72. package/test/agent-history.test.ts +0 -63
  73. package/test/ui/markdown-result.test.ts +0 -45
@@ -1,24 +1,30 @@
1
1
  /**
2
- * conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
2
+ * conversation-viewer.ts — Live and historical conversation overlay.
3
3
  *
4
- * Displays a scrollable, live-updating view of an agent's conversation.
5
- * Subscribes to session events for real-time streaming updates.
4
+ * The viewer deliberately keeps rendering state separate from the transcript:
5
+ * blocks are formatted data, while Markdown/ANSI lines are a bounded cache.
6
6
  */
7
7
 
8
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
9
- import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
- import { extractText } from "../context.js";
8
+ import type { ExtensionAPI, ExtensionContext, AgentSession } from "@earendil-works/pi-coding-agent";
9
+ import { copyToClipboard, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
10
+ import { type Component, Input, Key, Markdown, matchesKey, Text, type TUI, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
11
  import type { AgentRecord } from "../types.js";
12
12
  import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
13
13
  import type { Theme } from "./agent-widget.js";
14
14
  import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
15
+ import { formatConversationMessages, type ConversationBlock } from "./conversation-blocks.js";
16
+ import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
17
+ import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
15
18
  import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
16
19
 
17
- /** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
20
+ /** Base lines consumed by the overlay: borders, header, separator and footer. */
18
21
  const CHROME_LINES_BASE = 6;
19
22
  const MIN_VIEWPORT = 3;
20
- /** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
21
23
  export const VIEWPORT_HEIGHT_PCT = 70;
24
+ const MAX_COPY_CHARS = 500_000;
25
+ const MATCH_BG = "\x1b[48;5;238m";
26
+ const MATCH_BG_CURRENT = "\x1b[48;5;220m\x1b[30m";
27
+ const BG_RESET = "\x1b[49m\x1b[39m";
22
28
 
23
29
  /** The live fields needed by the viewer; historical viewers use a static source. */
24
30
  export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
@@ -29,17 +35,42 @@ export function createStaticConversationSource(
29
35
  return { messages, subscribe: () => () => {} };
30
36
  }
31
37
 
38
+ type RenderLine = {
39
+ text: string;
40
+ plain: string;
41
+ blockIndex: number;
42
+ railable?: boolean;
43
+ };
44
+
45
+ /** Optional operations are appended so all existing constructor call sites stay valid. */
46
+ export interface ConversationViewerOperations {
47
+ pi: ExtensionAPI;
48
+ ctx: ExtensionContext;
49
+ readOnly?: boolean;
50
+ }
51
+
32
52
  export class ConversationViewer implements Component {
53
+ private blocks: ConversationBlock[] = [];
54
+ private showTools = true;
55
+ private selectedMessageIdx = 0;
56
+ private cachedWidth = 0;
57
+ private cachedLines: RenderLine[] = [];
58
+ private cachedMessageStarts: number[] = [];
59
+ private cachedMessageBlocks: ConversationBlock[] = [];
60
+ private cacheVersion = 0;
61
+ private cachedVersion = -1;
33
62
  private scrollOffset = 0;
34
63
  private autoScroll = true;
35
64
  private unsubscribe: (() => void) | undefined;
36
65
  private lastInnerW = 0;
37
66
  private closed = false;
38
- /** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
39
67
  private stopArmed = false;
40
68
  private keys: ViewerKeys;
41
- /** Steering composer — present while the user is typing a message to the agent. */
42
69
  private composer: Input | undefined;
70
+ private searchMode = false;
71
+ private searchQuery = "";
72
+ private searchMatches: ConversationMatch[] = [];
73
+ private currentMatchIdx = -1;
43
74
 
44
75
  constructor(
45
76
  private tui: TUI,
@@ -48,46 +79,46 @@ export class ConversationViewer implements Component {
48
79
  private activity: AgentActivity | undefined,
49
80
  private theme: Theme,
50
81
  private done: (result: undefined) => void,
51
- /** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
82
+ /** Abort the agent shown here. Omitted means read-only history. */
52
83
  private onStop?: () => void,
53
- /** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
84
+ /** User keybindings from ctx.ui.custom(). */
54
85
  keybindings?: ViewerKeybindings,
55
- /** Send a steering message to the agent. Omitted → no compose affordance. */
86
+ /** Send a steering message to the agent. */
56
87
  private onSteer?: (message: string) => void,
88
+ /** Optional clipboard/nvim operation context, appended for compatibility. */
89
+ private operations?: ConversationViewerOperations,
57
90
  ) {
58
91
  this.keys = createViewerKeys(keybindings);
92
+ this.blocks = formatConversationMessages(session.messages);
59
93
  this.unsubscribe = session.subscribe(() => {
60
94
  if (this.closed) return;
95
+ this.blocks = formatConversationMessages(this.session.messages);
96
+ this.cacheVersion++;
97
+ this.invalidate();
61
98
  this.tui.requestRender();
62
99
  });
63
100
  }
64
101
 
65
102
  handleInput(data: string): void {
66
- // While composing a steer message, the input owns all keys (Enter sends,
67
- // Esc cancels — both wired in openComposer()). Editing keys flow through.
68
103
  if (this.composer) {
69
104
  this.composer.handleInput(data);
70
105
  this.tui.requestRender();
71
106
  return;
72
107
  }
73
-
108
+ if (this.searchMode) {
109
+ this.handleSearchInput(data);
110
+ return;
111
+ }
74
112
  if (matchesKey(data, "escape") || matchesKey(data, "q")) {
75
113
  this.closed = true;
76
114
  this.done(undefined);
77
115
  return;
78
116
  }
79
-
80
- // Enter opens the steering composer (only while the agent can still be
81
- // steered) — then type + Enter sends, Esc or an empty submit returns. When
82
- // not steerable, fall through so the key still disarms a pending stop.
83
117
  if (matchesKey(data, "enter") && this.canSteer()) {
84
118
  this.stopArmed = false;
85
119
  this.openComposer();
86
120
  return;
87
121
  }
88
-
89
- // Stop/abort the agent (only while it can still be stopped). Two-press:
90
- // first "x" arms, second confirms — any other key disarms.
91
122
  if (matchesKey(data, "x")) {
92
123
  if (this.isStoppable()) {
93
124
  if (this.stopArmed) {
@@ -102,62 +133,74 @@ export class ConversationViewer implements Component {
102
133
  }
103
134
  if (this.stopArmed) this.stopArmed = false;
104
135
 
105
- const totalLines = this.buildContentLines(this.lastInnerW).length;
136
+ // Input can arrive before the first render in tests and in a freshly-opened overlay.
137
+ this.buildContentLines(this.lastInnerW || 80);
106
138
  const viewportHeight = this.viewportHeight();
107
- const maxScroll = Math.max(0, totalLines - viewportHeight);
139
+ const maxScroll = Math.max(0, this.cachedLines.length - viewportHeight);
108
140
 
109
- if (this.keys.scrollUp(data)) {
110
- this.scrollOffset = Math.max(0, this.scrollOffset - 1);
111
- this.autoScroll = this.scrollOffset >= maxScroll;
141
+ if (data === "t") {
142
+ this.showTools = !this.showTools;
143
+ this.invalidate();
144
+ this.tui.requestRender();
145
+ } else if (data === "J" || matchesKey(data, Key.shift("j"))) {
146
+ this.jumpToMessage(1);
147
+ } else if (data === "K" || matchesKey(data, Key.shift("k"))) {
148
+ this.jumpToMessage(-1);
149
+ } else if (data === "g") {
150
+ this.scrollToTop();
151
+ } else if (data === "G" || matchesKey(data, Key.shift("g"))) {
152
+ this.scrollToBottom();
153
+ } else if (data === "/") {
154
+ this.enterSearch();
155
+ } else if (data === "n") {
156
+ this.gotoMatch(1);
157
+ } else if (data === "N") {
158
+ this.gotoMatch(-1);
159
+ } else if (data === "M") {
160
+ void this.copyCurrentMessage();
161
+ } else if (data === "o") {
162
+ void this.viewCurrentInNvim();
163
+ } else if (data === "O" && this.isLive()) {
164
+ void this.editCurrentInNvim();
165
+ } else if (this.keys.scrollUp(data)) {
166
+ this.scrollBy(-1);
112
167
  } else if (this.keys.scrollDown(data)) {
113
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
114
- this.autoScroll = this.scrollOffset >= maxScroll;
168
+ this.scrollBy(1);
115
169
  } else if (this.keys.pageUp(data)) {
116
- this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
117
- this.autoScroll = false;
170
+ this.scrollBy(-viewportHeight);
118
171
  } else if (this.keys.pageDown(data)) {
119
- this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
120
- this.autoScroll = this.scrollOffset >= maxScroll;
172
+ this.scrollBy(viewportHeight);
121
173
  } else if (matchesKey(data, "home")) {
122
174
  this.scrollOffset = 0;
175
+ this.selectedMessageIdx = 0;
123
176
  this.autoScroll = false;
177
+ this.tui.requestRender();
124
178
  } else if (matchesKey(data, "end")) {
125
- this.scrollOffset = maxScroll;
126
- this.autoScroll = true;
179
+ this.scrollToBottom();
127
180
  }
181
+
182
+ // Keep the computed max alive for the key path even when the cache is empty.
183
+ void maxScroll;
128
184
  }
129
185
 
130
186
  render(width: number): string[] {
131
- if (width < 6) return []; // too narrow for any meaningful rendering
187
+ if (width < 6) return [];
132
188
  const th = this.theme;
133
- const innerW = width - 4; // border + padding
189
+ const innerW = Math.max(1, width - 4);
134
190
  this.lastInnerW = innerW;
135
191
  const lines: string[] = [];
136
-
137
- const pad = (s: string, len: number) => {
138
- const vis = visibleWidth(s);
139
- return s + " ".repeat(Math.max(0, len - vis));
140
- };
141
- const row = (content: string) =>
142
- th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
143
- const hrTop = th.fg("border", `╭${"─".repeat(width - 2)}╮`);
144
- const hrBot = th.fg("border", `╰${"─".repeat(width - 2)}╯`);
192
+ const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
193
+ const row = (content: string) => th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
194
+ const hrTop = th.fg("border", `╭${"─".repeat(Math.max(0, width - 2))}╮`);
195
+ const hrBot = th.fg("border", `╰${"─".repeat(Math.max(0, width - 2))}╯`);
145
196
  const hrMid = row(th.fg("dim", "─".repeat(innerW)));
146
197
 
147
- // Header
148
198
  lines.push(hrTop);
149
199
  const name = getDisplayName(this.record.type);
150
200
  const modeLabel = getPromptModeLabel(this.record.type);
151
201
  const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
152
- const statusIcon = this.record.status === "running"
153
- ? th.fg("accent", "●")
154
- : this.record.status === "completed"
155
- ? th.fg("success", "✓")
156
- : this.record.status === "error"
157
- ? th.fg("error", "✗")
158
- : th.fg("dim", "○");
202
+ const statusIcon = this.record.status === "running" ? th.fg("accent", "●") : this.record.status === "completed" ? th.fg("success", "✓") : this.record.status === "error" ? th.fg("error", "✗") : th.fg("dim", "○");
159
203
  const duration = formatDuration(this.record.startedAt, this.record.completedAt);
160
-
161
204
  const headerParts: string[] = [duration];
162
205
  const toolUses = this.activity?.toolUses ?? this.record.toolUses;
163
206
  if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
@@ -166,206 +209,334 @@ export class ConversationViewer implements Component {
166
209
  const percent = getSessionContextPercent(this.activity?.session);
167
210
  headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
168
211
  }
169
-
170
- lines.push(row(
171
- `${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
172
- ));
212
+ lines.push(row(`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`));
173
213
  const invocationLine = this.invocationLine();
174
214
  if (invocationLine) lines.push(row(invocationLine));
175
215
  lines.push(hrMid);
176
216
 
177
- // Content area — rebuild every render (live data, no cache needed)
178
217
  const contentLines = this.buildContentLines(innerW);
179
218
  const viewportHeight = this.viewportHeight();
180
219
  const maxScroll = Math.max(0, contentLines.length - viewportHeight);
181
-
182
- if (this.autoScroll) {
183
- this.scrollOffset = maxScroll;
184
- }
185
-
220
+ if (this.autoScroll) this.scrollOffset = maxScroll;
186
221
  const visibleStart = Math.min(this.scrollOffset, maxScroll);
187
222
  const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
188
-
189
- for (let i = 0; i < viewportHeight; i++) {
190
- lines.push(row(visible[i] ?? ""));
223
+ const currentBlockIdx = this.currentBlockGlobalIndex();
224
+ const headerLine = this.activeHeaderLine();
225
+ const showSticky = headerLine >= 0 && headerLine < visibleStart && visible.length > 0;
226
+ const displayed = visible.slice();
227
+ if (showSticky) {
228
+ const block = this.cachedMessageBlocks[this.currentMessageIndex()];
229
+ if (block) displayed[0] = { text: ` ${block.header}`, plain: block.header, blockIndex: currentBlockIdx, railable: true };
191
230
  }
231
+ for (let i = 0; i < displayed.length; i++) {
232
+ const line = displayed[i];
233
+ let text = line.text;
234
+ if (this.searchMatches.length > 0 && !(showSticky && i === 0)) {
235
+ const hits = this.searchMatches.filter((match) => match.lineIdx === visibleStart + i).map((match) => ({ lineIdx: match.lineIdx, col: match.col, len: match.len, current: this.searchMatches.indexOf(match) === this.currentMatchIdx }));
236
+ if (hits.length > 0) text = highlightConversationLine(text, hits, MATCH_BG, MATCH_BG_CURRENT, BG_RESET);
237
+ }
238
+ if (line.railable && line.blockIndex === currentBlockIdx) {
239
+ text = text.startsWith(" ") ? th.fg("accent", "▎") + text.slice(1) : th.fg("accent", "▎") + text;
240
+ }
241
+ lines.push(row(text));
242
+ }
243
+ for (let i = displayed.length; i < viewportHeight; i++) lines.push(row(""));
192
244
 
193
- // Footer
194
245
  lines.push(hrMid);
195
246
  if (this.composer) {
196
- // Composer row: the Input renders its own `> ` prompt and cursor.
197
247
  lines.push(row(this.composer.render(innerW)[0] ?? ""));
198
- const composeHint = th.fg("dim", "Enter send · Esc cancel");
199
- const composeLeft = th.fg("accent", "✎ steer");
200
- const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
201
- lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
248
+ const hint = th.fg("dim", "Enter send · Esc cancel");
249
+ const label = th.fg("accent", "✎ steer");
250
+ lines.push(row(label + " ".repeat(Math.max(1, innerW - visibleWidth(label) - visibleWidth(hint))) + hint));
202
251
  } else {
203
- // Actions on the left, navigation on the right. The scroll hint keeps its
204
- // full key list so the less-obvious bindings stay discoverable; it leads
205
- // the right group so "Esc close" is the only part that truncates first.
206
- const sep = th.fg("dim", " · ");
207
252
  const actions: string[] = [];
208
253
  if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
209
- if (this.isStoppable()) {
210
- actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
211
- }
212
- const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
213
-
214
- // Prepend the line-count/scroll-% readout only when there's spare width —
215
- // it's the first thing dropped so it never crowds out the hints.
216
- const scrollPct = contentLines.length <= viewportHeight
217
- ? "100%"
218
- : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
254
+ if (this.isStoppable()) actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
255
+ const editHint = this.isLive() ? " O" : "";
256
+ const shortcutHints = [
257
+ `j/k · J/K · g/G t M o${editHint} /n N q`,
258
+ `M o${editHint} /n N q`,
259
+ `M o${editHint} /n N q`,
260
+ ];
261
+ const scrollPct = contentLines.length <= viewportHeight ? "100%" : `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
219
262
  const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
220
- const withCount = [count, ...actions].join(sep);
221
- const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
222
- ? withCount
223
- : actions.join(sep);
224
-
225
- const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
226
- lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
263
+ const countWidth = visibleWidth(count);
264
+ let status = "";
265
+ for (const hint of shortcutHints) {
266
+ const styledHint = th.fg("dim", hint);
267
+ const withActions = [styledHint, ...actions].join(" ");
268
+ if (visibleWidth(withActions) <= innerW) {
269
+ status = withActions;
270
+ break;
271
+ }
272
+ if (visibleWidth(styledHint) <= innerW) status ||= styledHint;
273
+ }
274
+ if (!status) status = th.fg("dim", shortcutHints[shortcutHints.length - 1]);
275
+ const statusWidth = visibleWidth(status);
276
+ const footer = statusWidth + countWidth + 1 <= innerW
277
+ ? status + " ".repeat(Math.max(1, innerW - statusWidth - countWidth)) + count
278
+ : status;
279
+ lines.push(row(truncateToWidth(footer, innerW, "...", true)));
227
280
  }
228
281
  lines.push(hrBot);
229
-
230
282
  return lines;
231
283
  }
232
284
 
233
- /** Stoppable only when a stop handler exists and the agent is still active. */
234
- private isStoppable(): boolean {
235
- return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
285
+ invalidate(): void {
286
+ this.cachedWidth = 0;
287
+ this.cachedLines = [];
288
+ this.cachedMessageStarts = [];
289
+ this.cachedMessageBlocks = [];
290
+ this.cachedVersion = -1;
236
291
  }
237
292
 
238
- /** Steerable only when a steer handler exists and the agent is still active. */
239
- private canSteer(): boolean {
240
- return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
241
- }
242
-
243
- /** Open the inline steering composer and route subsequent input to it. */
244
- private openComposer(): void {
245
- const input = new Input();
246
- input.focused = true;
247
- input.onSubmit = (value: string) => {
248
- const message = value.trim();
249
- this.composer = undefined;
250
- if (message) this.onSteer?.(message);
251
- this.tui.requestRender();
252
- };
253
- input.onEscape = () => {
254
- this.composer = undefined;
255
- this.tui.requestRender();
256
- };
257
- this.composer = input;
258
- this.tui.requestRender();
259
- }
260
-
261
- invalidate(): void { /* no cached state to clear */ }
262
-
263
293
  dispose(): void {
264
294
  this.closed = true;
265
- if (this.unsubscribe) {
266
- this.unsubscribe();
267
- this.unsubscribe = undefined;
268
- }
295
+ this.unsubscribe?.();
296
+ this.unsubscribe = undefined;
269
297
  }
270
298
 
271
- // ---- Private ----
272
-
273
299
  private viewportHeight(): number {
274
- // Cap mirrors the overlay's maxHeight — otherwise the viewer would render
275
- // more lines than the overlay shows and clip the footer.
276
300
  const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
277
301
  return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
278
302
  }
279
303
 
280
304
  private chromeLines(): number {
281
- // The composer adds one row above the footer hint while it's open.
282
305
  return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
283
306
  }
284
307
 
285
308
  private invocationLine(): string | undefined {
286
309
  const { modelName, tags } = buildInvocationTags(this.record.invocation);
287
310
  const parts = modelName ? [modelName, ...tags] : tags;
288
- if (parts.length === 0) return undefined;
289
- return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
311
+ return parts.length === 0 ? undefined : this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
290
312
  }
291
313
 
292
- private buildContentLines(width: number): string[] {
314
+ private buildContentLines(width: number): RenderLine[] {
293
315
  if (width <= 0) return [];
294
-
295
- const th = this.theme;
296
- const messages = this.session.messages;
297
- const lines: string[] = [];
298
-
299
- if (messages.length === 0) {
300
- lines.push(th.fg("dim", "(waiting for first message...)"));
301
- return lines;
316
+ if (this.cachedVersion === this.cacheVersion && this.cachedWidth === width) return this.cachedLines;
317
+ const visibleBlocks = this.blocks.filter((block) => this.showTools || block.kind !== "tool");
318
+ const lines: RenderLine[] = [];
319
+ this.cachedMessageStarts = [];
320
+ this.cachedMessageBlocks = [];
321
+ if (visibleBlocks.length === 0) {
322
+ const empty = this.blocks.length === 0 ? "(waiting for first message...)" : "(tools hidden)";
323
+ lines.push({ text: this.theme.fg("dim", empty), plain: empty, blockIndex: -1 });
302
324
  }
303
-
304
- let needsSeparator = false;
305
- for (const msg of messages) {
306
- if (msg.role === "user") {
307
- const text = typeof msg.content === "string"
308
- ? msg.content
309
- : extractText(msg.content);
310
- if (!text.trim()) continue;
311
- if (needsSeparator) lines.push(th.fg("dim", "───"));
312
- lines.push(th.fg("accent", "[User]"));
313
- for (const line of wrapTextWithAnsi(text.trim(), width)) {
314
- lines.push(line);
315
- }
316
- } else if (msg.role === "assistant") {
317
- const textParts: string[] = [];
318
- const toolCalls: string[] = [];
319
- for (const c of msg.content) {
320
- if (c.type === "text" && c.text) textParts.push(c.text);
321
- else if (c.type === "toolCall") {
322
- toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
323
- }
324
- }
325
- if (needsSeparator) lines.push(th.fg("dim", "───"));
326
- lines.push(th.bold("[Assistant]"));
327
- if (textParts.length > 0) {
328
- for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
329
- lines.push(line);
330
- }
331
- }
332
- for (const name of toolCalls) {
333
- lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
334
- }
335
- } else if (msg.role === "toolResult") {
336
- const text = extractText(msg.content);
337
- const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
338
- if (!truncated.trim()) continue;
339
- if (needsSeparator) lines.push(th.fg("dim", "───"));
340
- lines.push(th.fg("dim", "[Result]"));
341
- for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
342
- lines.push(th.fg("dim", line));
343
- }
344
- } else if ((msg as any).role === "bashExecution") {
345
- const bash = msg as any;
346
- if (needsSeparator) lines.push(th.fg("dim", "───"));
347
- lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
348
- if (bash.output?.trim()) {
349
- const out = bash.output.length > 500
350
- ? bash.output.slice(0, 500) + "... (truncated)"
351
- : bash.output;
352
- for (const line of wrapTextWithAnsi(out.trim(), width)) {
353
- lines.push(th.fg("dim", line));
354
- }
355
- }
356
- } else {
325
+ for (const block of visibleBlocks) {
326
+ const blockIndex = this.blocks.indexOf(block);
327
+ if (block.kind === "tool") {
328
+ const raw = (block.toolLine ?? block.copyText).replace(/^·\s*/, "");
329
+ const line = this.theme.fg("dim", ` └─ ${raw}`);
330
+ lines.push({ text: truncateToWidth(line, width), plain: stripAnsi(line), blockIndex });
357
331
  continue;
358
332
  }
359
- needsSeparator = true;
333
+ if (lines.length > 0) {
334
+ const separator = this.theme.fg("border", ` ${"─ ".repeat(Math.max(1, Math.floor((width - 6) / 2)))}`.trimEnd());
335
+ lines.push({ text: truncateToWidth(separator, width), plain: stripAnsi(separator), blockIndex });
336
+ }
337
+ this.cachedMessageStarts.push(lines.length);
338
+ this.cachedMessageBlocks.push(block);
339
+ const header = ` ${block.header}`;
340
+ lines.push({ text: truncateToWidth(header, width), plain: block.header, blockIndex, railable: true });
341
+ try {
342
+ const markdown = new Markdown(block.markdown || "∅", 2, 0, getMarkdownTheme(), {
343
+ color: (text) => this.theme.fg(block.role === "meta" ? "muted" : "text", text),
344
+ });
345
+ for (const text of markdown.render(Math.max(1, width))) lines.push({ text: truncateToWidth(text, width), plain: stripAnsi(text), blockIndex, railable: true });
346
+ } catch {
347
+ try {
348
+ for (const text of new Text(block.markdown || "∅").render(Math.max(1, width))) lines.push({ text: truncateToWidth(text, width), plain: stripAnsi(text), blockIndex, railable: true });
349
+ } catch {
350
+ const text = block.markdown || "∅";
351
+ for (const line of text.split("\n")) lines.push({ text: truncateToWidth(line, width), plain: line, blockIndex, railable: true });
352
+ }
353
+ }
360
354
  }
361
-
362
- // Streaming indicator for running agents
363
355
  if (this.record.status === "running" && this.activity) {
364
356
  const act = describeActivity(this.activity.activeTools, this.activity.responseText);
365
- lines.push("");
366
- lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
357
+ const text = `▍ ${act}`;
358
+ lines.push({ text: truncateToWidth(this.theme.fg("accent", "▍ ") + this.theme.fg("dim", act), width), plain: text, blockIndex: -1 });
359
+ }
360
+ this.cachedWidth = width;
361
+ this.cachedVersion = this.cacheVersion;
362
+ this.cachedLines = lines;
363
+ if (this.searchMode || this.searchQuery) this.recomputeMatches();
364
+ return lines;
365
+ }
366
+
367
+ private currentMessageIndex(): number {
368
+ return this.cachedMessageStarts.length === 0 ? 0 : Math.max(0, Math.min(this.selectedMessageIdx, this.cachedMessageStarts.length - 1));
369
+ }
370
+
371
+ private currentBlockGlobalIndex(): number {
372
+ const block = this.cachedMessageBlocks[this.currentMessageIndex()];
373
+ return block ? this.blocks.indexOf(block) : -1;
374
+ }
375
+
376
+ private activeHeaderLine(): number {
377
+ return this.cachedMessageStarts[this.currentMessageIndex()] ?? -1;
378
+ }
379
+
380
+ private currentBlock(): ConversationBlock | undefined {
381
+ return this.cachedMessageBlocks[this.currentMessageIndex()];
382
+ }
383
+
384
+ private messageIndexForOffset(offset: number): number {
385
+ let result = 0;
386
+ for (let i = 0; i < this.cachedMessageStarts.length; i++) {
387
+ if (this.cachedMessageStarts[i] <= offset) result = i;
388
+ else break;
389
+ }
390
+ return result;
391
+ }
392
+
393
+ private jumpToMessage(dir: 1 | -1): void {
394
+ if (this.cachedMessageStarts.length === 0) return;
395
+ const target = this.currentMessageIndex() + dir;
396
+ if (target < 0 || target >= this.cachedMessageStarts.length) return;
397
+ this.selectedMessageIdx = target;
398
+ this.scrollOffset = Math.min(this.cachedMessageStarts[target], Math.max(0, this.cachedLines.length - this.viewportHeight()));
399
+ this.autoScroll = false;
400
+ this.tui.requestRender();
401
+ }
402
+
403
+ private scrollBy(delta: number): void {
404
+ const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
405
+ this.scrollOffset = Math.max(0, Math.min(max, this.scrollOffset + delta));
406
+ this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
407
+ this.autoScroll = this.scrollOffset >= max;
408
+ this.tui.requestRender();
409
+ }
410
+
411
+ private scrollToTop(): void {
412
+ this.scrollOffset = 0;
413
+ this.selectedMessageIdx = 0;
414
+ this.autoScroll = false;
415
+ this.tui.requestRender();
416
+ }
417
+
418
+ private scrollToBottom(): void {
419
+ this.scrollOffset = Math.max(0, this.cachedLines.length - this.viewportHeight());
420
+ this.selectedMessageIdx = Math.max(0, this.cachedMessageStarts.length - 1);
421
+ this.autoScroll = true;
422
+ this.tui.requestRender();
423
+ }
424
+
425
+ private enterSearch(): void {
426
+ this.searchMode = true;
427
+ this.searchQuery = "";
428
+ this.searchMatches = [];
429
+ this.currentMatchIdx = -1;
430
+ this.tui.requestRender();
431
+ }
432
+
433
+ private handleSearchInput(data: string): void {
434
+ if (matchesKey(data, "escape") || matchesKey(data, Key.ctrl("c"))) {
435
+ this.searchMode = false;
436
+ this.searchQuery = "";
437
+ this.searchMatches = [];
438
+ this.currentMatchIdx = -1;
439
+ } else if (matchesKey(data, "enter") || data === "\r" || data === "\n") {
440
+ this.searchMode = false;
441
+ if (this.searchMatches.length > 0) this.scrollToMatch(this.currentMatchIdx < 0 ? 0 : this.currentMatchIdx);
442
+ } else if (matchesKey(data, "backspace") || data === "\x7f" || data === "\b") {
443
+ this.searchQuery = this.searchQuery.slice(0, -1);
444
+ this.recomputeMatches();
445
+ } else if (data.length === 1 && data >= " " && data !== "\x7f") {
446
+ this.searchQuery += data;
447
+ this.recomputeMatches();
367
448
  }
449
+ this.tui.requestRender();
450
+ }
451
+
452
+ private recomputeMatches(): void {
453
+ this.searchMatches = findConversationMatches(this.cachedLines, this.searchQuery);
454
+ this.currentMatchIdx = this.searchMatches.length > 0 ? Math.min(Math.max(this.currentMatchIdx, 0), this.searchMatches.length - 1) : -1;
455
+ }
456
+
457
+ private gotoMatch(dir: 1 | -1): void {
458
+ if (this.searchMatches.length === 0) return;
459
+ this.currentMatchIdx = this.currentMatchIdx < 0
460
+ ? (dir === 1 ? nearestConversationMatchAfter(this.searchMatches, this.scrollOffset) : nearestConversationMatchBefore(this.searchMatches, this.scrollOffset))
461
+ : (this.currentMatchIdx + dir + this.searchMatches.length) % this.searchMatches.length;
462
+ this.scrollToMatch(this.currentMatchIdx);
463
+ this.tui.requestRender();
464
+ }
465
+
466
+ private scrollToMatch(index: number): void {
467
+ const match = this.searchMatches[index];
468
+ if (!match) return;
469
+ const max = Math.max(0, this.cachedLines.length - this.viewportHeight());
470
+ this.scrollOffset = Math.max(0, Math.min(max, match.lineIdx - Math.floor(this.viewportHeight() / 2)));
471
+ this.selectedMessageIdx = this.messageIndexForOffset(this.scrollOffset);
472
+ this.autoScroll = false;
473
+ }
368
474
 
369
- return lines.map(l => truncateToWidth(l, width));
475
+ private async copyCurrentMessage(): Promise<void> {
476
+ const block = this.currentBlock();
477
+ if (!block || !this.operations) {
478
+ if (!this.operations) return;
479
+ return;
480
+ }
481
+ const source = `${stripAnsi(block.header)}\n${block.copyText}`.trimEnd();
482
+ const clipped = source.length > MAX_COPY_CHARS ? `${source.slice(0, MAX_COPY_CHARS)}\n… [truncated by subagent copy]` : source;
483
+ try {
484
+ await copyToClipboard(clipped);
485
+ this.operations.ctx.ui.notify(`Copied current message (${clipped.length} chars)`, "info");
486
+ } catch (error) {
487
+ this.operations.ctx.ui.notify(`Copy failed: ${error instanceof Error ? error.message : String(error)}`, "error");
488
+ }
489
+ }
490
+
491
+ private async viewCurrentInNvim(): Promise<void> {
492
+ const block = this.currentBlock();
493
+ if (!block || !this.operations) return;
494
+ await viewConversationBlockInNvim(this.tui, this.operations.ctx, block);
495
+ this.invalidate();
496
+ this.tui.requestRender(true);
497
+ }
498
+
499
+ private async editCurrentInNvim(): Promise<void> {
500
+ const block = this.currentBlock();
501
+ if (!block || !this.operations || !this.isLive() || block.role !== "assistant") return;
502
+ const sent = await editLiveAssistantBlockInNvim(this.operations.pi, this.tui, this.operations.ctx, block);
503
+ if (sent) this.done(undefined);
504
+ else {
505
+ this.invalidate();
506
+ this.tui.requestRender(true);
507
+ }
508
+ }
509
+
510
+ private isReadOnly(): boolean {
511
+ return this.operations?.readOnly === true;
512
+ }
513
+
514
+ private isLive(): boolean {
515
+ return !this.isReadOnly() && this.record.session !== undefined;
516
+ }
517
+
518
+ private isStoppable(): boolean {
519
+ return !this.isReadOnly() && !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
520
+ }
521
+
522
+ private canSteer(): boolean {
523
+ return !this.isReadOnly() && !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
524
+ }
525
+
526
+ private openComposer(): void {
527
+ const input = new Input();
528
+ input.focused = true;
529
+ input.onSubmit = (value: string) => {
530
+ const message = value.trim();
531
+ this.composer = undefined;
532
+ if (message) this.onSteer?.(message);
533
+ this.tui.requestRender();
534
+ };
535
+ input.onEscape = () => {
536
+ this.composer = undefined;
537
+ this.tui.requestRender();
538
+ };
539
+ this.composer = input;
540
+ this.tui.requestRender();
370
541
  }
371
542
  }