@esso0428/pi-subagents 0.15.6 → 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.
- package/CHANGELOG.md +9 -0
- package/dist/agent-history-list.d.ts +1 -0
- package/dist/agent-history-list.d.ts.map +1 -0
- package/dist/agent-history-list.js +1 -0
- package/dist/agent-history-list.js.map +1 -0
- package/dist/agent-history.d.ts +1 -0
- package/dist/agent-history.d.ts.map +1 -0
- package/dist/agent-history.js +1 -0
- package/dist/agent-history.js.map +1 -0
- package/dist/agent-manager.d.ts +2 -0
- package/dist/agent-manager.d.ts.map +1 -0
- package/dist/agent-manager.js +71 -0
- package/dist/agent-manager.js.map +1 -0
- package/dist/agent-runner.d.ts +1 -0
- package/dist/agent-runner.d.ts.map +1 -0
- package/dist/agent-runner.js +8 -0
- package/dist/agent-runner.js.map +1 -0
- package/dist/agent-types.d.ts +1 -0
- package/dist/agent-types.d.ts.map +1 -0
- package/dist/agent-types.js +1 -0
- package/dist/agent-types.js.map +1 -0
- package/dist/context.d.ts +1 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +1 -0
- package/dist/context.js.map +1 -0
- package/dist/cross-extension-rpc.d.ts +1 -0
- package/dist/cross-extension-rpc.d.ts.map +1 -0
- package/dist/cross-extension-rpc.js +1 -0
- package/dist/cross-extension-rpc.js.map +1 -0
- package/dist/custom-agents.d.ts +1 -0
- package/dist/custom-agents.d.ts.map +1 -0
- package/dist/custom-agents.js +1 -0
- package/dist/custom-agents.js.map +1 -0
- package/dist/default-agents.d.ts +1 -0
- package/dist/default-agents.d.ts.map +1 -0
- package/dist/default-agents.js +1 -0
- package/dist/default-agents.js.map +1 -0
- package/dist/enabled-models.d.ts +1 -0
- package/dist/enabled-models.d.ts.map +1 -0
- package/dist/enabled-models.js +1 -0
- package/dist/enabled-models.js.map +1 -0
- package/dist/env.d.ts +1 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +1 -0
- package/dist/env.js.map +1 -0
- package/dist/group-join.d.ts +1 -0
- package/dist/group-join.d.ts.map +1 -0
- package/dist/group-join.js +1 -0
- package/dist/group-join.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -10
- package/dist/index.js.map +1 -0
- package/dist/invocation-config.d.ts +1 -0
- package/dist/invocation-config.d.ts.map +1 -0
- package/dist/invocation-config.js +1 -0
- package/dist/invocation-config.js.map +1 -0
- package/dist/memory.d.ts +1 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +1 -0
- package/dist/memory.js.map +1 -0
- package/dist/model-resolver.d.ts +1 -0
- package/dist/model-resolver.d.ts.map +1 -0
- package/dist/model-resolver.js +1 -0
- package/dist/model-resolver.js.map +1 -0
- package/dist/nico-overrides.d.ts +1 -0
- package/dist/nico-overrides.d.ts.map +1 -0
- package/dist/nico-overrides.js +1 -0
- package/dist/nico-overrides.js.map +1 -0
- package/dist/output-file.d.ts +1 -0
- package/dist/output-file.d.ts.map +1 -0
- package/dist/output-file.js +1 -0
- package/dist/output-file.js.map +1 -0
- package/dist/prompts.d.ts +1 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +1 -0
- package/dist/prompts.js.map +1 -0
- package/dist/schedule-store.d.ts +1 -0
- package/dist/schedule-store.d.ts.map +1 -0
- package/dist/schedule-store.js +1 -0
- package/dist/schedule-store.js.map +1 -0
- package/dist/schedule.d.ts +1 -0
- package/dist/schedule.d.ts.map +1 -0
- package/dist/schedule.js +1 -0
- package/dist/schedule.js.map +1 -0
- package/dist/settings.d.ts +1 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +1 -0
- package/dist/settings.js.map +1 -0
- package/dist/skill-loader.d.ts +1 -0
- package/dist/skill-loader.d.ts.map +1 -0
- package/dist/skill-loader.js +1 -0
- package/dist/skill-loader.js.map +1 -0
- package/dist/status-note.d.ts +1 -0
- package/dist/status-note.d.ts.map +1 -0
- package/dist/status-note.js +1 -0
- package/dist/status-note.js.map +1 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/types.js.map +1 -0
- package/dist/ui/agent-widget.d.ts +1 -0
- package/dist/ui/agent-widget.d.ts.map +1 -0
- package/dist/ui/agent-widget.js +4 -2
- package/dist/ui/agent-widget.js.map +1 -0
- package/dist/ui/ccstyle/diff/ansi-utils.d.ts +11 -0
- package/dist/ui/ccstyle/diff/ansi-utils.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/ansi-utils.js +145 -0
- package/dist/ui/ccstyle/diff/ansi-utils.js.map +1 -0
- package/dist/ui/ccstyle/diff/diff-presentation.d.ts +12 -0
- package/dist/ui/ccstyle/diff/diff-presentation.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/diff-presentation.js +49 -0
- package/dist/ui/ccstyle/diff/diff-presentation.js.map +1 -0
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts +46 -0
- package/dist/ui/ccstyle/diff/diff-renderer.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/diff-renderer.js +2044 -0
- package/dist/ui/ccstyle/diff/diff-renderer.js.map +1 -0
- package/dist/ui/ccstyle/diff/line-width-safety.d.ts +12 -0
- package/dist/ui/ccstyle/diff/line-width-safety.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/line-width-safety.js +59 -0
- package/dist/ui/ccstyle/diff/line-width-safety.js.map +1 -0
- package/dist/ui/ccstyle/diff/render-utils.d.ts +6 -0
- package/dist/ui/ccstyle/diff/render-utils.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/render-utils.js +25 -0
- package/dist/ui/ccstyle/diff/render-utils.js.map +1 -0
- package/dist/ui/ccstyle/diff/types.d.ts +22 -0
- package/dist/ui/ccstyle/diff/types.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/types.js +10 -0
- package/dist/ui/ccstyle/diff/types.js.map +1 -0
- package/dist/ui/ccstyle/diff/write-display-utils.d.ts +2 -0
- package/dist/ui/ccstyle/diff/write-display-utils.d.ts.map +1 -0
- package/dist/ui/ccstyle/diff/write-display-utils.js +12 -0
- package/dist/ui/ccstyle/diff/write-display-utils.js.map +1 -0
- package/dist/ui/ccstyle/tool-renderer.d.ts +17 -0
- package/dist/ui/ccstyle/tool-renderer.d.ts.map +1 -0
- package/dist/ui/ccstyle/tool-renderer.js +58 -0
- package/dist/ui/ccstyle/tool-renderer.js.map +1 -0
- package/dist/ui/ccstyle/tool-result.d.ts +24 -0
- package/dist/ui/ccstyle/tool-result.d.ts.map +1 -0
- package/dist/ui/ccstyle/tool-result.js +193 -0
- package/dist/ui/ccstyle/tool-result.js.map +1 -0
- package/dist/ui/conversation-blocks.d.ts +28 -0
- package/dist/ui/conversation-blocks.d.ts.map +1 -0
- package/dist/ui/conversation-blocks.js +119 -27
- package/dist/ui/conversation-blocks.js.map +1 -0
- package/dist/ui/conversation-nvim.d.ts +1 -0
- package/dist/ui/conversation-nvim.d.ts.map +1 -0
- package/dist/ui/conversation-nvim.js +1 -0
- package/dist/ui/conversation-nvim.js.map +1 -0
- package/dist/ui/conversation-role.d.ts +13 -0
- package/dist/ui/conversation-role.d.ts.map +1 -0
- package/dist/ui/conversation-role.js +39 -0
- package/dist/ui/conversation-role.js.map +1 -0
- package/dist/ui/conversation-search.d.ts +1 -0
- package/dist/ui/conversation-search.d.ts.map +1 -0
- package/dist/ui/conversation-search.js +1 -0
- package/dist/ui/conversation-search.js.map +1 -0
- package/dist/ui/conversation-timeline.d.ts +62 -0
- package/dist/ui/conversation-timeline.d.ts.map +1 -0
- package/dist/ui/conversation-timeline.js +263 -0
- package/dist/ui/conversation-timeline.js.map +1 -0
- package/dist/ui/conversation-viewer.d.ts +7 -1
- package/dist/ui/conversation-viewer.d.ts.map +1 -0
- package/dist/ui/conversation-viewer.js +96 -54
- package/dist/ui/conversation-viewer.js.map +1 -0
- package/dist/ui/fleet-list.d.ts +1 -0
- package/dist/ui/fleet-list.d.ts.map +1 -0
- package/dist/ui/fleet-list.js +1 -0
- package/dist/ui/fleet-list.js.map +1 -0
- package/dist/ui/schedule-menu.d.ts +1 -0
- package/dist/ui/schedule-menu.d.ts.map +1 -0
- package/dist/ui/schedule-menu.js +1 -0
- package/dist/ui/schedule-menu.js.map +1 -0
- package/dist/ui/viewer-keys.d.ts +1 -0
- package/dist/ui/viewer-keys.d.ts.map +1 -0
- package/dist/ui/viewer-keys.js +1 -0
- package/dist/ui/viewer-keys.js.map +1 -0
- package/dist/usage.d.ts +1 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +1 -0
- package/dist/usage.js.map +1 -0
- package/dist/worktree.d.ts +1 -0
- package/dist/worktree.d.ts.map +1 -0
- package/dist/worktree.js +1 -0
- package/dist/worktree.js.map +1 -0
- package/dist/write-execution.d.ts +42 -0
- package/dist/write-execution.d.ts.map +1 -0
- package/dist/write-execution.js +138 -0
- package/dist/write-execution.js.map +1 -0
- package/package.json +1 -1
- package/src/agent-manager.ts +61 -0
- package/src/agent-runner.ts +7 -0
- package/src/index.ts +35 -11
- package/src/types.ts +4 -0
- package/src/ui/agent-widget.ts +2 -1
- package/src/ui/ccstyle/ATTRIBUTION.md +37 -0
- package/src/ui/ccstyle/diff/ansi-utils.ts +168 -0
- package/src/ui/ccstyle/diff/diff-presentation.ts +70 -0
- package/src/ui/ccstyle/diff/diff-renderer.ts +3148 -0
- package/src/ui/ccstyle/diff/line-width-safety.ts +92 -0
- package/src/ui/ccstyle/diff/render-utils.ts +32 -0
- package/src/ui/ccstyle/diff/types.ts +33 -0
- package/src/ui/ccstyle/diff/write-display-utils.ts +12 -0
- package/src/ui/ccstyle/tool-renderer.ts +71 -0
- package/src/ui/ccstyle/tool-result.ts +238 -0
- package/src/ui/conversation-blocks.ts +160 -26
- package/src/ui/conversation-role.ts +45 -0
- package/src/ui/conversation-timeline.ts +315 -0
- package/src/ui/conversation-viewer.ts +91 -54
- package/src/write-execution.ts +180 -0
- package/test/output-file-history.test.ts +50 -0
- package/test/ui/conversation-blocks.test.ts +31 -0
- package/test/ui/conversation-diff.test.ts +91 -0
- package/test/ui/conversation-viewer.test.ts +50 -0
- package/test/write-execution.test.ts +83 -0
- package/tsconfig.json +16 -0
- package/vitest.temp.config.mts +6 -0
|
@@ -6,13 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type { ExtensionAPI, ExtensionContext, AgentSession } from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { copyToClipboard
|
|
10
|
-
import { type Component, Input, Key,
|
|
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
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";
|
|
16
18
|
import { editLiveAssistantBlockInNvim, viewConversationBlockInNvim } from "./conversation-nvim.js";
|
|
17
19
|
import { findConversationMatches, highlightConversationLine, nearestConversationMatchAfter, nearestConversationMatchBefore, stripAnsi, type ConversationMatch } from "./conversation-search.js";
|
|
18
20
|
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
|
|
@@ -35,12 +37,7 @@ export function createStaticConversationSource(
|
|
|
35
37
|
return { messages, subscribe: () => () => {} };
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
type RenderLine =
|
|
39
|
-
text: string;
|
|
40
|
-
plain: string;
|
|
41
|
-
blockIndex: number;
|
|
42
|
-
railable?: boolean;
|
|
43
|
-
};
|
|
40
|
+
type RenderLine = TimelineRenderLine;
|
|
44
41
|
|
|
45
42
|
/** Optional operations are appended so all existing constructor call sites stay valid. */
|
|
46
43
|
export interface ConversationViewerOperations {
|
|
@@ -71,6 +68,7 @@ export class ConversationViewer implements Component {
|
|
|
71
68
|
private searchQuery = "";
|
|
72
69
|
private searchMatches: ConversationMatch[] = [];
|
|
73
70
|
private currentMatchIdx = -1;
|
|
71
|
+
private timeline: ConversationTimeline;
|
|
74
72
|
|
|
75
73
|
constructor(
|
|
76
74
|
private tui: TUI,
|
|
@@ -90,9 +88,20 @@ export class ConversationViewer implements Component {
|
|
|
90
88
|
) {
|
|
91
89
|
this.keys = createViewerKeys(keybindings);
|
|
92
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);
|
|
93
101
|
this.unsubscribe = session.subscribe(() => {
|
|
94
102
|
if (this.closed) return;
|
|
95
103
|
this.blocks = formatConversationMessages(this.session.messages);
|
|
104
|
+
this.timeline.setBlocks(this.blocks);
|
|
96
105
|
this.cacheVersion++;
|
|
97
106
|
this.invalidate();
|
|
98
107
|
this.tui.requestRender();
|
|
@@ -114,10 +123,17 @@ export class ConversationViewer implements Component {
|
|
|
114
123
|
this.done(undefined);
|
|
115
124
|
return;
|
|
116
125
|
}
|
|
117
|
-
if (matchesKey(data, "enter")
|
|
118
|
-
this.
|
|
119
|
-
this.
|
|
120
|
-
|
|
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
|
+
}
|
|
121
137
|
}
|
|
122
138
|
if (matchesKey(data, "x")) {
|
|
123
139
|
if (this.isStoppable()) {
|
|
@@ -140,6 +156,15 @@ export class ConversationViewer implements Component {
|
|
|
140
156
|
|
|
141
157
|
if (data === "t") {
|
|
142
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);
|
|
143
168
|
this.invalidate();
|
|
144
169
|
this.tui.requestRender();
|
|
145
170
|
} else if (data === "J" || matchesKey(data, Key.shift("j"))) {
|
|
@@ -183,6 +208,25 @@ export class ConversationViewer implements Component {
|
|
|
183
208
|
void maxScroll;
|
|
184
209
|
}
|
|
185
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;
|
|
228
|
+
}
|
|
229
|
+
|
|
186
230
|
render(width: number): string[] {
|
|
187
231
|
if (width < 6) return [];
|
|
188
232
|
const th = this.theme;
|
|
@@ -226,7 +270,10 @@ export class ConversationViewer implements Component {
|
|
|
226
270
|
const displayed = visible.slice();
|
|
227
271
|
if (showSticky) {
|
|
228
272
|
const block = this.cachedMessageBlocks[this.currentMessageIndex()];
|
|
229
|
-
if (block)
|
|
273
|
+
if (block) {
|
|
274
|
+
const header = renderConversationRoleHeader(block, this.theme);
|
|
275
|
+
displayed[0] = { text: ` ${header}`, plain: header, blockIndex: currentBlockIdx, railable: true };
|
|
276
|
+
}
|
|
230
277
|
}
|
|
231
278
|
for (let i = 0; i < displayed.length; i++) {
|
|
232
279
|
const line = displayed[i];
|
|
@@ -248,6 +295,13 @@ export class ConversationViewer implements Component {
|
|
|
248
295
|
const hint = th.fg("dim", "Enter send · Esc cancel");
|
|
249
296
|
const label = th.fg("accent", "✎ steer");
|
|
250
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)));
|
|
251
305
|
} else {
|
|
252
306
|
const actions: string[] = [];
|
|
253
307
|
if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
|
|
@@ -306,52 +360,35 @@ export class ConversationViewer implements Component {
|
|
|
306
360
|
}
|
|
307
361
|
|
|
308
362
|
private invocationLine(): string | undefined {
|
|
309
|
-
const
|
|
310
|
-
const
|
|
311
|
-
|
|
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];
|
|
380
|
+
return this.theme.fg("dim", ` ↳ ${parts.join(" · ")}`);
|
|
312
381
|
}
|
|
313
382
|
|
|
314
383
|
private buildContentLines(width: number): RenderLine[] {
|
|
315
384
|
if (width <= 0) return [];
|
|
316
385
|
if (this.cachedVersion === this.cacheVersion && this.cachedWidth === width) return this.cachedLines;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
lines.push({ text: this.theme.fg("dim", empty), plain: empty, blockIndex: -1 });
|
|
324
|
-
}
|
|
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 });
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
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
|
-
}
|
|
354
|
-
}
|
|
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];
|
|
355
392
|
if (this.record.status === "running" && this.activity) {
|
|
356
393
|
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
|
|
357
394
|
const text = `▍ ${act}`;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createWriteToolDefinition, generateDiffString, generateUnifiedPatch, type ExtensionContext, type ToolDefinition, withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Maximum UTF-8 size of the durable display diff plus unified patch. */
|
|
6
|
+
export const MAX_DURABLE_DIFF_BYTES = 4 * 1024 * 1024;
|
|
7
|
+
/** Avoid retaining arbitrarily large previous files in the live-only store. */
|
|
8
|
+
export const MAX_COMPARABLE_WRITE_BYTES = 512_000;
|
|
9
|
+
export const MAX_WRITE_METADATA_ENTRIES = 100;
|
|
10
|
+
|
|
11
|
+
export interface DurableWriteDetails {
|
|
12
|
+
diff?: string;
|
|
13
|
+
patch?: string;
|
|
14
|
+
firstChangedLine?: number;
|
|
15
|
+
diffUnavailableReason?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Ephemeral execution metadata used by the live renderer. Never persisted as a before/after pair. */
|
|
19
|
+
export interface WriteExecutionMeta extends DurableWriteDetails {
|
|
20
|
+
fileExistedBeforeWrite: boolean;
|
|
21
|
+
previousContent?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class WriteExecutionMetadataStore {
|
|
25
|
+
readonly entries = new Map<string, WriteExecutionMeta>();
|
|
26
|
+
|
|
27
|
+
set(toolCallId: string, metadata: WriteExecutionMeta): void {
|
|
28
|
+
this.entries.delete(toolCallId);
|
|
29
|
+
this.entries.set(toolCallId, metadata);
|
|
30
|
+
while (this.entries.size > MAX_WRITE_METADATA_ENTRIES) {
|
|
31
|
+
const oldest = this.entries.keys().next().value;
|
|
32
|
+
if (oldest === undefined) break;
|
|
33
|
+
this.entries.delete(oldest);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get(toolCallId: unknown): WriteExecutionMeta | undefined {
|
|
38
|
+
return typeof toolCallId === "string" ? this.entries.get(toolCallId) : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
delete(toolCallId: string): void {
|
|
42
|
+
this.entries.delete(toolCallId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
clear(): void {
|
|
46
|
+
this.entries.clear();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type WriteParams = { path: string; content: string };
|
|
51
|
+
|
|
52
|
+
function resolveWritePath(path: string, cwd: string): string {
|
|
53
|
+
if (path === "~") return process.env.HOME ?? cwd;
|
|
54
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) {
|
|
55
|
+
return resolve(process.env.HOME ?? cwd, path.slice(2));
|
|
56
|
+
}
|
|
57
|
+
return isAbsolute(path) ? path : resolve(cwd, path);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function capturePreviousContent(absolutePath: string): Promise<WriteExecutionMeta> {
|
|
61
|
+
let info;
|
|
62
|
+
try {
|
|
63
|
+
info = await lstat(absolutePath);
|
|
64
|
+
} catch (error: any) {
|
|
65
|
+
if (error?.code === "ENOENT") return { fileExistedBeforeWrite: false, previousContent: "" };
|
|
66
|
+
return {
|
|
67
|
+
fileExistedBeforeWrite: true,
|
|
68
|
+
diffUnavailableReason: "unable to inspect the previous file",
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!info.isFile()) {
|
|
73
|
+
return {
|
|
74
|
+
fileExistedBeforeWrite: true,
|
|
75
|
+
diffUnavailableReason: "previous path is not a regular file",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (info.size > MAX_COMPARABLE_WRITE_BYTES) {
|
|
79
|
+
return {
|
|
80
|
+
fileExistedBeforeWrite: true,
|
|
81
|
+
diffUnavailableReason: `previous file exceeds ${MAX_COMPARABLE_WRITE_BYTES} bytes`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const bytes = await readFile(absolutePath);
|
|
87
|
+
if (bytes.byteLength > MAX_COMPARABLE_WRITE_BYTES) {
|
|
88
|
+
return {
|
|
89
|
+
fileExistedBeforeWrite: true,
|
|
90
|
+
diffUnavailableReason: `previous file exceeds ${MAX_COMPARABLE_WRITE_BYTES} bytes`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const previousContent = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
94
|
+
return { fileExistedBeforeWrite: true, previousContent };
|
|
95
|
+
} catch {
|
|
96
|
+
return {
|
|
97
|
+
fileExistedBeforeWrite: true,
|
|
98
|
+
diffUnavailableReason: "previous file is not comparable UTF-8 text",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function durableDetails(
|
|
104
|
+
metadata: WriteExecutionMeta,
|
|
105
|
+
path: string,
|
|
106
|
+
content: string,
|
|
107
|
+
): DurableWriteDetails {
|
|
108
|
+
if (metadata.diffUnavailableReason) {
|
|
109
|
+
return { diffUnavailableReason: metadata.diffUnavailableReason };
|
|
110
|
+
}
|
|
111
|
+
const previousContent = metadata.previousContent ?? "";
|
|
112
|
+
const diffResult = generateDiffString(previousContent, content);
|
|
113
|
+
const patch = generateUnifiedPatch(path, previousContent, content);
|
|
114
|
+
const totalBytes = Buffer.byteLength(diffResult.diff, "utf8") + Buffer.byteLength(patch, "utf8");
|
|
115
|
+
if (totalBytes > MAX_DURABLE_DIFF_BYTES) {
|
|
116
|
+
return { diffUnavailableReason: `durable diff exceeds ${MAX_DURABLE_DIFF_BYTES} bytes` };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
diff: diffResult.diff,
|
|
120
|
+
patch,
|
|
121
|
+
firstChangedLine: diffResult.firstChangedLine,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function executeWriteWithMetadata(
|
|
126
|
+
store: WriteExecutionMetadataStore,
|
|
127
|
+
toolCallId: string,
|
|
128
|
+
params: WriteParams,
|
|
129
|
+
signal: AbortSignal | undefined,
|
|
130
|
+
cwd: string,
|
|
131
|
+
): Promise<{ content: Array<{ type: "text"; text: string }>; details?: DurableWriteDetails }> {
|
|
132
|
+
const absolutePath = resolveWritePath(params.path, cwd);
|
|
133
|
+
store.delete(toolCallId);
|
|
134
|
+
|
|
135
|
+
return withFileMutationQueue(absolutePath, async () => {
|
|
136
|
+
const throwIfAborted = () => {
|
|
137
|
+
if (signal?.aborted) throw new Error("Operation aborted");
|
|
138
|
+
};
|
|
139
|
+
throwIfAborted();
|
|
140
|
+
const metadata = await capturePreviousContent(absolutePath);
|
|
141
|
+
throwIfAborted();
|
|
142
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
143
|
+
throwIfAborted();
|
|
144
|
+
await writeFile(absolutePath, params.content, "utf8");
|
|
145
|
+
throwIfAborted();
|
|
146
|
+
|
|
147
|
+
const details = durableDetails(metadata, params.path, params.content);
|
|
148
|
+
store.set(toolCallId, { ...metadata, ...details });
|
|
149
|
+
return {
|
|
150
|
+
content: [{ type: "text" as const, text: `Successfully wrote to ${params.path}` }],
|
|
151
|
+
details,
|
|
152
|
+
};
|
|
153
|
+
}).catch((error) => {
|
|
154
|
+
store.delete(toolCallId);
|
|
155
|
+
throw error;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A session-local write definition. It retains Pi's public schema/description
|
|
161
|
+
* and replaces only execution so the parent registry is never mutated.
|
|
162
|
+
*/
|
|
163
|
+
export function createTrackedWriteTool(
|
|
164
|
+
cwd: string,
|
|
165
|
+
store = new WriteExecutionMetadataStore(),
|
|
166
|
+
): ToolDefinition<any, DurableWriteDetails> {
|
|
167
|
+
const nativeWrite = createWriteToolDefinition(cwd);
|
|
168
|
+
return {
|
|
169
|
+
...nativeWrite,
|
|
170
|
+
async execute(
|
|
171
|
+
toolCallId: string,
|
|
172
|
+
params: WriteParams,
|
|
173
|
+
signal: AbortSignal | undefined,
|
|
174
|
+
_onUpdate: unknown,
|
|
175
|
+
ctx: ExtensionContext,
|
|
176
|
+
) {
|
|
177
|
+
return executeWriteWithMetadata(store, toolCallId, params, signal, ctx.cwd || cwd);
|
|
178
|
+
},
|
|
179
|
+
} as ToolDefinition<any, DurableWriteDetails>;
|
|
180
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { streamToOutputFile, writeInitialEntry } from "../src/output-file.js";
|
|
6
|
+
|
|
7
|
+
const tempDirs: string[] = [];
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("streamToOutputFile durable message snapshots", () => {
|
|
14
|
+
it("writes tool result details to both output and project history files", async () => {
|
|
15
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-output-test-"));
|
|
16
|
+
tempDirs.push(dir);
|
|
17
|
+
const outputPath = join(dir, "agent.output");
|
|
18
|
+
const historyPath = join(dir, "history.jsonl");
|
|
19
|
+
writeInitialEntry(outputPath, "agent-1", "run", dir);
|
|
20
|
+
writeInitialEntry(historyPath, "agent-1", "run", dir);
|
|
21
|
+
|
|
22
|
+
let listener: ((event: any) => void) | undefined;
|
|
23
|
+
const session = {
|
|
24
|
+
messages: [
|
|
25
|
+
{ role: "user", content: "run" },
|
|
26
|
+
{
|
|
27
|
+
role: "toolResult",
|
|
28
|
+
toolCallId: "call-1",
|
|
29
|
+
toolName: "write",
|
|
30
|
+
content: [{ type: "text", text: "Successfully wrote to file.txt" }],
|
|
31
|
+
details: { diff: "+1 new", patch: "+new" },
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
subscribe(next: (event: any) => void) {
|
|
35
|
+
listener = next;
|
|
36
|
+
return () => { listener = undefined; };
|
|
37
|
+
},
|
|
38
|
+
} as any;
|
|
39
|
+
|
|
40
|
+
const cleanup = streamToOutputFile(session, outputPath, "agent-1", dir, historyPath);
|
|
41
|
+
listener?.({ type: "turn_end" });
|
|
42
|
+
cleanup();
|
|
43
|
+
|
|
44
|
+
for (const path of [outputPath, historyPath]) {
|
|
45
|
+
const entries = (await readFile(path, "utf8")).trim().split("\n").map((line) => JSON.parse(line));
|
|
46
|
+
expect(entries).toHaveLength(2);
|
|
47
|
+
expect(entries[1].message.details).toEqual({ diff: "+1 new", patch: "+new" });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -31,6 +31,37 @@ describe("formatConversationMessages", () => {
|
|
|
31
31
|
expect(result?.fullText.length).toBeLessThanOrEqual(40);
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
it("pairs tool results by id, then same-name source order, and preserves orphans", () => {
|
|
35
|
+
const blocks = formatConversationMessages([
|
|
36
|
+
{ role: "assistant", content: [
|
|
37
|
+
{ type: "toolCall", id: "call-read", name: "read", arguments: { path: "a.ts" } },
|
|
38
|
+
{ type: "toolCall", id: "call-write", name: "write", arguments: { path: "b.ts", content: "new" } },
|
|
39
|
+
] },
|
|
40
|
+
{ role: "toolResult", toolCallId: "call-read", toolName: "read", isError: false, details: { source: "id" }, content: [{ type: "text", text: "read output" }] },
|
|
41
|
+
{ role: "toolResult", toolName: "write", isError: true, details: { source: "name" }, content: [{ type: "text", text: "write failed" }] },
|
|
42
|
+
{ role: "toolResult", toolCallId: "missing", toolName: "bash", isError: true, content: [{ type: "text", text: "orphan" }] },
|
|
43
|
+
] as any);
|
|
44
|
+
|
|
45
|
+
const tools = blocks.filter((block) => block.kind === "tool");
|
|
46
|
+
expect(tools).toHaveLength(3);
|
|
47
|
+
expect(tools[0]?.toolCallId).toBe("call-read");
|
|
48
|
+
expect(tools[0]?.toolResult?.details).toEqual({ source: "id" });
|
|
49
|
+
expect(tools[1]?.toolCallId).toBe("call-write");
|
|
50
|
+
expect(tools[1]?.toolResult?.isError).toBe(true);
|
|
51
|
+
expect(tools[2]?.toolResult?.content).toEqual([{ type: "text", text: "orphan" }]);
|
|
52
|
+
expect(tools[2]?.fullText).toContain("orphan");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("uses a stable synthetic id when a tool call omits its id", () => {
|
|
56
|
+
const blocks = formatConversationMessages([
|
|
57
|
+
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "a.ts" } }] },
|
|
58
|
+
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "ok" }] },
|
|
59
|
+
] as any);
|
|
60
|
+
|
|
61
|
+
expect(blocks[0]?.toolCallId).toBe("message-0-tool-0");
|
|
62
|
+
expect(blocks[0]?.toolResult?.content).toEqual([{ type: "text", text: "ok" }]);
|
|
63
|
+
});
|
|
64
|
+
|
|
34
65
|
it("normalizes CRLF and bounds text with a deterministic truncation marker", () => {
|
|
35
66
|
const blocks = formatConversationMessages([
|
|
36
67
|
{ role: "user", content: "one\r\ntwo\rthree\nfour" },
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
renderEditDiffResult,
|
|
5
|
+
renderWriteDiffResult,
|
|
6
|
+
} from "../../src/ui/ccstyle/diff/diff-renderer.js";
|
|
7
|
+
import { renderRichToolResult } from "../../src/ui/ccstyle/tool-result.js";
|
|
8
|
+
import { DEFAULT_TOOL_DISPLAY_CONFIG } from "../../src/ui/ccstyle/diff/types.js";
|
|
9
|
+
|
|
10
|
+
const theme = {
|
|
11
|
+
fg(color: string, text: string) {
|
|
12
|
+
return `<${color}>${text}</${color}>`;
|
|
13
|
+
},
|
|
14
|
+
bg(color: string, text: string) {
|
|
15
|
+
return `<bg:${color}>${text}</bg:${color}>`;
|
|
16
|
+
},
|
|
17
|
+
bold(text: string) {
|
|
18
|
+
return `<bold>${text}</bold>`;
|
|
19
|
+
},
|
|
20
|
+
getBgAnsi() {
|
|
21
|
+
return "";
|
|
22
|
+
},
|
|
23
|
+
getFgAnsi() {
|
|
24
|
+
return "";
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const config = {
|
|
29
|
+
...DEFAULT_TOOL_DISPLAY_CONFIG,
|
|
30
|
+
diffViewMode: "unified" as const,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("viewer-local ccstyle diff renderer", () => {
|
|
34
|
+
it("renders persisted write diff after the underlying file changes", () => {
|
|
35
|
+
const persisted = {
|
|
36
|
+
diff: "-1 old\n+1 new",
|
|
37
|
+
patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const first = renderWriteDiffResult(undefined, {
|
|
41
|
+
expanded: true,
|
|
42
|
+
filePath: "file.txt",
|
|
43
|
+
persistedDiff: persisted,
|
|
44
|
+
}, config, theme, "");
|
|
45
|
+
const second = renderWriteDiffResult(undefined, {
|
|
46
|
+
expanded: true,
|
|
47
|
+
filePath: "file.txt",
|
|
48
|
+
persistedDiff: persisted,
|
|
49
|
+
}, config, theme, "");
|
|
50
|
+
|
|
51
|
+
expect(second.render(100).join("\n")).toContain("new");
|
|
52
|
+
expect(second.render(100).join("\n")).toBe(first.render(100).join("\n"));
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("prefers persisted write diff over the current content fallback", () => {
|
|
56
|
+
const component = renderWriteDiffResult("later\n", {
|
|
57
|
+
expanded: true,
|
|
58
|
+
filePath: "file.txt",
|
|
59
|
+
previousContent: "old\n",
|
|
60
|
+
fileExistedBeforeWrite: true,
|
|
61
|
+
persistedDiff: { diff: "-1 old\n+1 new", patch: "--- file.txt\n+++ file.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n" },
|
|
62
|
+
}, config, theme, "");
|
|
63
|
+
|
|
64
|
+
const rendered = component.render(100).join("\n");
|
|
65
|
+
expect(rendered).toContain("new");
|
|
66
|
+
expect(rendered).not.toContain("later");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns an explicit unavailable row instead of an empty success card", () => {
|
|
70
|
+
const component = renderRichToolResult("write", {
|
|
71
|
+
content: [{ type: "text", text: "Successfully wrote" }],
|
|
72
|
+
details: { diffUnavailableReason: "durable diff exceeds limit" },
|
|
73
|
+
}, { expanded: true }, theme, { args: { path: "large.txt" } });
|
|
74
|
+
|
|
75
|
+
expect(component?.render(100).join("\n")).toContain("diff unavailable");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("keeps edit diff and all output rows within the requested width", () => {
|
|
79
|
+
const component = renderEditDiffResult(
|
|
80
|
+
{ diff: "-1 a very long removed line\n+1 a very long added line" },
|
|
81
|
+
{ expanded: true, filePath: "file.ts" },
|
|
82
|
+
config,
|
|
83
|
+
theme,
|
|
84
|
+
"",
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
for (const width of [20, 40, 100]) {
|
|
88
|
+
expect(component.render(width).every((line) => visibleWidth(line) <= width)).toBe(true);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -103,6 +103,56 @@ describe("ConversationViewer", () => {
|
|
|
103
103
|
expect(historyFooter).not.toContain("M o O");
|
|
104
104
|
});
|
|
105
105
|
|
|
106
|
+
it("shows semantic role headers, effective invocation metadata, and the search row", () => {
|
|
107
|
+
const semanticTheme = {
|
|
108
|
+
fg: (name: string, text: string) => `<${name}>${text}</${name}>`,
|
|
109
|
+
bold: (text: string) => `<bold>${text}</bold>`,
|
|
110
|
+
};
|
|
111
|
+
const viewer = new ConversationViewer(
|
|
112
|
+
tui(),
|
|
113
|
+
createStaticConversationSource([
|
|
114
|
+
{ role: "user", content: "find model" },
|
|
115
|
+
{ role: "assistant", content: [{ type: "text", text: "model found" }] },
|
|
116
|
+
] as any),
|
|
117
|
+
record({ invocation: { effectiveModelName: "claude-sonnet", effectiveThinking: "high" } }),
|
|
118
|
+
undefined,
|
|
119
|
+
semanticTheme,
|
|
120
|
+
vi.fn(),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const initial = viewer.render(120).join("\n");
|
|
124
|
+
expect(initial).toContain("<userMessageText>USER</userMessageText>");
|
|
125
|
+
expect(initial).toContain("<accent>ASSISTANT</accent>");
|
|
126
|
+
expect(initial).toContain("model: claude-sonnet");
|
|
127
|
+
expect(initial).toContain("thinking: high");
|
|
128
|
+
|
|
129
|
+
viewer.handleInput("/");
|
|
130
|
+
const searchRow = viewer.render(120).join("\n");
|
|
131
|
+
expect(searchRow).toContain("<accent>/</accent>");
|
|
132
|
+
expect(searchRow).toContain("Enter apply · Esc cancel");
|
|
133
|
+
for (const character of "model") viewer.handleInput(character);
|
|
134
|
+
expect(viewer.render(120).join("\n")).toContain("model");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("focuses and expands a bounded fallback tool card", () => {
|
|
138
|
+
const viewer = new ConversationViewer(
|
|
139
|
+
tui(),
|
|
140
|
+
createStaticConversationSource([
|
|
141
|
+
{ role: "assistant", content: [{ type: "toolCall", name: "read", arguments: { path: "x" } }] },
|
|
142
|
+
{ role: "toolResult", toolName: "read", content: [{ type: "text", text: "one\ntwo\nthree\nfour\nfive\nsix" }] },
|
|
143
|
+
] as any),
|
|
144
|
+
record(),
|
|
145
|
+
undefined,
|
|
146
|
+
theme,
|
|
147
|
+
vi.fn(),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
expect(viewer.render(120).join("\n")).not.toContain("six");
|
|
151
|
+
viewer.handleInput("\t");
|
|
152
|
+
viewer.handleInput("\r");
|
|
153
|
+
expect(viewer.render(120).join("\n")).toContain("six");
|
|
154
|
+
});
|
|
155
|
+
|
|
106
156
|
it("keeps historical viewers read-only and closes on Escape", () => {
|
|
107
157
|
const ui = tui();
|
|
108
158
|
const steer = vi.fn();
|