pi-better-harness 0.20.0 → 0.22.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/README.md +49 -8
- package/extensions/minimal-output/hook.ts +39 -7
- package/extensions/minimal-output/index.ts +34 -11
- package/extensions/settings/index.ts +46 -0
- package/extensions/settings/page.ts +96 -0
- package/extensions/settings/registry.ts +44 -0
- package/node_modules/pi-better-background-tasks/package.json +1 -1
- package/node_modules/pi-better-background-tasks/src/shared-callback-batcher.ts +206 -15
- package/node_modules/pi-better-background-tasks/src/tools.ts +13 -3
- package/node_modules/pi-better-goal/src/index.ts +36 -21
- package/node_modules/pi-better-plan/README.md +3 -1
- package/node_modules/pi-better-plan/package.json +1 -1
- package/node_modules/pi-better-plan/src/plan-presentation.ts +8 -3
- package/node_modules/pi-better-plan/src/workflow-plan.ts +17 -2
- package/node_modules/pi-better-sandbox/README.md +22 -6
- package/node_modules/pi-better-sandbox/index.ts +26 -18
- package/node_modules/pi-better-sandbox/permissions-page.ts +1 -1
- package/node_modules/pi-better-sandbox/shared-task-tools.ts +10 -5
- package/node_modules/pi-better-ssh/README.md +20 -0
- package/node_modules/pi-better-subagents/docs/usage.md +19 -2
- package/node_modules/pi-better-subagents/index.ts +31 -15
- package/node_modules/pi-better-subagents/package.json +1 -1
- package/node_modules/pi-better-subagents/shared-callback-batcher.ts +206 -15
- package/node_modules/pi-better-subagents/shared-task-tools.ts +10 -5
- package/package.json +7 -6
package/README.md
CHANGED
|
@@ -64,14 +64,20 @@ need interactive confirmation. Full policy: [pi-better-sandbox](https://github.c
|
|
|
64
64
|
|
|
65
65
|
## Minimal Tool Output
|
|
66
66
|
|
|
67
|
-
The bundled harness
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
67
|
+
The bundled harness provides **Tool output: Normal / Minimal** in
|
|
68
|
+
`/harness-settings`. `/tool-output minimal`, `/tool-output normal`, and
|
|
69
|
+
`/tool-output` (toggle) remain compatibility shortcuts to the same preference.
|
|
70
|
+
Minimal mode folds each tool call into a single-line header, including running
|
|
71
|
+
calls. Muted tool names and quieter inline command/path context distinguish
|
|
72
|
+
activity from conversation text; running and failed calls retain visible state
|
|
73
|
+
markers. Long headers are truncated to the terminal width; result bodies,
|
|
74
|
+
images, boxes, and tool spacers are hidden. Built-in, extension, and MCP calls
|
|
75
|
+
are included. Execution, sandboxing, and agent-facing payloads are unchanged.
|
|
76
|
+
In newer Pi fullscreen mode, click a folded row to expand its original details,
|
|
77
|
+
then click the expanded call/result to collapse it. Regular terminals keep mouse
|
|
78
|
+
input for terminal selection and scrollback; Ctrl+O expands tools in all modes.
|
|
79
|
+
`/tool-output normal` restores ordinary rendering. Error result bodies are also
|
|
80
|
+
hidden in minimal mode and remain available when expanded.
|
|
75
81
|
|
|
76
82
|
Normal mode is the default. The preference is saved in the current session,
|
|
77
83
|
including resume/reload. This is a version-sensitive internal TUI adapter,
|
|
@@ -86,6 +92,41 @@ Load the bundled harness or run it directly from a checkout:
|
|
|
86
92
|
pi -e ./packages/pi-better-harness/extensions/minimal-output/index.ts
|
|
87
93
|
```
|
|
88
94
|
|
|
95
|
+
## Harness Settings
|
|
96
|
+
|
|
97
|
+
The bundled harness provides `/harness-settings`, using Pi's native settings
|
|
98
|
+
list. It opens the settings screens of loaded Sandbox, Subagents, and Goal
|
|
99
|
+
packages without duplicating their configuration. `/sandbox`, `/subagents
|
|
100
|
+
settings`, and `/goal settings` remain available in standalone installations.
|
|
101
|
+
Packages without a settings screen are not listed. Pi's `/settings` is unchanged.
|
|
102
|
+
|
|
103
|
+
The hub includes **Tool output** when the bundled renderer extension is loaded.
|
|
104
|
+
Changes apply immediately to the current session and do not save a global
|
|
105
|
+
default. The hub also owns **Completions while busy**, shared by Subagents and Background
|
|
106
|
+
Tasks. Choose **Wait until idle** (the default) or **Steer active run**. Changes
|
|
107
|
+
apply immediately and autosave to the current session branch, including across
|
|
108
|
+
reloads. Press **Ctrl+S** to save the current choice as your default for future
|
|
109
|
+
Pi sessions; changing a session afterward does not change that saved default.
|
|
110
|
+
Saving a default leaves already-open sessions unchanged, including when
|
|
111
|
+
navigating to a branch without a session override.
|
|
112
|
+
The user default is stored in
|
|
113
|
+
`<agent-dir>/extensions/pi-better-callback-preferences.json` and also applies
|
|
114
|
+
when either callback package is loaded standalone. The former
|
|
115
|
+
`PI_BETTER_CALLBACK_WHILE_BUSY` environment variable is no longer supported.
|
|
116
|
+
|
|
117
|
+
Next-prompt inference is deferred pending safe public auth/header resolution in
|
|
118
|
+
Pi's SDK. It is not loaded by the bundle, has no toggle or preference store in
|
|
119
|
+
this version, and makes no auxiliary model requests. The integration blocker is
|
|
120
|
+
[issue #426](https://github.com/1aboveio/pi-better-harness/issues/426).
|
|
121
|
+
|
|
122
|
+
The hub is TUI-only. The standalone-package installer does not install
|
|
123
|
+
Harness-only extensions; load the bundle or the settings extension from the
|
|
124
|
+
checkout to use the hub:
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
pi -e ./packages/pi-better-harness/extensions/settings/index.ts
|
|
128
|
+
```
|
|
129
|
+
|
|
89
130
|
## When To Use
|
|
90
131
|
|
|
91
132
|
Use the installer when you want every core extension with standalone package identities. Install an individual package instead when you only need the sandbox, subagents, shell task supervision, synchronous SSH, goal tracking, or plans.
|
|
@@ -4,12 +4,15 @@ import { createRequire } from "node:module";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { stripVTControlCharacters } from "node:util";
|
|
7
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
8
|
|
|
8
9
|
interface ToolComponent {
|
|
9
10
|
toolName: string;
|
|
10
11
|
args: unknown;
|
|
11
12
|
callRendererComponent?: Component;
|
|
12
13
|
expanded: boolean;
|
|
14
|
+
isPartial?: boolean;
|
|
15
|
+
result?: { isError?: boolean };
|
|
13
16
|
showImages: boolean;
|
|
14
17
|
resultRendererComponent?: unknown;
|
|
15
18
|
ui: { requestRender(): void };
|
|
@@ -19,7 +22,10 @@ interface ToolComponent {
|
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
type Method = (this: ToolComponent, ...args: unknown[]) => unknown;
|
|
25
|
+
interface ToolMouseEvent { type: string; button: string; y: number; shift?: boolean; alt?: boolean; ctrl?: boolean }
|
|
26
|
+
type MouseHandler = (this: ToolComponent, event: ToolMouseEvent) => unknown;
|
|
22
27
|
interface ToolPrototype {
|
|
28
|
+
handleMouse?: MouseHandler;
|
|
23
29
|
updateDisplay: Method;
|
|
24
30
|
getResultRenderer: Method;
|
|
25
31
|
getTextOutput: Method;
|
|
@@ -42,7 +48,7 @@ export interface MinimalOutputHook {
|
|
|
42
48
|
const HOOK = Symbol.for("pi-better-harness.minimal-output-hook");
|
|
43
49
|
const emptyResult = () => ({ render: () => [] as string[], invalidate() {} });
|
|
44
50
|
|
|
45
|
-
function compactCall(component: ToolComponent, width: number): string[] {
|
|
51
|
+
function compactCall(component: ToolComponent, width: number, theme?: Theme): string[] {
|
|
46
52
|
if (width <= 0) return [];
|
|
47
53
|
// Render the call alone at a wider width so terminal wrapping becomes truncation.
|
|
48
54
|
const header = component.callRendererComponent?.render(Math.max(4096, width + 1))
|
|
@@ -50,10 +56,24 @@ function compactCall(component: ToolComponent, width: number): string[] {
|
|
|
50
56
|
.find((line) => line.length > 0);
|
|
51
57
|
let text = header;
|
|
52
58
|
if (!text) {
|
|
53
|
-
const args =
|
|
54
|
-
|
|
59
|
+
const args = component.args as Record<string, unknown> | undefined;
|
|
60
|
+
const hint = args?.command ?? args?.path ?? args?.pattern;
|
|
61
|
+
const detail = typeof hint === "string" ? hint.replace(/\r?\n/g, " ") : JSON.stringify(component.args);
|
|
62
|
+
text = `${component.toolName}${detail && detail !== "{}" ? ` ${detail}` : ""}`;
|
|
55
63
|
}
|
|
56
|
-
|
|
64
|
+
const failed = component.result?.isError === true;
|
|
65
|
+
const running = !component.result || component.isPartial;
|
|
66
|
+
const detail = text.startsWith(`${component.toolName} `) ? text.slice(component.toolName.length).trimStart()
|
|
67
|
+
: text === component.toolName ? "" : text;
|
|
68
|
+
const label = component.toolName === "bash" ? "Shell"
|
|
69
|
+
: component.toolName.replace(/_/g, " ").replace(/^./, letter => letter.toUpperCase());
|
|
70
|
+
const marker = failed ? "!" : running ? "~" : ">";
|
|
71
|
+
const suffix = failed ? " (failed)" : running ? " (running)" : "";
|
|
72
|
+
const title = `${marker} ${label}`;
|
|
73
|
+
const styled = theme
|
|
74
|
+
? `${theme.fg(failed ? "error" : "muted", title)}${detail ? ` ${theme.fg("dim", detail)}` : ""}${theme.fg(failed ? "error" : "muted", suffix)}`
|
|
75
|
+
: `${title}${detail ? ` ${detail}` : ""}${suffix}`;
|
|
76
|
+
return [truncateToWidth(styled, width)];
|
|
57
77
|
}
|
|
58
78
|
|
|
59
79
|
function hostRequire(): NodeRequire {
|
|
@@ -104,7 +124,7 @@ function sdkEntry(require: ReturnType<typeof createRequire>): string {
|
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
/** Internal TUI adapter: no tools are replaced and no result content is changed. */
|
|
107
|
-
export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutputHook {
|
|
127
|
+
export function installMinimalOutputHook(prototype: ToolPrototype, getTheme?: () => Theme): MinimalOutputHook {
|
|
108
128
|
for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "setExpanded", "invalidate", "render"] as const) {
|
|
109
129
|
if (typeof prototype[name] !== "function") {
|
|
110
130
|
throw new Error(`Pi's tool display API is incompatible: missing ${name}. Normal output remains enabled.`);
|
|
@@ -117,6 +137,7 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
|
|
|
117
137
|
getResultRenderer: prototype.getResultRenderer,
|
|
118
138
|
getTextOutput: prototype.getTextOutput,
|
|
119
139
|
render: prototype.render,
|
|
140
|
+
...(typeof prototype.handleMouse === "function" ? { handleMouse: prototype.handleMouse } : {}),
|
|
120
141
|
};
|
|
121
142
|
const seen = new WeakSet<ToolComponent>();
|
|
122
143
|
const components = new Set<WeakRef<ToolComponent>>();
|
|
@@ -133,10 +154,11 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
|
|
|
133
154
|
}
|
|
134
155
|
},
|
|
135
156
|
restore() {
|
|
136
|
-
for (const name of
|
|
157
|
+
for (const name of ["updateDisplay", "getResultRenderer", "getTextOutput", "render"] as const) {
|
|
137
158
|
// Do not remove another extension's subsequently installed wrapper.
|
|
138
159
|
if (prototype[name] === wrappers[name]) Object.assign(prototype, { [name]: originals[name] });
|
|
139
160
|
}
|
|
161
|
+
if (originals.handleMouse && prototype.handleMouse === mouseWrapper) prototype.handleMouse = originals.handleMouse;
|
|
140
162
|
delete prototype[HOOK];
|
|
141
163
|
components.clear();
|
|
142
164
|
},
|
|
@@ -164,9 +186,19 @@ export function installMinimalOutputHook(prototype: ToolPrototype): MinimalOutpu
|
|
|
164
186
|
return current.enabled && !this.expanded ? "" : originals.getTextOutput.apply(this, args);
|
|
165
187
|
},
|
|
166
188
|
render(this: ToolComponent, width: number): string[] {
|
|
167
|
-
return current.enabled && !this.expanded ? compactCall(this, width) : originals.render.call(this, width);
|
|
189
|
+
return current.enabled && !this.expanded ? compactCall(this, width, getTheme?.()) : originals.render.call(this, width);
|
|
168
190
|
},
|
|
169
191
|
};
|
|
192
|
+
const mouseWrapper: MouseHandler = function(event) {
|
|
193
|
+
if (current.enabled && !this.expanded) {
|
|
194
|
+
if (event.type !== "click" || event.button !== "left" || event.y !== 0 || event.shift || event.alt || event.ctrl) return undefined;
|
|
195
|
+
this.setExpanded(true);
|
|
196
|
+
this.ui.requestRender();
|
|
197
|
+
return { handled: true, render: true };
|
|
198
|
+
}
|
|
199
|
+
return originals.handleMouse?.call(this, event);
|
|
200
|
+
};
|
|
201
|
+
if (originals.handleMouse) prototype.handleMouse = mouseWrapper;
|
|
170
202
|
Object.assign(prototype, wrappers);
|
|
171
203
|
prototype[HOOK] = state;
|
|
172
204
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { installMinimalOutputHook, loadToolPrototype, type MinimalOutputHook } from "./hook.ts";
|
|
3
|
+
import type { SettingsControl } from "../settings/registry.ts";
|
|
3
4
|
|
|
4
5
|
const ENTRY = "pi-better-harness-tool-output";
|
|
5
6
|
|
|
@@ -24,7 +25,7 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
24
25
|
}
|
|
25
26
|
if (hook) return true;
|
|
26
27
|
try {
|
|
27
|
-
hook = installMinimalOutputHook(await loadToolPrototype());
|
|
28
|
+
hook = installMinimalOutputHook(await loadToolPrototype(), () => ctx.ui.theme);
|
|
28
29
|
return true;
|
|
29
30
|
} catch (error) {
|
|
30
31
|
ctx.ui.notify(`Minimal tool output is unavailable: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
@@ -32,13 +33,13 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
32
33
|
}
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
async function restore(ctx: ExtensionContext): Promise<void> {
|
|
36
37
|
if (ctx.mode !== "tui") return;
|
|
37
38
|
enabled = false;
|
|
38
39
|
for (const entry of ctx.sessionManager.getBranch()) {
|
|
39
40
|
if (entry.type === "custom" && entry.customType === ENTRY) {
|
|
40
|
-
const data = entry.data as { enabled?: unknown } | undefined;
|
|
41
|
-
if (typeof data
|
|
41
|
+
const data = entry.data as { version?: unknown; enabled?: unknown } | undefined;
|
|
42
|
+
if (data?.version === 1 && typeof data.enabled === "boolean") enabled = data.enabled;
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
// Observe transcript components from startup, so the toggle also redraws existing rows.
|
|
@@ -47,9 +48,35 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
47
48
|
hook!.setEnabled(enabled);
|
|
48
49
|
} else enabled = false;
|
|
49
50
|
status(ctx);
|
|
50
|
-
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function change(value: string, ctx: ExtensionContext): Promise<void> {
|
|
54
|
+
if (value !== "Normal" && value !== "Minimal") throw new Error("Invalid tool output mode.");
|
|
55
|
+
if (!await ensureHook(ctx)) throw new Error("Tool output settings are unavailable in this Pi runtime.");
|
|
56
|
+
const data = { version: 1, enabled: value === "Minimal" };
|
|
57
|
+
try { pi.appendEntry(ENTRY, data); }
|
|
58
|
+
catch (error) { data.version = 0; data.enabled = enabled; throw error; }
|
|
59
|
+
enabled = data.enabled;
|
|
60
|
+
if (enabled) collapseTools(ctx);
|
|
61
|
+
hook!.setEnabled(enabled);
|
|
62
|
+
status(ctx);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const setting: SettingsControl = {
|
|
66
|
+
id: "tool-output", label: "Tool output", values: ["Normal", "Minimal"],
|
|
67
|
+
get: () => enabled ? "Minimal" : "Normal", change,
|
|
68
|
+
};
|
|
69
|
+
const register = () => pi.events.emit("harness-settings:register", setting);
|
|
70
|
+
let stopSettings: (() => void) | undefined;
|
|
71
|
+
const subscribe = () => { stopSettings ??= pi.events.on("harness-settings:request", register); };
|
|
72
|
+
subscribe();
|
|
73
|
+
register();
|
|
74
|
+
pi.on("session_start", async (_event, ctx) => { subscribe(); await restore(ctx); register(); });
|
|
75
|
+
pi.on("session_tree", async (_event, ctx) => { await restore(ctx); });
|
|
51
76
|
|
|
52
77
|
pi.on("session_shutdown", () => {
|
|
78
|
+
stopSettings?.();
|
|
79
|
+
stopSettings = undefined;
|
|
53
80
|
hook?.dispose();
|
|
54
81
|
hook = undefined;
|
|
55
82
|
enabled = false;
|
|
@@ -71,12 +98,8 @@ export default function minimalOutputExtension(pi: ExtensionAPI): void {
|
|
|
71
98
|
ctx.ui.notify("Usage: /tool-output [minimal|normal]", "warning");
|
|
72
99
|
return;
|
|
73
100
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (enabled) collapseTools(ctx);
|
|
77
|
-
hook!.setEnabled(enabled);
|
|
78
|
-
pi.appendEntry(ENTRY, { version: 1, enabled });
|
|
79
|
-
status(ctx);
|
|
101
|
+
try { await change((mode ? mode === "minimal" : !enabled) ? "Minimal" : "Normal", ctx); }
|
|
102
|
+
catch (error) { ctx.ui.notify(error instanceof Error ? error.message : String(error), "warning"); return; }
|
|
80
103
|
ctx.ui.notify(enabled
|
|
81
104
|
? "Minimal tool output on. Compact call headers remain visible. Ctrl+O reveals results."
|
|
82
105
|
: "Normal tool output restored.", "info");
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { chooseHarnessSetting } from "./page.ts";
|
|
3
|
+
import { createSettingsRegistry } from "./registry.ts";
|
|
4
|
+
import { changeCallbackSetting, getCallbackSettings, saveCallbackDefault } from "pi-better-background-tasks/src/shared-callback-batcher.ts";
|
|
5
|
+
|
|
6
|
+
export default function harnessSettingsExtension(pi: ExtensionAPI): void {
|
|
7
|
+
const registry = createSettingsRegistry(pi);
|
|
8
|
+
pi.on("session_start", () => registry.refresh());
|
|
9
|
+
pi.on("session_shutdown", () => registry.dispose());
|
|
10
|
+
|
|
11
|
+
pi.registerCommand("harness-settings", {
|
|
12
|
+
description: "Open loaded package settings",
|
|
13
|
+
handler: async (_args, ctx) => {
|
|
14
|
+
if (ctx.mode !== "tui") { ctx.ui.notify("Harness settings requires the interactive TUI.", "warning"); return; }
|
|
15
|
+
registry.refresh();
|
|
16
|
+
let selected: string | undefined;
|
|
17
|
+
const reported = new Set<string>();
|
|
18
|
+
const readSettings = (): ReturnType<typeof getCallbackSettings> => {
|
|
19
|
+
try { return getCallbackSettings(ctx); }
|
|
20
|
+
catch (error) {
|
|
21
|
+
const message = `Callback default unavailable; using Wait until idle: ${error instanceof Error ? error.message : String(error)}`;
|
|
22
|
+
if (!reported.has(message)) { ctx.ui.notify(message, "error"); reported.add(message); }
|
|
23
|
+
return { mode: "hold", source: "default" };
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
while (true) {
|
|
27
|
+
const links = registry.list();
|
|
28
|
+
try {
|
|
29
|
+
selected = await chooseHarnessSetting(ctx, links, selected, {
|
|
30
|
+
get: readSettings,
|
|
31
|
+
change: mode => changeCallbackSetting(pi, ctx, mode),
|
|
32
|
+
save: () => saveCallbackDefault(readSettings().mode),
|
|
33
|
+
}, registry.controls());
|
|
34
|
+
} catch (error) {
|
|
35
|
+
ctx.ui.notify(`Settings unavailable: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (!selected) return;
|
|
39
|
+
const link = links.find(item => `link:${item.id}` === selected);
|
|
40
|
+
if (!link) continue;
|
|
41
|
+
try { await link.open(ctx); }
|
|
42
|
+
catch (error) { ctx.ui.notify(`Settings unavailable: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Container, SettingsList, Text, getKeybindings, setKeybindings, KeybindingsManager, TUI_KEYBINDINGS, matchesKey, type SettingItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import type { SettingsControl, SettingsLink } from "./registry.ts";
|
|
4
|
+
|
|
5
|
+
export interface CallbackSettingsControl {
|
|
6
|
+
get(): { mode: "hold" | "steer"; source: "session" | "default" };
|
|
7
|
+
change(mode: "hold" | "steer"): void;
|
|
8
|
+
save(): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const CALLBACK_SETTING_ID = "callbacks:while-busy";
|
|
12
|
+
const callbackLabel = (mode: "hold" | "steer") => mode === "steer" ? "Steer active run" : "Wait until idle";
|
|
13
|
+
|
|
14
|
+
export async function chooseHarnessSetting(ctx: any, links: SettingsLink[], selected?: string, callbacks?: CallbackSettingsControl, controls: SettingsControl[] = []): Promise<string | undefined> {
|
|
15
|
+
return ctx.ui.custom((tui: any, theme: Theme, _kb: unknown, done: (id?: string) => void) => {
|
|
16
|
+
let callbackMode = callbacks?.get().mode ?? "hold";
|
|
17
|
+
const callbackSetting: SettingItem | undefined = callbacks ? {
|
|
18
|
+
id: CALLBACK_SETTING_ID,
|
|
19
|
+
label: "Completions while busy",
|
|
20
|
+
currentValue: callbackLabel(callbackMode),
|
|
21
|
+
description: callbacks.get().source === "session" ? "Session setting" : "User default",
|
|
22
|
+
values: ["Wait until idle", "Steer active run"],
|
|
23
|
+
} : undefined;
|
|
24
|
+
const items: SettingItem[] = [
|
|
25
|
+
...(callbackSetting ? [callbackSetting] : []),
|
|
26
|
+
...controls.map(control => ({ id: `setting:${control.id}`, label: control.label, currentValue: control.get(), description: "Session setting", values: control.values })),
|
|
27
|
+
...links.map(link => ({ id: `link:${link.id}`, label: link.label, currentValue: link.command, description: "Open package settings", values: [link.command] })),
|
|
28
|
+
];
|
|
29
|
+
const feedback = new Text("", 0, 0);
|
|
30
|
+
let changing = false;
|
|
31
|
+
const report = (message: string, error = false) => {
|
|
32
|
+
feedback.setText(theme.fg(error ? "error" : "muted", message));
|
|
33
|
+
tui.requestRender?.();
|
|
34
|
+
};
|
|
35
|
+
const list = new SettingsList(items, 10, {
|
|
36
|
+
label: (text, active) => theme.fg(active ? "accent" : "text", text),
|
|
37
|
+
value: (text, active) => active ? theme.inverse(text) : theme.fg("muted", text),
|
|
38
|
+
description: text => theme.fg("muted", text), cursor: "> ", hint: text => theme.fg("dim", text),
|
|
39
|
+
}, (id, value) => {
|
|
40
|
+
const control = controls.find(item => `setting:${item.id}` === id);
|
|
41
|
+
if (control) {
|
|
42
|
+
const previous = control.get();
|
|
43
|
+
changing = true;
|
|
44
|
+
report("Saving session setting...");
|
|
45
|
+
Promise.resolve().then(() => control.change(value, ctx)).then(() => {
|
|
46
|
+
list.updateValue(id, control.get());
|
|
47
|
+
report("Session setting saved.");
|
|
48
|
+
}).catch(error => {
|
|
49
|
+
list.updateValue(id, previous);
|
|
50
|
+
report(error instanceof Error ? error.message : String(error), true);
|
|
51
|
+
}).finally(() => { changing = false; tui.requestRender?.(); });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (id !== CALLBACK_SETTING_ID || !callbacks || !callbackSetting) { done(id); return; }
|
|
55
|
+
try {
|
|
56
|
+
const nextMode = value === "Steer active run" ? "steer" : "hold";
|
|
57
|
+
callbacks.change(nextMode);
|
|
58
|
+
callbackMode = nextMode;
|
|
59
|
+
callbackSetting.description = "Session setting";
|
|
60
|
+
report("Session setting saved.");
|
|
61
|
+
} catch (error) {
|
|
62
|
+
list.updateValue(CALLBACK_SETTING_ID, callbackLabel(callbackMode));
|
|
63
|
+
report(error instanceof Error ? error.message : String(error), true);
|
|
64
|
+
}
|
|
65
|
+
}, () => done(), { enableSearch: false });
|
|
66
|
+
|
|
67
|
+
const index = selected ? items.findIndex(item => item.id === selected) : 0;
|
|
68
|
+
if (index > 0) {
|
|
69
|
+
// SettingsList has no public selection setter. Restore synchronously using
|
|
70
|
+
// default navigation, then return the untouched user bindings before input.
|
|
71
|
+
const bindings = getKeybindings();
|
|
72
|
+
try {
|
|
73
|
+
setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS));
|
|
74
|
+
for (let i = 0; i < index; i++) list.handleInput("\x1b[B");
|
|
75
|
+
} finally { setKeybindings(bindings); }
|
|
76
|
+
}
|
|
77
|
+
const page = new Container();
|
|
78
|
+
page.addChild(new Text(theme.fg("accent", theme.bold("Harness settings")), 0, 0));
|
|
79
|
+
page.addChild(list);
|
|
80
|
+
if (callbacks) page.addChild(new Text(theme.fg("dim", "Callbacks autosave to this session. Ctrl+S saves the default for future sessions."), 0, 0));
|
|
81
|
+
page.addChild(feedback);
|
|
82
|
+
return {
|
|
83
|
+
render: (width: number) => page.render(width),
|
|
84
|
+
invalidate: () => page.invalidate(),
|
|
85
|
+
handleInput(data: string) {
|
|
86
|
+
if (changing) return;
|
|
87
|
+
if (callbacks && matchesKey(data, "ctrl+s")) {
|
|
88
|
+
try { callbacks.save(); report("Callback default saved."); }
|
|
89
|
+
catch (error) { report(error instanceof Error ? error.message : String(error), true); }
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
list.handleInput(data);
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export interface SettingsLink {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
command: string;
|
|
7
|
+
open(ctx: ExtensionCommandContext): Promise<void> | void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface SettingsControl {
|
|
11
|
+
id: string;
|
|
12
|
+
label: string;
|
|
13
|
+
values: string[];
|
|
14
|
+
get(): string;
|
|
15
|
+
change(value: string, ctx: ExtensionCommandContext): Promise<void> | void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createSettingsRegistry(pi: ExtensionAPI) {
|
|
19
|
+
const links = new Map<string, SettingsLink>();
|
|
20
|
+
const controls = new Map<string, SettingsControl>();
|
|
21
|
+
const conflicts = new Set<string>();
|
|
22
|
+
const stop = pi.events.on("harness-settings:register", (candidate: unknown) => {
|
|
23
|
+
const link = candidate as Partial<SettingsLink & SettingsControl> | null;
|
|
24
|
+
if (!link || !/^[a-z][a-z0-9-]*$/.test(link.id ?? "") || typeof link.label !== "string") return;
|
|
25
|
+
const control = Array.isArray(link.values) && link.values.length > 1 && link.values.every(value => typeof value === "string")
|
|
26
|
+
&& typeof link.get === "function" && typeof link.change === "function";
|
|
27
|
+
if (!control && (typeof link.command !== "string" || !link.command.startsWith("/") || typeof link.open !== "function")) return;
|
|
28
|
+
if (conflicts.has(link.id!)) return;
|
|
29
|
+
const prior = links.get(link.id!);
|
|
30
|
+
const priorControl = controls.get(link.id!);
|
|
31
|
+
if ((prior && (control || prior.open !== link.open)) ||
|
|
32
|
+
(priorControl && (!control || priorControl.change !== link.change || priorControl.get !== link.get))) {
|
|
33
|
+
links.delete(link.id!); controls.delete(link.id!); conflicts.add(link.id!); return;
|
|
34
|
+
}
|
|
35
|
+
if (control) controls.set(link.id!, link as SettingsControl);
|
|
36
|
+
else links.set(link.id!, link as SettingsLink);
|
|
37
|
+
});
|
|
38
|
+
return {
|
|
39
|
+
refresh() { links.clear(); controls.clear(); conflicts.clear(); pi.events.emit("harness-settings:request", undefined); },
|
|
40
|
+
list() { return [...links.values()].sort((a, b) => a.label.localeCompare(b.label)); },
|
|
41
|
+
controls() { return [...controls.values()].sort((a, b) => a.label.localeCompare(b.label)); },
|
|
42
|
+
dispose() { stop(); links.clear(); controls.clear(); conflicts.clear(); },
|
|
43
|
+
};
|
|
44
|
+
}
|