@jerryan/pi-subagent-tools 0.3.0 → 0.4.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 +100 -70
- package/LICENSE +21 -21
- package/README.md +102 -102
- package/agents.ts +881 -878
- package/index.ts +19 -19
- package/package.json +63 -63
- package/prompts/delegate.md +6 -6
- package/prompts/explore.md +4 -4
- package/prompts/review.md +4 -4
- package/render.ts +87 -87
- package/sandbox-bash.ts +312 -148
- package/sandbox-python.ts +169 -0
- package/tui.ts +73 -73
- package/ui-bridge.ts +198 -198
package/tui.ts
CHANGED
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* TUI rendering helpers for pi-subagent-tools.
|
|
3
|
-
*
|
|
4
|
-
* Tool call cards, result display, progress indicators.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import * as os from "node:os";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Shorten a path by replacing the home directory with ~
|
|
11
|
-
*/
|
|
12
|
-
export function shortenPath(p: string): string {
|
|
13
|
-
const normalized = p.toLowerCase();
|
|
14
|
-
for (const home of [process.env.HOME, process.env.USERPROFILE, os.homedir()]) {
|
|
15
|
-
if (home && normalized.startsWith(home.toLowerCase())) {
|
|
16
|
-
return `~${p.slice(home.length)}`;
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
return p;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Format duration in human-readable form
|
|
24
|
-
*/
|
|
25
|
-
export function formatDuration(ms: number): string {
|
|
26
|
-
if (ms < 1000) return `${ms}ms`;
|
|
27
|
-
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
28
|
-
return `${Math.floor(ms / 60000)}m${String(Math.floor((ms % 60000) / 1000)).padStart(2, "0")}s`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Format token count with k suffix for large numbers
|
|
33
|
-
*/
|
|
34
|
-
export function formatTokens(n: number): string {
|
|
35
|
-
let s: string;
|
|
36
|
-
if (n < 1000) s = String(n) + " "; // trailing spaces give same visual weight as k/M suffix; padStart right-aligns
|
|
37
|
-
else if (n < 1_000_000) s = (Math.trunc(n / 100) / 10).toFixed(1) + "k";
|
|
38
|
-
else s = (Math.trunc(n / 100_000) / 10).toFixed(1) + "M";
|
|
39
|
-
return s.padStart(6);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** Cap a string at max chars, replacing overflow with "...". */
|
|
43
|
-
export function truncate(s: string, max: number): string {
|
|
44
|
-
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Prefix marking tool-call summary lines in subagent progress text. */
|
|
48
|
-
export const TOOL_LINE_PREFIX = "▸";
|
|
49
|
-
|
|
50
|
-
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Time-derived spinner frame. Stateless — no per-caller state to create,
|
|
54
|
-
* leak, or clean up. All concurrent spinners share the global clock, which
|
|
55
|
-
* is visually indistinguishable from per-caller frames.
|
|
56
|
-
*/
|
|
57
|
-
export function spinnerFrame(): string {
|
|
58
|
-
return SPINNER[Math.floor(Date.now() / 100) % SPINNER.length];
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export interface UsageStats {
|
|
62
|
-
turns: number;
|
|
63
|
-
input: number;
|
|
64
|
-
output: number;
|
|
65
|
-
durationMs: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function formatUsage(u: UsageStats, spin: () => string): string {
|
|
69
|
-
const parts: string[] = [`${spin()} · Turn ${u.turns}`];
|
|
70
|
-
parts.push(`Tokens: input ${formatTokens(u.input)} | output ${formatTokens(u.output)}`);
|
|
71
|
-
parts.push(formatDuration(u.durationMs));
|
|
72
|
-
return parts.join(" · ");
|
|
73
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* TUI rendering helpers for pi-subagent-tools.
|
|
3
|
+
*
|
|
4
|
+
* Tool call cards, result display, progress indicators.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as os from "node:os";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shorten a path by replacing the home directory with ~
|
|
11
|
+
*/
|
|
12
|
+
export function shortenPath(p: string): string {
|
|
13
|
+
const normalized = p.toLowerCase();
|
|
14
|
+
for (const home of [process.env.HOME, process.env.USERPROFILE, os.homedir()]) {
|
|
15
|
+
if (home && normalized.startsWith(home.toLowerCase())) {
|
|
16
|
+
return `~${p.slice(home.length)}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format duration in human-readable form
|
|
24
|
+
*/
|
|
25
|
+
export function formatDuration(ms: number): string {
|
|
26
|
+
if (ms < 1000) return `${ms}ms`;
|
|
27
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
28
|
+
return `${Math.floor(ms / 60000)}m${String(Math.floor((ms % 60000) / 1000)).padStart(2, "0")}s`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Format token count with k suffix for large numbers
|
|
33
|
+
*/
|
|
34
|
+
export function formatTokens(n: number): string {
|
|
35
|
+
let s: string;
|
|
36
|
+
if (n < 1000) s = String(n) + " "; // trailing spaces give same visual weight as k/M suffix; padStart right-aligns
|
|
37
|
+
else if (n < 1_000_000) s = (Math.trunc(n / 100) / 10).toFixed(1) + "k";
|
|
38
|
+
else s = (Math.trunc(n / 100_000) / 10).toFixed(1) + "M";
|
|
39
|
+
return s.padStart(6);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Cap a string at max chars, replacing overflow with "...". */
|
|
43
|
+
export function truncate(s: string, max: number): string {
|
|
44
|
+
return s.length > max ? s.slice(0, max - 3) + "..." : s;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Prefix marking tool-call summary lines in subagent progress text. */
|
|
48
|
+
export const TOOL_LINE_PREFIX = "▸";
|
|
49
|
+
|
|
50
|
+
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Time-derived spinner frame. Stateless — no per-caller state to create,
|
|
54
|
+
* leak, or clean up. All concurrent spinners share the global clock, which
|
|
55
|
+
* is visually indistinguishable from per-caller frames.
|
|
56
|
+
*/
|
|
57
|
+
export function spinnerFrame(): string {
|
|
58
|
+
return SPINNER[Math.floor(Date.now() / 100) % SPINNER.length];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UsageStats {
|
|
62
|
+
turns: number;
|
|
63
|
+
input: number;
|
|
64
|
+
output: number;
|
|
65
|
+
durationMs: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function formatUsage(u: UsageStats, spin: () => string): string {
|
|
69
|
+
const parts: string[] = [`${spin()} · Turn ${u.turns}`];
|
|
70
|
+
parts.push(`Tokens: input ${formatTokens(u.input)} | output ${formatTokens(u.output)}`);
|
|
71
|
+
parts.push(formatDuration(u.durationMs));
|
|
72
|
+
return parts.join(" · ");
|
|
73
|
+
}
|
package/ui-bridge.ts
CHANGED
|
@@ -1,198 +1,198 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* UI bridge — presents an ExtensionUIContext to an in-process subagent
|
|
3
|
-
* session that forwards interactive prompts to the parent session's TUI.
|
|
4
|
-
*
|
|
5
|
-
* Usage:
|
|
6
|
-
* const bridge = createUIBridge(ctx.ui, { label: "delegate" });
|
|
7
|
-
* await childSession.bindExtensions({ uiContext: bridge, mode: "rpc" });
|
|
8
|
-
*
|
|
9
|
-
* Mode is "rpc" (not "tui") because the bridge deliberately mirrors the
|
|
10
|
-
* documented RPC degradation contract: dialogs work, terminal chrome does
|
|
11
|
-
* not. Extensions running in the child should not assume direct TUI access.
|
|
12
|
-
*
|
|
13
|
-
* Method mapping
|
|
14
|
-
* ──────────────
|
|
15
|
-
* Dialogs (serialized through a shared queue, title prefixed with label):
|
|
16
|
-
* select, confirm, input, editor, custom
|
|
17
|
-
* Dialog opts (signal, timeout) pass through unchanged, so the child can
|
|
18
|
-
* abort-dismiss and auto-dismiss exactly as documented.
|
|
19
|
-
*
|
|
20
|
-
* Fire-and-forget (forwarded):
|
|
21
|
-
* notify — message prefixed with [label]
|
|
22
|
-
* setStatus — key namespaced as "<label>:<key>"
|
|
23
|
-
* setWidget — key namespaced; string arrays and component factories
|
|
24
|
-
* both forwarded (factories are valid in-process)
|
|
25
|
-
*
|
|
26
|
-
* Fire-and-forget (dropped — they would hijack the parent's chrome):
|
|
27
|
-
* setTitle, setWorkingMessage, setWorkingVisible, setWorkingIndicator,
|
|
28
|
-
* setHiddenThinkingLabel, onTerminalInput, pasteToEditor, setEditorText,
|
|
29
|
-
* setEditorComponent, setFooter, setHeader, addAutocompleteProvider,
|
|
30
|
-
* setToolsExpanded
|
|
31
|
-
*
|
|
32
|
-
* Reads:
|
|
33
|
-
* theme, getAllThemes, getTheme — read-through to the parent
|
|
34
|
-
* getToolsExpanded — read-through
|
|
35
|
-
* getEditorText, getEditorComponent — return "" / undefined
|
|
36
|
-
*
|
|
37
|
-
* Writes blocked:
|
|
38
|
-
* setTheme — returns { success: false, error }
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
import type {
|
|
42
|
-
ExtensionUIContext,
|
|
43
|
-
ExtensionWidgetOptions,
|
|
44
|
-
Theme,
|
|
45
|
-
} from "@earendil-works/pi-coding-agent";
|
|
46
|
-
|
|
47
|
-
export interface UIBridgeOptions {
|
|
48
|
-
/**
|
|
49
|
-
* Short name identifying the subagent (e.g. "delegate", "review").
|
|
50
|
-
* Used to prefix dialog titles and notifications, and to namespace
|
|
51
|
-
* status/widget keys so concurrent subagents don't clobber each other.
|
|
52
|
-
*/
|
|
53
|
-
label: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// ---------------------------------------------------------------------------
|
|
57
|
-
// Dialog serialization
|
|
58
|
-
//
|
|
59
|
-
// The parent TUI can only show one modal at a time. Multiple subagents may
|
|
60
|
-
// request dialogs concurrently (parallel tool calls), so all dialog methods
|
|
61
|
-
// across ALL bridge instances funnel through one process-wide queue.
|
|
62
|
-
// ---------------------------------------------------------------------------
|
|
63
|
-
|
|
64
|
-
let dialogQueue: Promise<unknown> = Promise.resolve();
|
|
65
|
-
|
|
66
|
-
function enqueueDialog<T>(fn: () => Promise<T>): Promise<T> {
|
|
67
|
-
const result = dialogQueue.then(fn, fn);
|
|
68
|
-
// Keep the queue alive even if fn rejects.
|
|
69
|
-
dialogQueue = result.then(
|
|
70
|
-
() => undefined,
|
|
71
|
-
() => undefined,
|
|
72
|
-
);
|
|
73
|
-
return result;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Test hook: reset the dialog queue. Not part of the public API. */
|
|
77
|
-
export function _resetDialogQueue(): void {
|
|
78
|
-
dialogQueue = Promise.resolve();
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
// ---------------------------------------------------------------------------
|
|
82
|
-
// Bridge factory
|
|
83
|
-
// ---------------------------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
export function createUIBridge(
|
|
86
|
-
parent: ExtensionUIContext,
|
|
87
|
-
options: UIBridgeOptions,
|
|
88
|
-
): ExtensionUIContext {
|
|
89
|
-
const { label } = options;
|
|
90
|
-
const tag = `[${label}]`;
|
|
91
|
-
const titled = (title: string) => `${tag} ${title}`;
|
|
92
|
-
const keyed = (key: string) => `${label}:${key}`;
|
|
93
|
-
|
|
94
|
-
const noop = () => {};
|
|
95
|
-
const noopUnsubscribe = () => noop;
|
|
96
|
-
|
|
97
|
-
const bridge: ExtensionUIContext = {
|
|
98
|
-
// --- Dialogs (serialized, forwarded) ----------------------------------
|
|
99
|
-
|
|
100
|
-
select(title, options_, opts) {
|
|
101
|
-
return enqueueDialog(() => parent.select(titled(title), options_, opts));
|
|
102
|
-
},
|
|
103
|
-
|
|
104
|
-
confirm(title, message, opts) {
|
|
105
|
-
return enqueueDialog(() => parent.confirm(titled(title), message, opts));
|
|
106
|
-
},
|
|
107
|
-
|
|
108
|
-
input(title, placeholder, opts) {
|
|
109
|
-
return enqueueDialog(() => parent.input(titled(title), placeholder, opts));
|
|
110
|
-
},
|
|
111
|
-
|
|
112
|
-
editor(title, prefill) {
|
|
113
|
-
return enqueueDialog(() => parent.editor(titled(title), prefill));
|
|
114
|
-
},
|
|
115
|
-
|
|
116
|
-
// Contextually typed against the generic interface signature; the
|
|
117
|
-
// explicit <T> is inferred from the ExtensionUIContext annotation.
|
|
118
|
-
custom(factory, customOptions) {
|
|
119
|
-
// In-process the factory executes against the parent's real TUI, so
|
|
120
|
-
// custom components work — unlike RPC mode, which degrades to undefined.
|
|
121
|
-
return enqueueDialog(() => parent.custom(factory, customOptions));
|
|
122
|
-
},
|
|
123
|
-
|
|
124
|
-
// --- Fire-and-forget (forwarded) ---------------------------------------
|
|
125
|
-
|
|
126
|
-
notify(message, type) {
|
|
127
|
-
parent.notify(`${tag} ${message}`, type);
|
|
128
|
-
},
|
|
129
|
-
|
|
130
|
-
setStatus(key, text) {
|
|
131
|
-
parent.setStatus(keyed(key), text);
|
|
132
|
-
},
|
|
133
|
-
|
|
134
|
-
setWidget(
|
|
135
|
-
key: string,
|
|
136
|
-
content: string[] | ((...args: any[]) => any) | undefined,
|
|
137
|
-
widgetOptions?: ExtensionWidgetOptions,
|
|
138
|
-
) {
|
|
139
|
-
parent.setWidget(keyed(key), content as any, widgetOptions);
|
|
140
|
-
},
|
|
141
|
-
|
|
142
|
-
// --- Fire-and-forget (dropped) ------------------------------------------
|
|
143
|
-
// These manipulate the parent session's chrome (terminal title, streaming
|
|
144
|
-
// indicator, editor, footer/header). A subagent has no business touching
|
|
145
|
-
// them. Mirrors the RPC-mode degradation contract.
|
|
146
|
-
|
|
147
|
-
setTitle: noop,
|
|
148
|
-
setWorkingMessage: noop,
|
|
149
|
-
setWorkingVisible: noop,
|
|
150
|
-
setWorkingIndicator: noop,
|
|
151
|
-
setHiddenThinkingLabel: noop,
|
|
152
|
-
pasteToEditor: noop,
|
|
153
|
-
setEditorText: noop,
|
|
154
|
-
setEditorComponent: noop,
|
|
155
|
-
setFooter: noop,
|
|
156
|
-
setHeader: noop,
|
|
157
|
-
addAutocompleteProvider: noop,
|
|
158
|
-
setToolsExpanded: noop,
|
|
159
|
-
onTerminalInput: noopUnsubscribe,
|
|
160
|
-
|
|
161
|
-
// --- Reads ---------------------------------------------------------------
|
|
162
|
-
|
|
163
|
-
get theme(): Theme {
|
|
164
|
-
return parent.theme;
|
|
165
|
-
},
|
|
166
|
-
|
|
167
|
-
getAllThemes() {
|
|
168
|
-
return parent.getAllThemes();
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
getTheme(name) {
|
|
172
|
-
return parent.getTheme(name);
|
|
173
|
-
},
|
|
174
|
-
|
|
175
|
-
getToolsExpanded() {
|
|
176
|
-
return parent.getToolsExpanded();
|
|
177
|
-
},
|
|
178
|
-
|
|
179
|
-
getEditorText() {
|
|
180
|
-
return "";
|
|
181
|
-
},
|
|
182
|
-
|
|
183
|
-
getEditorComponent() {
|
|
184
|
-
return undefined;
|
|
185
|
-
},
|
|
186
|
-
|
|
187
|
-
// --- Writes blocked -------------------------------------------------------
|
|
188
|
-
|
|
189
|
-
setTheme() {
|
|
190
|
-
return {
|
|
191
|
-
success: false,
|
|
192
|
-
error: "Subagents cannot change the parent session's theme.",
|
|
193
|
-
};
|
|
194
|
-
},
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
return bridge;
|
|
198
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* UI bridge — presents an ExtensionUIContext to an in-process subagent
|
|
3
|
+
* session that forwards interactive prompts to the parent session's TUI.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* const bridge = createUIBridge(ctx.ui, { label: "delegate" });
|
|
7
|
+
* await childSession.bindExtensions({ uiContext: bridge, mode: "rpc" });
|
|
8
|
+
*
|
|
9
|
+
* Mode is "rpc" (not "tui") because the bridge deliberately mirrors the
|
|
10
|
+
* documented RPC degradation contract: dialogs work, terminal chrome does
|
|
11
|
+
* not. Extensions running in the child should not assume direct TUI access.
|
|
12
|
+
*
|
|
13
|
+
* Method mapping
|
|
14
|
+
* ──────────────
|
|
15
|
+
* Dialogs (serialized through a shared queue, title prefixed with label):
|
|
16
|
+
* select, confirm, input, editor, custom
|
|
17
|
+
* Dialog opts (signal, timeout) pass through unchanged, so the child can
|
|
18
|
+
* abort-dismiss and auto-dismiss exactly as documented.
|
|
19
|
+
*
|
|
20
|
+
* Fire-and-forget (forwarded):
|
|
21
|
+
* notify — message prefixed with [label]
|
|
22
|
+
* setStatus — key namespaced as "<label>:<key>"
|
|
23
|
+
* setWidget — key namespaced; string arrays and component factories
|
|
24
|
+
* both forwarded (factories are valid in-process)
|
|
25
|
+
*
|
|
26
|
+
* Fire-and-forget (dropped — they would hijack the parent's chrome):
|
|
27
|
+
* setTitle, setWorkingMessage, setWorkingVisible, setWorkingIndicator,
|
|
28
|
+
* setHiddenThinkingLabel, onTerminalInput, pasteToEditor, setEditorText,
|
|
29
|
+
* setEditorComponent, setFooter, setHeader, addAutocompleteProvider,
|
|
30
|
+
* setToolsExpanded
|
|
31
|
+
*
|
|
32
|
+
* Reads:
|
|
33
|
+
* theme, getAllThemes, getTheme — read-through to the parent
|
|
34
|
+
* getToolsExpanded — read-through
|
|
35
|
+
* getEditorText, getEditorComponent — return "" / undefined
|
|
36
|
+
*
|
|
37
|
+
* Writes blocked:
|
|
38
|
+
* setTheme — returns { success: false, error }
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import type {
|
|
42
|
+
ExtensionUIContext,
|
|
43
|
+
ExtensionWidgetOptions,
|
|
44
|
+
Theme,
|
|
45
|
+
} from "@earendil-works/pi-coding-agent";
|
|
46
|
+
|
|
47
|
+
export interface UIBridgeOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Short name identifying the subagent (e.g. "delegate", "review").
|
|
50
|
+
* Used to prefix dialog titles and notifications, and to namespace
|
|
51
|
+
* status/widget keys so concurrent subagents don't clobber each other.
|
|
52
|
+
*/
|
|
53
|
+
label: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Dialog serialization
|
|
58
|
+
//
|
|
59
|
+
// The parent TUI can only show one modal at a time. Multiple subagents may
|
|
60
|
+
// request dialogs concurrently (parallel tool calls), so all dialog methods
|
|
61
|
+
// across ALL bridge instances funnel through one process-wide queue.
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
let dialogQueue: Promise<unknown> = Promise.resolve();
|
|
65
|
+
|
|
66
|
+
function enqueueDialog<T>(fn: () => Promise<T>): Promise<T> {
|
|
67
|
+
const result = dialogQueue.then(fn, fn);
|
|
68
|
+
// Keep the queue alive even if fn rejects.
|
|
69
|
+
dialogQueue = result.then(
|
|
70
|
+
() => undefined,
|
|
71
|
+
() => undefined,
|
|
72
|
+
);
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Test hook: reset the dialog queue. Not part of the public API. */
|
|
77
|
+
export function _resetDialogQueue(): void {
|
|
78
|
+
dialogQueue = Promise.resolve();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Bridge factory
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
export function createUIBridge(
|
|
86
|
+
parent: ExtensionUIContext,
|
|
87
|
+
options: UIBridgeOptions,
|
|
88
|
+
): ExtensionUIContext {
|
|
89
|
+
const { label } = options;
|
|
90
|
+
const tag = `[${label}]`;
|
|
91
|
+
const titled = (title: string) => `${tag} ${title}`;
|
|
92
|
+
const keyed = (key: string) => `${label}:${key}`;
|
|
93
|
+
|
|
94
|
+
const noop = () => {};
|
|
95
|
+
const noopUnsubscribe = () => noop;
|
|
96
|
+
|
|
97
|
+
const bridge: ExtensionUIContext = {
|
|
98
|
+
// --- Dialogs (serialized, forwarded) ----------------------------------
|
|
99
|
+
|
|
100
|
+
select(title, options_, opts) {
|
|
101
|
+
return enqueueDialog(() => parent.select(titled(title), options_, opts));
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
confirm(title, message, opts) {
|
|
105
|
+
return enqueueDialog(() => parent.confirm(titled(title), message, opts));
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
input(title, placeholder, opts) {
|
|
109
|
+
return enqueueDialog(() => parent.input(titled(title), placeholder, opts));
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
editor(title, prefill) {
|
|
113
|
+
return enqueueDialog(() => parent.editor(titled(title), prefill));
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
// Contextually typed against the generic interface signature; the
|
|
117
|
+
// explicit <T> is inferred from the ExtensionUIContext annotation.
|
|
118
|
+
custom(factory, customOptions) {
|
|
119
|
+
// In-process the factory executes against the parent's real TUI, so
|
|
120
|
+
// custom components work — unlike RPC mode, which degrades to undefined.
|
|
121
|
+
return enqueueDialog(() => parent.custom(factory, customOptions));
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// --- Fire-and-forget (forwarded) ---------------------------------------
|
|
125
|
+
|
|
126
|
+
notify(message, type) {
|
|
127
|
+
parent.notify(`${tag} ${message}`, type);
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
setStatus(key, text) {
|
|
131
|
+
parent.setStatus(keyed(key), text);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
setWidget(
|
|
135
|
+
key: string,
|
|
136
|
+
content: string[] | ((...args: any[]) => any) | undefined,
|
|
137
|
+
widgetOptions?: ExtensionWidgetOptions,
|
|
138
|
+
) {
|
|
139
|
+
parent.setWidget(keyed(key), content as any, widgetOptions);
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// --- Fire-and-forget (dropped) ------------------------------------------
|
|
143
|
+
// These manipulate the parent session's chrome (terminal title, streaming
|
|
144
|
+
// indicator, editor, footer/header). A subagent has no business touching
|
|
145
|
+
// them. Mirrors the RPC-mode degradation contract.
|
|
146
|
+
|
|
147
|
+
setTitle: noop,
|
|
148
|
+
setWorkingMessage: noop,
|
|
149
|
+
setWorkingVisible: noop,
|
|
150
|
+
setWorkingIndicator: noop,
|
|
151
|
+
setHiddenThinkingLabel: noop,
|
|
152
|
+
pasteToEditor: noop,
|
|
153
|
+
setEditorText: noop,
|
|
154
|
+
setEditorComponent: noop,
|
|
155
|
+
setFooter: noop,
|
|
156
|
+
setHeader: noop,
|
|
157
|
+
addAutocompleteProvider: noop,
|
|
158
|
+
setToolsExpanded: noop,
|
|
159
|
+
onTerminalInput: noopUnsubscribe,
|
|
160
|
+
|
|
161
|
+
// --- Reads ---------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
get theme(): Theme {
|
|
164
|
+
return parent.theme;
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
getAllThemes() {
|
|
168
|
+
return parent.getAllThemes();
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
getTheme(name) {
|
|
172
|
+
return parent.getTheme(name);
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
getToolsExpanded() {
|
|
176
|
+
return parent.getToolsExpanded();
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
getEditorText() {
|
|
180
|
+
return "";
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
getEditorComponent() {
|
|
184
|
+
return undefined;
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
// --- Writes blocked -------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
setTheme() {
|
|
190
|
+
return {
|
|
191
|
+
success: false,
|
|
192
|
+
error: "Subagents cannot change the parent session's theme.",
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
return bridge;
|
|
198
|
+
}
|