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