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,582 @@
1
+ /**
2
+ * chassis: an OpenCode-style TUI for pi, with a brand that another package can replace (brand.ts).
3
+ *
4
+ * Pieces (each in its own file):
5
+ * layout.ts right sidebar (fullscreen split, overlay fallback) ctrl+x b
6
+ * home.ts OpenCode-style home screen (logo, centred prompt, tip, bottom bar)
7
+ * sidebar.ts session title, context/cost, status widgets, files, todo
8
+ * footer.ts one-line status bar under the prompt
9
+ * editor.ts left-rail prompt with mode badge + model, leader key, Tab mode switch
10
+ * commands.ts ctrl+x leader actions and the ctrl+p command palette
11
+ * modes.ts Build / Plan (read-only) agent modes
12
+ * tools.ts one-line tool rows ("→ Read x.ts 120 lines")
13
+ * messages.ts user prompts with a coloured left bar
14
+ * peek.ts thinking peek in the hidden-thinking label
15
+ * telemetry.ts per-run TPS/TTFT/tokens/cost summary
16
+ * api.ts the widget registry other extensions use (registerWidget)
17
+ *
18
+ * Commands: /chassis [on|off|sidebar|status]
19
+ */
20
+ import { SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
21
+ import { iconSet, iconSource, saveIconSetting, setIconSetting } from "./icons.js";
22
+ import { alert as ringAlert, describeAlert, loadAlert, parseAlertArg, plainTitle, runOutcome, saveAlert, shouldAlert } from "./alert.js";
23
+ import { formatWidgetList, getRegistry, installHost, invalidateWidgets, refreshWidget, setWidgetDisabled, widgetActions, widgetActionValue, widgetInfo } from "./api.js";
24
+ import { CommandPalette, leaderActions, leaderHelpLines, paletteItems, runPaletteValue, } from "./commands.js";
25
+ import { PromptEditor } from "./editor.js";
26
+ import { installFooter } from "./footer.js";
27
+ import { renderHeader } from "./header.js";
28
+ import { PLACEHOLDERS, TIPS } from "./home.js";
29
+ import { installLayout, piRootOf } from "./layout.js";
30
+ import { installMessageStyle, setChatContainer } from "./messages.js";
31
+ import { installModes } from "./modes.js";
32
+ import { fit, sanitize, spinnerFrame } from "./palette.js";
33
+ import { ThinkingPeek } from "./peek.js";
34
+ import { readGit } from "./session-info.js";
35
+ import { SIDEBAR_WIDTH } from "./sidebar.js";
36
+ import { createState } from "./state.js";
37
+ import { formatTelemetry, TelemetryTracker } from "./telemetry.js";
38
+ import { installToolRenderers } from "./tools.js";
39
+ // pi's built-in slash commands: pi.getCommands() only lists extension, prompt and skill commands.
40
+ const BUILTIN_COMMANDS = [
41
+ { name: "settings", description: "Open settings menu" },
42
+ { name: "model", description: "Select model" },
43
+ { name: "tree", description: "Navigate session tree" },
44
+ { name: "thinking", description: "Set thinking level" },
45
+ { name: "scoped-models", description: "Enable/disable models for cycling" },
46
+ { name: "export", description: "Export session" },
47
+ { name: "import", description: "Import a session from JSONL" },
48
+ { name: "share", description: "Share session as a secret gist" },
49
+ { name: "copy", description: "Copy last agent message" },
50
+ { name: "name", description: "Set session display name" },
51
+ { name: "session", description: "Show session info and stats" },
52
+ { name: "changelog", description: "Show changelog entries" },
53
+ { name: "hotkeys", description: "Show all keyboard shortcuts" },
54
+ { name: "fork", description: "Fork from a previous user message" },
55
+ { name: "clone", description: "Duplicate the current session" },
56
+ { name: "trust", description: "Save project trust decision" },
57
+ { name: "login", description: "Configure provider authentication" },
58
+ { name: "logout", description: "Remove provider authentication" },
59
+ { name: "new", description: "Start a new session" },
60
+ { name: "compact", description: "Compact the session context" },
61
+ { name: "resume", description: "Resume a different session" },
62
+ { name: "reload", description: "Reload extensions, themes, keybindings" },
63
+ { name: "quit", description: "Quit pi" },
64
+ ];
65
+ const LEADER_TIMEOUT_MS = 2000;
66
+ const SIDEBAR_MIN_TERMINAL_WIDTH = 116; // keeps the transcript as wide as it was at the old 42-column sidebar and 110 columns
67
+ const GIT_POLL_MS = 4000;
68
+ export default function (pi) {
69
+ const state = createState();
70
+ const registry = getRegistry();
71
+ installHost();
72
+ const telemetry = new TelemetryTracker();
73
+ let enabled = true;
74
+ let alertSettings = loadAlert();
75
+ /** Set while the window title carries an alert mark, so the next prompt clears it. */
76
+ let titleMarked = false;
77
+ /** How the last run ended, from agent_end, for the alert at agent_settled. */
78
+ let lastOutcome = "finished";
79
+ let active = false;
80
+ let theme;
81
+ let tui;
82
+ let editor;
83
+ let layout;
84
+ let cleanupFooter;
85
+ let cleanupMessages;
86
+ let requestFooter;
87
+ let workingTimer;
88
+ const peek = new ThinkingPeek(() => tui, () => Math.max(10, (tui?.terminal.columns ?? 100) - (layout?.isShown() ? SIDEBAR_WIDTH : 0) - 4));
89
+ const render = () => {
90
+ requestFooter?.();
91
+ tui?.requestRender();
92
+ };
93
+ /** pi's chat container: the Container whose children include message/tool components. */
94
+ const findChatContainer = () => {
95
+ const stack = [piRootOf(tui)];
96
+ const seen = new Set();
97
+ while (stack.length) {
98
+ const v = stack.pop();
99
+ if (!v || typeof v !== "object" || seen.has(v))
100
+ continue;
101
+ seen.add(v);
102
+ const kids = v.children;
103
+ if (Array.isArray(kids)) {
104
+ if (kids.some((k) => /^(UserMessage|AssistantMessage|ToolExecution)Component$/.test(k?.constructor?.name ?? "")))
105
+ return v;
106
+ for (const k of kids)
107
+ stack.push(k);
108
+ }
109
+ if (v.child)
110
+ stack.push(v.child);
111
+ }
112
+ return undefined;
113
+ };
114
+ let foundChat;
115
+ const refreshChatContainer = () => {
116
+ const c = findChatContainer();
117
+ if (!c)
118
+ return;
119
+ setChatContainer(c);
120
+ // Messages drawn before the chat was found (a resumed session) guessed their mode colour;
121
+ // draw them again now that they can be matched to the session.
122
+ if (c !== foundChat) {
123
+ foundChat = c;
124
+ tui?.requestRender();
125
+ }
126
+ };
127
+ registry.requestRender = render;
128
+ installToolRenderers(pi);
129
+ const modes = installModes(pi, state, render);
130
+ const submit = (text) => {
131
+ // pi's command dispatcher is the editor's onSubmit (wired by pi when it installs our editor).
132
+ const fn = editor?.onSubmit;
133
+ if (fn)
134
+ fn(text);
135
+ else
136
+ state.ctx?.ui.setEditorText(text);
137
+ };
138
+ const showLeaderHelp = () => {
139
+ const ctx = state.ctx;
140
+ if (!ctx || !theme)
141
+ return;
142
+ void ctx.ui.custom((_t, th, _kb, done) => {
143
+ const lines = leaderHelpLines(actions, th);
144
+ return {
145
+ invalidate() { },
146
+ handleInput: () => done(),
147
+ render: (w) => {
148
+ const inner = w - 4;
149
+ const b = (s) => th.fg("borderMuted", s);
150
+ return [b(`╭${"─".repeat(w - 2)}╮`), ...lines.map((l) => `${b("│")} ${fit(l, inner)} ${b("│")}`), b(`╰${"─".repeat(w - 2)}╯`)];
151
+ },
152
+ };
153
+ }, { overlay: true, overlayOptions: { anchor: "center", width: 56 } });
154
+ };
155
+ const actions = leaderActions({
156
+ toggleSidebar: () => {
157
+ layout?.toggle();
158
+ render();
159
+ },
160
+ cycleMode: () => {
161
+ if (state.ctx)
162
+ modes.cycle(state.ctx);
163
+ },
164
+ showHelp: showLeaderHelp,
165
+ });
166
+ const runLeader = (data) => {
167
+ const key = data.length === 1 ? data.toLowerCase() : undefined;
168
+ const action = actions.find((a) => a.key === key);
169
+ if (!action)
170
+ return false;
171
+ if (typeof action.run === "function")
172
+ action.run();
173
+ else
174
+ submit(action.run);
175
+ return true;
176
+ };
177
+ const openPalette = async (ctx) => {
178
+ const widgetItems = widgetActions().map((a) => ({
179
+ value: widgetActionValue(a.widgetId, a.id),
180
+ label: `${a.widgetTitle}: ${a.label}`,
181
+ description: a.description,
182
+ hint: "widget",
183
+ }));
184
+ const items = paletteItems(pi, BUILTIN_COMMANDS, actions, widgetItems);
185
+ const value = await ctx.ui.custom((_t, th, _kb, done) => new CommandPalette(items, th, done), { overlay: true, overlayOptions: { anchor: "top-center", width: "60%", minWidth: 50, offsetY: 3, maxHeight: "70%" } });
186
+ if (value)
187
+ runPaletteValue(value, ctx, actions, submit);
188
+ };
189
+ const modelParts = () => {
190
+ const m = state.ctx?.model;
191
+ if (!m)
192
+ return { model: "no model" };
193
+ const provider = m.provider ? m.provider[0].toUpperCase() + m.provider.slice(1) : undefined;
194
+ return { model: m.name ?? m.id, provider, effort: m.reasoning ? pi.getThinkingLevel() : undefined };
195
+ };
196
+ /** One line for the startup header: "Claude Opus 5.5 · Anthropic · medium". */
197
+ const modelLabel = () => {
198
+ const p = modelParts();
199
+ return [p.model, p.provider, p.effort].filter(Boolean).join(" · ");
200
+ };
201
+ /** True once the current branch has a user or assistant message. */
202
+ const sessionStarted = () => {
203
+ const c = state.ctx;
204
+ if (!c)
205
+ return false;
206
+ try {
207
+ return c.sessionManager.getBranch().some((e) => e.type === "message" && (e.message?.role === "user" || e.message?.role === "assistant"));
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ };
213
+ /** Most recent other session with messages in this directory (home screen resume hint). */
214
+ const refreshLastSession = async (ctx) => {
215
+ try {
216
+ const current = ctx.sessionManager.getSessionId();
217
+ const list = await SessionManager.list(ctx.cwd, ctx.sessionManager.getSessionDir());
218
+ const last = list
219
+ .filter((s) => s.id !== current && s.messageCount > 0)
220
+ .sort((a, b) => b.modified.getTime() - a.modified.getTime())[0];
221
+ if (state.ctx !== ctx)
222
+ return;
223
+ const label = sanitize(last?.name || last?.firstMessage || "");
224
+ state.lastSession = last ? { path: last.path, label: label || "untitled", modified: last.modified } : undefined;
225
+ render();
226
+ }
227
+ catch {
228
+ // listing is best-effort
229
+ }
230
+ };
231
+ const refreshGit = async (ctx) => {
232
+ const info = await readGit(ctx.cwd);
233
+ if (state.ctx !== ctx)
234
+ return;
235
+ // Repaint only on a change: this also runs on a timer.
236
+ if (JSON.stringify(info) === JSON.stringify(state.git))
237
+ return;
238
+ state.git = info;
239
+ render();
240
+ };
241
+ /**
242
+ * Git changes outside pi (a commit or checkout in another terminal, edits by other tools) fire
243
+ * no event, so poll. readGit skips optional locks, so this never blocks the user's own git.
244
+ */
245
+ let gitTimer;
246
+ let gitBusy = false;
247
+ const startGitPoll = (ctx) => {
248
+ stopGitPoll();
249
+ gitTimer = setInterval(() => {
250
+ if (gitBusy || state.ctx !== ctx)
251
+ return;
252
+ gitBusy = true;
253
+ void refreshGit(ctx).finally(() => (gitBusy = false));
254
+ }, GIT_POLL_MS);
255
+ gitTimer.unref?.();
256
+ };
257
+ const stopGitPoll = () => {
258
+ if (gitTimer)
259
+ clearInterval(gitTimer);
260
+ gitTimer = undefined;
261
+ };
262
+ const install = (ctx) => {
263
+ if (active || !enabled || ctx.mode !== "tui")
264
+ return;
265
+ active = true;
266
+ const placeholder = PLACEHOLDERS[Math.floor(Math.random() * PLACEHOLDERS.length)];
267
+ ctx.ui.setHeader((t, th) => {
268
+ tui = t;
269
+ theme = th;
270
+ // OpenCode shows its logo on the home screen only; once the session has a message the
271
+ // header collapses so the transcript starts at the top.
272
+ return {
273
+ invalidate() { },
274
+ render: (w) => sessionStarted() || state.home() ? [] : renderHeader(th, w, { model: modelLabel(), cwd: state.ctx?.cwd ?? ctx.cwd, version: VERSION }),
275
+ };
276
+ });
277
+ cleanupFooter = installFooter(ctx, state, (fn) => {
278
+ requestFooter = fn;
279
+ });
280
+ ctx.ui.setEditorComponent((t, editorTheme, keybindings) => {
281
+ tui = t;
282
+ editor = new PromptEditor(t, editorTheme, keybindings, {
283
+ state,
284
+ theme: () => theme ?? ctx.ui.theme,
285
+ modelParts,
286
+ placeholder: () => (state.home() ? `Ask anything… "${placeholder}"` : undefined),
287
+ blink: () => state.home(),
288
+ onModeCycle: () => {
289
+ if (state.ctx)
290
+ modes.cycle(state.ctx);
291
+ },
292
+ onLeaderKey: runLeader,
293
+ onLeaderState: () => tui?.requestRender(),
294
+ onPalette: () => {
295
+ if (state.ctx)
296
+ void openPalette(state.ctx);
297
+ },
298
+ onStatusChange: () => requestFooter?.(),
299
+ leaderTimeoutMs: LEADER_TIMEOUT_MS,
300
+ });
301
+ return editor;
302
+ });
303
+ theme = ctx.ui.theme;
304
+ cleanupMessages = installMessageStyle(state, () => theme ?? state.ctx?.ui.theme);
305
+ layout = installLayout(ctx, state, {
306
+ width: SIDEBAR_WIDTH,
307
+ minTerminalWidth: SIDEBAR_MIN_TERMINAL_WIDTH,
308
+ piVersion: VERSION,
309
+ home: () => !sessionStarted(),
310
+ tipIndex: Math.floor(Math.random() * TIPS.length),
311
+ });
312
+ state.sidebarShown = () => layout?.isShown() ?? false;
313
+ state.home = () => state.layout === "split" && !sessionStarted();
314
+ ctx.ui.setWorkingIndicator({ frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].map((f) => spinnerFrame(ctx.ui.theme, f)), intervalMs: 80 });
315
+ };
316
+ const uninstall = (ctx) => {
317
+ if (!active)
318
+ return;
319
+ active = false;
320
+ layout?.dispose();
321
+ layout = undefined;
322
+ state.sidebarShown = () => false;
323
+ state.home = () => false;
324
+ cleanupFooter?.();
325
+ cleanupFooter = undefined;
326
+ cleanupMessages?.();
327
+ cleanupMessages = undefined;
328
+ setChatContainer(undefined);
329
+ requestFooter = undefined;
330
+ if (ctx) {
331
+ try {
332
+ ctx.ui.setHeader(undefined);
333
+ ctx.ui.setEditorComponent(undefined);
334
+ ctx.ui.setWorkingIndicator();
335
+ ctx.ui.setHiddenThinkingLabel();
336
+ }
337
+ catch {
338
+ // stale ctx
339
+ }
340
+ }
341
+ editor?.dispose();
342
+ editor = undefined;
343
+ };
344
+ const stopWorkingTimer = () => {
345
+ if (workingTimer)
346
+ clearInterval(workingTimer);
347
+ workingTimer = undefined;
348
+ };
349
+ pi.registerShortcut("ctrl+alt+b", {
350
+ description: "chassis: toggle sidebar",
351
+ handler: () => {
352
+ layout?.toggle();
353
+ render();
354
+ },
355
+ });
356
+ pi.on("session_start", async (_e, ctx) => {
357
+ state.ctx = ctx;
358
+ registry.ctx = ctx;
359
+ state.workingSince = undefined;
360
+ state.lastDurationMs = undefined;
361
+ if (ctx.mode !== "tui")
362
+ return;
363
+ install(ctx);
364
+ modes.restore(ctx);
365
+ void refreshGit(ctx);
366
+ startGitPoll(ctx);
367
+ state.lastSession = undefined;
368
+ if (!sessionStarted())
369
+ void refreshLastSession(ctx);
370
+ // Resumed sessions render their history after session_start.
371
+ setTimeout(refreshChatContainer, 500);
372
+ render();
373
+ });
374
+ pi.on("session_shutdown", async (_e, ctx) => {
375
+ stopWorkingTimer();
376
+ stopGitPoll();
377
+ uninstall(ctx);
378
+ state.ctx = undefined;
379
+ registry.ctx = undefined;
380
+ registry.requestRender = () => { };
381
+ });
382
+ // The title goes out as an OSC sequence, and the folder name is chosen by whoever made the folder.
383
+ const sessionTitle = (ctx) => sanitize(plainTitle(ctx.cwd, safeName(ctx)));
384
+ const safeName = (ctx) => {
385
+ try {
386
+ return sanitize(ctx.sessionManager.getSessionName()) || undefined;
387
+ }
388
+ catch {
389
+ return undefined; // stale ctx after a session switch
390
+ }
391
+ };
392
+ /** Ring the bell and mark the title, for a run that has lasted long enough. */
393
+ const notifyUser = (ctx, reason, durationMs) => {
394
+ if (!active || ctx.mode !== "tui" || !shouldAlert(alertSettings, durationMs))
395
+ return;
396
+ try {
397
+ ringAlert({ write: (d) => process.stdout.write(d), setTitle: (t) => ctx.ui.setTitle(t) }, reason, sessionTitle(ctx));
398
+ titleMarked = true;
399
+ }
400
+ catch {
401
+ // a closed terminal or stale ctx: nothing to alert
402
+ }
403
+ };
404
+ const clearTitleMark = (ctx) => {
405
+ if (!titleMarked)
406
+ return;
407
+ titleMarked = false;
408
+ try {
409
+ ctx.ui.setTitle(sessionTitle(ctx));
410
+ }
411
+ catch {
412
+ // stale ctx
413
+ }
414
+ };
415
+ // A prompt that opens while the agent is running (a tool asking you to confirm, say) is the agent
416
+ // waiting for you. One you opened yourself while idle is not, so it has no duration.
417
+ pi.on("ui_prompt_start", async (_e, ctx) => notifyUser(ctx, "waiting", state.workingSince ? Date.now() - state.workingSince : undefined));
418
+ pi.on("ui_prompt_end", async (_e, ctx) => clearTitleMark(ctx));
419
+ pi.on("agent_start", async (e, ctx) => {
420
+ clearTitleMark(ctx);
421
+ telemetry.handle(e);
422
+ state.workingSince = Date.now();
423
+ state.lastDurationMs = undefined;
424
+ stopWorkingTimer();
425
+ workingTimer = setInterval(() => requestFooter?.(), 1000);
426
+ workingTimer.unref?.();
427
+ });
428
+ pi.on("turn_start", async (e) => {
429
+ telemetry.handle(e);
430
+ });
431
+ pi.on("message_start", async (e) => {
432
+ telemetry.handle(e);
433
+ if (e.message?.role === "assistant" || e.message?.role === "user")
434
+ peek.reset();
435
+ if (e.message?.role === "user")
436
+ setTimeout(refreshChatContainer, 0);
437
+ });
438
+ pi.on("message_update", async (e, ctx) => {
439
+ telemetry.handle(e);
440
+ if (!active || ctx.isIdle() || e.message?.role !== "assistant")
441
+ return;
442
+ peek.update(e.message);
443
+ });
444
+ pi.on("message_end", async (e) => {
445
+ telemetry.handle(e);
446
+ if (e.message?.role === "assistant")
447
+ peek.end();
448
+ render();
449
+ });
450
+ pi.on("turn_end", async (e) => {
451
+ telemetry.handle(e);
452
+ });
453
+ pi.on("tool_execution_start", async () => refreshChatContainer());
454
+ pi.on("tool_execution_end", async () => render());
455
+ pi.on("agent_end", async (e, ctx) => {
456
+ lastOutcome = runOutcome(e.messages);
457
+ stopWorkingTimer();
458
+ if (state.workingSince)
459
+ state.lastDurationMs = Date.now() - state.workingSince;
460
+ state.workingSince = undefined;
461
+ void refreshGit(ctx);
462
+ state.lastSession = undefined;
463
+ if (!sessionStarted())
464
+ void refreshLastSession(ctx);
465
+ render();
466
+ });
467
+ pi.on("agent_settled", async (e, ctx) => {
468
+ const t = telemetry.handle(e);
469
+ if (t && active && ctx.mode === "tui")
470
+ ctx.ui.notify(formatTelemetry(t, ctx.ui.theme), "info");
471
+ peek.reset();
472
+ // After retries and compaction: the run is over for good. A user abort never rings.
473
+ if (lastOutcome !== "aborted")
474
+ notifyUser(ctx, lastOutcome, state.lastDurationMs);
475
+ });
476
+ pi.on("model_select", async () => render());
477
+ pi.on("thinking_level_select", async () => render());
478
+ pi.on("session_tree", async (_e, ctx) => {
479
+ modes.restore(ctx);
480
+ render();
481
+ });
482
+ pi.on("session_compact", async () => render());
483
+ pi.on("session_info_changed", async () => render());
484
+ const resumeLastCommand = {
485
+ description: "chassis: resume the most recent other session in this directory",
486
+ handler: async (_args, ctx) => {
487
+ if (!state.lastSession)
488
+ await refreshLastSession(ctx);
489
+ const last = state.lastSession;
490
+ if (!last) {
491
+ ctx.ui.notify("No earlier session in this directory.", "info");
492
+ return;
493
+ }
494
+ await ctx.switchSession(last.path);
495
+ },
496
+ };
497
+ pi.registerCommand("chassis-resume-last", resumeLastCommand);
498
+ pi.registerCommand("widgets", {
499
+ description: "chassis widgets: /widgets [list|disable ID|enable ID|refresh ID]",
500
+ getArgumentCompletions: (prefix) => {
501
+ const parts = prefix.split(/\s+/);
502
+ if (parts.length <= 1) {
503
+ const opts = ["list", "disable", "enable", "refresh"].filter((o) => o.startsWith(parts[0] ?? ""));
504
+ return opts.length ? opts.map((o) => ({ value: o, label: o })) : null;
505
+ }
506
+ const ids = widgetInfo().map((w) => w.id).filter((id) => id.startsWith(parts[1] ?? ""));
507
+ return ids.length ? ids.map((id) => ({ value: `${parts[0]} ${id}`, label: id })) : null;
508
+ },
509
+ handler: async (args, ctx) => {
510
+ const [sub = "list", id] = args.trim().split(/\s+/);
511
+ if (sub === "disable" || sub === "enable" || sub === "refresh") {
512
+ const ok = !id ? false : sub === "refresh" ? refreshWidget(id) : setWidgetDisabled(id, sub === "disable");
513
+ ctx.ui.notify(ok ? `widget ${id}: ${sub}d`.replace("refreshd", "refreshing") : `No widget ${id ? `"${id}"` : "id given"}. Try /widgets list.`, ok ? "info" : "warning");
514
+ return;
515
+ }
516
+ ctx.ui.notify(formatWidgetList(widgetInfo()), "info");
517
+ },
518
+ });
519
+ const chassisCommand = {
520
+ description: "chassis: /chassis [on|off|sidebar|status|alert [off|on|SECONDS]|icons [nerd|plain|auto]]",
521
+ getArgumentCompletions: (prefix) => {
522
+ const opts = ["on", "off", "sidebar", "status", "alert", "icons"].filter((o) => o.startsWith(prefix.trim()));
523
+ return opts.length ? opts.map((o) => ({ value: o, label: o })) : null;
524
+ },
525
+ handler: async (args, ctx) => {
526
+ const sub = args.trim().toLowerCase();
527
+ if (sub === "icons" || sub.startsWith("icons ")) {
528
+ const arg = sub.slice("icons".length).trim();
529
+ if (arg) {
530
+ if (arg !== "nerd" && arg !== "plain" && arg !== "auto") {
531
+ ctx.ui.notify("Usage: /chassis icons [nerd|plain|auto]", "warning");
532
+ return;
533
+ }
534
+ const choice = arg === "auto" ? undefined : arg;
535
+ setIconSetting(choice);
536
+ saveIconSetting(choice);
537
+ invalidateWidgets();
538
+ refreshChatContainer();
539
+ render();
540
+ }
541
+ const src = iconSource();
542
+ const why = src === "env" ? " (set by PI_CHASSIS_ICONS, which wins over the saved setting)" : src === "setting" ? "" : src === "brand" ? " (the brand's default)" : " (the default)";
543
+ ctx.ui.notify(`icons: ${iconSet()}${why}. Nerd needs a Nerd Font in the terminal. /chassis icons nerd|plain|auto`, "info");
544
+ return;
545
+ }
546
+ if (sub === "alert" || sub.startsWith("alert ")) {
547
+ const arg = sub.slice("alert".length).trim();
548
+ if (arg) {
549
+ const change = parseAlertArg(arg);
550
+ if (!change) {
551
+ ctx.ui.notify("Usage: /chassis alert [off|on|SECONDS] (for example 45, 90s or 2m)", "warning");
552
+ return;
553
+ }
554
+ alertSettings = { ...alertSettings, ...change };
555
+ saveAlert(alertSettings);
556
+ }
557
+ ctx.ui.notify(describeAlert(alertSettings), "info");
558
+ return;
559
+ }
560
+ if (sub === "off") {
561
+ enabled = false;
562
+ uninstall(ctx);
563
+ ctx.ui.notify("chassis off for this session. /chassis on to restore.", "info");
564
+ return;
565
+ }
566
+ if (sub === "on") {
567
+ enabled = true;
568
+ install(ctx);
569
+ render();
570
+ return;
571
+ }
572
+ if (sub === "sidebar") {
573
+ layout?.toggle();
574
+ render();
575
+ return;
576
+ }
577
+ const widgets = [...registry.widgets.keys()].join(", ") || "none";
578
+ ctx.ui.notify(`chassis ${active ? "on" : "off"} · layout ${state.layout} · sidebar ${layout?.isShown() ? "shown" : "hidden"} · mode ${state.mode}\nwidgets: ${widgets}`, "info");
579
+ },
580
+ };
581
+ pi.registerCommand("chassis", chassisCommand);
582
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Sidebar layout.
3
+ *
4
+ * Fullscreen mode: pi renders one layout root (a VStack of transcript + input dock). We wrap it in
5
+ * an HStack with the sidebar as a fixed-width right column, which is what OpenCode looks like.
6
+ * There is no extension API for this: it relies on the TUI's `layoutRoot` field and
7
+ * `setLayoutRoot()` (pi-tui ViewportTUI), verified on pi 1.1.0. If either is missing, or pi
8
+ * swaps the root back (renderer switch), the guard widget re-wraps it or we fall back.
9
+ *
10
+ * Regular mode has no layout root, so the sidebar is a toggleable overlay instead.
11
+ *
12
+ * Home screen: until the session has a message, the same root swaps to OpenCode's home layout
13
+ * (home.ts): logo, centred prompt, tip and a bottom bar, with no transcript or sidebar. The root
14
+ * picks its layout node each frame, so the switch happens on the first message with no remount.
15
+ */
16
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
17
+ import type { UiState } from "./state.ts";
18
+ /**
19
+ * The tree to search for pi's own components. Once chassis has wrapped the layout root in its
20
+ * sidebar split, `tui.layoutRoot` is a plain object with no children; pi's real root hangs off it
21
+ * under a symbol. Without it, a search for message and tool components finds nothing.
22
+ */
23
+ export declare function piRootOf(tui: unknown): unknown;
24
+ export interface LayoutOptions {
25
+ width: number;
26
+ /** Hide the split sidebar below this terminal width. */
27
+ minTerminalWidth: number;
28
+ piVersion: string;
29
+ /** True while the home screen should show (no message in the session yet). */
30
+ home: () => boolean;
31
+ /** Tip index for this launch. */
32
+ tipIndex: number;
33
+ }
34
+ export interface LayoutHandle {
35
+ /** True when the sidebar is currently on screen. */
36
+ isShown(): boolean;
37
+ toggle(): void;
38
+ dispose(): void;
39
+ }
40
+ export declare function installLayout(ctx: ExtensionContext, state: UiState, opts: LayoutOptions): LayoutHandle;