@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.
- package/CHANGELOG.md +4 -22
- package/README.md +5 -5
- package/dist/agent-history-list.d.ts +18 -0
- package/dist/agent-history-list.js +68 -0
- package/dist/agent-history.d.ts +1 -1
- package/dist/agent-history.js +31 -34
- package/dist/agent-manager.d.ts +22 -8
- package/dist/agent-manager.js +100 -93
- package/dist/agent-runner.js +67 -86
- package/dist/agent-types.js +27 -45
- package/dist/context.js +2 -6
- package/dist/cross-extension-rpc.js +5 -9
- package/dist/custom-agents.js +15 -18
- package/dist/default-agents.js +1 -4
- package/dist/enabled-models.js +11 -16
- package/dist/env.js +1 -4
- package/dist/group-join.js +1 -5
- package/dist/index.js +273 -286
- package/dist/invocation-config.js +2 -6
- package/dist/memory.js +24 -34
- package/dist/model-resolver.js +1 -4
- package/dist/nico-overrides.js +14 -20
- package/dist/output-file.js +16 -22
- package/dist/prompts.js +1 -4
- package/dist/schedule-store.js +16 -21
- package/dist/schedule.js +8 -12
- package/dist/settings.js +15 -23
- package/dist/skill-loader.js +20 -23
- package/dist/status-note.js +1 -4
- package/dist/types.d.ts +0 -1
- package/dist/types.js +1 -4
- package/dist/ui/agent-widget.d.ts +1 -5
- package/dist/ui/agent-widget.js +25 -49
- package/dist/ui/conversation-blocks.d.ts +18 -0
- package/dist/ui/conversation-blocks.js +214 -0
- package/dist/ui/conversation-nvim.d.ts +7 -0
- package/dist/ui/conversation-nvim.js +116 -0
- package/dist/ui/conversation-search.d.ts +38 -0
- package/dist/ui/conversation-search.js +123 -0
- package/dist/ui/conversation-viewer.d.ts +55 -19
- package/dist/ui/conversation-viewer.js +398 -215
- package/dist/ui/fleet-list.d.ts +23 -11
- package/dist/ui/fleet-list.js +85 -78
- package/dist/ui/schedule-menu.js +1 -4
- package/dist/ui/viewer-keys.js +7 -10
- package/dist/usage.js +4 -10
- package/dist/worktree.js +26 -31
- package/package.json +1 -1
- package/src/agent-history-list.ts +91 -0
- package/src/agent-history.ts +12 -17
- package/src/agent-manager.ts +108 -81
- package/src/agent-runner.ts +5 -13
- package/src/index.ts +84 -89
- package/src/output-file.ts +1 -1
- package/src/types.ts +0 -1
- package/src/ui/agent-widget.ts +1 -8
- package/src/ui/conversation-blocks.ts +270 -0
- package/src/ui/conversation-nvim.ts +127 -0
- package/src/ui/conversation-search.ts +161 -0
- package/src/ui/conversation-viewer.ts +376 -205
- package/src/ui/fleet-list.ts +73 -52
- package/test/agent-history-list.test.ts +121 -0
- package/test/agent-manager-history.test.ts +59 -58
- package/test/agent-menu-history-navigation.test.ts +86 -0
- package/test/ui/conversation-blocks.test.ts +72 -0
- package/test/ui/conversation-search.test.ts +55 -0
- package/test/ui/conversation-viewer.test.ts +132 -0
- package/test/ui/fleet-list.test.ts +131 -0
- package/dist/ui/markdown-result.d.ts +0 -3
- package/dist/ui/markdown-result.js +0 -53
- package/src/ui/markdown-result.ts +0 -56
- package/test/agent-history.test.ts +0 -63
- package/test/ui/markdown-result.test.ts +0 -45
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* conversation-viewer.ts — Live conversation overlay
|
|
2
|
+
* conversation-viewer.ts — Live and historical conversation overlay.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
|
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
|
|
82
|
+
/** Abort the agent shown here. Omitted means read-only history. */
|
|
52
83
|
private onStop?: () => void,
|
|
53
|
-
/** User keybindings from
|
|
84
|
+
/** User keybindings from ctx.ui.custom(). */
|
|
54
85
|
keybindings?: ViewerKeybindings,
|
|
55
|
-
/** Send a steering message to the agent.
|
|
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
|
-
|
|
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,
|
|
139
|
+
const maxScroll = Math.max(0, this.cachedLines.length - viewportHeight);
|
|
108
140
|
|
|
109
|
-
if (
|
|
110
|
-
this.
|
|
111
|
-
this.
|
|
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.
|
|
114
|
-
this.autoScroll = this.scrollOffset >= maxScroll;
|
|
168
|
+
this.scrollBy(1);
|
|
115
169
|
} else if (this.keys.pageUp(data)) {
|
|
116
|
-
this.
|
|
117
|
-
this.autoScroll = false;
|
|
170
|
+
this.scrollBy(-viewportHeight);
|
|
118
171
|
} else if (this.keys.pageDown(data)) {
|
|
119
|
-
this.
|
|
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.
|
|
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 [];
|
|
187
|
+
if (width < 6) return [];
|
|
132
188
|
const th = this.theme;
|
|
133
|
-
const innerW = width - 4;
|
|
189
|
+
const innerW = Math.max(1, width - 4);
|
|
134
190
|
this.lastInnerW = innerW;
|
|
135
191
|
const lines: string[] = [];
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
199
|
-
const
|
|
200
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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):
|
|
314
|
+
private buildContentLines(width: number): RenderLine[] {
|
|
293
315
|
if (width <= 0) return [];
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const
|
|
308
|
-
|
|
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
|
-
|
|
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
|
-
|
|
366
|
-
lines.push(truncateToWidth(
|
|
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
|
-
|
|
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
|
}
|