@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.
- package/CHANGELOG.md +4 -17
- 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 -284
- 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 -85
- 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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
const DEFAULT_MAX_CHARS = 16_000;
|
|
2
|
+
const TRUNCATION_NOTICE = "\n… [truncated in subagent viewer]";
|
|
3
|
+
function charLimit(value, fallback) {
|
|
4
|
+
if (value === undefined || !Number.isFinite(value))
|
|
5
|
+
return fallback;
|
|
6
|
+
return Math.max(0, Math.floor(value));
|
|
7
|
+
}
|
|
8
|
+
/** Normalize terminal/platform line endings, then keep the result bounded. */
|
|
9
|
+
function bound(value, limit) {
|
|
10
|
+
const text = value.replace(/\r\n?/g, "\n");
|
|
11
|
+
if (text.length <= limit)
|
|
12
|
+
return text;
|
|
13
|
+
if (limit <= 0)
|
|
14
|
+
return "";
|
|
15
|
+
if (limit <= TRUNCATION_NOTICE.length)
|
|
16
|
+
return TRUNCATION_NOTICE.slice(0, limit);
|
|
17
|
+
return text.slice(0, limit - TRUNCATION_NOTICE.length) + TRUNCATION_NOTICE;
|
|
18
|
+
}
|
|
19
|
+
function asString(value) {
|
|
20
|
+
return typeof value === "string" ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
/** Extract displayable text without leaking provider-specific or unknown parts. */
|
|
23
|
+
function contentText(content) {
|
|
24
|
+
if (typeof content === "string")
|
|
25
|
+
return content;
|
|
26
|
+
if (!Array.isArray(content))
|
|
27
|
+
return "";
|
|
28
|
+
const parts = [];
|
|
29
|
+
for (const part of content) {
|
|
30
|
+
if (!part || typeof part !== "object")
|
|
31
|
+
continue;
|
|
32
|
+
const item = part;
|
|
33
|
+
if (item.type === "text" && typeof item.text === "string") {
|
|
34
|
+
parts.push(item.text);
|
|
35
|
+
}
|
|
36
|
+
else if (item.type === "image") {
|
|
37
|
+
parts.push("[image]");
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return parts.join("\n");
|
|
41
|
+
}
|
|
42
|
+
function firstNonEmptyLine(text) {
|
|
43
|
+
const line = text.split("\n").find((candidate) => candidate.trim().length > 0);
|
|
44
|
+
if (!line)
|
|
45
|
+
return "(no output)";
|
|
46
|
+
return line.trim().replace(/\s+/g, " ");
|
|
47
|
+
}
|
|
48
|
+
function toolSummary(name, call) {
|
|
49
|
+
const args = call.arguments && typeof call.arguments === "object"
|
|
50
|
+
? call.arguments
|
|
51
|
+
: call.input && typeof call.input === "object"
|
|
52
|
+
? call.input
|
|
53
|
+
: {};
|
|
54
|
+
const path = asString(args.path) ?? asString(args.filePath) ?? asString(args.filename);
|
|
55
|
+
if (["read", "edit", "write"].includes(name) && path)
|
|
56
|
+
return path;
|
|
57
|
+
if (asString(args.command))
|
|
58
|
+
return asString(args.command);
|
|
59
|
+
if (path)
|
|
60
|
+
return path;
|
|
61
|
+
try {
|
|
62
|
+
const encoded = JSON.stringify(args);
|
|
63
|
+
return encoded === "{}" ? "" : encoded;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function blockId(messageIndex, emitted, toolIndex) {
|
|
70
|
+
if (emitted === 0)
|
|
71
|
+
return `message-${messageIndex}`;
|
|
72
|
+
return `message-${messageIndex}-tool-${toolIndex ?? emitted}`;
|
|
73
|
+
}
|
|
74
|
+
function textBlock(id, role, markdown) {
|
|
75
|
+
return {
|
|
76
|
+
id,
|
|
77
|
+
kind: "text",
|
|
78
|
+
role,
|
|
79
|
+
header: role === "user" ? "User" : "Assistant",
|
|
80
|
+
markdown,
|
|
81
|
+
copyText: markdown,
|
|
82
|
+
fullText: markdown,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function toolCallBlock(id, name, summary) {
|
|
86
|
+
const toolLine = `· ${name} → ${summary || "(no details)"}`;
|
|
87
|
+
return {
|
|
88
|
+
id,
|
|
89
|
+
kind: "tool",
|
|
90
|
+
role: "assistant",
|
|
91
|
+
header: "Assistant tool call",
|
|
92
|
+
markdown: "",
|
|
93
|
+
copyText: toolLine,
|
|
94
|
+
fullText: toolLine,
|
|
95
|
+
toolLine,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function toolResultBlock(id, message, maxChars) {
|
|
99
|
+
const name = asString(message.toolName) ?? "tool";
|
|
100
|
+
const output = contentText(message.content);
|
|
101
|
+
const normalized = output.replace(/\r\n?/g, "\n");
|
|
102
|
+
if (!normalized.trim() && maxChars === 0)
|
|
103
|
+
return undefined;
|
|
104
|
+
const success = message.isError === true ? "✗" : "✓";
|
|
105
|
+
const lineCount = normalized.length === 0 ? 0 : normalized.split("\n").length;
|
|
106
|
+
const charCount = normalized.length;
|
|
107
|
+
const summary = firstNonEmptyLine(normalized);
|
|
108
|
+
const toolLine = `· ${name} ${success} ${summary} ${lineCount}L/${charCount}c`;
|
|
109
|
+
const fullText = bound(`${name} ${success}\n${normalized}`, maxChars);
|
|
110
|
+
return {
|
|
111
|
+
id,
|
|
112
|
+
kind: "tool",
|
|
113
|
+
role: "tool",
|
|
114
|
+
header: `${name} result`,
|
|
115
|
+
markdown: "",
|
|
116
|
+
copyText: fullText,
|
|
117
|
+
fullText,
|
|
118
|
+
toolLine,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function bashBlock(id, message, maxChars) {
|
|
122
|
+
const command = asString(message.command) ?? "";
|
|
123
|
+
const output = asString(message.output) ?? contentText(message.content);
|
|
124
|
+
const body = command ? `$ ${command}${output ? `\n${output}` : ""}` : output;
|
|
125
|
+
if (!body.trim() && maxChars === 0)
|
|
126
|
+
return undefined;
|
|
127
|
+
const toolLine = `· bash → ${command || "(no command)"}`;
|
|
128
|
+
const fullText = bound(body, maxChars);
|
|
129
|
+
return {
|
|
130
|
+
id,
|
|
131
|
+
kind: "tool",
|
|
132
|
+
role: "tool",
|
|
133
|
+
header: "bash",
|
|
134
|
+
markdown: "",
|
|
135
|
+
copyText: fullText,
|
|
136
|
+
fullText,
|
|
137
|
+
toolLine,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function metaBlock(id, header, value, maxChars) {
|
|
141
|
+
const text = typeof value === "string" ? value : contentText(value);
|
|
142
|
+
const markdown = bound(text, maxChars);
|
|
143
|
+
if (!markdown.trim())
|
|
144
|
+
return undefined;
|
|
145
|
+
return {
|
|
146
|
+
id,
|
|
147
|
+
kind: "meta",
|
|
148
|
+
role: "meta",
|
|
149
|
+
header,
|
|
150
|
+
markdown,
|
|
151
|
+
copyText: markdown,
|
|
152
|
+
fullText: markdown,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
export function formatConversationMessages(messages, options) {
|
|
156
|
+
const maxEntryChars = charLimit(options?.maxEntryChars, DEFAULT_MAX_CHARS);
|
|
157
|
+
const maxToolResultChars = charLimit(options?.maxToolResultChars, DEFAULT_MAX_CHARS);
|
|
158
|
+
const blocks = [];
|
|
159
|
+
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
|
|
160
|
+
const message = messages[messageIndex];
|
|
161
|
+
const role = message?.role;
|
|
162
|
+
let emitted = 0;
|
|
163
|
+
if (role === "user") {
|
|
164
|
+
const text = bound(contentText(message.content), maxEntryChars);
|
|
165
|
+
if (text.trim())
|
|
166
|
+
blocks.push(textBlock(blockId(messageIndex, emitted++), "user", text));
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (role === "assistant") {
|
|
170
|
+
const parts = Array.isArray(message.content) ? message.content : [];
|
|
171
|
+
const text = bound(contentText(parts), maxEntryChars);
|
|
172
|
+
if (text.trim())
|
|
173
|
+
blocks.push(textBlock(blockId(messageIndex, emitted++), "assistant", text));
|
|
174
|
+
let toolIndex = 0;
|
|
175
|
+
for (const part of parts) {
|
|
176
|
+
if (!part || typeof part !== "object" || part.type !== "toolCall")
|
|
177
|
+
continue;
|
|
178
|
+
const call = part;
|
|
179
|
+
const name = asString(call.name) ?? asString(call.toolName) ?? "unknown";
|
|
180
|
+
blocks.push(toolCallBlock(blockId(messageIndex, emitted++, toolIndex), name, toolSummary(name, call)));
|
|
181
|
+
toolIndex++;
|
|
182
|
+
}
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
if (role === "toolResult") {
|
|
186
|
+
const block = toolResultBlock(blockId(messageIndex, emitted), message, maxToolResultChars);
|
|
187
|
+
if (block)
|
|
188
|
+
blocks.push(block);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (role === "bashExecution") {
|
|
192
|
+
const block = bashBlock(blockId(messageIndex, emitted), message, maxToolResultChars);
|
|
193
|
+
if (block)
|
|
194
|
+
blocks.push(block);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (role === "custom") {
|
|
198
|
+
if (message.display === false)
|
|
199
|
+
continue;
|
|
200
|
+
const header = asString(message.customType) || "Custom";
|
|
201
|
+
const block = metaBlock(blockId(messageIndex, emitted), header, message.content, maxEntryChars);
|
|
202
|
+
if (block)
|
|
203
|
+
blocks.push({ ...block, role: "custom" });
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (role === "compactionSummary" || role === "branchSummary" || role === "summary") {
|
|
207
|
+
const header = role === "summary" ? "Summary" : role === "compactionSummary" ? "Compaction summary" : "Branch summary";
|
|
208
|
+
const block = metaBlock(blockId(messageIndex, emitted), header, message.summary, maxEntryChars);
|
|
209
|
+
if (block)
|
|
210
|
+
blocks.push(block);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return blocks;
|
|
214
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { ConversationBlock } from "./conversation-blocks.js";
|
|
4
|
+
/** Open a bounded conversation block without allowing edits to the transcript. */
|
|
5
|
+
export declare function viewConversationBlockInNvim(tui: TUI, ctx: ExtensionContext, block: ConversationBlock): Promise<void>;
|
|
6
|
+
/** Edit a live assistant block and send the saved text as a new user message. */
|
|
7
|
+
export declare function editLiveAssistantBlockInNvim(pi: ExtensionAPI, tui: TUI, ctx: ExtensionContext, block: ConversationBlock): Promise<boolean>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
const DEFAULT_EDITOR = "nvim";
|
|
7
|
+
function safeFilename(role, id) {
|
|
8
|
+
const safeRole = role.replace(/[^a-z0-9]/gi, "") || "message";
|
|
9
|
+
const safeId = id.replace(/[^a-z0-9]/gi, "").slice(0, 24) || "x";
|
|
10
|
+
return `${safeRole}-${safeId}.md`;
|
|
11
|
+
}
|
|
12
|
+
function vimStringLiteral(value) {
|
|
13
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
14
|
+
}
|
|
15
|
+
function notify(ctx, message, level = "error") {
|
|
16
|
+
ctx.ui.notify(message, level);
|
|
17
|
+
}
|
|
18
|
+
function runFullscreen(tui, ctx, editor, args) {
|
|
19
|
+
try {
|
|
20
|
+
tui.stop();
|
|
21
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
22
|
+
const result = spawnSync(editor, args, {
|
|
23
|
+
cwd: ctx.cwd,
|
|
24
|
+
env: process.env,
|
|
25
|
+
stdio: "inherit",
|
|
26
|
+
});
|
|
27
|
+
if (result.error) {
|
|
28
|
+
notify(ctx, `${editor} failed: ${result.error.message}`);
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return result.status;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
notify(ctx, `${editor} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
try {
|
|
39
|
+
tui.start();
|
|
40
|
+
tui.requestRender(true);
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
notify(ctx, `Could not restore terminal UI: ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Open a bounded conversation block without allowing edits to the transcript. */
|
|
48
|
+
export async function viewConversationBlockInNvim(tui, ctx, block) {
|
|
49
|
+
let dir;
|
|
50
|
+
try {
|
|
51
|
+
dir = await mkdtemp(path.join(tmpdir(), "pi-subagents-view-"));
|
|
52
|
+
const filePath = path.join(dir, safeFilename(block.role, block.id));
|
|
53
|
+
await writeFile(filePath, `${block.fullText}\n`, "utf8");
|
|
54
|
+
const editor = process.env.VISUAL || process.env.EDITOR || DEFAULT_EDITOR;
|
|
55
|
+
runFullscreen(tui, ctx, editor, ["-R", filePath]);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
notify(ctx, `Could not open block in nvim: ${error instanceof Error ? error.message : String(error)}`);
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (dir) {
|
|
62
|
+
try {
|
|
63
|
+
await rm(dir, { recursive: true, force: true });
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
notify(ctx, `Could not remove temporary nvim file: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Edit a live assistant block and send the saved text as a new user message. */
|
|
72
|
+
export async function editLiveAssistantBlockInNvim(pi, tui, ctx, block) {
|
|
73
|
+
if (block.role !== "assistant") {
|
|
74
|
+
notify(ctx, "Only assistant messages can be edited and sent back", "warning");
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
let dir;
|
|
78
|
+
try {
|
|
79
|
+
dir = await mkdtemp(path.join(tmpdir(), "pi-subagents-edit-"));
|
|
80
|
+
const filePath = path.join(dir, safeFilename(block.role, block.id));
|
|
81
|
+
const savedPath = path.join(dir, "saved");
|
|
82
|
+
await writeFile(filePath, `${block.fullText}\n`, "utf8");
|
|
83
|
+
const editor = process.env.VISUAL || process.env.EDITOR || DEFAULT_EDITOR;
|
|
84
|
+
const autocmd = `autocmd BufWritePost <buffer> call writefile(['1'], ${vimStringLiteral(savedPath)}) | qall`;
|
|
85
|
+
const status = runFullscreen(tui, ctx, editor, ["-c", autocmd, filePath]);
|
|
86
|
+
if (status !== 0) {
|
|
87
|
+
notify(ctx, `${editor} exited with code ${status ?? "unknown"}`, "warning");
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (!existsSync(savedPath)) {
|
|
91
|
+
notify(ctx, "No save detected; nothing sent", "info");
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
const edited = (await readFile(filePath, "utf8")).trimEnd();
|
|
95
|
+
if (!edited.trim()) {
|
|
96
|
+
notify(ctx, "Saved text is empty; nothing sent", "warning");
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
pi.sendUserMessage(`I edited an earlier assistant response in Neovim. Treat this as my correction / direction:\n\n${edited}`);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
notify(ctx, `Could not edit block in nvim: ${error instanceof Error ? error.message : String(error)}`);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
if (dir) {
|
|
108
|
+
try {
|
|
109
|
+
await rm(dir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
notify(ctx, `Could not remove temporary nvim file: ${error instanceof Error ? error.message : String(error)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Search and highlighting helpers for rendered conversation lines. */
|
|
2
|
+
/** A substring match in a rendered line's plain-text representation. */
|
|
3
|
+
export type ConversationMatch = {
|
|
4
|
+
lineIdx: number;
|
|
5
|
+
col: number;
|
|
6
|
+
len: number;
|
|
7
|
+
};
|
|
8
|
+
type ConversationLine = {
|
|
9
|
+
plain: string;
|
|
10
|
+
};
|
|
11
|
+
type HighlightMatch = ConversationMatch & {
|
|
12
|
+
current?: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Remove ANSI CSI/OSC control sequences from a string.
|
|
16
|
+
*
|
|
17
|
+
* This deliberately follows the escape-sequence grammar used by the terminal
|
|
18
|
+
* renderer, rather than stripping only colour codes: hyperlinks and other
|
|
19
|
+
* styled output must not affect plain-text search columns either.
|
|
20
|
+
*/
|
|
21
|
+
export declare function stripAnsi(text: string): string;
|
|
22
|
+
/**
|
|
23
|
+
* Find all non-overlapping substring matches using smartcase semantics.
|
|
24
|
+
* Lowercase queries match case-insensitively; queries containing uppercase
|
|
25
|
+
* characters match case-sensitively.
|
|
26
|
+
*/
|
|
27
|
+
export declare function findConversationMatches(lines: ConversationLine[], query: string): ConversationMatch[];
|
|
28
|
+
/**
|
|
29
|
+
* Inject match backgrounds into an ANSI-styled line without counting escape
|
|
30
|
+
* sequences as plain-text columns. Existing foreground/style sequences are
|
|
31
|
+
* copied verbatim, so syntax highlighting remains intact.
|
|
32
|
+
*/
|
|
33
|
+
export declare function highlightConversationLine(text: string, matches: HighlightMatch[], openMatch: string, openCurrent: string, close: string): string;
|
|
34
|
+
/** Find the first match at or after a line, wrapping to the first match. */
|
|
35
|
+
export declare function nearestConversationMatchAfter(matches: ConversationMatch[], lineIdx: number): number;
|
|
36
|
+
/** Find the last match at or before a line, wrapping to the last match. */
|
|
37
|
+
export declare function nearestConversationMatchBefore(matches: ConversationMatch[], lineIdx: number): number;
|
|
38
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Search and highlighting helpers for rendered conversation lines. */
|
|
2
|
+
function isSmartcaseInsensitive(query) {
|
|
3
|
+
return query === query.toLowerCase();
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Remove ANSI CSI/OSC control sequences from a string.
|
|
7
|
+
*
|
|
8
|
+
* This deliberately follows the escape-sequence grammar used by the terminal
|
|
9
|
+
* renderer, rather than stripping only colour codes: hyperlinks and other
|
|
10
|
+
* styled output must not affect plain-text search columns either.
|
|
11
|
+
*/
|
|
12
|
+
export function stripAnsi(text) {
|
|
13
|
+
let result = "";
|
|
14
|
+
let i = 0;
|
|
15
|
+
while (i < text.length) {
|
|
16
|
+
if (text[i] !== "\x1b") {
|
|
17
|
+
result += text[i];
|
|
18
|
+
i++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
const match = text.slice(i).match(ANSI_ESCAPE_RE);
|
|
22
|
+
if (match) {
|
|
23
|
+
i += match[0].length;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
// An incomplete escape sequence has no visible representation. This also
|
|
27
|
+
// prevents a malformed trailing escape from leaking into search text.
|
|
28
|
+
if (i + 1 < text.length) {
|
|
29
|
+
i += 2;
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Find all non-overlapping substring matches using smartcase semantics.
|
|
39
|
+
* Lowercase queries match case-insensitively; queries containing uppercase
|
|
40
|
+
* characters match case-sensitively.
|
|
41
|
+
*/
|
|
42
|
+
export function findConversationMatches(lines, query) {
|
|
43
|
+
if (!query)
|
|
44
|
+
return [];
|
|
45
|
+
const matches = [];
|
|
46
|
+
const caseInsensitive = isSmartcaseInsensitive(query);
|
|
47
|
+
const needle = caseInsensitive ? query.toLowerCase() : query;
|
|
48
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
49
|
+
const plain = lines[lineIdx]?.plain ?? "";
|
|
50
|
+
const haystack = caseInsensitive ? plain.toLowerCase() : plain;
|
|
51
|
+
let position = 0;
|
|
52
|
+
while (position < haystack.length) {
|
|
53
|
+
const col = haystack.indexOf(needle, position);
|
|
54
|
+
if (col === -1)
|
|
55
|
+
break;
|
|
56
|
+
matches.push({ lineIdx, col, len: query.length });
|
|
57
|
+
position = col + Math.max(1, query.length);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return matches;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Inject match backgrounds into an ANSI-styled line without counting escape
|
|
64
|
+
* sequences as plain-text columns. Existing foreground/style sequences are
|
|
65
|
+
* copied verbatim, so syntax highlighting remains intact.
|
|
66
|
+
*/
|
|
67
|
+
export function highlightConversationLine(text, matches, openMatch, openCurrent, close) {
|
|
68
|
+
if (matches.length === 0)
|
|
69
|
+
return text;
|
|
70
|
+
const sorted = [...matches].sort((a, b) => a.col - b.col);
|
|
71
|
+
let result = "";
|
|
72
|
+
let plainCol = 0;
|
|
73
|
+
let i = 0;
|
|
74
|
+
let active = null;
|
|
75
|
+
let nextIdx = 0;
|
|
76
|
+
while (i < text.length) {
|
|
77
|
+
if (text[i] === "\x1b") {
|
|
78
|
+
const escape = text.slice(i).match(ANSI_ESCAPE_RE);
|
|
79
|
+
if (escape) {
|
|
80
|
+
result += escape[0];
|
|
81
|
+
i += escape[0].length;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (!active && nextIdx < sorted.length && plainCol >= sorted[nextIdx].col) {
|
|
86
|
+
active = sorted[nextIdx];
|
|
87
|
+
result += active.current ? openCurrent : openMatch;
|
|
88
|
+
}
|
|
89
|
+
result += text[i];
|
|
90
|
+
plainCol++;
|
|
91
|
+
i++;
|
|
92
|
+
if (active && plainCol >= active.col + active.len) {
|
|
93
|
+
result += close;
|
|
94
|
+
active = null;
|
|
95
|
+
nextIdx++;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (active)
|
|
99
|
+
result += close;
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
/** Find the first match at or after a line, wrapping to the first match. */
|
|
103
|
+
export function nearestConversationMatchAfter(matches, lineIdx) {
|
|
104
|
+
if (matches.length === 0)
|
|
105
|
+
return -1;
|
|
106
|
+
for (let i = 0; i < matches.length; i++) {
|
|
107
|
+
if (matches[i].lineIdx >= lineIdx)
|
|
108
|
+
return i;
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
112
|
+
/** Find the last match at or before a line, wrapping to the last match. */
|
|
113
|
+
export function nearestConversationMatchBefore(matches, lineIdx) {
|
|
114
|
+
if (matches.length === 0)
|
|
115
|
+
return -1;
|
|
116
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
117
|
+
if (matches[i].lineIdx <= lineIdx)
|
|
118
|
+
return i;
|
|
119
|
+
}
|
|
120
|
+
return matches.length - 1;
|
|
121
|
+
}
|
|
122
|
+
/** ANSI CSI/OSC sequences emitted by the TUI renderer. */
|
|
123
|
+
const ANSI_ESCAPE_RE = /^(?:\x1b\][^\x07]*(?:\x07|\x1b\\)|\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]))/;
|
|
@@ -1,20 +1,25 @@
|
|
|
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
|
-
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext, AgentSession } from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { type Component, type TUI } from "@earendil-works/pi-tui";
|
|
9
9
|
import type { AgentRecord } from "../types.js";
|
|
10
10
|
import type { Theme } from "./agent-widget.js";
|
|
11
11
|
import { type AgentActivity } from "./agent-widget.js";
|
|
12
12
|
import { type ViewerKeybindings } from "./viewer-keys.js";
|
|
13
|
-
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
|
|
14
13
|
export declare const VIEWPORT_HEIGHT_PCT = 70;
|
|
15
14
|
/** The live fields needed by the viewer; historical viewers use a static source. */
|
|
16
15
|
export type ConversationSource = Pick<AgentSession, "messages" | "subscribe">;
|
|
17
16
|
export declare function createStaticConversationSource(messages: AgentSession["messages"]): ConversationSource;
|
|
17
|
+
/** Optional operations are appended so all existing constructor call sites stay valid. */
|
|
18
|
+
export interface ConversationViewerOperations {
|
|
19
|
+
pi: ExtensionAPI;
|
|
20
|
+
ctx: ExtensionContext;
|
|
21
|
+
readOnly?: boolean;
|
|
22
|
+
}
|
|
18
23
|
export declare class ConversationViewer implements Component {
|
|
19
24
|
private tui;
|
|
20
25
|
private session;
|
|
@@ -22,39 +27,70 @@ export declare class ConversationViewer implements Component {
|
|
|
22
27
|
private activity;
|
|
23
28
|
private theme;
|
|
24
29
|
private done;
|
|
25
|
-
/** Abort the agent shown here. Omitted
|
|
30
|
+
/** Abort the agent shown here. Omitted means read-only history. */
|
|
26
31
|
private onStop?;
|
|
27
|
-
/** Send a steering message to the agent.
|
|
32
|
+
/** Send a steering message to the agent. */
|
|
28
33
|
private onSteer?;
|
|
34
|
+
/** Optional clipboard/nvim operation context, appended for compatibility. */
|
|
35
|
+
private operations?;
|
|
36
|
+
private blocks;
|
|
37
|
+
private showTools;
|
|
38
|
+
private selectedMessageIdx;
|
|
39
|
+
private cachedWidth;
|
|
40
|
+
private cachedLines;
|
|
41
|
+
private cachedMessageStarts;
|
|
42
|
+
private cachedMessageBlocks;
|
|
43
|
+
private cacheVersion;
|
|
44
|
+
private cachedVersion;
|
|
29
45
|
private scrollOffset;
|
|
30
46
|
private autoScroll;
|
|
31
47
|
private unsubscribe;
|
|
32
48
|
private lastInnerW;
|
|
33
49
|
private closed;
|
|
34
|
-
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
|
|
35
50
|
private stopArmed;
|
|
36
51
|
private keys;
|
|
37
|
-
/** Steering composer — present while the user is typing a message to the agent. */
|
|
38
52
|
private composer;
|
|
53
|
+
private searchMode;
|
|
54
|
+
private searchQuery;
|
|
55
|
+
private searchMatches;
|
|
56
|
+
private currentMatchIdx;
|
|
39
57
|
constructor(tui: TUI, session: ConversationSource, record: AgentRecord, activity: AgentActivity | undefined, theme: Theme, done: (result: undefined) => void,
|
|
40
|
-
/** Abort the agent shown here. Omitted
|
|
58
|
+
/** Abort the agent shown here. Omitted means read-only history. */
|
|
41
59
|
onStop?: (() => void) | undefined,
|
|
42
|
-
/** User keybindings from
|
|
60
|
+
/** User keybindings from ctx.ui.custom(). */
|
|
43
61
|
keybindings?: ViewerKeybindings,
|
|
44
|
-
/** Send a steering message to the agent.
|
|
45
|
-
onSteer?: ((message: string) => void) | undefined
|
|
62
|
+
/** Send a steering message to the agent. */
|
|
63
|
+
onSteer?: ((message: string) => void) | undefined,
|
|
64
|
+
/** Optional clipboard/nvim operation context, appended for compatibility. */
|
|
65
|
+
operations?: ConversationViewerOperations | undefined);
|
|
46
66
|
handleInput(data: string): void;
|
|
47
67
|
render(width: number): string[];
|
|
48
|
-
/** Stoppable only when a stop handler exists and the agent is still active. */
|
|
49
|
-
private isStoppable;
|
|
50
|
-
/** Steerable only when a steer handler exists and the agent is still active. */
|
|
51
|
-
private canSteer;
|
|
52
|
-
/** Open the inline steering composer and route subsequent input to it. */
|
|
53
|
-
private openComposer;
|
|
54
68
|
invalidate(): void;
|
|
55
69
|
dispose(): void;
|
|
56
70
|
private viewportHeight;
|
|
57
71
|
private chromeLines;
|
|
58
72
|
private invocationLine;
|
|
59
73
|
private buildContentLines;
|
|
74
|
+
private currentMessageIndex;
|
|
75
|
+
private currentBlockGlobalIndex;
|
|
76
|
+
private activeHeaderLine;
|
|
77
|
+
private currentBlock;
|
|
78
|
+
private messageIndexForOffset;
|
|
79
|
+
private jumpToMessage;
|
|
80
|
+
private scrollBy;
|
|
81
|
+
private scrollToTop;
|
|
82
|
+
private scrollToBottom;
|
|
83
|
+
private enterSearch;
|
|
84
|
+
private handleSearchInput;
|
|
85
|
+
private recomputeMatches;
|
|
86
|
+
private gotoMatch;
|
|
87
|
+
private scrollToMatch;
|
|
88
|
+
private copyCurrentMessage;
|
|
89
|
+
private viewCurrentInNvim;
|
|
90
|
+
private editCurrentInNvim;
|
|
91
|
+
private isReadOnly;
|
|
92
|
+
private isLive;
|
|
93
|
+
private isStoppable;
|
|
94
|
+
private canSteer;
|
|
95
|
+
private openComposer;
|
|
60
96
|
}
|