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.
Files changed (169) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/LICENSE +21 -0
  3. package/README.md +51 -2
  4. package/THIRD_PARTY_NOTICES +30 -0
  5. package/dist/chassis/alert.d.ts +50 -0
  6. package/dist/chassis/alert.js +107 -0
  7. package/dist/chassis/api.d.ts +411 -0
  8. package/dist/chassis/api.js +676 -0
  9. package/dist/chassis/bash-output.d.ts +89 -0
  10. package/dist/chassis/bash-output.js +157 -0
  11. package/dist/chassis/brand.d.ts +99 -0
  12. package/dist/chassis/brand.js +79 -0
  13. package/dist/chassis/client.d.ts +87 -0
  14. package/dist/chassis/client.js +271 -0
  15. package/dist/chassis/commands.d.ts +60 -0
  16. package/dist/chassis/commands.js +211 -0
  17. package/dist/chassis/editor.d.ts +70 -0
  18. package/dist/chassis/editor.js +213 -0
  19. package/dist/chassis/footer.d.ts +14 -0
  20. package/dist/chassis/footer.js +136 -0
  21. package/dist/chassis/header.d.ts +12 -0
  22. package/dist/chassis/header.js +38 -0
  23. package/dist/chassis/home.d.ts +48 -0
  24. package/dist/chassis/home.js +184 -0
  25. package/dist/chassis/icons.d.ts +203 -0
  26. package/dist/chassis/icons.js +190 -0
  27. package/dist/chassis/index.d.ts +21 -0
  28. package/dist/chassis/index.js +582 -0
  29. package/dist/chassis/layout.d.ts +40 -0
  30. package/dist/chassis/layout.js +264 -0
  31. package/dist/chassis/message-modes.d.ts +21 -0
  32. package/dist/chassis/message-modes.js +49 -0
  33. package/dist/chassis/messages.d.ts +75 -0
  34. package/dist/chassis/messages.js +392 -0
  35. package/dist/chassis/modes.d.ts +21 -0
  36. package/dist/chassis/modes.js +99 -0
  37. package/dist/chassis/palette.d.ts +84 -0
  38. package/dist/chassis/palette.js +235 -0
  39. package/dist/chassis/peek.d.ts +61 -0
  40. package/dist/chassis/peek.js +156 -0
  41. package/dist/chassis/prompt-row.d.ts +42 -0
  42. package/dist/chassis/prompt-row.js +68 -0
  43. package/dist/chassis/provider-view.d.ts +34 -0
  44. package/dist/chassis/provider-view.js +107 -0
  45. package/dist/chassis/provider.d.ts +69 -0
  46. package/dist/chassis/provider.js +73 -0
  47. package/dist/chassis/readonly-command.d.ts +19 -0
  48. package/dist/chassis/readonly-command.js +454 -0
  49. package/dist/chassis/sanitize.d.ts +42 -0
  50. package/dist/chassis/sanitize.js +103 -0
  51. package/dist/chassis/session-info.d.ts +39 -0
  52. package/dist/chassis/session-info.js +107 -0
  53. package/dist/chassis/sidebar.d.ts +48 -0
  54. package/dist/chassis/sidebar.js +273 -0
  55. package/dist/chassis/state.d.ts +38 -0
  56. package/dist/chassis/state.js +19 -0
  57. package/dist/chassis/telemetry.d.ts +60 -0
  58. package/dist/chassis/telemetry.js +191 -0
  59. package/dist/chassis/tools.d.ts +112 -0
  60. package/dist/chassis/tools.js +588 -0
  61. package/dist/chassis/version.d.ts +2 -0
  62. package/dist/chassis/version.js +20 -0
  63. package/dist/diff/git-diff.d.ts +71 -0
  64. package/dist/diff/git-diff.js +325 -0
  65. package/dist/diff/host.d.ts +34 -0
  66. package/dist/diff/host.js +50 -0
  67. package/dist/diff/index.d.ts +13 -0
  68. package/dist/diff/index.js +86 -0
  69. package/dist/diff/render.d.ts +67 -0
  70. package/dist/diff/render.js +196 -0
  71. package/dist/diff/types.d.ts +60 -0
  72. package/dist/diff/types.js +4 -0
  73. package/dist/diff/view.d.ts +118 -0
  74. package/dist/diff/view.js +528 -0
  75. package/dist/git/flows.d.ts +92 -0
  76. package/dist/git/flows.js +415 -0
  77. package/dist/git/forge.d.ts +26 -0
  78. package/dist/git/forge.js +41 -0
  79. package/dist/git/index.d.ts +20 -0
  80. package/dist/git/index.js +121 -0
  81. package/dist/git/repo.d.ts +64 -0
  82. package/dist/git/repo.js +186 -0
  83. package/dist/git/run.d.ts +38 -0
  84. package/dist/git/run.js +127 -0
  85. package/dist/git/settings.d.ts +8 -0
  86. package/dist/git/settings.js +38 -0
  87. package/dist/git/text.d.ts +51 -0
  88. package/dist/git/text.js +109 -0
  89. package/dist/widgets/container/detect.d.ts +23 -0
  90. package/dist/widgets/container/detect.js +67 -0
  91. package/dist/widgets/container/index.d.ts +9 -0
  92. package/dist/widgets/container/index.js +26 -0
  93. package/dist/widgets/mcp/index.d.ts +13 -0
  94. package/dist/widgets/mcp/index.js +50 -0
  95. package/dist/widgets/mcp/status.d.ts +50 -0
  96. package/dist/widgets/mcp/status.js +70 -0
  97. package/dist/widgets/session-todos/index.d.ts +12 -0
  98. package/dist/widgets/session-todos/index.js +52 -0
  99. package/dist/widgets/subagents/index.d.ts +16 -0
  100. package/dist/widgets/subagents/index.js +173 -0
  101. package/dist/widgets/subagents/status.d.ts +61 -0
  102. package/dist/widgets/subagents/status.js +198 -0
  103. package/dist/widgets/todo/index.d.ts +13 -0
  104. package/dist/widgets/todo/index.js +65 -0
  105. package/dist/widgets/todo/parse.d.ts +29 -0
  106. package/dist/widgets/todo/parse.js +122 -0
  107. package/docs/branding.md +25 -0
  108. package/docs/development.md +22 -0
  109. package/docs/diff-api.md +18 -0
  110. package/docs/git.md +23 -0
  111. package/docs/internals.md +13 -0
  112. package/docs/provider-status.md +23 -0
  113. package/docs/status-widgets.md +157 -0
  114. package/docs/tool-rows.md +50 -0
  115. package/docs/usage.md +62 -0
  116. package/docs/widgets.md +113 -0
  117. package/extensions/chassis/alert.ts +127 -0
  118. package/extensions/chassis/api.ts +967 -0
  119. package/extensions/chassis/bash-output.ts +186 -0
  120. package/extensions/chassis/brand.ts +151 -0
  121. package/extensions/chassis/client.ts +284 -0
  122. package/extensions/chassis/commands.ts +244 -0
  123. package/extensions/chassis/editor.ts +253 -0
  124. package/extensions/chassis/footer.ts +131 -0
  125. package/extensions/chassis/header.ts +43 -0
  126. package/extensions/chassis/home.ts +186 -0
  127. package/extensions/chassis/icons.ts +220 -0
  128. package/extensions/chassis/index.ts +605 -0
  129. package/extensions/chassis/layout.ts +298 -0
  130. package/extensions/chassis/message-modes.ts +48 -0
  131. package/extensions/chassis/messages.ts +403 -0
  132. package/extensions/chassis/modes.ts +108 -0
  133. package/extensions/chassis/palette.ts +258 -0
  134. package/extensions/chassis/peek.ts +167 -0
  135. package/extensions/chassis/prompt-row.ts +86 -0
  136. package/extensions/chassis/provider-view.ts +118 -0
  137. package/extensions/chassis/provider.ts +115 -0
  138. package/extensions/chassis/readonly-command.ts +440 -0
  139. package/extensions/chassis/sanitize.ts +108 -0
  140. package/extensions/chassis/session-info.ts +127 -0
  141. package/extensions/chassis/sidebar.ts +285 -0
  142. package/extensions/chassis/state.ts +50 -0
  143. package/extensions/chassis/telemetry.ts +222 -0
  144. package/extensions/chassis/tools.ts +618 -0
  145. package/extensions/chassis/version.ts +20 -0
  146. package/extensions/diff/git-diff.ts +365 -0
  147. package/extensions/diff/host.ts +58 -0
  148. package/extensions/diff/index.ts +101 -0
  149. package/extensions/diff/render.ts +217 -0
  150. package/extensions/diff/types.ts +67 -0
  151. package/extensions/diff/view.ts +523 -0
  152. package/extensions/git/flows.ts +485 -0
  153. package/extensions/git/forge.ts +54 -0
  154. package/extensions/git/index.ts +117 -0
  155. package/extensions/git/repo.ts +200 -0
  156. package/extensions/git/run.ts +143 -0
  157. package/extensions/git/settings.ts +38 -0
  158. package/extensions/git/text.ts +133 -0
  159. package/extensions/widgets/container/detect.ts +81 -0
  160. package/extensions/widgets/container/index.ts +29 -0
  161. package/extensions/widgets/mcp/index.ts +53 -0
  162. package/extensions/widgets/mcp/status.ts +94 -0
  163. package/extensions/widgets/session-todos/index.ts +55 -0
  164. package/extensions/widgets/subagents/index.ts +170 -0
  165. package/extensions/widgets/subagents/status.ts +237 -0
  166. package/extensions/widgets/todo/index.ts +66 -0
  167. package/extensions/widgets/todo/parse.ts +119 -0
  168. package/package.json +91 -4
  169. package/scripts/link-pi.mjs +76 -0
@@ -0,0 +1,676 @@
1
+ /**
2
+ * Widget registry: the one place info and status widgets are declared.
3
+ *
4
+ * A widget is a small object with an id and a render function. The TUI decides where it shows up
5
+ * (sidebar section, footer segment, or both) from its `slots`, orders it by `order`, and refreshes
6
+ * it on `refreshMs` or when the widget calls `handle.refresh()`.
7
+ *
8
+ * import { registerWidget } from "../chassis/api.ts";
9
+ *
10
+ * registerWidget(pi, {
11
+ * id: "deploy",
12
+ * title: "Deploy",
13
+ * slots: ["footer", "sidebar"],
14
+ * order: 10,
15
+ * refreshMs: 30_000,
16
+ * render: () => ({ icon: "\uF0E7", text: "staging·v42", color: "brand" }),
17
+ * detail: () => ["Last deploy 12m ago"],
18
+ * });
19
+ *
20
+ * The registry lives on globalThis because pi loads every extension file in its own module scope
21
+ * (jiti, moduleCache off), so a plain module-level Map would give each extension its own copy.
22
+ * Widgets registered before chassis loads are kept and shown once it starts.
23
+ *
24
+ * Plain `ctx.ui.setStatus()` from other extensions still works: the TUI shows those statuses as
25
+ * footer segments too, unless a registered widget claims the same key (`statusKey`).
26
+ */
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join } from "node:path";
30
+ import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
31
+ import { safeLinkUrl, scrubText } from "./sanitize.js";
32
+ /** A render or detail call slower than this counts as a strike against the widget. */
33
+ export const SLOW_RENDER_MS = 50;
34
+ /** This many slow calls in a row suspend the widget until `/widgets enable ID`. */
35
+ export const SLOW_RENDER_STRIKES = 3;
36
+ /** An `update` that has not settled after this long is abandoned and reported as an error. */
37
+ export const DEFAULT_UPDATE_TIMEOUT_MS = 10_000;
38
+ /** Bumped on a breaking change to the registry or WidgetSpec; client.ts checks it. */
39
+ export const API_VERSION = 1;
40
+ /** Version of the tool row API (`registerToolRows`). Bumped on a breaking change to it or to ToolRowSpec. */
41
+ export const TOOL_ROWS_VERSION = 1;
42
+ const PENDING = Symbol.for("pi-chassis/pending");
43
+ const PENDING_TOOL_ROWS = Symbol.for("pi-chassis/pendingToolRows");
44
+ function pendingList() {
45
+ const g = globalThis;
46
+ g[PENDING] ??= [];
47
+ return g[PENDING];
48
+ }
49
+ function pendingToolRowsList() {
50
+ const g = globalThis;
51
+ g[PENDING_TOOL_ROWS] ??= [];
52
+ return g[PENDING_TOOL_ROWS];
53
+ }
54
+ /** Register everything that client.ts queued before the host loaded. */
55
+ export function drainPending() {
56
+ const list = pendingList().splice(0);
57
+ for (const item of list) {
58
+ try {
59
+ item.attach(registerWidget(item.pi, item.spec));
60
+ }
61
+ catch {
62
+ // one bad registration must not stop the others
63
+ }
64
+ }
65
+ for (const item of pendingToolRowsList().splice(0)) {
66
+ try {
67
+ item.attach(registerToolRows(item.specs, { id: item.id }));
68
+ }
69
+ catch {
70
+ // one bad registration must not stop the others
71
+ }
72
+ }
73
+ }
74
+ /**
75
+ * Called once by chassis when it loads: announces the host (so client.ts registers straight into
76
+ * the registry) and takes over widgets that were queued before it loaded.
77
+ */
78
+ export function installHost() {
79
+ const reg = getRegistry();
80
+ reg.apiVersion = API_VERSION;
81
+ reg.register = registerWidget;
82
+ reg.toolRowsVersion = TOOL_ROWS_VERSION;
83
+ reg.registerToolRows = registerToolRows;
84
+ drainPending();
85
+ }
86
+ /** Where the settings file is stored. PI_CHASSIS_STATE overrides it (used by tests). */
87
+ export function statePath() {
88
+ const override = process.env.PI_CHASSIS_STATE;
89
+ if (override)
90
+ return override;
91
+ const dir = process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
92
+ return join(dir, "chassis.json");
93
+ }
94
+ function loadDisabled() {
95
+ try {
96
+ const data = JSON.parse(readFileSync(statePath(), "utf8"));
97
+ return new Set(Array.isArray(data?.disabledWidgets) ? data.disabledWidgets.filter((x) => typeof x === "string") : []);
98
+ }
99
+ catch {
100
+ return new Set();
101
+ }
102
+ }
103
+ function saveDisabled(ids) {
104
+ try {
105
+ const path = statePath();
106
+ let data = {};
107
+ if (existsSync(path)) {
108
+ try {
109
+ data = JSON.parse(readFileSync(path, "utf8")) ?? {};
110
+ }
111
+ catch {
112
+ data = {};
113
+ }
114
+ }
115
+ data.disabledWidgets = [...ids].sort();
116
+ mkdirSync(dirname(path), { recursive: true });
117
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
118
+ }
119
+ catch {
120
+ // Persistence is best effort; the in-memory list still applies for this run.
121
+ }
122
+ }
123
+ const KEY = Symbol.for("pi-chassis/registry");
124
+ export function getRegistry() {
125
+ const g = globalThis;
126
+ g[KEY] ??= {
127
+ widgets: new Map(),
128
+ ctx: undefined,
129
+ requestRender: () => { },
130
+ listeners: new Set(),
131
+ version: 0,
132
+ disabled: loadDisabled(),
133
+ };
134
+ // A registry created by an older chassis in the same process lacks newer fields.
135
+ g[KEY].disabled ??= loadDisabled();
136
+ return g[KEY];
137
+ }
138
+ /** The registered tool row groups, oldest first (created on first use, so an older registry works). */
139
+ export function toolRowGroups() {
140
+ const reg = getRegistry();
141
+ return (reg.toolRows ??= []);
142
+ }
143
+ /** A spec that failed this many finished calls is no longer used until it is registered again. */
144
+ export const TOOL_ROW_FAILURE_LIMIT = 3;
145
+ const toolRowFailures = new WeakMap();
146
+ /** Count a failure of `spec` on a finished call (not while arguments or output were still arriving). */
147
+ export function noteToolRowFailure(spec) {
148
+ toolRowFailures.set(spec, (toolRowFailures.get(spec) ?? 0) + 1);
149
+ }
150
+ /** Whether `spec` failed too many finished calls to be used for new ones. */
151
+ export function toolRowBroken(spec) {
152
+ return (toolRowFailures.get(spec) ?? 0) >= TOOL_ROW_FAILURE_LIMIT;
153
+ }
154
+ /** Whether `spec` has the shape chassis needs: a string title, functions to describe and summarize, and `expand` a function if given. */
155
+ function isToolRowSpec(spec) {
156
+ const s = spec;
157
+ return !!s && typeof s === "object" && typeof s.title === "string" && typeof s.describe === "function" && typeof s.summarize === "function" && (s.expand === undefined || typeof s.expand === "function");
158
+ }
159
+ /**
160
+ * Register chassis-styled rows for tools that belong to another extension, by tool name. Returns a
161
+ * function that removes them (calling it twice is harmless).
162
+ *
163
+ * The rows are looked up when a call is drawn, so it does not matter whether the extension that
164
+ * owns the tool, the one registering the rows or chassis loads first. A call already on screen keeps
165
+ * the renderer it was created with.
166
+ *
167
+ * chassis's own rows for pi's built-in tools (read, write, edit, bash, grep, find, ls) always win: a
168
+ * spec registered under one of those names is never used. For any other name the most recently
169
+ * registered spec wins, and removing it brings back the one before it. An entry is used only if
170
+ * it has a string `title`, function `describe` and `summarize`, and `expand` is a function when
171
+ * given; any other entry is skipped.
172
+ *
173
+ * Pass `{ id }` when the registrant can run more than once in a process (an extension that
174
+ * registers on every `session_start`, or is loaded again by /reload): registering again with the
175
+ * same id replaces its earlier group instead of adding another. Without an id, call the returned
176
+ * remover (for example on `session_shutdown`) before registering again.
177
+ */
178
+ export function registerToolRows(specs, options) {
179
+ const groups = toolRowGroups();
180
+ const id = typeof options?.id === "string" && options.id ? options.id : undefined;
181
+ const group = { id, specs: new Map() };
182
+ for (const [name, spec] of Object.entries(specs ?? {})) {
183
+ if (!name || !isToolRowSpec(spec))
184
+ continue;
185
+ toolRowFailures.delete(spec); // registering again gives a spec a fresh start
186
+ group.specs.set(name, spec);
187
+ }
188
+ if (id !== undefined) {
189
+ for (let i = groups.length - 1; i >= 0; i--)
190
+ if (groups[i].id === id)
191
+ groups.splice(i, 1);
192
+ }
193
+ groups.push(group);
194
+ getRegistry().requestRender();
195
+ return () => {
196
+ const live = toolRowGroups();
197
+ const i = live.indexOf(group);
198
+ if (i < 0)
199
+ return;
200
+ live.splice(i, 1);
201
+ getRegistry().requestRender();
202
+ };
203
+ }
204
+ /** The registered row spec for a tool, newest registration first, or undefined. */
205
+ export function toolRowFor(toolName) {
206
+ const groups = toolRowGroups();
207
+ for (let i = groups.length - 1; i >= 0; i--) {
208
+ const spec = groups[i].specs.get(toolName);
209
+ if (spec)
210
+ return spec;
211
+ }
212
+ return undefined;
213
+ }
214
+ /** Make every widget render again (an icon set change, say), dropping cached output. */
215
+ export function invalidateWidgets() {
216
+ changed(getRegistry());
217
+ }
218
+ function changed(reg) {
219
+ reg.version++;
220
+ for (const l of reg.listeners)
221
+ l();
222
+ reg.requestRender();
223
+ }
224
+ const messageOf = (e) => (e instanceof Error ? e.message : String(e)).split("\n")[0].slice(0, 200) || "unknown error";
225
+ function runUpdate(reg, entry) {
226
+ const { spec } = entry;
227
+ const ctx = reg.ctx;
228
+ if (reg.disabled.has(spec.id))
229
+ return;
230
+ if (!spec.update || !ctx) {
231
+ changed(reg);
232
+ return;
233
+ }
234
+ // One update at a time: a slow source must not pile up overlapping runs.
235
+ if (entry.inflight)
236
+ return;
237
+ entry.inflight = true;
238
+ const started = Date.now();
239
+ const timeoutMs = spec.updateTimeoutMs ?? DEFAULT_UPDATE_TIMEOUT_MS;
240
+ let settled = false;
241
+ const finish = (err) => {
242
+ if (settled)
243
+ return; // a late result after the timeout is ignored
244
+ settled = true;
245
+ clearTimeout(timer);
246
+ entry.inflight = false;
247
+ entry.lastDurationMs = Date.now() - started;
248
+ entry.lastUpdateAt = Date.now();
249
+ if (err !== undefined)
250
+ entry.error = { phase: "update", message: messageOf(err), at: Date.now() };
251
+ else if (entry.error?.phase === "update")
252
+ entry.error = undefined;
253
+ changed(reg);
254
+ };
255
+ const timer = setTimeout(() => finish(new Error(`update timed out after ${timeoutMs}ms`)), timeoutMs);
256
+ timer.unref?.();
257
+ try {
258
+ const r = spec.update(ctx);
259
+ if (r && typeof r.then === "function") {
260
+ r.then(() => finish(), (e) => finish(e ?? new Error("update rejected")));
261
+ }
262
+ else {
263
+ finish();
264
+ }
265
+ }
266
+ catch (e) {
267
+ finish(e ?? new Error("update threw"));
268
+ }
269
+ }
270
+ function startTimer(reg, entry) {
271
+ if (entry.timer || !entry.spec.refreshMs || !reg.ctx || reg.disabled.has(entry.spec.id))
272
+ return;
273
+ entry.timer = setInterval(() => runUpdate(reg, entry), entry.spec.refreshMs);
274
+ entry.timer.unref?.();
275
+ }
276
+ /** Remove the pi.on handlers a widget added, so re-registering or disposing doesn't leak them. */
277
+ function unlisten(entry) {
278
+ for (const off of entry.offs.splice(0)) {
279
+ try {
280
+ off();
281
+ }
282
+ catch {
283
+ // the pi instance may already be gone
284
+ }
285
+ }
286
+ }
287
+ function stopTimer(entry) {
288
+ if (entry.timer)
289
+ clearInterval(entry.timer);
290
+ entry.timer = undefined;
291
+ }
292
+ /**
293
+ * Register (or replace) a widget. Returns a handle for on-demand refresh and removal.
294
+ *
295
+ * Pass `pi` so the widget's timers follow the session lifecycle: they start on session_start and
296
+ * stop on session_shutdown. The widget itself stays registered across /reload of chassis.
297
+ *
298
+ * Registering from inside a `session_start` handler (as a package that does not import chassis does)
299
+ * misses that session's own `session_start` event for the widget, so pass the handler's `ctx`.
300
+ */
301
+ export function registerWidget(pi, spec, ctx) {
302
+ const reg = getRegistry();
303
+ if (ctx)
304
+ reg.ctx ??= ctx;
305
+ const old = reg.widgets.get(spec.id);
306
+ if (old) {
307
+ stopTimer(old);
308
+ unlisten(old);
309
+ }
310
+ const entry = { spec, strikes: 0, offs: [] };
311
+ reg.widgets.set(spec.id, entry);
312
+ const listen = (off) => {
313
+ if (typeof off === "function")
314
+ entry.offs.push(off);
315
+ };
316
+ listen(pi.on("session_start", async (_e, ctx) => {
317
+ if (reg.widgets.get(spec.id) !== entry)
318
+ return;
319
+ // chassis may not have set reg.ctx yet (load order), so use this ctx for the first update.
320
+ reg.ctx ??= ctx;
321
+ startTimer(reg, entry);
322
+ runUpdate(reg, entry);
323
+ }));
324
+ listen(pi.on("session_shutdown", async () => {
325
+ stopTimer(entry);
326
+ }));
327
+ if (reg.ctx) {
328
+ startTimer(reg, entry);
329
+ runUpdate(reg, entry);
330
+ }
331
+ else {
332
+ changed(reg);
333
+ }
334
+ return {
335
+ refresh: () => {
336
+ if (reg.widgets.get(spec.id) === entry)
337
+ runUpdate(reg, entry);
338
+ },
339
+ dispose: () => {
340
+ stopTimer(entry);
341
+ unlisten(entry);
342
+ if (reg.widgets.get(spec.id) === entry)
343
+ reg.widgets.delete(spec.id);
344
+ changed(reg);
345
+ },
346
+ };
347
+ }
348
+ /** Widgets for a slot, sorted by order then id. */
349
+ export function widgetsFor(slot) {
350
+ return [...getRegistry().widgets.values()]
351
+ .map((e) => e.spec)
352
+ .filter((s) => !getRegistry().disabled.has(s.id))
353
+ .filter((s) => (s.slots ?? ["footer", "sidebar"]).includes(slot))
354
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id));
355
+ }
356
+ /** setStatus() keys claimed by registered widgets. */
357
+ export function claimedStatusKeys() {
358
+ const keys = new Set();
359
+ for (const { spec } of getRegistry().widgets.values()) {
360
+ keys.add(spec.statusKey ?? spec.id);
361
+ for (const k of spec.statusKeys ?? [])
362
+ keys.add(k);
363
+ }
364
+ return keys;
365
+ }
366
+ function record(id, phase, err) {
367
+ const entry = getRegistry().widgets.get(id);
368
+ if (entry)
369
+ entry.error = { phase, message: messageOf(err), at: Date.now() };
370
+ }
371
+ const nowMs = () => performance.now();
372
+ /** The cache slot for this call, or undefined when the widget doesn't cache or something changed. */
373
+ function cacheFor(entry, rc) {
374
+ if (!entry.spec.cacheKey)
375
+ return undefined;
376
+ let key;
377
+ try {
378
+ key = entry.spec.cacheKey(rc);
379
+ }
380
+ catch {
381
+ return undefined; // a broken key just disables caching for this call
382
+ }
383
+ const version = getRegistry().version;
384
+ const c = entry.cache;
385
+ if (c && c.key === key && c.theme === rc.theme && c.ctx === rc.ctx && c.width === rc.width && c.version === version)
386
+ return c;
387
+ entry.cache = { key, theme: rc.theme, ctx: rc.ctx, width: rc.width, version };
388
+ return entry.cache;
389
+ }
390
+ /** Time a real (uncached) call. Too many slow ones in a row suspend the widget. */
391
+ function timed(entry, fn) {
392
+ const t0 = nowMs();
393
+ try {
394
+ return fn();
395
+ }
396
+ finally {
397
+ const ms = nowMs() - t0;
398
+ entry.lastRenderMs = ms;
399
+ if (ms > SLOW_RENDER_MS) {
400
+ if (++entry.strikes >= SLOW_RENDER_STRIKES && !entry.suspended) {
401
+ entry.suspended = true;
402
+ entry.error = { phase: "render", message: `suspended: ${SLOW_RENDER_STRIKES} slow renders (last ${Math.round(ms)}ms)`, at: Date.now() };
403
+ }
404
+ }
405
+ else {
406
+ entry.strikes = 0;
407
+ }
408
+ }
409
+ }
410
+ const SUSPENDED_VIEW = { text: "suspended", level: "warn" };
411
+ /**
412
+ * A widget's text is untrusted: it may carry a file name, a branch, a server's reply. Escape
413
+ * sequences (titles, links, clipboard writes, cursor moves), control characters and bidi controls
414
+ * are removed and line breaks become spaces, so one value stays on one line and cannot reach the
415
+ * terminal. SGR colour is kept for widgets that colour their own text; spacing is left as given.
416
+ */
417
+ const cleanText = (value) => (value === undefined || value === null ? undefined : scrubText(value, true));
418
+ function cleanIcon(icon) {
419
+ if (typeof icon === "string")
420
+ return scrubText(icon);
421
+ if (icon && typeof icon === "object")
422
+ return icon.plain === undefined ? { nerd: scrubText(icon.nerd) } : { nerd: scrubText(icon.nerd), plain: scrubText(icon.plain) };
423
+ return icon;
424
+ }
425
+ /** A view with every text field cleaned (see `cleanText`). The colour and level are the widget's own. */
426
+ function cleanView(view) {
427
+ if (!view || typeof view !== "object")
428
+ return undefined;
429
+ const out = { ...view };
430
+ if (view.icon !== undefined)
431
+ out.icon = cleanIcon(view.icon);
432
+ for (const key of ["text", "label", "tag"]) {
433
+ if (view[key] !== undefined)
434
+ out[key] = cleanText(view[key]);
435
+ }
436
+ return out;
437
+ }
438
+ /** Detail lines cleaned the same way; anything that is not an array of lines yields none. */
439
+ /** Whether the terminal draws OSC 8 links. A failure to find out means no. */
440
+ function terminalLinks() {
441
+ try {
442
+ return getCapabilities().hyperlinks;
443
+ }
444
+ catch {
445
+ return false;
446
+ }
447
+ }
448
+ /**
449
+ * One detail line as a clean string. Every piece of text goes through `cleanText`, so nothing a
450
+ * widget passes in can add an escape sequence; the only OSC 8 sequences in the result are the ones
451
+ * built here, from a URL `safeLinkUrl` accepted around a label that is not empty once cleaned.
452
+ */
453
+ function cleanDetailLine(line, links) {
454
+ if (!Array.isArray(line))
455
+ return cleanText(line) ?? "";
456
+ let out = "";
457
+ for (const piece of line) {
458
+ if (typeof piece === "string") {
459
+ out += cleanText(piece) ?? "";
460
+ }
461
+ else if (piece && typeof piece === "object" && typeof piece.text === "string") {
462
+ const label = cleanText(piece.text) ?? "";
463
+ const url = links ? safeLinkUrl(piece.url) : undefined;
464
+ out += url && label.trim() ? hyperlink(label, url) : label;
465
+ }
466
+ }
467
+ return out;
468
+ }
469
+ const cleanLines = (lines) => {
470
+ if (!Array.isArray(lines))
471
+ return [];
472
+ const links = terminalLinks();
473
+ return lines.map((l) => cleanDetailLine(l, links));
474
+ };
475
+ /**
476
+ * Render a widget without letting it throw: a failure is recorded on the widget and returned as
477
+ * an "error" view. Returns undefined when the widget itself returns undefined (hidden).
478
+ * Widgets with `cacheKey` are only re-rendered when the key, width, theme, context or registry
479
+ * version changes; a widget that renders slowly several times in a row is suspended.
480
+ */
481
+ export function renderWidget(spec, rc) {
482
+ const entry = getRegistry().widgets.get(spec.id);
483
+ if (entry?.suspended)
484
+ return SUSPENDED_VIEW;
485
+ const cache = entry ? cacheFor(entry, rc) : undefined;
486
+ if (cache?.view)
487
+ return cache.view.value;
488
+ try {
489
+ const view = cleanView(entry ? timed(entry, () => spec.render(rc)) : spec.render(rc));
490
+ if (entry?.error?.phase === "render" && !entry.suspended)
491
+ entry.error = undefined;
492
+ if (cache && !entry?.suspended)
493
+ cache.view = { value: view };
494
+ return entry?.suspended ? SUSPENDED_VIEW : view;
495
+ }
496
+ catch (e) {
497
+ record(spec.id, "render", e);
498
+ return { text: "error", level: "error" };
499
+ }
500
+ }
501
+ /** Detail lines of a widget; a failure is recorded and yields no lines. Cached like `renderWidget`. */
502
+ export function detailWidget(spec, rc) {
503
+ const entry = getRegistry().widgets.get(spec.id);
504
+ if (entry?.suspended)
505
+ return [];
506
+ const cache = entry ? cacheFor(entry, rc) : undefined;
507
+ if (cache?.lines)
508
+ return cache.lines.value;
509
+ try {
510
+ const lines = cleanLines((entry ? timed(entry, () => spec.detail?.(rc)) : spec.detail?.(rc)) ?? []);
511
+ if (entry?.error?.phase === "detail")
512
+ entry.error = undefined;
513
+ if (cache && !entry?.suspended)
514
+ cache.lines = { value: lines };
515
+ return entry?.suspended ? [] : lines;
516
+ }
517
+ catch (e) {
518
+ record(spec.id, "detail", e);
519
+ return [];
520
+ }
521
+ }
522
+ /** The last recorded failure of a widget, if any. */
523
+ export function widgetError(id) {
524
+ return getRegistry().widgets.get(id)?.error;
525
+ }
526
+ /** Turn a widget off or on. The choice is saved and survives restarts. */
527
+ export function setWidgetDisabled(id, disabled) {
528
+ const reg = getRegistry();
529
+ const entry = reg.widgets.get(id);
530
+ if (!entry)
531
+ return false;
532
+ if (disabled) {
533
+ reg.disabled.add(id);
534
+ stopTimer(entry);
535
+ }
536
+ else {
537
+ reg.disabled.delete(id);
538
+ if (entry.suspended)
539
+ entry.error = undefined;
540
+ entry.suspended = false;
541
+ entry.strikes = 0;
542
+ entry.cache = undefined;
543
+ startTimer(reg, entry);
544
+ runUpdate(reg, entry);
545
+ }
546
+ saveDisabled(reg.disabled);
547
+ changed(reg);
548
+ return true;
549
+ }
550
+ /** Run a widget's update now, even outside its timer. */
551
+ export function refreshWidget(id) {
552
+ const reg = getRegistry();
553
+ const entry = reg.widgets.get(id);
554
+ if (!entry)
555
+ return false;
556
+ runUpdate(reg, entry);
557
+ return true;
558
+ }
559
+ /** Snapshot of every registered widget, for `/widgets`. */
560
+ export function widgetInfo() {
561
+ const reg = getRegistry();
562
+ return [...reg.widgets.values()]
563
+ .map((e) => ({
564
+ id: e.spec.id,
565
+ title: e.spec.title ?? e.spec.id,
566
+ slots: e.spec.slots ?? ["footer", "sidebar"],
567
+ disabled: reg.disabled.has(e.spec.id),
568
+ refreshMs: e.spec.refreshMs,
569
+ lastUpdateAt: e.lastUpdateAt,
570
+ lastDurationMs: e.lastDurationMs,
571
+ inflight: !!e.inflight,
572
+ suspended: !!e.suspended,
573
+ lastRenderMs: e.lastRenderMs,
574
+ error: e.error,
575
+ }))
576
+ .sort((a, b) => a.id.localeCompare(b.id));
577
+ }
578
+ /** Plain-text table for `/widgets list`. */
579
+ export function formatWidgetList(list, now = Date.now()) {
580
+ if (!list.length)
581
+ return "No widgets registered.";
582
+ const ago = (t) => (t === undefined ? "never" : `${Math.max(0, Math.round((now - t) / 1000))}s ago`);
583
+ return list
584
+ .map((w) => {
585
+ const state = w.disabled ? "disabled" : w.suspended ? "suspended" : w.error ? "error" : w.inflight ? "updating" : "ok";
586
+ const parts = [`${w.id} [${state}]`, w.slots.join("+") || "command", `updated ${ago(w.lastUpdateAt)}`];
587
+ if (w.lastDurationMs !== undefined)
588
+ parts.push(`${w.lastDurationMs}ms`);
589
+ if (w.error)
590
+ parts.push(`${w.error.phase}: ${w.error.message}`);
591
+ return parts.join(" · ");
592
+ })
593
+ .join("\n");
594
+ }
595
+ /** Palette actions of every enabled, non-suspended widget, in widget order. */
596
+ export function widgetActions() {
597
+ const reg = getRegistry();
598
+ const out = [];
599
+ for (const spec of widgetsForAny()) {
600
+ const entry = reg.widgets.get(spec.id);
601
+ if (!entry || entry.suspended)
602
+ continue;
603
+ for (const a of spec.actions ?? []) {
604
+ out.push({ widgetId: spec.id, widgetTitle: spec.title ?? spec.id, id: a.id, label: a.label, description: a.description });
605
+ }
606
+ }
607
+ return out;
608
+ }
609
+ function widgetsForAny() {
610
+ const reg = getRegistry();
611
+ return [...reg.widgets.values()]
612
+ .map((e) => e.spec)
613
+ .filter((s) => !reg.disabled.has(s.id))
614
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.id.localeCompare(b.id));
615
+ }
616
+ /** Palette value for an action: ids are encoded, so they may contain any character. */
617
+ export function widgetActionValue(widgetId, actionId) {
618
+ return `widget:${encodeURIComponent(widgetId)}:${encodeURIComponent(actionId)}`;
619
+ }
620
+ export function parseWidgetActionValue(value) {
621
+ const m = value.match(/^widget:([^:]*):([^:]*)$/);
622
+ if (!m)
623
+ return undefined;
624
+ try {
625
+ return { widgetId: decodeURIComponent(m[1]), actionId: decodeURIComponent(m[2]) };
626
+ }
627
+ catch {
628
+ return undefined;
629
+ }
630
+ }
631
+ /**
632
+ * Run a widget's `onDetailClick` for the clicked detail line. Resolves to undefined on success, or
633
+ * to a short message when it failed (also recorded on the widget as an "action" error).
634
+ */
635
+ export async function runWidgetClick(widgetId, index, ctx) {
636
+ const reg = getRegistry();
637
+ const entry = reg.widgets.get(widgetId);
638
+ const click = entry && !reg.disabled.has(widgetId) && !entry.suspended ? entry.spec.onDetailClick : undefined;
639
+ if (!entry || !click)
640
+ return undefined;
641
+ try {
642
+ await click(index, ctx);
643
+ if (entry.error?.phase === "action")
644
+ entry.error = undefined;
645
+ changed(reg);
646
+ return undefined;
647
+ }
648
+ catch (e) {
649
+ record(widgetId, "action", e);
650
+ changed(reg);
651
+ return `${entry.spec.title ?? widgetId}: click failed: ${messageOf(e)}`;
652
+ }
653
+ }
654
+ /**
655
+ * Run a widget action. Resolves to undefined on success, or to a short message when the action
656
+ * is unknown or failed (the failure is also recorded on the widget).
657
+ */
658
+ export async function runWidgetAction(widgetId, actionId, ctx) {
659
+ const reg = getRegistry();
660
+ const entry = reg.widgets.get(widgetId);
661
+ const action = entry && !reg.disabled.has(widgetId) ? entry.spec.actions?.find((a) => a.id === actionId) : undefined;
662
+ if (!entry || !action)
663
+ return `No action "${actionId}" on widget "${widgetId}".`;
664
+ try {
665
+ await action.run(ctx);
666
+ if (entry.error?.phase === "action")
667
+ entry.error = undefined;
668
+ changed(reg);
669
+ return undefined;
670
+ }
671
+ catch (e) {
672
+ record(widgetId, "action", e);
673
+ changed(reg);
674
+ return `${entry.spec.title ?? widgetId}: ${action.label} failed: ${messageOf(e)}`;
675
+ }
676
+ }