pi-chassis 0.0.0-stage → 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/CHANGELOG.md +118 -0
- package/LICENSE +21 -0
- package/README.md +51 -2
- package/THIRD_PARTY_NOTICES +30 -0
- package/dist/chassis/alert.d.ts +50 -0
- package/dist/chassis/alert.js +107 -0
- package/dist/chassis/api.d.ts +411 -0
- package/dist/chassis/api.js +676 -0
- package/dist/chassis/bash-output.d.ts +89 -0
- package/dist/chassis/bash-output.js +157 -0
- package/dist/chassis/brand.d.ts +99 -0
- package/dist/chassis/brand.js +79 -0
- package/dist/chassis/client.d.ts +87 -0
- package/dist/chassis/client.js +271 -0
- package/dist/chassis/commands.d.ts +60 -0
- package/dist/chassis/commands.js +211 -0
- package/dist/chassis/editor.d.ts +70 -0
- package/dist/chassis/editor.js +213 -0
- package/dist/chassis/footer.d.ts +14 -0
- package/dist/chassis/footer.js +136 -0
- package/dist/chassis/header.d.ts +12 -0
- package/dist/chassis/header.js +38 -0
- package/dist/chassis/home.d.ts +48 -0
- package/dist/chassis/home.js +184 -0
- package/dist/chassis/icons.d.ts +203 -0
- package/dist/chassis/icons.js +190 -0
- package/dist/chassis/index.d.ts +21 -0
- package/dist/chassis/index.js +582 -0
- package/dist/chassis/layout.d.ts +40 -0
- package/dist/chassis/layout.js +264 -0
- package/dist/chassis/message-modes.d.ts +21 -0
- package/dist/chassis/message-modes.js +49 -0
- package/dist/chassis/messages.d.ts +75 -0
- package/dist/chassis/messages.js +392 -0
- package/dist/chassis/modes.d.ts +21 -0
- package/dist/chassis/modes.js +99 -0
- package/dist/chassis/palette.d.ts +84 -0
- package/dist/chassis/palette.js +235 -0
- package/dist/chassis/peek.d.ts +61 -0
- package/dist/chassis/peek.js +156 -0
- package/dist/chassis/prompt-row.d.ts +42 -0
- package/dist/chassis/prompt-row.js +68 -0
- package/dist/chassis/provider-view.d.ts +34 -0
- package/dist/chassis/provider-view.js +107 -0
- package/dist/chassis/provider.d.ts +69 -0
- package/dist/chassis/provider.js +73 -0
- package/dist/chassis/readonly-command.d.ts +19 -0
- package/dist/chassis/readonly-command.js +454 -0
- package/dist/chassis/sanitize.d.ts +42 -0
- package/dist/chassis/sanitize.js +103 -0
- package/dist/chassis/session-info.d.ts +39 -0
- package/dist/chassis/session-info.js +107 -0
- package/dist/chassis/sidebar.d.ts +48 -0
- package/dist/chassis/sidebar.js +273 -0
- package/dist/chassis/state.d.ts +38 -0
- package/dist/chassis/state.js +19 -0
- package/dist/chassis/telemetry.d.ts +60 -0
- package/dist/chassis/telemetry.js +191 -0
- package/dist/chassis/tools.d.ts +112 -0
- package/dist/chassis/tools.js +588 -0
- package/dist/chassis/version.d.ts +2 -0
- package/dist/chassis/version.js +20 -0
- package/dist/diff/git-diff.d.ts +71 -0
- package/dist/diff/git-diff.js +325 -0
- package/dist/diff/host.d.ts +34 -0
- package/dist/diff/host.js +50 -0
- package/dist/diff/index.d.ts +13 -0
- package/dist/diff/index.js +86 -0
- package/dist/diff/render.d.ts +67 -0
- package/dist/diff/render.js +196 -0
- package/dist/diff/types.d.ts +60 -0
- package/dist/diff/types.js +4 -0
- package/dist/diff/view.d.ts +118 -0
- package/dist/diff/view.js +528 -0
- package/dist/git/flows.d.ts +92 -0
- package/dist/git/flows.js +415 -0
- package/dist/git/forge.d.ts +26 -0
- package/dist/git/forge.js +41 -0
- package/dist/git/index.d.ts +20 -0
- package/dist/git/index.js +121 -0
- package/dist/git/repo.d.ts +64 -0
- package/dist/git/repo.js +186 -0
- package/dist/git/run.d.ts +38 -0
- package/dist/git/run.js +127 -0
- package/dist/git/settings.d.ts +8 -0
- package/dist/git/settings.js +38 -0
- package/dist/git/text.d.ts +51 -0
- package/dist/git/text.js +109 -0
- package/dist/widgets/container/detect.d.ts +23 -0
- package/dist/widgets/container/detect.js +67 -0
- package/dist/widgets/container/index.d.ts +9 -0
- package/dist/widgets/container/index.js +26 -0
- package/dist/widgets/mcp/index.d.ts +13 -0
- package/dist/widgets/mcp/index.js +50 -0
- package/dist/widgets/mcp/status.d.ts +50 -0
- package/dist/widgets/mcp/status.js +70 -0
- package/dist/widgets/session-todos/index.d.ts +12 -0
- package/dist/widgets/session-todos/index.js +52 -0
- package/dist/widgets/subagents/index.d.ts +16 -0
- package/dist/widgets/subagents/index.js +173 -0
- package/dist/widgets/subagents/status.d.ts +61 -0
- package/dist/widgets/subagents/status.js +198 -0
- package/dist/widgets/todo/index.d.ts +13 -0
- package/dist/widgets/todo/index.js +65 -0
- package/dist/widgets/todo/parse.d.ts +29 -0
- package/dist/widgets/todo/parse.js +122 -0
- package/docs/branding.md +25 -0
- package/docs/development.md +22 -0
- package/docs/diff-api.md +18 -0
- package/docs/git.md +23 -0
- package/docs/internals.md +13 -0
- package/docs/provider-status.md +23 -0
- package/docs/status-widgets.md +157 -0
- package/docs/tool-rows.md +50 -0
- package/docs/usage.md +62 -0
- package/docs/widgets.md +113 -0
- package/extensions/chassis/alert.ts +127 -0
- package/extensions/chassis/api.ts +967 -0
- package/extensions/chassis/bash-output.ts +186 -0
- package/extensions/chassis/brand.ts +151 -0
- package/extensions/chassis/client.ts +284 -0
- package/extensions/chassis/commands.ts +244 -0
- package/extensions/chassis/editor.ts +253 -0
- package/extensions/chassis/footer.ts +131 -0
- package/extensions/chassis/header.ts +43 -0
- package/extensions/chassis/home.ts +186 -0
- package/extensions/chassis/icons.ts +220 -0
- package/extensions/chassis/index.ts +605 -0
- package/extensions/chassis/layout.ts +298 -0
- package/extensions/chassis/message-modes.ts +48 -0
- package/extensions/chassis/messages.ts +403 -0
- package/extensions/chassis/modes.ts +108 -0
- package/extensions/chassis/palette.ts +258 -0
- package/extensions/chassis/peek.ts +167 -0
- package/extensions/chassis/prompt-row.ts +86 -0
- package/extensions/chassis/provider-view.ts +118 -0
- package/extensions/chassis/provider.ts +115 -0
- package/extensions/chassis/readonly-command.ts +440 -0
- package/extensions/chassis/sanitize.ts +108 -0
- package/extensions/chassis/session-info.ts +127 -0
- package/extensions/chassis/sidebar.ts +285 -0
- package/extensions/chassis/state.ts +50 -0
- package/extensions/chassis/telemetry.ts +222 -0
- package/extensions/chassis/tools.ts +618 -0
- package/extensions/chassis/version.ts +20 -0
- package/extensions/diff/git-diff.ts +365 -0
- package/extensions/diff/host.ts +58 -0
- package/extensions/diff/index.ts +101 -0
- package/extensions/diff/render.ts +217 -0
- package/extensions/diff/types.ts +67 -0
- package/extensions/diff/view.ts +523 -0
- package/extensions/git/flows.ts +485 -0
- package/extensions/git/forge.ts +54 -0
- package/extensions/git/index.ts +117 -0
- package/extensions/git/repo.ts +200 -0
- package/extensions/git/run.ts +143 -0
- package/extensions/git/settings.ts +38 -0
- package/extensions/git/text.ts +133 -0
- package/extensions/widgets/container/detect.ts +81 -0
- package/extensions/widgets/container/index.ts +29 -0
- package/extensions/widgets/mcp/index.ts +53 -0
- package/extensions/widgets/mcp/status.ts +94 -0
- package/extensions/widgets/session-todos/index.ts +55 -0
- package/extensions/widgets/subagents/index.ts +170 -0
- package/extensions/widgets/subagents/status.ts +237 -0
- package/extensions/widgets/todo/index.ts +66 -0
- package/extensions/widgets/todo/parse.ts +119 -0
- package/package.json +91 -4
- package/scripts/link-pi.mjs +76 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Widget client: the entry point for widgets that live in other extensions or packages.
|
|
3
|
+
*
|
|
4
|
+
* import { registerWidget } from "pi-chassis/client";
|
|
5
|
+
*
|
|
6
|
+
* registerWidget(pi, { id: "git", render: () => ({ text: "main ●2" }) });
|
|
7
|
+
*
|
|
8
|
+
* Same spec and handle as api.ts, but it never loads the registry code: it only looks for the
|
|
9
|
+
* host on globalThis (the Symbol.for keys below), so it works whichever extension loads first
|
|
10
|
+
* and it works without chassis installed:
|
|
11
|
+
*
|
|
12
|
+
* - chassis already loaded: the widget is registered with it straight away.
|
|
13
|
+
* - chassis loads later: the widget is queued and chassis registers it when it loads.
|
|
14
|
+
* - chassis is not installed (checked at session_start, when every extension has loaded): the
|
|
15
|
+
* widget falls back to ctx.ui.setStatus(id, "<label> <text>"), updated on `refreshMs`. Only
|
|
16
|
+
* the one-line view is shown there: no detail lines, colours or caching.
|
|
17
|
+
*
|
|
18
|
+
* Import it as "pi-chassis/client" (the package's `exports` map), which resolves to compiled
|
|
19
|
+
* JavaScript with type declarations (dist/), so a consumer's plain Node tests and tsc need no
|
|
20
|
+
* TypeScript loader. pi itself loads this package's extensions from the .ts sources. It loads
|
|
21
|
+
* two value modules, `./brand.ts` and `./provider.ts`, and `../diff/host.ts` for the diff view API,
|
|
22
|
+
* and the rest of what it imports is types. To vendor it, copy those four files and keep the
|
|
23
|
+
* `chassis/` and `diff/` folder layout, or import it from the package instead. Any one of the
|
|
24
|
+
* three missing fails the load. Those modules import only types themselves, so they add no
|
|
25
|
+
* further files.
|
|
26
|
+
*/
|
|
27
|
+
import { setBrand } from "./brand.js";
|
|
28
|
+
import { setProviderStatus } from "./provider.js";
|
|
29
|
+
/** The registry API version this client was written for. */
|
|
30
|
+
export const CLIENT_API_VERSION = 1;
|
|
31
|
+
/** The tool row API version this client was written for (`toolRowsVersion` on the registry). */
|
|
32
|
+
export const CLIENT_TOOL_ROWS_VERSION = 1;
|
|
33
|
+
const REGISTRY = Symbol.for("pi-chassis/registry");
|
|
34
|
+
const PENDING = Symbol.for("pi-chassis/pending");
|
|
35
|
+
const PENDING_TOOL_ROWS = Symbol.for("pi-chassis/pendingToolRows");
|
|
36
|
+
const DEFAULT_UPDATE_TIMEOUT_MS = 10_000;
|
|
37
|
+
/** The host registry, if chassis has loaded and speaks an API version this client understands. */
|
|
38
|
+
function findHost() {
|
|
39
|
+
const reg = globalThis[REGISTRY];
|
|
40
|
+
if (!reg?.register || typeof reg.apiVersion !== "number" || reg.apiVersion < CLIENT_API_VERSION)
|
|
41
|
+
return undefined;
|
|
42
|
+
return { register: reg.register };
|
|
43
|
+
}
|
|
44
|
+
function pending() {
|
|
45
|
+
const g = globalThis;
|
|
46
|
+
g[PENDING] ??= [];
|
|
47
|
+
return g[PENDING];
|
|
48
|
+
}
|
|
49
|
+
/** Plain-pi mode: show the widget as a setStatus() line. */
|
|
50
|
+
function startFallback(spec, ctx) {
|
|
51
|
+
const timeoutMs = spec.updateTimeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS;
|
|
52
|
+
let stopped = false;
|
|
53
|
+
let inflight = false;
|
|
54
|
+
const setStatus = (text) => {
|
|
55
|
+
try {
|
|
56
|
+
ctx.ui.setStatus(spec.id, text);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// stale ctx after a session switch
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
const paint = () => {
|
|
63
|
+
if (stopped)
|
|
64
|
+
return;
|
|
65
|
+
try {
|
|
66
|
+
const view = spec.render({ theme: ctx.ui.theme, ctx, width: 80 });
|
|
67
|
+
// Without chassis there is no icon set to ask, so an icon pair gives its plain side.
|
|
68
|
+
const glyph = typeof view?.icon === "object" ? view.icon.plain : view?.icon;
|
|
69
|
+
if (!view || (!view.text && !glyph))
|
|
70
|
+
return setStatus(undefined);
|
|
71
|
+
setStatus([glyph, view.label ?? spec.title ?? spec.id, view.text].filter(Boolean).join(" "));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
setStatus(`${spec.title ?? spec.id} error`);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
const refresh = async () => {
|
|
78
|
+
if (stopped || inflight)
|
|
79
|
+
return;
|
|
80
|
+
inflight = true;
|
|
81
|
+
let timer;
|
|
82
|
+
try {
|
|
83
|
+
if (spec.update) {
|
|
84
|
+
const timeout = new Promise((_, reject) => {
|
|
85
|
+
timer = setTimeout(() => reject(new Error("update timed out")), timeoutMs);
|
|
86
|
+
timer.unref?.();
|
|
87
|
+
});
|
|
88
|
+
await Promise.race([Promise.resolve(spec.update(ctx)), timeout]);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
// keep showing the last state
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (timer)
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
inflight = false;
|
|
98
|
+
paint();
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
void refresh();
|
|
102
|
+
const interval = spec.refreshMs ? setInterval(() => void refresh(), spec.refreshMs) : undefined;
|
|
103
|
+
interval?.unref?.();
|
|
104
|
+
return {
|
|
105
|
+
refresh: () => void refresh(),
|
|
106
|
+
stop: () => {
|
|
107
|
+
stopped = true;
|
|
108
|
+
if (interval)
|
|
109
|
+
clearInterval(interval);
|
|
110
|
+
setStatus(undefined);
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Register (or replace) a widget with chassis if present, else show it as a plain status.
|
|
116
|
+
*
|
|
117
|
+
* Call it from the extension's default export. If you call it from inside a `session_start`
|
|
118
|
+
* handler instead (to be sure every extension has loaded), pass that handler's `ctx`: the widget
|
|
119
|
+
* missed that session's `session_start` event, and `ctx` lets chassis run its first `update` and
|
|
120
|
+
* start its timer now. A call made there when chassis is not installed shows nothing.
|
|
121
|
+
*/
|
|
122
|
+
export function registerWidget(pi, spec, ctx) {
|
|
123
|
+
let real;
|
|
124
|
+
let fallback;
|
|
125
|
+
let disposed = false;
|
|
126
|
+
const offs = [];
|
|
127
|
+
const unlisten = () => {
|
|
128
|
+
for (const off of offs.splice(0)) {
|
|
129
|
+
try {
|
|
130
|
+
off();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// the pi instance may already be gone
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
const listen = (off) => {
|
|
138
|
+
if (typeof off === "function")
|
|
139
|
+
offs.push(off);
|
|
140
|
+
};
|
|
141
|
+
const handle = {
|
|
142
|
+
refresh: () => (real ? real.refresh() : fallback?.refresh()),
|
|
143
|
+
dispose: () => {
|
|
144
|
+
disposed = true;
|
|
145
|
+
real?.dispose();
|
|
146
|
+
fallback?.stop();
|
|
147
|
+
fallback = undefined;
|
|
148
|
+
unlisten();
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
const host = findHost();
|
|
152
|
+
if (host) {
|
|
153
|
+
real = host.register(pi, spec, ctx);
|
|
154
|
+
return handle;
|
|
155
|
+
}
|
|
156
|
+
// Host not loaded yet (or absent). Queue for it; if it never shows up, fall back at session_start.
|
|
157
|
+
const entry = {
|
|
158
|
+
pi,
|
|
159
|
+
spec,
|
|
160
|
+
attach: (h) => {
|
|
161
|
+
real = h;
|
|
162
|
+
fallback?.stop();
|
|
163
|
+
fallback = undefined;
|
|
164
|
+
unlisten(); // the host owns the lifecycle now
|
|
165
|
+
if (disposed)
|
|
166
|
+
h.dispose();
|
|
167
|
+
},
|
|
168
|
+
};
|
|
169
|
+
pending().push(entry);
|
|
170
|
+
listen(pi.on("session_start", async (_e, ctx) => {
|
|
171
|
+
if (real || disposed)
|
|
172
|
+
return; // chassis took it
|
|
173
|
+
const list = pending();
|
|
174
|
+
const i = list.indexOf(entry);
|
|
175
|
+
if (i >= 0)
|
|
176
|
+
list.splice(i, 1);
|
|
177
|
+
fallback?.stop();
|
|
178
|
+
fallback = startFallback(spec, ctx);
|
|
179
|
+
}));
|
|
180
|
+
listen(pi.on("session_shutdown", async () => {
|
|
181
|
+
fallback?.stop();
|
|
182
|
+
fallback = undefined;
|
|
183
|
+
}));
|
|
184
|
+
return handle;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Draw the rows of tools that belong to your extension the way chassis draws its built-in tool rows:
|
|
188
|
+
* one line per call, an icon, a title, a short description and a right-aligned outcome, with an
|
|
189
|
+
* expanded view on ctrl+o. Keyed by tool name. Returns a function that removes the rows again.
|
|
190
|
+
*
|
|
191
|
+
* registerToolRows({
|
|
192
|
+
* deploy_status: {
|
|
193
|
+
* icon: { nerd: "\u{F0AD}", plain: "#" },
|
|
194
|
+
* title: "Deploy",
|
|
195
|
+
* describe: (args, _cwd, theme) => theme.fg("muted", String(args.env)),
|
|
196
|
+
* summarize: (result) => String(result.details?.state ?? "done"),
|
|
197
|
+
* },
|
|
198
|
+
* });
|
|
199
|
+
*
|
|
200
|
+
* It works whichever package loads first (rows registered before chassis loads are queued and taken
|
|
201
|
+
* over when it does) and does nothing when chassis is not installed: the tools keep their own rows.
|
|
202
|
+
* chassis draws the built-in tools (read, write, edit, bash, grep, find, ls) itself and ignores a spec
|
|
203
|
+
* for those names. See ToolRowSpec in api.ts for what a spec may return.
|
|
204
|
+
*
|
|
205
|
+
* Pass `{ id }` if this can run more than once in a process (on every session_start, or after
|
|
206
|
+
* /reload): registering again with the same id replaces the earlier rows instead of adding more.
|
|
207
|
+
* Without an id, call the returned function (on session_shutdown, say) before registering again.
|
|
208
|
+
*/
|
|
209
|
+
export function registerToolRows(specs, options) {
|
|
210
|
+
let undo;
|
|
211
|
+
let removed = false;
|
|
212
|
+
const reg = globalThis[REGISTRY];
|
|
213
|
+
if (typeof reg?.registerToolRows === "function" && typeof reg.toolRowsVersion === "number" && reg.toolRowsVersion >= CLIENT_TOOL_ROWS_VERSION) {
|
|
214
|
+
undo = reg.registerToolRows(specs, options);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
const g = globalThis;
|
|
218
|
+
const entry = {
|
|
219
|
+
specs,
|
|
220
|
+
id: options?.id,
|
|
221
|
+
attach: (unregister) => {
|
|
222
|
+
undo = unregister;
|
|
223
|
+
if (removed)
|
|
224
|
+
unregister();
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
(g[PENDING_TOOL_ROWS] ??= []).push(entry);
|
|
228
|
+
undo = () => {
|
|
229
|
+
const list = g[PENDING_TOOL_ROWS];
|
|
230
|
+
const i = list ? list.indexOf(entry) : -1;
|
|
231
|
+
if (i >= 0)
|
|
232
|
+
list.splice(i, 1);
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return () => {
|
|
236
|
+
if (removed)
|
|
237
|
+
return;
|
|
238
|
+
removed = true;
|
|
239
|
+
undo?.();
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Layer a brand (name, glyph, tagline, startup art, colours) on top of chassis. Call it from an
|
|
244
|
+
* extension's default export; it works whichever package loads first, and chassis repaints. The
|
|
245
|
+
* returned function restores the previous brand. See brand.ts for the fields.
|
|
246
|
+
*/
|
|
247
|
+
export function registerBrand(spec) {
|
|
248
|
+
const undo = setBrand(spec);
|
|
249
|
+
requestRender();
|
|
250
|
+
return () => {
|
|
251
|
+
undo();
|
|
252
|
+
requestRender();
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Add a provider's own facts (a plan or quota bar, a display name, a glyph colour) to the sidebar's
|
|
257
|
+
* provider section and the footer. See provider.ts. The returned function removes it.
|
|
258
|
+
*/
|
|
259
|
+
export function registerProviderStatus(spec) {
|
|
260
|
+
const undo = setProviderStatus(spec);
|
|
261
|
+
requestRender();
|
|
262
|
+
return () => {
|
|
263
|
+
undo();
|
|
264
|
+
requestRender();
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
/** Ask chassis to repaint now (no-op when it isn't loaded). For state a widget changes outside `update`. */
|
|
268
|
+
export function requestRender() {
|
|
269
|
+
globalThis[REGISTRY]?.requestRender?.();
|
|
270
|
+
}
|
|
271
|
+
export { addDiffAction, openChanges, openDiff } from "../diff/host.js";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ctrl+X leader actions and the Ctrl+P command palette (OpenCode keybinds).
|
|
3
|
+
*
|
|
4
|
+
* Leader (ctrl+x, then):
|
|
5
|
+
* n new session l sessions (resume) r resume last m model
|
|
6
|
+
* t theme b toggle sidebar d diff view a toggle Build/Plan
|
|
7
|
+
* c compact x export g tree y copy last msg
|
|
8
|
+
* s status (/session) h hotkeys q quit ? leader help
|
|
9
|
+
*
|
|
10
|
+
* Built-in pi commands (/new, /model, ...) are only reachable by submitting them through the
|
|
11
|
+
* editor, so leader actions that map to a slash command call the editor's onSubmit, which is
|
|
12
|
+
* pi's own command dispatcher.
|
|
13
|
+
*/
|
|
14
|
+
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { type Component, type Focusable } from "@earendil-works/pi-tui";
|
|
16
|
+
export interface LeaderAction {
|
|
17
|
+
key: string;
|
|
18
|
+
label: string;
|
|
19
|
+
/** Slash command submitted through pi's dispatcher, or a function. */
|
|
20
|
+
run: string | (() => void);
|
|
21
|
+
}
|
|
22
|
+
export declare function leaderActions(hooks: {
|
|
23
|
+
toggleSidebar: () => void;
|
|
24
|
+
cycleMode: () => void;
|
|
25
|
+
showHelp: () => void;
|
|
26
|
+
}): LeaderAction[];
|
|
27
|
+
export interface PaletteItem {
|
|
28
|
+
value: string;
|
|
29
|
+
label: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
/** Right-aligned hint, e.g. the key binding. */
|
|
32
|
+
hint?: string;
|
|
33
|
+
}
|
|
34
|
+
/** All slash commands for the palette: built-ins, extension commands, prompts, skills. */
|
|
35
|
+
export declare function paletteItems(pi: ExtensionAPI, builtins: ReadonlyArray<{
|
|
36
|
+
name: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
}>, leader: LeaderAction[], widgetItems?: PaletteItem[]): PaletteItem[];
|
|
39
|
+
/** Fuzzy-ish match: every query character in order, scored by compactness and prefix. */
|
|
40
|
+
export declare function fuzzyScore(query: string, text: string): number | undefined;
|
|
41
|
+
export declare function filterPalette(items: PaletteItem[], query: string): PaletteItem[];
|
|
42
|
+
/** Centered command palette: a search input over a filtered list. */
|
|
43
|
+
export declare class CommandPalette implements Component, Focusable {
|
|
44
|
+
focused: boolean;
|
|
45
|
+
private readonly input;
|
|
46
|
+
private readonly items;
|
|
47
|
+
private readonly theme;
|
|
48
|
+
private readonly done;
|
|
49
|
+
private readonly maxVisible;
|
|
50
|
+
private list;
|
|
51
|
+
private filtered;
|
|
52
|
+
constructor(items: PaletteItem[], theme: Theme, done: (value: string | undefined) => void, maxVisible?: number);
|
|
53
|
+
private makeList;
|
|
54
|
+
invalidate(): void;
|
|
55
|
+
handleInput(data: string): void;
|
|
56
|
+
render(width: number): string[];
|
|
57
|
+
}
|
|
58
|
+
/** Leader help overlay contents. */
|
|
59
|
+
export declare function leaderHelpLines(actions: LeaderAction[], theme: Theme): string[];
|
|
60
|
+
export declare function runPaletteValue(value: string, ctx: ExtensionContext, actions: LeaderAction[], submit: (text: string) => void): void;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ctrl+X leader actions and the Ctrl+P command palette (OpenCode keybinds).
|
|
3
|
+
*
|
|
4
|
+
* Leader (ctrl+x, then):
|
|
5
|
+
* n new session l sessions (resume) r resume last m model
|
|
6
|
+
* t theme b toggle sidebar d diff view a toggle Build/Plan
|
|
7
|
+
* c compact x export g tree y copy last msg
|
|
8
|
+
* s status (/session) h hotkeys q quit ? leader help
|
|
9
|
+
*
|
|
10
|
+
* Built-in pi commands (/new, /model, ...) are only reachable by submitting them through the
|
|
11
|
+
* editor, so leader actions that map to a slash command call the editor's onSubmit, which is
|
|
12
|
+
* pi's own command dispatcher.
|
|
13
|
+
*/
|
|
14
|
+
import { Input, matchesKey, SelectList, truncateToWidth, visibleWidth, } from "@earendil-works/pi-tui";
|
|
15
|
+
import { parseWidgetActionValue, runWidgetAction } from "./api.js";
|
|
16
|
+
import { getBrand } from "./brand.js";
|
|
17
|
+
import { safeGlyph } from "./icons.js";
|
|
18
|
+
import { fit, primary } from "./palette.js";
|
|
19
|
+
export function leaderActions(hooks) {
|
|
20
|
+
return [
|
|
21
|
+
{ key: "n", label: "New session", run: "/new" },
|
|
22
|
+
{ key: "l", label: "Sessions", run: "/resume" },
|
|
23
|
+
{ key: "r", label: "Resume last session", run: "/chassis-resume-last" },
|
|
24
|
+
{ key: "m", label: "Models", run: "/model" },
|
|
25
|
+
{ key: "t", label: "Themes", run: "/settings" },
|
|
26
|
+
{ key: "b", label: "Toggle sidebar", run: hooks.toggleSidebar },
|
|
27
|
+
{ key: "d", label: "Diff view", run: "/diff" },
|
|
28
|
+
{ key: "a", label: "Switch Build/Plan", run: hooks.cycleMode },
|
|
29
|
+
{ key: "c", label: "Compact session", run: "/compact" },
|
|
30
|
+
{ key: "x", label: "Export session", run: "/export" },
|
|
31
|
+
{ key: "g", label: "Session tree", run: "/tree" },
|
|
32
|
+
{ key: "y", label: "Copy last message", run: "/copy" },
|
|
33
|
+
{ key: "s", label: "Session status", run: "/session" },
|
|
34
|
+
{ key: "h", label: "Hotkeys", run: "/hotkeys" },
|
|
35
|
+
{ key: "q", label: "Quit", run: "/quit" },
|
|
36
|
+
{ key: "?", label: "Leader help", run: hooks.showHelp },
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
/** All slash commands for the palette: built-ins, extension commands, prompts, skills. */
|
|
40
|
+
export function paletteItems(pi, builtins, leader, widgetItems = []) {
|
|
41
|
+
const byName = new Map();
|
|
42
|
+
const leaderHint = new Map();
|
|
43
|
+
for (const a of leader)
|
|
44
|
+
if (typeof a.run === "string")
|
|
45
|
+
leaderHint.set(a.run.slice(1), `ctrl+x ${a.key}`);
|
|
46
|
+
for (const c of builtins)
|
|
47
|
+
byName.set(c.name, { value: `/${c.name}`, label: `/${c.name}`, description: c.description, hint: leaderHint.get(c.name) });
|
|
48
|
+
for (const c of pi.getCommands()) {
|
|
49
|
+
if (!c.name || byName.has(c.name))
|
|
50
|
+
continue;
|
|
51
|
+
byName.set(c.name, { value: `/${c.name}`, label: `/${c.name}`, description: c.description });
|
|
52
|
+
}
|
|
53
|
+
const items = [...byName.values()];
|
|
54
|
+
// Non-slash actions (sidebar, mode) go first so they're easy to find.
|
|
55
|
+
const actions = leader
|
|
56
|
+
.filter((a) => typeof a.run === "function")
|
|
57
|
+
.map((a) => ({ value: `leader:${a.key}`, label: a.label, hint: `ctrl+x ${a.key}` }));
|
|
58
|
+
return [...actions, ...widgetItems, ...items.sort((a, b) => a.label.localeCompare(b.label))];
|
|
59
|
+
}
|
|
60
|
+
/** Fuzzy-ish match: every query character in order, scored by compactness and prefix. */
|
|
61
|
+
export function fuzzyScore(query, text) {
|
|
62
|
+
const q = query.toLowerCase();
|
|
63
|
+
const t = text.toLowerCase();
|
|
64
|
+
if (!q)
|
|
65
|
+
return 0;
|
|
66
|
+
const idx = t.indexOf(q);
|
|
67
|
+
if (idx >= 0)
|
|
68
|
+
return idx === 0 || t[idx - 1] === "/" ? 0 : 1 + idx / 100;
|
|
69
|
+
let ti = 0;
|
|
70
|
+
let first = -1;
|
|
71
|
+
let last = -1;
|
|
72
|
+
for (const ch of q) {
|
|
73
|
+
const found = t.indexOf(ch, ti);
|
|
74
|
+
if (found < 0)
|
|
75
|
+
return undefined;
|
|
76
|
+
if (first < 0)
|
|
77
|
+
first = found;
|
|
78
|
+
last = found;
|
|
79
|
+
ti = found + 1;
|
|
80
|
+
}
|
|
81
|
+
return 10 + (last - first) / 10;
|
|
82
|
+
}
|
|
83
|
+
export function filterPalette(items, query) {
|
|
84
|
+
if (!query.trim())
|
|
85
|
+
return items;
|
|
86
|
+
return items
|
|
87
|
+
.map((item) => {
|
|
88
|
+
const s1 = fuzzyScore(query, item.label);
|
|
89
|
+
// Descriptions only match as a contiguous substring; subsequence matches there are noise.
|
|
90
|
+
const s2 = item.description?.toLowerCase().includes(query.toLowerCase()) ? 0 : undefined;
|
|
91
|
+
const score = s1 ?? (s2 !== undefined ? s2 + 20 : undefined);
|
|
92
|
+
return score === undefined ? undefined : { item, score };
|
|
93
|
+
})
|
|
94
|
+
.filter((x) => x !== undefined)
|
|
95
|
+
.sort((a, b) => a.score - b.score || a.item.label.localeCompare(b.item.label))
|
|
96
|
+
.map((x) => x.item);
|
|
97
|
+
}
|
|
98
|
+
/** Centered command palette: a search input over a filtered list. */
|
|
99
|
+
export class CommandPalette {
|
|
100
|
+
focused = false;
|
|
101
|
+
input = new Input({ placeholder: "Search commands" });
|
|
102
|
+
items;
|
|
103
|
+
theme;
|
|
104
|
+
done;
|
|
105
|
+
maxVisible;
|
|
106
|
+
list;
|
|
107
|
+
filtered;
|
|
108
|
+
constructor(items, theme, done, maxVisible = 12) {
|
|
109
|
+
this.items = items;
|
|
110
|
+
this.theme = theme;
|
|
111
|
+
this.done = done;
|
|
112
|
+
this.maxVisible = maxVisible;
|
|
113
|
+
this.filtered = items;
|
|
114
|
+
this.list = this.makeList(items);
|
|
115
|
+
}
|
|
116
|
+
makeList(items) {
|
|
117
|
+
const t = this.theme;
|
|
118
|
+
const selectItems = items.map((i) => ({
|
|
119
|
+
value: i.value,
|
|
120
|
+
label: i.hint ? `${i.label} ${t.fg("dim", i.hint)}` : i.label,
|
|
121
|
+
description: i.description,
|
|
122
|
+
}));
|
|
123
|
+
const list = new SelectList(selectItems, this.maxVisible, {
|
|
124
|
+
selectedPrefix: (s) => primary(t, s),
|
|
125
|
+
selectedText: (s) => t.bold(t.fg("text", s)),
|
|
126
|
+
description: (s) => t.fg("muted", s),
|
|
127
|
+
scrollInfo: (s) => t.fg("dim", s),
|
|
128
|
+
noMatch: (s) => t.fg("dim", s),
|
|
129
|
+
});
|
|
130
|
+
list.onSelect = (item) => this.done(item.value);
|
|
131
|
+
list.onCancel = () => this.done(undefined);
|
|
132
|
+
return list;
|
|
133
|
+
}
|
|
134
|
+
invalidate() {
|
|
135
|
+
this.list.invalidate();
|
|
136
|
+
}
|
|
137
|
+
handleInput(data) {
|
|
138
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
|
|
139
|
+
this.done(undefined);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (matchesKey(data, "up") || matchesKey(data, "down") || matchesKey(data, "pageUp") || matchesKey(data, "pageDown") || matchesKey(data, "enter")) {
|
|
143
|
+
if (matchesKey(data, "enter") && this.filtered.length === 0)
|
|
144
|
+
return;
|
|
145
|
+
this.list.handleInput(data);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const before = this.input.getValue();
|
|
149
|
+
this.input.handleInput(data);
|
|
150
|
+
const after = this.input.getValue();
|
|
151
|
+
if (after !== before) {
|
|
152
|
+
this.filtered = filterPalette(this.items, after);
|
|
153
|
+
this.list = this.makeList(this.filtered);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
render(width) {
|
|
157
|
+
const t = this.theme;
|
|
158
|
+
const inner = Math.max(10, width - 4);
|
|
159
|
+
this.input.focused = this.focused;
|
|
160
|
+
const border = (s) => t.fg("borderMuted", s);
|
|
161
|
+
const lines = [];
|
|
162
|
+
const title = `${primary(t, safeGlyph(getBrand().glyph))} ${t.bold("Commands")}`;
|
|
163
|
+
const esc = t.fg("dim", "esc");
|
|
164
|
+
lines.push(border("╭") + border("─".repeat(width - 2)) + border("╮"));
|
|
165
|
+
const head = `${title}${" ".repeat(Math.max(1, inner - visibleWidth(title) - visibleWidth(esc)))}${esc}`;
|
|
166
|
+
lines.push(`${border("│")} ${fit(head, inner)} ${border("│")}`);
|
|
167
|
+
for (const l of this.input.render(inner))
|
|
168
|
+
lines.push(`${border("│")} ${fit(l, inner)} ${border("│")}`);
|
|
169
|
+
lines.push(`${border("│")} ${fit("", inner)} ${border("│")}`);
|
|
170
|
+
const body = this.filtered.length ? this.list.render(inner) : [t.fg("dim", "No matching commands")];
|
|
171
|
+
for (const l of body)
|
|
172
|
+
lines.push(`${border("│")} ${fit(l, inner)} ${border("│")}`);
|
|
173
|
+
lines.push(border("╰") + border("─".repeat(width - 2)) + border("╯"));
|
|
174
|
+
return lines.map((l) => truncateToWidth(l, width, ""));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Leader help overlay contents. */
|
|
178
|
+
export function leaderHelpLines(actions, theme) {
|
|
179
|
+
return [
|
|
180
|
+
theme.bold("ctrl+x then…"),
|
|
181
|
+
"",
|
|
182
|
+
...actions.map((a) => ` ${primary(theme, a.key.padEnd(2))} ${a.label}${typeof a.run === "string" ? theme.fg("dim", ` ${a.run}`) : ""}`),
|
|
183
|
+
"",
|
|
184
|
+
theme.fg("dim", "tab on an empty prompt: switch Build/Plan"),
|
|
185
|
+
theme.fg("dim", "shift+tab: cycle thinking level"),
|
|
186
|
+
theme.fg("dim", "ctrl+p: command palette"),
|
|
187
|
+
];
|
|
188
|
+
}
|
|
189
|
+
export function runPaletteValue(value, ctx, actions, submit) {
|
|
190
|
+
const widgetAction = parseWidgetActionValue(value);
|
|
191
|
+
if (widgetAction) {
|
|
192
|
+
void runWidgetAction(widgetAction.widgetId, widgetAction.actionId, ctx).then((err) => {
|
|
193
|
+
if (err)
|
|
194
|
+
ctx.ui.notify(err, "warning");
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (value.startsWith("leader:")) {
|
|
199
|
+
const a = actions.find((x) => `leader:${x.key}` === value);
|
|
200
|
+
if (a && typeof a.run === "function")
|
|
201
|
+
a.run();
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
// Commands that take arguments go to the editor so the user can finish them.
|
|
205
|
+
const needsArgs = /^\/(name|login|thinking|bug|import)$/.test(value);
|
|
206
|
+
if (needsArgs) {
|
|
207
|
+
ctx.ui.setEditorText(`${value} `);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
submit(value);
|
|
211
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode-style prompt editor.
|
|
3
|
+
*
|
|
4
|
+
* ┃ ask anything…
|
|
5
|
+
* ┃
|
|
6
|
+
* ┃ BUILD Opus 5.5 Anthropic medium
|
|
7
|
+
* tab mode · ctrl+p commands · ctrl+x leader
|
|
8
|
+
*
|
|
9
|
+
* A shaded panel with a heavy left rail in the mode colour (the brand primary for build,
|
|
10
|
+
* the brand plan colour for plan) and the mode pill and model inside, like OpenCode's prompt; key hints sit under it.
|
|
11
|
+
* pi's working status (spinner, retry countdown, compaction) is shown in the footer instead.
|
|
12
|
+
*
|
|
13
|
+
* Keys handled here before pi sees them:
|
|
14
|
+
* - Tab on an empty prompt (and no autocomplete open): switch Build/Plan mode. Shift+Tab is left to
|
|
15
|
+
* pi, which cycles the thinking level with it.
|
|
16
|
+
* - Ctrl+X: leader key; the next key runs a leader action (see commands.ts)
|
|
17
|
+
* - Ctrl+P: command palette. pi binds ctrl+p to model cycling, a reserved action that
|
|
18
|
+
* registerShortcut() can't take over, so the editor intercepts it (model cycling stays on
|
|
19
|
+
* ctrl+x m / ctrl+l).
|
|
20
|
+
* Everything else goes to pi's CustomEditor, so app keybindings keep working.
|
|
21
|
+
*/
|
|
22
|
+
import { CustomEditor, type KeybindingsManager, type Theme } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
24
|
+
import { type ModelParts } from "./prompt-row.ts";
|
|
25
|
+
import type { UiState } from "./state.ts";
|
|
26
|
+
export interface EditorHooks {
|
|
27
|
+
state: UiState;
|
|
28
|
+
theme: () => Theme;
|
|
29
|
+
/** The model, provider and thinking level for the bottom row. */
|
|
30
|
+
modelParts: () => ModelParts;
|
|
31
|
+
/** Muted hint shown after the cursor while the prompt is empty (home screen). */
|
|
32
|
+
placeholder?: () => string | undefined;
|
|
33
|
+
/** Left side of the key-hint row under the box (home screen brand line). */
|
|
34
|
+
hintLeft?: () => string | undefined;
|
|
35
|
+
/** Blink the cursor (home screen only). */
|
|
36
|
+
blink?: () => boolean;
|
|
37
|
+
onModeCycle: () => void;
|
|
38
|
+
/** Called for the key after the leader. Return true if it was a leader action. */
|
|
39
|
+
onLeaderKey: (data: string) => boolean;
|
|
40
|
+
/** Leader key pressed (show a hint) or cleared. */
|
|
41
|
+
onLeaderState: (pending: boolean) => void;
|
|
42
|
+
/** Ctrl+P: open the command palette. */
|
|
43
|
+
onPalette: () => void;
|
|
44
|
+
/** The working/retry indicator changed (footer repaint). */
|
|
45
|
+
onStatusChange?: () => void;
|
|
46
|
+
leaderTimeoutMs: number;
|
|
47
|
+
}
|
|
48
|
+
export declare class PromptEditor extends CustomEditor {
|
|
49
|
+
private leaderPending;
|
|
50
|
+
private leaderTimer;
|
|
51
|
+
private readonly hooks;
|
|
52
|
+
constructor(tui: TUI, editorTheme: EditorTheme, keybindings: KeybindingsManager, hooks: EditorHooks);
|
|
53
|
+
setPaddingX(_padding: number): void;
|
|
54
|
+
/**
|
|
55
|
+
* pi passes its working/retry/compaction indicator here (embedWorkingStatus). Keep it in shared
|
|
56
|
+
* state for the footer instead of drawing it in the editor's top border.
|
|
57
|
+
*/
|
|
58
|
+
setWorkingStatusIndicator(indicator: Parameters<CustomEditor["setWorkingStatusIndicator"]>[0]): void;
|
|
59
|
+
private clearLeader;
|
|
60
|
+
/** Cursor blink: off phase hides the editor's reverse-video cursor; a keypress resets it. */
|
|
61
|
+
private blinkOff;
|
|
62
|
+
private blinkTimer;
|
|
63
|
+
private static readonly BLINK_MS;
|
|
64
|
+
private syncBlink;
|
|
65
|
+
private stopBlink;
|
|
66
|
+
dispose(): void;
|
|
67
|
+
handleInput(data: string): void;
|
|
68
|
+
private modeColor;
|
|
69
|
+
render(width: number): string[];
|
|
70
|
+
}
|