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,258 @@
1
+ /**
2
+ * Colours and width-safe text helpers.
3
+ *
4
+ * The chrome follows the active theme. The few colours that come from the brand (see brand.ts)
5
+ * are the primary mark colour, the plan-mode colour, a quiet neutral, and the two surface shades.
6
+ */
7
+
8
+ import { homedir } from "node:os";
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import { backgroundAnsi, type Color, colorToHex, colorToOkhsl, colorToRgb, foregroundAnsi, okhslColor, parseColor, rgbColor, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+ import type { WidgetColor, WidgetLevel } from "./api.ts";
12
+ import { type BrandColors, type BrandSurfaces, getBrand } from "./brand.ts";
13
+ import { safeGlyph } from "./icons.ts";
14
+ import { scrubText } from "./sanitize.ts";
15
+
16
+ const parsed = new Map<string, Color>();
17
+
18
+ /** Parse a colour string once; an unusable value falls back to `fallback`. */
19
+ export function toColor(value: string, fallback = "#808080"): Color {
20
+ let c = parsed.get(value);
21
+ if (!c) {
22
+ try {
23
+ c = parseColor(value);
24
+ } catch {
25
+ c = toColor(fallback, "#808080");
26
+ }
27
+ parsed.set(value, c);
28
+ }
29
+ return c;
30
+ }
31
+
32
+ /** A brand colour, parsed. */
33
+ export const brandColor = (name: keyof BrandColors): Color => toColor(getBrand().colors[name]);
34
+
35
+ /** Paint text with a concrete colour, honouring the theme's colour mode (truecolor/256). */
36
+ export function paint(theme: Theme, color: Color, text: string): string {
37
+ return `${foregroundAnsi(color, theme.getColorMode())}${text}\x1b[39m`;
38
+ }
39
+
40
+ /** True under a light theme. A theme without an `appearance` (a test double) counts as dark. */
41
+ export const isLight = (theme: Theme): boolean => theme.appearance === "light";
42
+
43
+ /** Relative luminance of a colour, 0 (black) to 1 (white). */
44
+ function luminance(color: Color): number {
45
+ const { r, g, b } = colorToRgb(color);
46
+ const lin = (v: number) => (v / 255 <= 0.03928 ? v / 255 / 12.92 : ((v / 255 + 0.055) / 1.055) ** 2.4);
47
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
48
+ }
49
+
50
+ /** WCAG contrast ratio of two colours, 1 to 21. */
51
+ export function contrast(a: Color, b: Color): number {
52
+ const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x) as [number, number];
53
+ return (hi + 0.05) / (lo + 0.05);
54
+ }
55
+
56
+ const readableCache = new Map<string, Color>();
57
+
58
+ /**
59
+ * `color` moved along its own lightness (hue and saturation kept) until it has at least `min`
60
+ * contrast against `bg`: darker on a light background, lighter on a dark one. A colour that is
61
+ * readable already comes back as it is. On a background in the middle, where that direction cannot
62
+ * reach `min`, the other direction is tried; failing both, black or white, whichever reads better.
63
+ */
64
+ export function readableOn(color: Color, bg: Color, min = 4.5): Color {
65
+ if (contrast(color, bg) >= min) return color;
66
+ const key = `${colorToHex(color)}|${colorToHex(bg)}|${min}`;
67
+ let out = readableCache.get(key);
68
+ if (!out) {
69
+ const { h, s, l } = colorToOkhsl(color);
70
+ const towardsDark = luminance(bg) > 0.5;
71
+ const search = (step: number): Color | undefined => {
72
+ for (let i = 1; i <= 50; i++) {
73
+ const c = okhslColor(h, s, Math.min(1, Math.max(0, l + step * i)));
74
+ if (contrast(c, bg) >= min) return c;
75
+ }
76
+ return undefined;
77
+ };
78
+ const black = rgbColor(0, 0, 0);
79
+ const white = rgbColor(255, 255, 255);
80
+ out = search(towardsDark ? -0.02 : 0.02) ?? search(towardsDark ? 0.02 : -0.02) ?? (contrast(black, bg) >= contrast(white, bg) ? black : white);
81
+ readableCache.set(key, out);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * A brand or widget colour used as text. On a light theme it is darkened until it can be read on the
88
+ * surfaces; on a dark theme it is used as given, which is how the brand colours were chosen.
89
+ */
90
+ export const textColor = (theme: Theme, color: Color): Color => (isLight(theme) ? readableOn(color, surfaceColor(theme, "element")) : color);
91
+
92
+ /** Text in the brand's primary colour: the active-mode mark, the brand glyph, the header art. */
93
+ export const primary = (theme: Theme, text: string) => paint(theme, textColor(theme, brandColor("primary")), text);
94
+
95
+ /**
96
+ * One frame of the working spinner: the glyph in the brand's primary colour, so it follows whatever
97
+ * brand is installed. A primary colour that cannot be parsed falls back to the theme's accent.
98
+ */
99
+ export function spinnerFrame(theme: Theme, glyph: string): string {
100
+ try {
101
+ parseColor(getBrand().colors.primary);
102
+ } catch {
103
+ return theme.fg("accent", glyph);
104
+ }
105
+ return primary(theme, glyph);
106
+ }
107
+
108
+ /** Text in the brand's neutral colour. */
109
+ export const neutral = (theme: Theme, text: string) => paint(theme, textColor(theme, brandColor("neutral")), text);
110
+
111
+ /** Text in the plan-mode colour. */
112
+ export const planColor = (theme: Theme, text: string) => paint(theme, textColor(theme, brandColor("plan")), text);
113
+
114
+ /** The brand mark and name: "<glyph> <name>", or just the glyph. Painted; no version. */
115
+ export function brandMark(theme: Theme): string {
116
+ const b = getBrand();
117
+ const glyph = primary(theme, safeGlyph(b.glyph));
118
+ return b.name ? `${glyph} ${theme.bold(theme.fg("text", b.name))}` : glyph;
119
+ }
120
+
121
+ /** "<mark> pi 1.1.0" (plus " · chassis 0.1.0" when given): the brand line on the home screen bottom bar and in the sidebar. */
122
+ export function brandLine(theme: Theme, version: string, chassisVersion?: string): string {
123
+ const chassis = chassisVersion ? ` ${theme.fg("dim", "·")} ${theme.fg("muted", `chassis ${chassisVersion}`)}` : "";
124
+ return `${brandMark(theme)} ${theme.fg("muted", `pi ${version}`)}${chassis}`;
125
+ }
126
+
127
+ const LEVEL_COLOR: Record<WidgetLevel, WidgetColor> = {
128
+ ok: "success",
129
+ warn: "warning",
130
+ error: "error",
131
+ off: "dim",
132
+ info: "text",
133
+ };
134
+
135
+ /**
136
+ * Surface shades, OpenCode-style steps above the background (OpenCode: #0a0a0a, panel #141414,
137
+ * element #1e1e1e):
138
+ * panel sidebar, user messages, tool blocks
139
+ * element the prompt box
140
+ * The brand supplies the values: `surfaces` for a dark theme, `lightSurfaces` for a light one. A theme should use the same ones for userMessageBg,
141
+ * toolSuccessBg and customMessageBg, so pi's own blocks match ours.
142
+ */
143
+ export type Surface = keyof BrandSurfaces;
144
+
145
+ export const surfaceColor = (theme: Theme, surface: Surface): Color => {
146
+ const b = getBrand();
147
+ return toColor((isLight(theme) ? b.lightSurfaces : b.surfaces)[surface]);
148
+ };
149
+
150
+ export const surfaceBg = (theme: Theme, surface: Surface) => backgroundAnsi(surfaceColor(theme, surface), theme.getColorMode());
151
+
152
+ /**
153
+ * Paint a line on a surface background across exactly `width` columns. Resets inside the line
154
+ * (\x1b[0m, \x1b[49m) would end the shading early, so the background is re-applied after each.
155
+ */
156
+ export function shade(theme: Theme, line: string, width: number, surface: Surface = "panel"): string {
157
+ const bg = surfaceBg(theme, surface);
158
+ const body = fit(line, width).replace(/\x1b\[0m/g, `\x1b[0m${bg}`).replace(/\x1b\[49m/g, bg);
159
+ return `${bg}${body}\x1b[49m`;
160
+ }
161
+
162
+ /** Resolve a widget colour (theme token, brand colour or hex) and paint text with it. */
163
+ export function widgetPaint(theme: Theme, color: WidgetColor | undefined, level: WidgetLevel | undefined, text: string): string {
164
+ const c = color ?? (level ? LEVEL_COLOR[level] : "text");
165
+ switch (c) {
166
+ case "brand":
167
+ return primary(theme, text);
168
+ case "brandDark":
169
+ return paint(theme, textColor(theme, brandColor("primaryDark")), text);
170
+ case "plan":
171
+ return planColor(theme, text);
172
+ default:
173
+ if (c.startsWith("#")) return paint(theme, textColor(theme, toColor(c)), text);
174
+ return theme.fg(c as Parameters<Theme["fg"]>[0], text);
175
+ }
176
+ }
177
+
178
+ /** Pad or truncate a (possibly styled) line to exactly `width` columns. */
179
+ export function fit(line: string, width: number): string {
180
+ if (width <= 0) return "";
181
+ const t = truncateToWidth(line, width, "…");
182
+ return t + " ".repeat(Math.max(0, width - visibleWidth(t)));
183
+ }
184
+
185
+ export { sanitize, sanitizeKeepColor, sanitizeLines, scrubText, stripEscapes } from "./sanitize.ts";
186
+
187
+ /** True if a string sets its own foreground colour. */
188
+ export function hasOwnColor(text: string): boolean {
189
+ for (const m of text.matchAll(/\x1b\[([0-9;:]*)m/g)) {
190
+ const params = m[1]!.split(/[;:]/).map(Number);
191
+ for (let i = 0; i < params.length; i++) {
192
+ const p = params[i]!;
193
+ if ((p >= 30 && p <= 37) || (p >= 90 && p <= 97) || p === 38) return true;
194
+ if (p === 48) i += params[i + 1] === 2 ? 4 : 2;
195
+ }
196
+ }
197
+ return false;
198
+ }
199
+
200
+ export function fmtTokens(n: number): string {
201
+ if (!Number.isFinite(n) || n < 1000) return String(Math.max(0, Math.round(n || 0)));
202
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
203
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
204
+ if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
205
+ return `${Math.round(n / 1_000_000)}M`;
206
+ }
207
+
208
+ export function fmtDuration(ms: number): string {
209
+ const s = Math.max(0, Math.floor(ms / 1000));
210
+ if (s < 60) return `${s}s`;
211
+ const m = Math.floor(s / 60);
212
+ if (m < 60) return `${m}m ${s % 60}s`;
213
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
214
+ }
215
+
216
+ /** "just now", "5m ago", "3h ago", "2d ago", "6w ago". */
217
+ export function fmtAgo(ms: number): string {
218
+ const m = Math.floor(Math.max(0, ms) / 60_000);
219
+ if (m < 1) return "just now";
220
+ if (m < 60) return `${m}m ago`;
221
+ const h = Math.floor(m / 60);
222
+ if (h < 24) return `${h}h ago`;
223
+ const d = Math.floor(h / 24);
224
+ if (d < 14) return `${d}d ago`;
225
+ return `${Math.floor(d / 7)}w ago`;
226
+ }
227
+
228
+ /**
229
+ * `path` as shown on screen: the home directory written as `~`, and escape sequences and control
230
+ * characters removed, because a directory name is chosen by whoever made the directory. Uses the OS
231
+ * home directory (HOME on Unix, the user profile on Windows) and accepts either path separator
232
+ * after it.
233
+ */
234
+ export function homeRelative(path: string): string {
235
+ return scrubText(withTilde(path));
236
+ }
237
+
238
+ function withTilde(path: string): string {
239
+ let home: string;
240
+ try {
241
+ home = homedir();
242
+ } catch {
243
+ return path;
244
+ }
245
+ if (!home) return path;
246
+ const sameCase = (a: string, b: string) => (process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b);
247
+ if (sameCase(path, home)) return "~";
248
+ const head = path.slice(0, home.length);
249
+ const next = path[home.length];
250
+ return sameCase(head, home) && (next === "/" || next === "\\") ? `~${path.slice(home.length)}` : path;
251
+ }
252
+
253
+ /** Index of the last path separator ("/" or "\\"), or -1. */
254
+ export function lastSep(path: string): number {
255
+ return Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
256
+ }
257
+
258
+ export const finite = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Thinking peek: while the model reasons with thinking blocks hidden, replace pi's
3
+ * "Thinking..." label with a spinner and the tail of the reasoning.
4
+ *
5
+ * Adapted from pi-open-tui's peek.ts (MIT, Copyright (c) 2026 pi-open-tui contributors;
6
+ * see THIRD_PARTY_NOTICES).
7
+ *
8
+ * pi only offers a global setHiddenThinkingLabel(), which would rewrite every past message's
9
+ * label. To update just the streaming message, this finds the latest AssistantMessageComponent
10
+ * (duck-typed: setHiddenThinkingLabel + updateContent) in the TUI tree, as pi-open-tui does.
11
+ */
12
+
13
+ import type { TUI } from "@earendil-works/pi-tui";
14
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+ import { piRootOf } from "./layout.ts";
16
+ import { sanitize } from "./palette.ts";
17
+
18
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
19
+
20
+ export type PeekPhase = "idle" | "thinking" | "done";
21
+
22
+ export interface PeekState {
23
+ phase: PeekPhase;
24
+ tail: string;
25
+ }
26
+
27
+ export function createPeek(): PeekState {
28
+ return { phase: "idle", tail: "" };
29
+ }
30
+
31
+ /** Cumulative thinking/text of an assistant message's content parts. */
32
+ export function collectParts(content: unknown): { thinking: string; text: string } {
33
+ let thinking = "";
34
+ let text = "";
35
+ if (!Array.isArray(content)) return { thinking, text };
36
+ for (const p of content as any[]) {
37
+ if (p?.type === "thinking") thinking += typeof p.thinking === "string" ? p.thinking : (p.text ?? "");
38
+ else if (p?.type === "text") text += p.text ?? "";
39
+ }
40
+ return { thinking, text };
41
+ }
42
+
43
+ /** Last non-empty line of the thinking text, whitespace-collapsed. */
44
+ export function lastLine(thinking: string): string {
45
+ const lines = thinking.split("\n");
46
+ for (let i = lines.length - 1; i >= 0; i--) {
47
+ const l = lines[i]!.replace(/\s+/g, " ").trim();
48
+ if (l) return l;
49
+ }
50
+ return "";
51
+ }
52
+
53
+ /**
54
+ * Phase machine: idle+thinking -> thinking; thinking+text -> done; done never restarts.
55
+ * Text without thinking leaves idle alone (nothing to peek at).
56
+ */
57
+ export function reducePeek(prev: PeekState, parts: { thinking: string; text: string }): PeekState {
58
+ const tail = parts.thinking ? lastLine(parts.thinking) : prev.tail;
59
+ let phase = prev.phase;
60
+ if (parts.thinking && phase === "idle") phase = "thinking";
61
+ if (parts.text && phase === "thinking") phase = "done";
62
+ return { phase, tail };
63
+ }
64
+
65
+ /** Keep the end of `text` within `width` columns, prefixing "…" when clipped. */
66
+ export function clipTail(text: string, width: number): string {
67
+ if (width <= 0) return "";
68
+ if (visibleWidth(text) <= width) return text;
69
+ const chars = [...text];
70
+ let out = "";
71
+ for (let i = chars.length - 1; i >= 0; i--) {
72
+ if (visibleWidth(`…${chars[i]}${out}`) > width) break;
73
+ out = chars[i] + out;
74
+ }
75
+ return `…${out}`;
76
+ }
77
+
78
+ export function peekLabel(state: PeekState, frame: number, width: number): string {
79
+ const w = Math.max(1, width);
80
+ if (state.phase === "thinking") {
81
+ const marker = `${SPINNER[frame % SPINNER.length]} thinking`;
82
+ const tail = sanitize(state.tail);
83
+ const room = w - visibleWidth(marker) - 1;
84
+ return truncateToWidth(tail && room > 4 ? `${marker} ${clipTail(tail, room)}` : marker, w, "…");
85
+ }
86
+ if (state.phase === "done") return truncateToWidth("✓ thought", w, "…");
87
+ return truncateToWidth("· thinking", w, "…");
88
+ }
89
+
90
+ interface LabelTarget {
91
+ setHiddenThinkingLabel(label: string): void;
92
+ updateContent(message: unknown, streaming?: boolean): void;
93
+ children: unknown[];
94
+ }
95
+
96
+ const isTarget = (v: any): v is LabelTarget =>
97
+ v && Array.isArray(v.children) && typeof v.setHiddenThinkingLabel === "function" && typeof v.updateContent === "function";
98
+
99
+ /** Latest assistant message component in the TUI tree, or undefined. */
100
+ export function findLatestAssistant(root: unknown): LabelTarget | undefined {
101
+ const stack: unknown[] = [root];
102
+ const seen = new Set<object>();
103
+ let latest: LabelTarget | undefined;
104
+ while (stack.length) {
105
+ const v = stack.pop() as any;
106
+ if (!v || typeof v !== "object" || seen.has(v)) continue;
107
+ seen.add(v);
108
+ if (isTarget(v)) latest = v;
109
+ // Layout nodes keep children in `children`; ScrollView keeps its content in `child`.
110
+ if (v.child) stack.push(v.child);
111
+ const kids = v.children;
112
+ if (Array.isArray(kids)) for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
113
+ }
114
+ return latest;
115
+ }
116
+
117
+ /** Drives the peek label for the streaming message. */
118
+ export class ThinkingPeek {
119
+ private state = createPeek();
120
+ private frame = 0;
121
+ private target: LabelTarget | undefined;
122
+ private active = false;
123
+
124
+ private readonly getTui: () => TUI | undefined;
125
+ private readonly width: () => number;
126
+
127
+ constructor(getTui: () => TUI | undefined, width: () => number) {
128
+ this.getTui = getTui;
129
+ this.width = width;
130
+ }
131
+
132
+ reset(): void {
133
+ this.state = createPeek();
134
+ this.target = undefined;
135
+ }
136
+
137
+ /** Feed an assistant message_update. Returns true when the label changed. */
138
+ update(message: any): void {
139
+ if (this.state.phase === "done") return;
140
+ const next = reducePeek(this.state, collectParts(message?.content));
141
+ if (next.phase === this.state.phase && next.tail === this.state.tail) return;
142
+ this.state = next;
143
+ if (next.phase === "thinking") this.frame++;
144
+ if (next.phase !== "idle") this.apply();
145
+ }
146
+
147
+ /** Assistant sub-message ended while still thinking: freeze the label. */
148
+ end(): void {
149
+ if (this.state.phase !== "thinking") return;
150
+ this.state = { ...this.state, phase: "done" };
151
+ this.apply();
152
+ }
153
+
154
+ private apply(): void {
155
+ const tui = this.getTui();
156
+ if (!tui) return;
157
+ this.target ??= findLatestAssistant(piRootOf(tui));
158
+ if (!this.target) return;
159
+ this.target.setHiddenThinkingLabel(peekLabel(this.state, this.frame, this.width()));
160
+ this.active = true;
161
+ tui.requestRender();
162
+ }
163
+
164
+ get isActive(): boolean {
165
+ return this.active;
166
+ }
167
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The agent and model row at the bottom of the prompt box:
3
+ *
4
+ * 󰚩 BUILD Sonnet 4.5 Anthropic medium
5
+ * ^^^^^^^^^^^
6
+ * a filled pill in the mode colour (brand primary for build, brand plan colour for plan) with the
7
+ * mode's icon, then the model, the provider, and the thinking level in chassis's own colour for it.
8
+ *
9
+ * Pure functions over a theme, so the row can be tested without an editor. With a Nerd Font the pill
10
+ * caps are the Powerline round caps and the mode has an icon; without one the caps are half blocks
11
+ * and the pill is just the label (icons.ts).
12
+ */
13
+
14
+ import type { Theme } from "@earendil-works/pi-coding-agent";
15
+ import { backgroundAnsi, type Color, foregroundAnsi, truncateToWidth } from "@earendil-works/pi-tui";
16
+ import { icon, withIcon } from "./icons.ts";
17
+ import { brandColor, isLight, paint, readableOn, surfaceColor, toColor } from "./palette.ts";
18
+
19
+ export type Mode = "build" | "plan";
20
+
21
+ /** The mode's icon in the current set: a robot for build and an eye for plan, or none. */
22
+ export const modeIcon = (mode: Mode): string => icon(mode === "plan" ? "modePlan" : "modeBuild");
23
+
24
+ /** The mode's colour as a parsed colour: the brand plan colour for plan, the primary for build. */
25
+ export const modeColorValue = (mode: Mode) => brandColor(mode === "plan" ? "plan" : "primary");
26
+
27
+ /** The pill's text: the dark panel shade on a dark theme, near-black on a light one, so it reads on the mode colour either way. */
28
+ const pillInk = (theme: Theme): Color => (isLight(theme) ? toColor("#121212") : surfaceColor(theme, "panel"));
29
+
30
+ /** A filled pill with round caps: "<icon> <LABEL> " in dark text on the mode colour. The left cap is already a curve, so the icon sits right against it. */
31
+ export function modePill(theme: Theme, mode: Mode): string {
32
+ const color = modeColorValue(mode);
33
+ const depth = theme.getColorMode();
34
+ const label = withIcon(modeIcon(mode), mode === "plan" ? "PLAN" : "BUILD");
35
+ // The row is shaded on the element surface, so the caps only set a foreground; the body sets
36
+ // both, and ends with \x1b[49m, which shade() turns back into the surface colour.
37
+ const body = `${backgroundAnsi(color, depth)}${foregroundAnsi(pillInk(theme), depth)}\x1b[1m${label} \x1b[22m\x1b[39m\x1b[49m`;
38
+ return `${paint(theme, color, icon("capLeft"))}${body}${paint(theme, color, icon("capRight"))}`;
39
+ }
40
+
41
+ /**
42
+ * One colour per thinking level, chassis's own and the same under every theme and brand. It warms
43
+ * up with the effort: grey when off, then blue, violet and amber, and red only at the top, so a red
44
+ * label means the most expensive setting and nothing else. Bright enough for the dark panel shades;
45
+ * under a light theme thinkingLabel darkens the same hues.
46
+ */
47
+ export const THINKING_COLORS: Readonly<Record<string, string>> = {
48
+ off: "#6E716E",
49
+ minimal: "#8E9398",
50
+ low: "#7FA6C9",
51
+ medium: "#5F9BE0",
52
+ high: "#A48BE0",
53
+ xhigh: "#E0A94F",
54
+ max: "#F0505F",
55
+ };
56
+
57
+ /**
58
+ * The thinking level's name in its colour from THINKING_COLORS; nothing for a model that does not
59
+ * reason. An unknown level takes the "off" colour.
60
+ */
61
+ export function thinkingLabel(theme: Theme, level: string | undefined): string {
62
+ if (!level) return "";
63
+ const color = toColor(THINKING_COLORS[level] ?? THINKING_COLORS.off!);
64
+ // Chosen for the dark panels; on a light theme the same hue is darkened until it can be read.
65
+ return paint(theme, isLight(theme) ? readableOn(color, surfaceColor(theme, "element")) : color, level);
66
+ }
67
+
68
+ export interface ModelParts {
69
+ /** Model name as pi reports it, for example "Claude Sonnet 5.5". */
70
+ model: string;
71
+ provider?: string;
72
+ /** Thinking level, when the model reasons. */
73
+ effort?: string;
74
+ }
75
+
76
+ /** The whole row, cut to `width` columns. */
77
+ export function promptRow(theme: Theme, mode: Mode, parts: ModelParts, width: number): string {
78
+ const name = parts.model.replace(/^Claude /, "");
79
+ const segments = [
80
+ modePill(theme, mode),
81
+ theme.fg("text", name),
82
+ ...(parts.provider ? [theme.fg("muted", parts.provider)] : []),
83
+ thinkingLabel(theme, parts.effort),
84
+ ].filter(Boolean);
85
+ return truncateToWidth(segments.join(" "), width, "…");
86
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Turns a provider status spec (provider.ts) into what the sidebar and footer draw.
3
+ */
4
+
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import type { WidgetColor } from "./api.ts";
7
+ import { sanitizeKeepColor } from "./palette.ts";
8
+ import { getProviderStatus, providerStatusKeys, type ProviderStatusSpec } from "./provider.ts";
9
+
10
+ export interface ResolvedMeter {
11
+ label: string;
12
+ percent?: number;
13
+ detail?: string;
14
+ color?: WidgetColor;
15
+ /** The status's own colour escape, for a `statusKey` meter whose provider colours it. */
16
+ sgr?: string;
17
+ }
18
+
19
+ const stripSgr = (s: string) => s.replace(/\x1b\[[0-9;:]*m/g, "");
20
+ const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
21
+
22
+ /** Drop a leading glyph and the provider's name or id from a status, leaving "71%" or "71% of plan". */
23
+ function withoutLabel(text: string, names: string[]): string {
24
+ let out = text.replace(/^[^\p{L}\p{N}]+/u, "");
25
+ for (const n of names) {
26
+ if (!n) continue;
27
+ const m = out.match(new RegExp(`^${escapeRe(n)}\\b[\\s:·-]*`, "i"));
28
+ if (m) {
29
+ out = out.slice(m[0].length);
30
+ break;
31
+ }
32
+ }
33
+ return out.trim();
34
+ }
35
+
36
+ function namesOf(spec: ProviderStatusSpec): string[] {
37
+ return [spec.name ?? "", ...(Array.isArray(spec.provider) ? spec.provider : [spec.provider])];
38
+ }
39
+
40
+ /** The bars for the provider section: the `statusKey` percentage first, then `meters`. */
41
+ export function providerMeters(spec: ProviderStatusSpec, ctx: ExtensionContext, statuses: ReadonlyMap<string, string>): ResolvedMeter[] {
42
+ const out: ResolvedMeter[] = [];
43
+ const raw = spec.statusKey ? statuses.get(spec.statusKey) : undefined;
44
+ if (raw) {
45
+ const clean = sanitizeKeepColor(raw);
46
+ const pct = Number.parseFloat(withoutLabel(stripSgr(clean), namesOf(spec)).match(/\d+(?:\.\d+)?/)?.[0] ?? "");
47
+ if (Number.isFinite(pct)) out.push({ label: spec.label ?? "Plan", percent: pct, sgr: clean.match(/\x1b\[[0-9;:]*m/)?.[0] });
48
+ }
49
+ if (spec.meters) {
50
+ try {
51
+ for (const m of spec.meters(ctx) ?? []) out.push({ ...m, percent: Number.isFinite(m.percent) ? m.percent : undefined });
52
+ } catch {
53
+ // a broken provider hook must not take the sidebar down
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+
59
+ /** Short text for the footer, next to the context: the status without its label (keeping its colour), or the bars as "Plan 71%". */
60
+ export function providerFooterText(spec: ProviderStatusSpec, ctx: ExtensionContext, statuses: ReadonlyMap<string, string>): string {
61
+ const raw = spec.statusKey ? statuses.get(spec.statusKey) : undefined;
62
+ if (raw) {
63
+ const clean = sanitizeKeepColor(raw);
64
+ const text = withoutLabel(stripSgr(clean), namesOf(spec));
65
+ const sgr = clean.match(/\x1b\[[0-9;:]*m/)?.[0];
66
+ return sgr && text ? `${sgr}${text}\x1b[39m` : text;
67
+ }
68
+ return providerMeters(spec, ctx, statuses)
69
+ .filter((m) => m.percent !== undefined)
70
+ .map((m) => `${m.label} ${Math.round(m.percent!)}%`)
71
+ .join(" ");
72
+ }
73
+
74
+ /**
75
+ * Heading for the provider: the spec's `name`, else the name the provider registered with pi
76
+ * (`ctx.modelRegistry.getProviderDisplayName`), else the id with a capital letter. A provider's
77
+ * name is often its login dialog title, such as "Acme (Builder ID / Google / GitHub)", so a
78
+ * trailing parenthetical is dropped to leave "Acme".
79
+ */
80
+ export function providerDisplayName(ctx: ExtensionContext | undefined, spec?: ProviderStatusSpec): string {
81
+ if (spec?.name) return spec.name;
82
+ const id = ctx?.model?.provider;
83
+ if (!id) return "no provider";
84
+ let name: string | undefined;
85
+ try {
86
+ name = ctx?.modelRegistry?.getProviderDisplayName(id);
87
+ } catch {
88
+ // stale ctx or an older pi
89
+ }
90
+ const short = name?.replace(/\s*\([^)]*\)\s*$/, "").trim();
91
+ return short && short !== id ? short : id.replace(/^./, (x) => x.toUpperCase());
92
+ }
93
+
94
+ /** The implicit spec for a provider that follows the `<provider>-usage` status convention. */
95
+ const conventional = (provider: string): ProviderStatusSpec => ({ provider, statusKey: `${provider}-usage` });
96
+
97
+ /**
98
+ * The spec in effect for the active provider: the registered one, else, when the provider publishes
99
+ * a `<provider>-usage` status that carries a percentage (a convention some provider extensions follow), one built from
100
+ * that convention, so a provider needs no registration just to get its Plan bar. A status without a
101
+ * number is left alone and shows in the generic status list.
102
+ */
103
+ export function activeProviderStatus(ctx: ExtensionContext | undefined, statuses: ReadonlyMap<string, string>): ProviderStatusSpec | undefined {
104
+ const id = ctx?.model?.provider;
105
+ if (!id) return undefined;
106
+ const registered = getProviderStatus(id);
107
+ if (registered) return registered;
108
+ const spec = conventional(id);
109
+ return providerMeters(spec, ctx!, statuses).length ? spec : undefined;
110
+ }
111
+
112
+ /** Status keys that belong to the provider section, so the generic status lists skip them. */
113
+ export function claimedProviderKeys(ctx: ExtensionContext | undefined, statuses: ReadonlyMap<string, string>): Set<string> {
114
+ const keys = providerStatusKeys();
115
+ const spec = activeProviderStatus(ctx, statuses);
116
+ if (spec?.statusKey) keys.add(spec.statusKey);
117
+ return keys;
118
+ }