@hheei/pi-ext-ui 0.6.0 → 0.7.1
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/README.md +2 -2
- package/dist/background.d.ts +18 -0
- package/dist/background.js +44 -0
- package/dist/extension.js +59 -7
- package/dist/host-transcript-patch.js +186 -58
- package/dist/index.d.ts +5 -3
- package/dist/index.js +5 -3
- package/dist/message-view.d.ts +4 -10
- package/dist/message-view.js +36 -18
- package/dist/native-tools.d.ts +69 -0
- package/dist/native-tools.js +414 -0
- package/dist/nested-calls-view.d.ts +6 -2
- package/dist/nested-calls-view.js +58 -73
- package/dist/payload-parser.d.ts +10 -28
- package/dist/payload-parser.js +9 -269
- package/dist/renderer.d.ts +6 -4
- package/dist/renderer.js +254 -189
- package/dist/runtime.d.ts +13 -0
- package/dist/runtime.js +16 -0
- package/dist/settings.d.ts +3 -0
- package/dist/settings.js +18 -1
- package/dist/tool-group-view.d.ts +54 -8
- package/dist/tool-group-view.js +399 -169
- package/dist/tool-view.d.ts +20 -6
- package/dist/tool-view.js +87 -34
- package/dist/tracker.d.ts +36 -17
- package/dist/tracker.js +157 -19
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@ The shared view API owns fold levels, compact sections, status glyphs, copy feed
|
|
|
8
8
|
|
|
9
9
|
Background Bash and subagent tools own their display formats through ext-core `ToolPresentation` providers. This resolver consumes those providers automatically, including nested codemode calls, and adds shared folding and copy controls. Without `pi-ext-ui`, the same tools retain native Pi renderers. No direct dependency on `pi-ext-ui` is required by the tool extensions.
|
|
10
10
|
|
|
11
|
-
Activity groups are on by default for Pi **>=1.0.1** TUI sessions. Turn them off with the Tool UI setting `toolGroups.enabled` in `ext_settings.json` (the ext-core settings page). The change applies to the current session. A heading such as `▸ codemode ×2 · grep ×3` keeps one live summary row per tool beneath a single outer rail. Each thinking round and notification also keeps a summary row; notifications are not counted. Groups form while tools run and can span consecutive tool/thinking rounds. Prose, user messages, unknown components, images, and unknown tool renderers break groups. Any MessageView transcript notice stays inside the group, so bg-bash, subagent, memory, optimizer, and codemode-guard notices do not need their own allowlist. Notification rows show their text
|
|
11
|
+
Activity groups are on by default for Pi **>=1.0.1** TUI sessions. Turn them off with the Tool UI setting `toolGroups.enabled` in `ext_settings.json` (the ext-core settings page). The change applies to the current session. A heading such as `▸ codemode ×2 · grep ×3` keeps one live summary row per tool beneath a single outer rail. Each thinking round and notification also keeps a summary row; notifications are not counted. A thinking item shows a content preview while Pi hides thinking and opens the full text on demand; while thinking is visible the category prints it flat. Groups form while tools run and can span consecutive tool/thinking rounds. Prose, user messages, unknown components, images, and unknown tool renderers break groups. Any MessageView transcript notice stays inside the group, so bg-bash, subagent, memory, optimizer, and codemode-guard notices do not need their own allowlist. Notification rows show their own text once, indented and width-wrapped; only a MessageView with a real body gets an expand pin. Streaming arguments, partial results, completion, and errors update the existing row.
|
|
12
12
|
|
|
13
|
-
Fullscreen: click the group heading to restore the original component layouts and interactions beneath the outer rail. Both terminal modes: Ctrl+O forces groups open; closing global expansion restores each group’s local choice. Appending members preserves that choice. Original per-tool fold choices remain intact. Groups are presentation only and are rebuilt on history navigation; session data, model context, and HTML export remain unchanged. Thinking
|
|
13
|
+
Fullscreen: click the group heading to restore the original component layouts and interactions beneath the outer rail. Both terminal modes: Ctrl+O forces groups open; closing global expansion restores each group’s local choice. Appending members preserves that choice. Original per-tool fold choices remain intact. Groups are presentation only and are rebuilt on history navigation; session data, model context, and HTML export remain unchanged. Thinking keeps its label; the hidden state shows a preview instead of dropping the text, and the visible state has no per-item arrow. Each `ctx.ui.notify` keeps its full text inline. Notifications remain session-local, as in Pi.
|
|
14
14
|
|
|
15
15
|
Grouping uses two centralized, version-checked host prototype hooks: tool lookup and extension notifications. Thinking uses the existing assistant message, with no extra hook. Unsupported hosts or incompatible layouts report a warning and retain normal rendering. Cleanup restores the original methods; if another extension replaces the same methods, restart Pi to unload the conflicting patch. For workspace development use `pnpm run pi-dev`: running the bundled CLI beside physical host devDependencies can resolve a second host class, unlike the published installation.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Theme, ThemeBg } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
/**
|
|
3
|
+
* Paint one surface's rows. A surface owns its own background and nothing else: after any reset
|
|
4
|
+
* (`0`, `49`, or a compound sequence containing one) it re-asserts *its* background, so a row keeps
|
|
5
|
+
* the fill across the styled spans a theme or child renderer ends with `\x1b[0m`.
|
|
6
|
+
*
|
|
7
|
+
* It never guesses another surface's background. A nested surface that ended its own fill is gone;
|
|
8
|
+
* the rest of the row belongs to the surface painting it. Nested surfaces stay separate because each
|
|
9
|
+
* one paints the rows it draws, not because this function tracks ANSI state.
|
|
10
|
+
*/
|
|
11
|
+
export declare function backgroundFill(theme: Theme, token: ThemeBg): (text: string) => string;
|
|
12
|
+
/**
|
|
13
|
+
* One independent surface: a filled padding row above and below, every row reaching `width`.
|
|
14
|
+
*
|
|
15
|
+
* A block that owns a surface also owns its breathing room, so two adjacent blocks separate with
|
|
16
|
+
* their own padding rows instead of sharing one unstyled gap.
|
|
17
|
+
*/
|
|
18
|
+
export declare function surfaceBlock(theme: Theme, token: ThemeBg, rows: readonly string[], width: number): string[];
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
const MARK = "\u0000";
|
|
3
|
+
/** Any SGR sequence: parameters are `;`-separated, an empty parameter list means reset. */
|
|
4
|
+
const SGR = /\x1b\[([0-9;]*)m/g;
|
|
5
|
+
/**
|
|
6
|
+
* Paint one surface's rows. A surface owns its own background and nothing else: after any reset
|
|
7
|
+
* (`0`, `49`, or a compound sequence containing one) it re-asserts *its* background, so a row keeps
|
|
8
|
+
* the fill across the styled spans a theme or child renderer ends with `\x1b[0m`.
|
|
9
|
+
*
|
|
10
|
+
* It never guesses another surface's background. A nested surface that ended its own fill is gone;
|
|
11
|
+
* the rest of the row belongs to the surface painting it. Nested surfaces stay separate because each
|
|
12
|
+
* one paints the rows it draws, not because this function tracks ANSI state.
|
|
13
|
+
*/
|
|
14
|
+
export function backgroundFill(theme, token) {
|
|
15
|
+
const [start, end] = theme.bg(token, MARK).split(MARK);
|
|
16
|
+
if (start === undefined || end === undefined)
|
|
17
|
+
return (text) => text;
|
|
18
|
+
return (text) => {
|
|
19
|
+
let out = start;
|
|
20
|
+
let from = 0;
|
|
21
|
+
for (const match of text.matchAll(SGR)) {
|
|
22
|
+
const params = match[1] ?? "";
|
|
23
|
+
const resets = params === "" || params.split(";").some((code) => code === "0" || code === "49");
|
|
24
|
+
const next = (match.index ?? 0) + match[0].length;
|
|
25
|
+
out += text.slice(from, next);
|
|
26
|
+
if (resets)
|
|
27
|
+
out += start;
|
|
28
|
+
from = next;
|
|
29
|
+
}
|
|
30
|
+
return `${out}${text.slice(from)}${end}`;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* One independent surface: a filled padding row above and below, every row reaching `width`.
|
|
35
|
+
*
|
|
36
|
+
* A block that owns a surface also owns its breathing room, so two adjacent blocks separate with
|
|
37
|
+
* their own padding rows instead of sharing one unstyled gap.
|
|
38
|
+
*/
|
|
39
|
+
export function surfaceBlock(theme, token, rows, width) {
|
|
40
|
+
const fill = backgroundFill(theme, token);
|
|
41
|
+
const pad = fill(" ".repeat(Math.max(0, width)));
|
|
42
|
+
const body = rows.map((row) => fill(row + " ".repeat(Math.max(0, width - visibleWidth(row)))));
|
|
43
|
+
return [pad, ...body, pad];
|
|
44
|
+
}
|
package/dist/extension.js
CHANGED
|
@@ -2,12 +2,15 @@ import { getRuntimeSettingsRegistry, messageViewPoint, registerExtensionHook, re
|
|
|
2
2
|
import { installHostToolGroups } from "./host-transcript-patch.js";
|
|
3
3
|
import { createMessageView } from "./message-view.js";
|
|
4
4
|
import { createOptionalToolRendererFactory, registerCollapsibleToolRenderer } from "./renderer.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { ToolUiRuntime } from "./runtime.js";
|
|
6
|
+
import { activityGroupsEnabled, createToolGroupSettings, toolBackgroundsEnabled, } from "./settings.js";
|
|
7
|
+
import { TOOL_SPINNER_INTERVAL_MS } from "./tool-view.js";
|
|
7
8
|
export default function (pi) {
|
|
9
|
+
// One runtime per extension instance: two Pi runtimes in one process never share these facts.
|
|
10
|
+
const runtime = new ToolUiRuntime();
|
|
8
11
|
let redrawGroups;
|
|
9
12
|
pi.on("agent_settled", () => redrawGroups?.());
|
|
10
|
-
registerCollapsibleToolRenderer(pi);
|
|
13
|
+
registerCollapsibleToolRenderer(pi, runtime);
|
|
11
14
|
registerExtensionLifecycle(pi, {
|
|
12
15
|
key: "@hheei/pi-ext-ui",
|
|
13
16
|
async start(context) {
|
|
@@ -31,18 +34,59 @@ export default function (pi) {
|
|
|
31
34
|
context.extension.ui.notify(error instanceof Error ? error.message : String(error), "warning");
|
|
32
35
|
}
|
|
33
36
|
};
|
|
34
|
-
const
|
|
37
|
+
const applyBackgrounds = (enabled) => {
|
|
38
|
+
runtime.backgrounds = enabled;
|
|
39
|
+
redrawGroups?.();
|
|
40
|
+
};
|
|
41
|
+
const provider = createToolGroupSettings({
|
|
42
|
+
onEnabled: apply,
|
|
43
|
+
onBackgrounds: applyBackgrounds,
|
|
44
|
+
});
|
|
35
45
|
const onAgentStart = pi.on("agent_start", () => {
|
|
36
46
|
patch?.beginRun();
|
|
37
47
|
redrawGroups?.();
|
|
38
48
|
});
|
|
49
|
+
// A live tool animates its status glyph. The ticker exists only while one is running, and the
|
|
50
|
+
// frame itself comes from one shared clock, so a phase never restarts on a status change.
|
|
51
|
+
let liveTools = 0;
|
|
52
|
+
let ticker;
|
|
53
|
+
const stopTicker = () => {
|
|
54
|
+
if (ticker === undefined)
|
|
55
|
+
return;
|
|
56
|
+
clearInterval(ticker);
|
|
57
|
+
ticker = undefined;
|
|
58
|
+
};
|
|
39
59
|
const onAgentEnd = pi.on("agent_end", () => {
|
|
40
60
|
patch?.endRun();
|
|
61
|
+
// No tool outlives the agent loop, so the animation cannot keep a ticker alive either.
|
|
62
|
+
liveTools = 0;
|
|
63
|
+
stopTicker();
|
|
41
64
|
redrawGroups?.();
|
|
42
65
|
});
|
|
66
|
+
const onToolStart = pi.on("tool_execution_start", () => {
|
|
67
|
+
liveTools += 1;
|
|
68
|
+
if (ticker !== undefined)
|
|
69
|
+
return;
|
|
70
|
+
ticker = setInterval(() => {
|
|
71
|
+
if (liveTools === 0 || redrawGroups === undefined) {
|
|
72
|
+
stopTicker();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
redrawGroups();
|
|
76
|
+
}, TOOL_SPINNER_INTERVAL_MS);
|
|
77
|
+
});
|
|
78
|
+
const onToolEnd = pi.on("tool_execution_end", () => {
|
|
79
|
+
liveTools = Math.max(0, liveTools - 1);
|
|
80
|
+
if (liveTools === 0)
|
|
81
|
+
stopTicker();
|
|
82
|
+
});
|
|
43
83
|
context.resources.add("run-clock", () => {
|
|
44
84
|
onAgentStart();
|
|
45
85
|
onAgentEnd();
|
|
86
|
+
onToolStart();
|
|
87
|
+
onToolEnd();
|
|
88
|
+
stopTicker();
|
|
89
|
+
liveTools = 0;
|
|
46
90
|
});
|
|
47
91
|
context.resources.add("tool-group-settings", registerSettings(provider, getRuntimeSettingsRegistry(pi)));
|
|
48
92
|
context.resources.add("tool-groups", () => {
|
|
@@ -51,9 +95,14 @@ export default function (pi) {
|
|
|
51
95
|
patch = undefined;
|
|
52
96
|
});
|
|
53
97
|
context.resources.add("tracker-cleanup", () => {
|
|
54
|
-
|
|
98
|
+
runtime.tracker.clear();
|
|
99
|
+
});
|
|
100
|
+
// Resume reads the host's own nested-call record for the current branch, never another one.
|
|
101
|
+
runtime.nestedCalls.setSource(context.extension.sessionManager);
|
|
102
|
+
context.resources.add("nested-call-source", () => {
|
|
103
|
+
runtime.nestedCalls.setSource(undefined);
|
|
55
104
|
});
|
|
56
|
-
const toolRenderers = registerExtensionHook(pi, toolRendererPoint, createOptionalToolRendererFactory(pi));
|
|
105
|
+
const toolRenderers = registerExtensionHook(pi, toolRendererPoint, createOptionalToolRendererFactory(pi, runtime));
|
|
57
106
|
context.resources.add("tool-renderer", () => toolRenderers.dispose());
|
|
58
107
|
const handle = registerExtensionHook(context.pi, messageViewPoint, createMessageView);
|
|
59
108
|
context.resources.add("message-view", () => handle.dispose());
|
|
@@ -62,14 +111,17 @@ export default function (pi) {
|
|
|
62
111
|
sessionId: context.extension.sessionManager.getSessionId(),
|
|
63
112
|
signal: context.signal,
|
|
64
113
|
});
|
|
65
|
-
if (!context.signal.aborted)
|
|
114
|
+
if (!context.signal.aborted) {
|
|
66
115
|
apply(activityGroupsEnabled(state));
|
|
116
|
+
applyBackgrounds(toolBackgroundsEnabled(state));
|
|
117
|
+
}
|
|
67
118
|
}
|
|
68
119
|
catch (error) {
|
|
69
120
|
if (!context.signal.aborted) {
|
|
70
121
|
const detail = error instanceof Error ? error.message : String(error);
|
|
71
122
|
context.extension.ui.notify(`Tool activity groups settings unreadable: ${detail}`, "warning");
|
|
72
123
|
apply(true);
|
|
124
|
+
applyBackgrounds(false);
|
|
73
125
|
}
|
|
74
126
|
}
|
|
75
127
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { AssistantMessageComponent, InteractiveMode, ToolExecutionComponent, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { Container,
|
|
2
|
+
import { Container, stripTerminalSequences, Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { MessageView } from "./message-view.js";
|
|
4
|
-
import { ToolGroupView } from "./tool-group-view.js";
|
|
4
|
+
import { memberParts, TailUnit, ToolGroupView, } from "./tool-group-view.js";
|
|
5
5
|
import { ToolView, toSingleRow } from "./tool-view.js";
|
|
6
6
|
const SEAMS = ["getRegisteredToolDefinition", "showExtensionNotify"];
|
|
7
7
|
function record(value) {
|
|
@@ -47,21 +47,25 @@ function assistantInfo(component) {
|
|
|
47
47
|
const hidden = Reflect.get(component, "hideThinkingBlock");
|
|
48
48
|
const calls = new Set();
|
|
49
49
|
let eligible = !["length", "aborted", "error"].includes(String(message.stopReason));
|
|
50
|
-
|
|
50
|
+
const thinking = [];
|
|
51
51
|
for (const block of message.content) {
|
|
52
52
|
if (!record(block))
|
|
53
53
|
throw new Error("Pi assistant content shape changed");
|
|
54
54
|
if (block.type === "toolCall" && typeof block.id === "string")
|
|
55
55
|
calls.add(block.id);
|
|
56
|
-
else if (block.type === "thinking" && typeof block.thinking === "string")
|
|
57
|
-
thinking
|
|
56
|
+
else if (block.type === "thinking" && typeof block.thinking === "string") {
|
|
57
|
+
// Pi joins a thinking run with blank lines; the same separator keeps paragraphs readable here.
|
|
58
|
+
const text = block.thinking.trim();
|
|
59
|
+
if (text !== "")
|
|
60
|
+
thinking.push(text);
|
|
61
|
+
}
|
|
58
62
|
else if (block.type !== "text" || typeof block.text !== "string" || block.text.trim() !== "")
|
|
59
63
|
eligible = false;
|
|
60
64
|
}
|
|
61
65
|
return {
|
|
62
66
|
calls,
|
|
63
67
|
eligible,
|
|
64
|
-
thinking,
|
|
68
|
+
thinking: thinking.join("\n\n"),
|
|
65
69
|
hidden: hidden === true,
|
|
66
70
|
usage: readUsage(message.usage),
|
|
67
71
|
};
|
|
@@ -88,10 +92,9 @@ function toolMember(component, calls) {
|
|
|
88
92
|
!result.content.every((block) => record(block) && block.type === "text") ||
|
|
89
93
|
!(renderer instanceof ToolView)))
|
|
90
94
|
return undefined;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (summary === undefined)
|
|
95
|
+
// The visible view decides the facts: a hidden call renderer reports no summary of its own.
|
|
96
|
+
const view = renderer instanceof ToolView ? renderer : callRenderer;
|
|
97
|
+
if (view.summary === undefined)
|
|
95
98
|
return undefined;
|
|
96
99
|
const status = !complete
|
|
97
100
|
? "streaming"
|
|
@@ -100,16 +103,17 @@ function toolMember(component, calls) {
|
|
|
100
103
|
: record(result) && result.isError === true
|
|
101
104
|
? "error"
|
|
102
105
|
: "ok";
|
|
103
|
-
const children =
|
|
104
|
-
const childCount = renderer instanceof ToolView ? renderer.childCount : undefined;
|
|
106
|
+
const children = view.children;
|
|
105
107
|
return {
|
|
106
108
|
kind: "tool",
|
|
107
109
|
component,
|
|
108
110
|
title: toSingleRow(stripTerminalSequences(name)),
|
|
109
|
-
summary,
|
|
110
111
|
status,
|
|
112
|
+
// The host keeps isPartial true until it stores a final result; argsComplete alone is not
|
|
113
|
+
// completion, and restored history never sets it. The result is the observed end fact.
|
|
114
|
+
completed: !partial && result !== undefined,
|
|
111
115
|
...(children === undefined || children.length === 0 ? {} : { children }),
|
|
112
|
-
...(childCount === undefined ? {} : { childCount }),
|
|
116
|
+
...(view.childCount === undefined ? {} : { childCount: view.childCount }),
|
|
113
117
|
};
|
|
114
118
|
}
|
|
115
119
|
function messageView(component) {
|
|
@@ -132,50 +136,50 @@ function noticeMember(component) {
|
|
|
132
136
|
component,
|
|
133
137
|
title: view.kind,
|
|
134
138
|
summary: view.activitySummary,
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
|
|
139
|
+
// Preview is visible content; only a real body opens. A preview-only notice therefore never
|
|
140
|
+
// grows a second expansion level, and the notice lays out both blocks itself.
|
|
141
|
+
content: (width) => view.previewRows(width),
|
|
142
|
+
...(view.expandable ? { detail: (width) => view.bodyRows(width) } : {}),
|
|
138
143
|
};
|
|
139
144
|
}
|
|
140
|
-
|
|
145
|
+
/**
|
|
146
|
+
* A captured `ui.notify`. It owns its content: one indented, width-wrapped block with the notice's
|
|
147
|
+
* semantic color. The group draws these rows once, so a plain notice has no expansion of its own.
|
|
148
|
+
*/
|
|
149
|
+
class NotifyView {
|
|
141
150
|
#text = new Text("", 1, 0);
|
|
142
151
|
#ui;
|
|
143
152
|
#message;
|
|
144
153
|
#type;
|
|
145
154
|
constructor(message, type, ui) {
|
|
146
|
-
super();
|
|
147
155
|
this.#ui = ui;
|
|
148
156
|
this.#message = message;
|
|
149
157
|
this.#type = type;
|
|
150
|
-
this.addChild(new Spacer(1));
|
|
151
|
-
this.addChild(this.#text);
|
|
152
|
-
}
|
|
153
|
-
get summary() {
|
|
154
|
-
return this.#ui.theme.fg(this.#type === "info" ? "dim" : this.#type, toSingleRow(this.#content));
|
|
155
158
|
}
|
|
156
159
|
get member() {
|
|
157
160
|
return {
|
|
158
161
|
kind: "notice",
|
|
159
162
|
component: this,
|
|
160
163
|
title: this.#type,
|
|
161
|
-
|
|
162
|
-
detail: (width) => this.#detail(width),
|
|
164
|
+
content: (width) => this.rows(width),
|
|
163
165
|
};
|
|
164
166
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
167
|
+
/** The notice body as it appears both in the transcript and inside the activity group. */
|
|
168
|
+
rows(width) {
|
|
169
|
+
// info is the terminal's own readable text; a warning or error keeps its status token and
|
|
170
|
+
// its written prefix, so the level never depends on color alone.
|
|
171
|
+
this.#text.setText(this.#ui.theme.fg(this.#type === "info" ? "text" : this.#type, this.#content));
|
|
172
|
+
return this.#text.render(width);
|
|
171
173
|
}
|
|
172
174
|
get #content() {
|
|
173
175
|
const prefix = this.#type === "error" ? "Error: " : this.#type === "warning" ? "Warning: " : "";
|
|
174
176
|
return `${prefix}${this.#message}`;
|
|
175
177
|
}
|
|
176
178
|
render(width) {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
+
return ["", ...this.rows(width)];
|
|
180
|
+
}
|
|
181
|
+
invalidate() {
|
|
182
|
+
this.#text.invalidate();
|
|
179
183
|
}
|
|
180
184
|
}
|
|
181
185
|
function attach(host, run) {
|
|
@@ -196,40 +200,144 @@ function attach(host, run) {
|
|
|
196
200
|
throw new Error("Pi transcript cannot accept instance methods");
|
|
197
201
|
const projection = new Container();
|
|
198
202
|
let groups = new Map();
|
|
203
|
+
/** One wrapper per tail component, so its reserved height survives a transcript rebuild. */
|
|
204
|
+
let tails = new Map();
|
|
199
205
|
let active = true;
|
|
206
|
+
const disposeGroups = () => {
|
|
207
|
+
for (const group of groups.values())
|
|
208
|
+
group.dispose();
|
|
209
|
+
groups.clear();
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* Record first observation time for new components during a live loop. Restored history stays
|
|
213
|
+
* unknown until live content joins its block.
|
|
214
|
+
*/
|
|
215
|
+
const claim = (child) => {
|
|
216
|
+
if (run.observed.has(child))
|
|
217
|
+
return;
|
|
218
|
+
const current = run.current;
|
|
219
|
+
run.observed.set(child, current !== undefined && current.run.endedAt === undefined
|
|
220
|
+
? { at: performance.now(), run: current.run }
|
|
221
|
+
: undefined);
|
|
222
|
+
};
|
|
223
|
+
const observe = () => {
|
|
224
|
+
for (const child of chat.children)
|
|
225
|
+
claim(child);
|
|
226
|
+
};
|
|
227
|
+
/** Create or recover one clock for each live continuous block. */
|
|
228
|
+
const clockOf = (members, endedAt) => {
|
|
229
|
+
const parts = members.flatMap(memberParts);
|
|
230
|
+
let clock;
|
|
231
|
+
for (const part of parts) {
|
|
232
|
+
clock = run.clocks.get(part);
|
|
233
|
+
if (clock !== undefined)
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
if (clock === undefined) {
|
|
237
|
+
const origin = parts
|
|
238
|
+
.map((part) => run.observed.get(part))
|
|
239
|
+
.find((value) => value !== undefined);
|
|
240
|
+
if (origin === undefined)
|
|
241
|
+
return undefined;
|
|
242
|
+
clock = { startedAt: origin.at };
|
|
243
|
+
if (origin.run.endedAt !== undefined)
|
|
244
|
+
clock.endedAt = origin.run.endedAt;
|
|
245
|
+
else
|
|
246
|
+
run.activeClocks.add(clock);
|
|
247
|
+
}
|
|
248
|
+
if (endedAt !== undefined && clock.endedAt === undefined) {
|
|
249
|
+
clock.endedAt = endedAt;
|
|
250
|
+
run.activeClocks.delete(clock);
|
|
251
|
+
}
|
|
252
|
+
for (const part of parts)
|
|
253
|
+
if (run.clocks.get(part) === undefined)
|
|
254
|
+
run.clocks.set(part, clock);
|
|
255
|
+
return clock;
|
|
256
|
+
};
|
|
200
257
|
const rebuild = () => {
|
|
201
258
|
const next = new Map();
|
|
259
|
+
const nextTails = new Map();
|
|
202
260
|
const children = [];
|
|
203
261
|
const blocks = [];
|
|
204
262
|
let calls = new Set();
|
|
205
263
|
let members = [];
|
|
206
|
-
/**
|
|
207
|
-
|
|
264
|
+
/** Every request this pending block owns, in transcript order, whether or not it is displayed. */
|
|
265
|
+
let requests = [];
|
|
266
|
+
/**
|
|
267
|
+
* Closes the pending block. `ended` is false only for the block still open at the transcript tail:
|
|
268
|
+
* that one keeps its last visible unit outside the group, and its facts still count here.
|
|
269
|
+
*/
|
|
270
|
+
const flush = (ended, boundary) => {
|
|
208
271
|
const first = members[0]?.component;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
272
|
+
let weight = 0;
|
|
273
|
+
for (const member of members) {
|
|
274
|
+
if (member.kind === "tool")
|
|
275
|
+
weight += 1;
|
|
276
|
+
else if (member.kind === "notice" ||
|
|
277
|
+
(member.thinking !== undefined && member.thinking !== ""))
|
|
278
|
+
weight += 0.5;
|
|
279
|
+
}
|
|
280
|
+
if (first !== undefined && weight >= 2) {
|
|
281
|
+
const group = groups.get(first) ?? new ToolGroupView(host.ui, host.requestRender);
|
|
282
|
+
const tail = ended ? undefined : members.at(-1);
|
|
283
|
+
const inside = tail === undefined ? members : members.slice(0, -1);
|
|
284
|
+
const boundaryTime = ended && boundary !== undefined
|
|
285
|
+
? (run.observed.get(boundary)?.at ?? performance.now())
|
|
286
|
+
: undefined;
|
|
287
|
+
blocks.push({
|
|
288
|
+
group,
|
|
289
|
+
members: inside,
|
|
290
|
+
tail,
|
|
291
|
+
requests,
|
|
292
|
+
clock: clockOf(members, boundaryTime),
|
|
293
|
+
});
|
|
213
294
|
next.set(first, group);
|
|
214
295
|
children.push(group);
|
|
296
|
+
if (tail !== undefined) {
|
|
297
|
+
const key = memberParts(tail)[0];
|
|
298
|
+
const unit = (key === undefined ? undefined : tails.get(key)) ?? new TailUnit(tail);
|
|
299
|
+
unit.update(tail);
|
|
300
|
+
if (key !== undefined)
|
|
301
|
+
nextTails.set(key, unit);
|
|
302
|
+
children.push(unit);
|
|
303
|
+
}
|
|
215
304
|
}
|
|
216
305
|
else
|
|
217
|
-
children.push(...members.
|
|
306
|
+
children.push(...members.flatMap(memberParts));
|
|
218
307
|
members = [];
|
|
308
|
+
requests = [];
|
|
219
309
|
};
|
|
220
310
|
for (const child of chat.children) {
|
|
311
|
+
claim(child);
|
|
221
312
|
if (child instanceof AssistantMessageComponent) {
|
|
222
313
|
const info = assistantInfo(child);
|
|
223
314
|
calls = info.calls;
|
|
315
|
+
// The request belongs to the block whose work it answers, even when its text is native
|
|
316
|
+
// content the aggregate never shows.
|
|
317
|
+
requests.push(info.usage === undefined ? {} : { usage: info.usage });
|
|
224
318
|
if (info.eligible) {
|
|
225
319
|
const thinking = info.thinking;
|
|
226
|
-
members.
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
320
|
+
const previous = members.at(-1);
|
|
321
|
+
if (thinking !== "" && previous?.kind === "thinking") {
|
|
322
|
+
// One continuous run: consecutive thinking blocks are one item, paragraphs kept.
|
|
323
|
+
members[members.length - 1] = {
|
|
324
|
+
...previous,
|
|
325
|
+
thinking: `${previous.thinking ?? ""}\n\n${thinking}`,
|
|
326
|
+
parts: [...memberParts(previous), child],
|
|
327
|
+
...(previous.thinkingHidden === true || info.hidden ? { thinkingHidden: true } : {}),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
members.push({
|
|
332
|
+
kind: "thinking",
|
|
333
|
+
component: child,
|
|
334
|
+
// The host's hide setting is a display preference, not a content filter: the text
|
|
335
|
+
// stays available to the group's preview and expansion, and is never written back.
|
|
336
|
+
...(thinking === ""
|
|
337
|
+
? {}
|
|
338
|
+
: { thinking, ...(info.hidden ? { thinkingHidden: true } : {}) }),
|
|
339
|
+
});
|
|
340
|
+
}
|
|
233
341
|
continue;
|
|
234
342
|
}
|
|
235
343
|
}
|
|
@@ -251,15 +359,20 @@ function attach(host, run) {
|
|
|
251
359
|
continue;
|
|
252
360
|
}
|
|
253
361
|
}
|
|
254
|
-
flush(true);
|
|
362
|
+
flush(true, child);
|
|
255
363
|
children.push(child);
|
|
256
364
|
}
|
|
257
365
|
flush(false);
|
|
258
|
-
const frozen = run.current === undefined || run.current.endedAt !== undefined;
|
|
259
366
|
for (const block of blocks) {
|
|
260
|
-
block.group.update(block.members,
|
|
367
|
+
block.group.update(block.members, block.tail, block.requests, block.clock);
|
|
368
|
+
}
|
|
369
|
+
// A block the new projection does not reuse is gone: its hold timer must go with it.
|
|
370
|
+
for (const [key, group] of groups) {
|
|
371
|
+
if (next.get(key) !== group)
|
|
372
|
+
group.dispose();
|
|
261
373
|
}
|
|
262
374
|
groups = next;
|
|
375
|
+
tails = nextTails;
|
|
263
376
|
projection.children = children;
|
|
264
377
|
};
|
|
265
378
|
const wrappers = {
|
|
@@ -271,7 +384,7 @@ function attach(host, run) {
|
|
|
271
384
|
}
|
|
272
385
|
catch (error) {
|
|
273
386
|
active = false;
|
|
274
|
-
|
|
387
|
+
disposeGroups();
|
|
275
388
|
projection.clear();
|
|
276
389
|
host.ui.notify(`Tool grouping disabled: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
277
390
|
return original.render.call(chat, width);
|
|
@@ -299,12 +412,13 @@ function attach(host, run) {
|
|
|
299
412
|
host.requestRender();
|
|
300
413
|
return true;
|
|
301
414
|
},
|
|
415
|
+
observe,
|
|
302
416
|
dispose() {
|
|
303
417
|
if (disposed)
|
|
304
418
|
return;
|
|
305
419
|
disposed = true;
|
|
306
420
|
active = false;
|
|
307
|
-
|
|
421
|
+
disposeGroups();
|
|
308
422
|
projection.clear();
|
|
309
423
|
let conflict = false;
|
|
310
424
|
for (const key of ["render", "handleMouse", "invalidate"]) {
|
|
@@ -431,12 +545,15 @@ export function installHostToolGroups(context) {
|
|
|
431
545
|
value: state.wrappers[seam],
|
|
432
546
|
});
|
|
433
547
|
}
|
|
434
|
-
const run = {
|
|
548
|
+
const run = {
|
|
549
|
+
observed: new WeakMap(),
|
|
550
|
+
clocks: new WeakMap(),
|
|
551
|
+
activeClocks: new Set(),
|
|
552
|
+
};
|
|
435
553
|
const owner = {
|
|
436
554
|
ui,
|
|
437
555
|
hosts: new Map(),
|
|
438
556
|
failed: new WeakSet(),
|
|
439
|
-
run,
|
|
440
557
|
attach: (host) => attach(host, run),
|
|
441
558
|
};
|
|
442
559
|
state.owners.set(ui, owner);
|
|
@@ -463,18 +580,29 @@ export function installHostToolGroups(context) {
|
|
|
463
580
|
host.requestRender();
|
|
464
581
|
},
|
|
465
582
|
beginRun() {
|
|
466
|
-
|
|
583
|
+
// History may not have rendered yet. Claim it before opening the new live run.
|
|
584
|
+
for (const host of owner.hosts.values())
|
|
585
|
+
host.observe();
|
|
586
|
+
run.current = { run: {} };
|
|
467
587
|
},
|
|
468
588
|
endRun() {
|
|
589
|
+
// Mark unseen transcript children before ending the run so later renders treat them as history.
|
|
590
|
+
for (const host of owner.hosts.values())
|
|
591
|
+
host.observe();
|
|
592
|
+
const endedAt = performance.now();
|
|
469
593
|
const current = run.current;
|
|
470
|
-
if (current !== undefined && current.endedAt === undefined)
|
|
471
|
-
current.endedAt =
|
|
594
|
+
if (current !== undefined && current.run.endedAt === undefined)
|
|
595
|
+
current.run.endedAt = endedAt;
|
|
596
|
+
for (const clock of run.activeClocks)
|
|
597
|
+
clock.endedAt = endedAt;
|
|
598
|
+
run.activeClocks.clear();
|
|
472
599
|
},
|
|
473
600
|
dispose() {
|
|
474
601
|
if (disposed)
|
|
475
602
|
return;
|
|
476
603
|
disposed = true;
|
|
477
604
|
run.current = undefined;
|
|
605
|
+
run.activeClocks.clear();
|
|
478
606
|
state.owners.delete(ui);
|
|
479
607
|
for (const host of owner.hosts.values())
|
|
480
608
|
host.dispose();
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { type DiffHunk, type DiffLine, type DiffSource, type DiffStats, DiffView, type DiffViewOptions, diffStats, } from "./diff-view.js";
|
|
2
2
|
export { createMessageView, type MessageRows, type MessageTone, MessageView, type MessageViewHeader, type MessageViewSpec, } from "./message-view.js";
|
|
3
|
-
export { extractChildToolCopyText,
|
|
4
|
-
export {
|
|
3
|
+
export { extractChildToolCopyText, inlinePresentation } from "./native-tools.js";
|
|
4
|
+
export { NestedCallsView, type NestedCallsViewProps, } from "./nested-calls-view.js";
|
|
5
|
+
export { type CollapsibleToolRendererOptions, createCollapsibleToolRendererResolver, estimateStreamingTokens, isMcpTool, type NestedCallRecord, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, type SpecializedToolRendererName, type ToolRenderContext, } from "./renderer.js";
|
|
6
|
+
export { ToolUiRuntime } from "./runtime.js";
|
|
5
7
|
export { type Collapsible, createToolView, type FoldLevel, formatStatusGlyph, formatToolHeaderText, isCollapsible, type RailColorToken, type RailSection, type ToolStatusKind, ToolView, type ToolViewChild, type ToolViewCopySpec, type ToolViewHeaderSpec, type ToolViewSpec, transitionFoldLevel, } from "./tool-view.js";
|
|
6
|
-
export { type CapturedToolCall,
|
|
8
|
+
export { type CapturedToolCall, type NestedCallArgsFact, NestedCallArgsIndex, ToolExecutionTracker, } from "./tracker.js";
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { DiffView, diffStats, } from "./diff-view.js";
|
|
2
2
|
export { createMessageView, MessageView, } from "./message-view.js";
|
|
3
|
-
export { extractChildToolCopyText,
|
|
4
|
-
export {
|
|
3
|
+
export { extractChildToolCopyText, inlinePresentation } from "./native-tools.js";
|
|
4
|
+
export { NestedCallsView, } from "./nested-calls-view.js";
|
|
5
|
+
export { createCollapsibleToolRendererResolver, estimateStreamingTokens, isMcpTool, registerCollapsibleToolRenderer, SPECIALIZED_TOOL_RENDERERS, } from "./renderer.js";
|
|
6
|
+
export { ToolUiRuntime } from "./runtime.js";
|
|
5
7
|
export { createToolView, formatStatusGlyph, formatToolHeaderText, isCollapsible, ToolView, transitionFoldLevel, } from "./tool-view.js";
|
|
6
|
-
export {
|
|
8
|
+
export { NestedCallArgsIndex, ToolExecutionTracker, } from "./tracker.js";
|
package/dist/message-view.d.ts
CHANGED
|
@@ -14,16 +14,10 @@ export declare class MessageView implements Component {
|
|
|
14
14
|
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
15
15
|
/** Provider-declared notice type, used to group notices without reading their text. */
|
|
16
16
|
get kind(): string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
detailRows(width: number): string[];
|
|
17
|
+
/** Preview rows inside an activity group: visible content below the summary, folded or not. */
|
|
18
|
+
previewRows(width: number): string[];
|
|
19
|
+
/** Body rows inside an activity group: what opening this notice reveals. */
|
|
20
|
+
bodyRows(width: number): string[];
|
|
22
21
|
/** Single-row label used when this notice sits inside an activity group. */
|
|
23
22
|
get activitySummary(): string;
|
|
24
|
-
/**
|
|
25
|
-
* Whether anything would render below the label. `expandable` asks about the body only; a
|
|
26
|
-
* preview-only notice still has detail to show, so the group keys its pin on this instead.
|
|
27
|
-
*/
|
|
28
|
-
get hasDetail(): boolean;
|
|
29
23
|
}
|