@esso0428/pi-subagents 0.15.5 → 0.15.7

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