pi-condense 2.0.0
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 +73 -0
- package/LICENSE +22 -0
- package/PRUNING.md +1028 -0
- package/README.md +243 -0
- package/index.ts +858 -0
- package/package.json +56 -0
- package/src/batch-capture.ts +226 -0
- package/src/block-refs.test.ts +42 -0
- package/src/block-refs.ts +16 -0
- package/src/budget.test.ts +66 -0
- package/src/budget.ts +39 -0
- package/src/chain-compressor.test.ts +283 -0
- package/src/chain-compressor.ts +132 -0
- package/src/chain-detector.test.ts +302 -0
- package/src/chain-detector.ts +128 -0
- package/src/chain-range-prune.test.ts +522 -0
- package/src/chain-range-prune.ts +128 -0
- package/src/commands.test.ts +67 -0
- package/src/commands.ts +1207 -0
- package/src/config.ts +126 -0
- package/src/content-hash.ts +35 -0
- package/src/error-purge.test.ts +186 -0
- package/src/error-purge.ts +71 -0
- package/src/frontier.ts +62 -0
- package/src/indexer.ts +393 -0
- package/src/nested-placeholders.test.ts +82 -0
- package/src/nested-placeholders.ts +20 -0
- package/src/oversized-spill.integration.test.ts +73 -0
- package/src/protected.test.ts +62 -0
- package/src/protected.ts +51 -0
- package/src/pruner.test.ts +508 -0
- package/src/pruner.ts +156 -0
- package/src/query-tool.ts +78 -0
- package/src/range-compression.integration.test.ts +252 -0
- package/src/spill.test.ts +102 -0
- package/src/spill.ts +90 -0
- package/src/stats.test.ts +114 -0
- package/src/stats.ts +190 -0
- package/src/summarizer.test.ts +17 -0
- package/src/summarizer.ts +262 -0
- package/src/summary-refs.ts +61 -0
- package/src/thinking-strip.test.ts +175 -0
- package/src/thinking-strip.ts +42 -0
- package/src/tree-browser.ts +382 -0
- package/src/types.ts +764 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { stripOldThinking } from "./thinking-strip.js";
|
|
3
|
+
import type { ThinkingStripConfig } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const cfg = (enabled: boolean, keepLastTurns: number): ThinkingStripConfig => ({ enabled, keepLastTurns });
|
|
6
|
+
|
|
7
|
+
function userMsg(ts: number): any {
|
|
8
|
+
return { role: "user", content: [{ type: "text", text: "go" }], timestamp: ts };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function assistantToolsThinking(ts: number, toolCallIds: string[], thinkingBlocks = 1): any {
|
|
12
|
+
const content: any[] = [];
|
|
13
|
+
for (let i = 0; i < thinkingBlocks; i++) {
|
|
14
|
+
content.push({ type: "thinking", thinking: `t${ts}-${i}`, thinkingSignature: `sig${ts}-${i}` });
|
|
15
|
+
}
|
|
16
|
+
content.push({ type: "text", text: "working" });
|
|
17
|
+
for (const id of toolCallIds) content.push({ type: "toolCall", id, name: "bash", arguments: { cmd: "ls" } });
|
|
18
|
+
return { role: "assistant", content, timestamp: ts, usage: {}, stopReason: "toolUse" };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function assistantTextThinking(ts: number): any {
|
|
22
|
+
return {
|
|
23
|
+
role: "assistant",
|
|
24
|
+
content: [
|
|
25
|
+
{ type: "thinking", thinking: "final reasoning", thinkingSignature: "sigf" },
|
|
26
|
+
{ type: "text", text: "done" },
|
|
27
|
+
],
|
|
28
|
+
timestamp: ts,
|
|
29
|
+
usage: {},
|
|
30
|
+
stopReason: "stop",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function toolResult(ts: number, toolCallId: string): any {
|
|
35
|
+
return {
|
|
36
|
+
role: "toolResult",
|
|
37
|
+
toolCallId,
|
|
38
|
+
toolName: "bash",
|
|
39
|
+
content: [{ type: "text", text: "out" }],
|
|
40
|
+
isError: false,
|
|
41
|
+
timestamp: ts,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasThinking(msg: any): boolean {
|
|
46
|
+
return Array.isArray(msg.content) && msg.content.some((c: any) => c.type === "thinking");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function countThinking(msg: any): number {
|
|
50
|
+
return Array.isArray(msg.content) ? msg.content.filter((c: any) => c.type === "thinking").length : 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** user, then (n-1) tool-using assistant turns each followed by a toolResult, then 1 final text assistant. */
|
|
54
|
+
function convo(nAssistantTurns: number): any[] {
|
|
55
|
+
const msgs: any[] = [userMsg(1)];
|
|
56
|
+
let ts = 2;
|
|
57
|
+
for (let i = 0; i < nAssistantTurns - 1; i++) {
|
|
58
|
+
const id = `tc${i}`;
|
|
59
|
+
msgs.push(assistantToolsThinking(ts++, [id]));
|
|
60
|
+
msgs.push(toolResult(ts++, id));
|
|
61
|
+
}
|
|
62
|
+
msgs.push(assistantTextThinking(ts++));
|
|
63
|
+
return msgs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
describe("stripOldThinking", () => {
|
|
67
|
+
test("disabled → same reference", () => {
|
|
68
|
+
const msgs = convo(20);
|
|
69
|
+
expect(stripOldThinking(msgs, cfg(false, 16))).toBe(msgs);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("fewer assistant turns than keepLastTurns → same reference", () => {
|
|
73
|
+
const msgs = convo(10);
|
|
74
|
+
expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("exactly keepLastTurns assistant turns → same reference (nothing older)", () => {
|
|
78
|
+
const msgs = convo(16);
|
|
79
|
+
expect(stripOldThinking(msgs, cfg(true, 16))).toBe(msgs);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("strips thinking from turns older than the last K, keeps the last K", () => {
|
|
83
|
+
const msgs = convo(20);
|
|
84
|
+
const out = stripOldThinking(msgs, cfg(true, 16));
|
|
85
|
+
expect(out).not.toBe(msgs);
|
|
86
|
+
const assistants = out.filter((m) => m.role === "assistant");
|
|
87
|
+
expect(assistants.length).toBe(20);
|
|
88
|
+
for (const a of assistants.slice(-16)) expect(hasThinking(a)).toBe(true);
|
|
89
|
+
for (const a of assistants.slice(0, 4)) expect(hasThinking(a)).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("keepLastTurns=1 keeps only the most-recent assistant turn's thinking", () => {
|
|
93
|
+
const msgs = convo(5);
|
|
94
|
+
const out = stripOldThinking(msgs, cfg(true, 1));
|
|
95
|
+
const assistants = out.filter((m) => m.role === "assistant");
|
|
96
|
+
expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
|
|
97
|
+
for (const a of assistants.slice(0, -1)) expect(hasThinking(a)).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("keepLastTurns=0 is clamped to 1 (never strips the last assistant turn)", () => {
|
|
101
|
+
const msgs = convo(5);
|
|
102
|
+
const out = stripOldThinking(msgs, cfg(true, 0));
|
|
103
|
+
const assistants = out.filter((m) => m.role === "assistant");
|
|
104
|
+
expect(hasThinking(assistants[assistants.length - 1])).toBe(true);
|
|
105
|
+
expect(hasThinking(assistants[0])).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("trailing tool-use assistant awaiting results keeps its thinking", () => {
|
|
109
|
+
const msgs: any[] = [userMsg(1)];
|
|
110
|
+
let ts = 2;
|
|
111
|
+
for (let i = 0; i < 4; i++) {
|
|
112
|
+
const id = `x${i}`;
|
|
113
|
+
msgs.push(assistantToolsThinking(ts++, [id]));
|
|
114
|
+
msgs.push(toolResult(ts++, id));
|
|
115
|
+
}
|
|
116
|
+
const out = stripOldThinking(msgs, cfg(true, 1));
|
|
117
|
+
const assistants = out.filter((m) => m.role === "assistant");
|
|
118
|
+
const last = assistants[assistants.length - 1];
|
|
119
|
+
expect(hasThinking(last)).toBe(true);
|
|
120
|
+
expect(last.content.some((c: any) => c.type === "toolCall")).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("stripped assistant keeps its text and toolCall blocks", () => {
|
|
124
|
+
const msgs = convo(20);
|
|
125
|
+
const out = stripOldThinking(msgs, cfg(true, 16));
|
|
126
|
+
const firstAssistant = out.find((m) => m.role === "assistant");
|
|
127
|
+
expect(hasThinking(firstAssistant)).toBe(false);
|
|
128
|
+
expect(firstAssistant.content.some((c: any) => c.type === "text")).toBe(true);
|
|
129
|
+
expect(firstAssistant.content.some((c: any) => c.type === "toolCall")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("strips all thinking blocks from a message (all-or-nothing)", () => {
|
|
133
|
+
const msgs: any[] = [userMsg(1), assistantToolsThinking(2, ["a"], 2), toolResult(3, "a")];
|
|
134
|
+
let ts = 4;
|
|
135
|
+
for (let i = 0; i < 3; i++) {
|
|
136
|
+
const id = `b${i}`;
|
|
137
|
+
msgs.push(assistantToolsThinking(ts++, [id], 2));
|
|
138
|
+
msgs.push(toolResult(ts++, id));
|
|
139
|
+
}
|
|
140
|
+
msgs.push(assistantTextThinking(ts++));
|
|
141
|
+
const out = stripOldThinking(msgs, cfg(true, 2));
|
|
142
|
+
expect(countThinking(out[1])).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("no thinking anywhere → same reference", () => {
|
|
146
|
+
const msgs: any[] = [userMsg(1)];
|
|
147
|
+
let ts = 2;
|
|
148
|
+
for (let i = 0; i < 20; i++) {
|
|
149
|
+
const id = `n${i}`;
|
|
150
|
+
msgs.push({
|
|
151
|
+
role: "assistant",
|
|
152
|
+
content: [{ type: "text", text: "x" }, { type: "toolCall", id, name: "bash", arguments: {} }],
|
|
153
|
+
timestamp: ts++,
|
|
154
|
+
usage: {},
|
|
155
|
+
stopReason: "toolUse",
|
|
156
|
+
});
|
|
157
|
+
msgs.push(toolResult(ts++, id));
|
|
158
|
+
}
|
|
159
|
+
expect(stripOldThinking(msgs, cfg(true, 4))).toBe(msgs);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("idempotent: second pass returns same reference", () => {
|
|
163
|
+
const msgs = convo(20);
|
|
164
|
+
const once = stripOldThinking(msgs, cfg(true, 16));
|
|
165
|
+
const twice = stripOldThinking(once, cfg(true, 16));
|
|
166
|
+
expect(twice).toBe(once);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("preserves message order and length", () => {
|
|
170
|
+
const msgs = convo(20);
|
|
171
|
+
const out = stripOldThinking(msgs, cfg(true, 16));
|
|
172
|
+
expect(out.length).toBe(msgs.length);
|
|
173
|
+
out.forEach((m, i) => expect(m.role).toBe(msgs[i].role));
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { withoutThinkingBlocks } from "./chain-range-prune.js";
|
|
2
|
+
import type { ThinkingStripConfig } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rolling main-loop thinking strip.
|
|
6
|
+
*
|
|
7
|
+
* Keeps `thinking` blocks on the last `keepLastTurns` assistant turns and
|
|
8
|
+
* strips them from all older assistant messages, preserving each message's
|
|
9
|
+
* `text` and `toolCall` blocks. "Turn" counts ASSISTANT messages, not
|
|
10
|
+
* user-bounded spans — the target failure mode is a single long open chain
|
|
11
|
+
* (zero subagents, near-zero user turns) where a span-based window keeps
|
|
12
|
+
* everything.
|
|
13
|
+
*
|
|
14
|
+
* Provider safety (Anthropic): during tool use only the LAST assistant turn's
|
|
15
|
+
* thinking is required; prior turns may be omitted, and a message's thinking
|
|
16
|
+
* blocks must be dropped all-or-nothing. `keepLastTurns` is clamped to >= 1 so
|
|
17
|
+
* the most-recent assistant turn always keeps its thinking. Stripping reuses
|
|
18
|
+
* `withoutThinkingBlocks` (drops the whole block incl. signature).
|
|
19
|
+
*
|
|
20
|
+
* Returns the original array reference unchanged when nothing is stripped, so
|
|
21
|
+
* `pruneMessages` can skip reconstruction.
|
|
22
|
+
*/
|
|
23
|
+
export function stripOldThinking(messages: any[], config: ThinkingStripConfig): any[] {
|
|
24
|
+
if (!config.enabled) return messages;
|
|
25
|
+
const keep = Math.max(1, config.keepLastTurns);
|
|
26
|
+
|
|
27
|
+
const assistantIdx: number[] = [];
|
|
28
|
+
for (let i = 0; i < messages.length; i++) {
|
|
29
|
+
if (messages[i]?.role === "assistant") assistantIdx.push(i);
|
|
30
|
+
}
|
|
31
|
+
if (assistantIdx.length <= keep) return messages;
|
|
32
|
+
|
|
33
|
+
const firstKeptAssistant = assistantIdx[assistantIdx.length - keep];
|
|
34
|
+
let changed = false;
|
|
35
|
+
const out = messages.map((msg, i) => {
|
|
36
|
+
if (i >= firstKeptAssistant || msg?.role !== "assistant") return msg;
|
|
37
|
+
if (!Array.isArray(msg.content) || !msg.content.some((c: any) => c.type === "thinking")) return msg;
|
|
38
|
+
changed = true;
|
|
39
|
+
return withoutThinkingBlocks(msg);
|
|
40
|
+
});
|
|
41
|
+
return changed ? out : messages;
|
|
42
|
+
}
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
2
|
+
import { Markdown, getKeybindings, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { ToolCallRecord } from "./types.js";
|
|
7
|
+
import { CUSTOM_TYPE_SUMMARY } from "./types.js";
|
|
8
|
+
import { normalizeSummaryToolCallRefs } from "./summary-refs.js";
|
|
9
|
+
import type { ToolCallIndexer } from "./indexer.js";
|
|
10
|
+
|
|
11
|
+
// ── Tree node types ─────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export interface TreeNode {
|
|
14
|
+
id: string;
|
|
15
|
+
label: string;
|
|
16
|
+
children: TreeNode[];
|
|
17
|
+
expanded: boolean;
|
|
18
|
+
depth: number;
|
|
19
|
+
isLeaf: boolean;
|
|
20
|
+
/** Optional extra detail shown when expanded (e.g. result preview) */
|
|
21
|
+
detail?: string;
|
|
22
|
+
/** Character count of this node's content (result text for tools, summary text for summaries) */
|
|
23
|
+
charCount?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface VisibleRow {
|
|
27
|
+
node: TreeNode;
|
|
28
|
+
index: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface SummaryOverlayState {
|
|
32
|
+
title: string;
|
|
33
|
+
text: string;
|
|
34
|
+
scrollOffset: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Formatting helpers ──────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
function formatChars(n: number): string {
|
|
40
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
41
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
42
|
+
return `${n}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function padToWidth(str: string, width: number): string {
|
|
46
|
+
const vis = visibleWidth(str);
|
|
47
|
+
if (vis >= width) return str;
|
|
48
|
+
return str + " ".repeat(width - vis);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isCtrlO(data: string): boolean {
|
|
52
|
+
return matchesKey(data, "ctrl+o") || data === "\u000f";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Box drawing ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function boxLines(
|
|
58
|
+
lines: string[],
|
|
59
|
+
width: number,
|
|
60
|
+
title: string,
|
|
61
|
+
theme: Theme,
|
|
62
|
+
): string[] {
|
|
63
|
+
const innerWidth = Math.max(0, width - 2);
|
|
64
|
+
const result: string[] = [];
|
|
65
|
+
|
|
66
|
+
// Top border with title
|
|
67
|
+
const titlePrefix = title ? `─ ${title} ` : "";
|
|
68
|
+
const titleVis = visibleWidth(titlePrefix);
|
|
69
|
+
const topFill = "─".repeat(Math.max(0, innerWidth - titleVis));
|
|
70
|
+
result.push("┌" + titlePrefix + topFill + "┐");
|
|
71
|
+
|
|
72
|
+
// Content lines
|
|
73
|
+
for (const line of lines) {
|
|
74
|
+
result.push("│" + padToWidth(line, innerWidth) + "│");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Bottom border
|
|
78
|
+
result.push("└" + "─".repeat(innerWidth) + "┘");
|
|
79
|
+
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ── Tree data builder ───────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Scans the current session branch for prune-summary entries and builds a
|
|
87
|
+
* foldable tree where each summary is a parent node and its pruned tool calls
|
|
88
|
+
* are children. Tool call records are looked up via the indexer.
|
|
89
|
+
*
|
|
90
|
+
* Each node carries a `charCount` so the UI can show how many characters the
|
|
91
|
+
* summary replaced (making it obvious whether pruning is saving space).
|
|
92
|
+
*/
|
|
93
|
+
export function buildPruneTree(
|
|
94
|
+
ctx: ExtensionCommandContext,
|
|
95
|
+
indexer: ToolCallIndexer,
|
|
96
|
+
): TreeNode[] {
|
|
97
|
+
const branch = ctx.sessionManager.getBranch();
|
|
98
|
+
const roots: TreeNode[] = [];
|
|
99
|
+
let summaryIndex = 0;
|
|
100
|
+
|
|
101
|
+
for (const entry of branch) {
|
|
102
|
+
if (entry.type !== "custom_message") continue;
|
|
103
|
+
const customEntry = entry as any;
|
|
104
|
+
if (customEntry.customType !== CUSTOM_TYPE_SUMMARY) continue;
|
|
105
|
+
|
|
106
|
+
const details = customEntry.details as {
|
|
107
|
+
toolCallRefs?: { shortId: string; toolCallId: string }[];
|
|
108
|
+
toolCallIds?: string[];
|
|
109
|
+
toolNames: string[];
|
|
110
|
+
turnIndex: number;
|
|
111
|
+
timestamp: number;
|
|
112
|
+
} | undefined;
|
|
113
|
+
|
|
114
|
+
const toolCallRefs = normalizeSummaryToolCallRefs(details);
|
|
115
|
+
const turnIndex = details?.turnIndex ?? "?";
|
|
116
|
+
const timestamp = details?.timestamp
|
|
117
|
+
? new Date(details.timestamp).toLocaleString()
|
|
118
|
+
: "";
|
|
119
|
+
|
|
120
|
+
const children: TreeNode[] = [];
|
|
121
|
+
for (const ref of toolCallRefs) {
|
|
122
|
+
const record = indexer.getRecord(ref.toolCallId);
|
|
123
|
+
if (!record) continue;
|
|
124
|
+
children.push(toolCallNode(record, 1));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const summaryText =
|
|
128
|
+
typeof customEntry.content === "string" ? customEntry.content : "";
|
|
129
|
+
const summaryChars = summaryText.length;
|
|
130
|
+
const totalOriginalChars = children.reduce(
|
|
131
|
+
(sum, c) => sum + (c.charCount ?? 0),
|
|
132
|
+
0,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const header = `[pruner] Turn ${turnIndex} summary (${children.length} tool${children.length === 1 ? "" : "s"} · ${formatChars(summaryChars)} chars · original ${formatChars(totalOriginalChars)})`;
|
|
136
|
+
const label = timestamp ? `${header} · ${timestamp}` : header;
|
|
137
|
+
|
|
138
|
+
roots.push({
|
|
139
|
+
id: `summary-${summaryIndex++}`,
|
|
140
|
+
label,
|
|
141
|
+
children,
|
|
142
|
+
expanded: false,
|
|
143
|
+
depth: 0,
|
|
144
|
+
isLeaf: children.length === 0,
|
|
145
|
+
detail: summaryText || undefined,
|
|
146
|
+
charCount: summaryChars,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return roots;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toolCallNode(record: ToolCallRecord, depth: number): TreeNode {
|
|
154
|
+
const argsText = Object.entries(record.args)
|
|
155
|
+
.map(([k, v]) => `${k}=${JSON.stringify(v)}`)
|
|
156
|
+
.join(", ");
|
|
157
|
+
const charCount = record.resultText.length || record.spillBytes || 0;
|
|
158
|
+
const unit = record.resultText.length ? "chars" : (record.spillBytes ? "bytes" : "chars");
|
|
159
|
+
const label = `${record.toolName}(${argsText}) · ${formatChars(charCount)} ${unit}${record.isError ? " [error]" : ""}`;
|
|
160
|
+
const previewSource = record.resultText || record.resultPreview || "";
|
|
161
|
+
const resultPreview = previewSource.slice(0, 200).replace(/\s+/g, " ");
|
|
162
|
+
return {
|
|
163
|
+
id: record.toolCallId,
|
|
164
|
+
label,
|
|
165
|
+
children: [],
|
|
166
|
+
expanded: false,
|
|
167
|
+
depth,
|
|
168
|
+
isLeaf: true,
|
|
169
|
+
detail: resultPreview,
|
|
170
|
+
charCount,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ── TreeBrowser component ───────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
export class TreeBrowser implements Component {
|
|
177
|
+
private flatRows: VisibleRow[] = [];
|
|
178
|
+
private selectedIndex = 0;
|
|
179
|
+
private theme: Theme;
|
|
180
|
+
private onDone: () => void;
|
|
181
|
+
private summaryOverlay: SummaryOverlayState | null = null;
|
|
182
|
+
|
|
183
|
+
constructor(
|
|
184
|
+
private readonly roots: TreeNode[],
|
|
185
|
+
theme: Theme,
|
|
186
|
+
onDone: () => void,
|
|
187
|
+
) {
|
|
188
|
+
this.theme = theme;
|
|
189
|
+
this.onDone = onDone;
|
|
190
|
+
this.rebuildFlatRows();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
invalidate(): void {
|
|
194
|
+
this.rebuildFlatRows();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private rebuildFlatRows(): void {
|
|
198
|
+
this.flatRows = [];
|
|
199
|
+
let index = 0;
|
|
200
|
+
const walk = (nodes: TreeNode[]) => {
|
|
201
|
+
for (const node of nodes) {
|
|
202
|
+
this.flatRows.push({ node, index: index++ });
|
|
203
|
+
if (node.expanded && node.children.length > 0) {
|
|
204
|
+
walk(node.children);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
walk(this.roots);
|
|
209
|
+
if (this.selectedIndex >= this.flatRows.length) {
|
|
210
|
+
this.selectedIndex = Math.max(0, this.flatRows.length - 1);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
handleInput(data: string): void {
|
|
215
|
+
const kb = getKeybindings();
|
|
216
|
+
|
|
217
|
+
if (this.summaryOverlay) {
|
|
218
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
219
|
+
this.summaryOverlay.scrollOffset = Math.max(0, this.summaryOverlay.scrollOffset - 1);
|
|
220
|
+
} else if (kb.matches(data, "tui.select.down")) {
|
|
221
|
+
this.summaryOverlay.scrollOffset += 1;
|
|
222
|
+
} else if (kb.matches(data, "tui.select.cancel") || data === "q" || isCtrlO(data)) {
|
|
223
|
+
this.summaryOverlay = null;
|
|
224
|
+
}
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (kb.matches(data, "tui.select.up")) {
|
|
229
|
+
this.selectedIndex =
|
|
230
|
+
this.selectedIndex === 0
|
|
231
|
+
? this.flatRows.length - 1
|
|
232
|
+
: this.selectedIndex - 1;
|
|
233
|
+
} else if (kb.matches(data, "tui.select.down")) {
|
|
234
|
+
this.selectedIndex =
|
|
235
|
+
this.selectedIndex === this.flatRows.length - 1
|
|
236
|
+
? 0
|
|
237
|
+
: this.selectedIndex + 1;
|
|
238
|
+
} else if (kb.matches(data, "tui.select.confirm") || data === " ") {
|
|
239
|
+
const row = this.flatRows[this.selectedIndex];
|
|
240
|
+
if (row && !row.node.isLeaf) {
|
|
241
|
+
row.node.expanded = !row.node.expanded;
|
|
242
|
+
this.rebuildFlatRows();
|
|
243
|
+
}
|
|
244
|
+
} else if (isCtrlO(data)) {
|
|
245
|
+
this.openSelectedSummary();
|
|
246
|
+
} else if (kb.matches(data, "tui.select.cancel") || data === "q") {
|
|
247
|
+
this.onDone();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
render(width: number): string[] {
|
|
252
|
+
const innerWidth = Math.max(0, width - 2);
|
|
253
|
+
|
|
254
|
+
let baseLines: string[];
|
|
255
|
+
if (this.flatRows.length === 0) {
|
|
256
|
+
const msg = this.theme.fg("muted", "(no pruned tool calls in this session)");
|
|
257
|
+
baseLines = boxLines([msg], width, "Pruned Tool Calls", this.theme);
|
|
258
|
+
} else {
|
|
259
|
+
const contentLines: string[] = [
|
|
260
|
+
this.theme.fg("dim", "Enter/Space expand • Ctrl-O open summary • Esc/q close"),
|
|
261
|
+
"",
|
|
262
|
+
];
|
|
263
|
+
for (let i = 0; i < this.flatRows.length; i++) {
|
|
264
|
+
const row = this.flatRows[i];
|
|
265
|
+
const line = this.renderRow(row.node, innerWidth, i === this.selectedIndex);
|
|
266
|
+
contentLines.push(line);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
baseLines = boxLines(contentLines, width, "Pruned Tool Calls", this.theme);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!this.summaryOverlay) {
|
|
273
|
+
return baseLines;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return this.renderWithSummaryOverlay(baseLines, width);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private openSelectedSummary(): void {
|
|
280
|
+
const row = this.flatRows[this.selectedIndex];
|
|
281
|
+
if (!row || row.node.isLeaf || !row.node.detail) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
this.summaryOverlay = {
|
|
286
|
+
title: row.node.label,
|
|
287
|
+
text: row.node.detail,
|
|
288
|
+
scrollOffset: 0,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
private renderWithSummaryOverlay(baseLines: string[], width: number): string[] {
|
|
293
|
+
const overlay = this.summaryOverlay;
|
|
294
|
+
if (!overlay) return baseLines;
|
|
295
|
+
|
|
296
|
+
const overlayWidth = Math.max(60, width - 2);
|
|
297
|
+
const overlayInnerWidth = Math.max(1, overlayWidth - 2);
|
|
298
|
+
const markdown = new Markdown(overlay.text, 1, 0, getMarkdownTheme());
|
|
299
|
+
const markdownLines = markdown.render(Math.max(1, overlayInnerWidth));
|
|
300
|
+
const reservedLines = 5;
|
|
301
|
+
const maxContentHeight = Math.max(12, Math.min(markdownLines.length, baseLines.length + 8));
|
|
302
|
+
const maxScroll = Math.max(0, markdownLines.length - maxContentHeight);
|
|
303
|
+
overlay.scrollOffset = Math.min(overlay.scrollOffset, maxScroll);
|
|
304
|
+
|
|
305
|
+
const visibleContent = markdownLines.slice(
|
|
306
|
+
overlay.scrollOffset,
|
|
307
|
+
overlay.scrollOffset + maxContentHeight,
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
const footer = this.theme.fg(
|
|
311
|
+
"dim",
|
|
312
|
+
`↑/↓ scroll • Ctrl-O/Esc/q close${maxScroll > 0 ? ` • ${overlay.scrollOffset + 1}-${Math.min(overlay.scrollOffset + maxContentHeight, markdownLines.length)} / ${markdownLines.length}` : ""}`,
|
|
313
|
+
);
|
|
314
|
+
const overlayLines = boxLines(
|
|
315
|
+
[
|
|
316
|
+
this.theme.fg("accent", truncateToWidth(overlay.title, overlayInnerWidth, "…", false)),
|
|
317
|
+
this.theme.fg("dim", "Pruned summary message"),
|
|
318
|
+
"",
|
|
319
|
+
...visibleContent,
|
|
320
|
+
"",
|
|
321
|
+
footer,
|
|
322
|
+
],
|
|
323
|
+
overlayWidth,
|
|
324
|
+
"Pruned Summary",
|
|
325
|
+
this.theme,
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const canvasHeight = Math.max(baseLines.length, overlayLines.length + 2);
|
|
329
|
+
const blankLine = " ".repeat(width);
|
|
330
|
+
const composed = Array.from({ length: canvasHeight }, (_, index) => baseLines[index] ?? blankLine);
|
|
331
|
+
const startRow = Math.max(0, Math.floor((canvasHeight - overlayLines.length) / 2));
|
|
332
|
+
const leftPad = Math.max(0, Math.floor((width - overlayWidth) / 2));
|
|
333
|
+
const rightPad = Math.max(0, width - leftPad - overlayWidth);
|
|
334
|
+
|
|
335
|
+
for (let i = 0; i < overlayLines.length && startRow + i < composed.length; i++) {
|
|
336
|
+
composed[startRow + i] = " ".repeat(leftPad) + overlayLines[i] + " ".repeat(rightPad);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return composed;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
private renderRow(
|
|
343
|
+
node: TreeNode,
|
|
344
|
+
width: number,
|
|
345
|
+
isSelected: boolean,
|
|
346
|
+
): string {
|
|
347
|
+
const indent = " ".repeat(node.depth);
|
|
348
|
+
const prefix = node.isLeaf
|
|
349
|
+
? " "
|
|
350
|
+
: node.expanded
|
|
351
|
+
? "▾ "
|
|
352
|
+
: "▸ ";
|
|
353
|
+
|
|
354
|
+
let text: string;
|
|
355
|
+
if (node.isLeaf) {
|
|
356
|
+
text = this.theme.fg("text", node.label);
|
|
357
|
+
} else {
|
|
358
|
+
text = this.theme.fg("accent", node.label);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const fullLine = indent + prefix + text;
|
|
362
|
+
const plainText = indent + prefix + node.label;
|
|
363
|
+
const visibleLen = visibleWidth(plainText);
|
|
364
|
+
|
|
365
|
+
let rendered: string;
|
|
366
|
+
if (visibleLen > width) {
|
|
367
|
+
rendered = truncateToWidth(fullLine, width, "…", false);
|
|
368
|
+
} else {
|
|
369
|
+
rendered = fullLine;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (isSelected) {
|
|
373
|
+
const padLen = width - visibleWidth(rendered);
|
|
374
|
+
if (padLen > 0) {
|
|
375
|
+
rendered += " ".repeat(padLen);
|
|
376
|
+
}
|
|
377
|
+
rendered = this.theme.bg("selectedBg", rendered);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return rendered;
|
|
381
|
+
}
|
|
382
|
+
}
|