@leo-alvarenga/pi-zen-frame 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Leonardo A. Alvarenga
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,148 @@
1
+ # pi-zen-frame
2
+
3
+ A [pi-coding-agent](https://github.com/earendil-works/pi) extension that gives the TUI editor a simple yet polished look and feel.
4
+
5
+ - **Frame** — a box (`╭ ╮ ╰ ╯ │ ─`) with live status segments embedded in the top/bottom border.
6
+ - **Header** — an optional Claude-welcome-style top box: logo (left, 40%) + model/provider and cwd/git info (right, 60%, split in two halves).
7
+ - **Working messages** — randomized messages in pi's built-in working loader, swapped on a configurable interval (on by default).
8
+ - **Theme native** — every color is a pi `ThemeColor`, so it follows your active pi theme.
9
+
10
+ ![Preview](./docs/preview.png)
11
+
12
+ ## Install
13
+
14
+ It's distributed as a pi package. Add it to `settings.json` → `packages`, e.g. install from
15
+ a local checkout or registry:
16
+
17
+ ```jsonc
18
+ // ~/.pi/agent/settings.json
19
+ {
20
+ "packages": ["npm:@leo-alvarenga/pi-zen-frame", "path/to/pi-zen-frame"],
21
+ }
22
+ ```
23
+
24
+ Restart pi (or `/reload`).
25
+
26
+ ## Config
27
+
28
+ zen editor reads `~/.pi/agent/pi-zen-frame.json`. All keys are optional; defaults are shown.
29
+
30
+ ```jsonc
31
+ {
32
+ // Accent used by segments/frame when a more specific color isn't set.
33
+ "accentColor": "accent",
34
+
35
+ // Master mute: every segment except agent-mode renders muted.
36
+ // Toggle at runtime with /zen_mode or the piZenFrame.zenMode keybinding.
37
+ "zenMode": true,
38
+
39
+ "header": {
40
+ "enable": true,
41
+ "logo": [" ", "██████████", "███ ███ ", "██████ ", ...],
42
+ "heading": "Zen Pi",
43
+ "subheading": "A pi-coding-agent powered terminal editor",
44
+ "logoColor": "text",
45
+ "accentColor": "customMessageLabel"
46
+ },
47
+
48
+ "frame": {
49
+ "enable": true,
50
+ // Below this terminal width the box is skipped (plain editor).
51
+ "minWidth": 20,
52
+
53
+ // Blank lines inside the box (padding) / outside it (margin).
54
+ "paddingTop": 1,
55
+ "paddingBottom": 1,
56
+ "paddingX": 1,
57
+ "marginTop": 0,
58
+ "marginBottom": 0,
59
+
60
+ // Border segments on/off.
61
+ "showCwd": true,
62
+ "showModel": true,
63
+ "showContext": true,
64
+ "showThinking": true,
65
+ "showSpinner": false,
66
+ "showAgentMode": true,
67
+
68
+ // Defaults to accentColor, then "border"; Can also be set to "agentMode" to match the current agent's color
69
+ "borderColor": "border",
70
+
71
+ // Glyph shown before the editor content. Default "❯".
72
+ "prefix": "❯",
73
+
74
+ // Prefix color source: "agentMode" (current agent), "frameBorder" (frame
75
+ // border color), or any ThemeColor. Default: text color.
76
+ "prefixColor": "muted",
77
+
78
+ // Override the Nerd-Font glyphs (folder, model, context, thinking,
79
+ // gitDirty, gitBranch).
80
+ "icons": {},
81
+
82
+ // Per-segment fg overrides (any subset; supersede the built-in colors,
83
+ // e.g. the ctx traffic-light). Keys: model, thinking, context, cwd,
84
+ // agentMode. agentMode only applies when zenMode is off and no agent
85
+ // color is set.
86
+ "colors": { "model": "accent", "cwd": "accent" }
87
+ },
88
+
89
+ // Randomized messages shown in pi's built-in working loader (the
90
+ // "Working..." line while streaming). A new random message is picked
91
+ // every `intervalMs`; no repeats until the pool is exhausted. Default on.
92
+ "workingMessage": {
93
+ "enable": true,
94
+ "intervalMs": 3000,
95
+ "messages": ["Exploring the seas", "Tinkering with strange objects", "..."]
96
+ }
97
+ }
98
+ ```
99
+
100
+ ## Commands & keybindings
101
+
102
+ - `/zen_mode` — toggle zen mode (all segments muted except agent-mode).
103
+ - Toggle keybinding: `piZenFrame.zenMode`, default `ctrl+shift+z`. Rebind or
104
+ disable it in `~/.pi/agent/keybindings.json`:
105
+
106
+ ```jsonc
107
+ // ~/.pi/agent/keybindings.json
108
+ {
109
+ "piZenFrame.zenMode": "ctrl+shift+z", // or [] to disable; /zen_mode still works
110
+ }
111
+ ```
112
+
113
+ After editing `keybindings.json`, run `/reload` to apply.
114
+
115
+ ## The frame
116
+
117
+ The editor's content is boxed with rounded corner glyphs and two live status rails painted into the top and bottom borders.
118
+
119
+ ### Top border (left)
120
+
121
+ - **model** — active model name + provider, accented.
122
+ - **reasoning** — current thinking level, tinted with pi's own thinking token (`thinkingLow` … `thinkingXhigh`).
123
+ - **spinner** — streaming phase (`thinking` / `outputting` / `toolcall` / `exec`), replaces the left slot while active.
124
+
125
+ ### Top border (right)
126
+
127
+ - **ctx** — context window usage: percentage + `used/window` tokens. Color winds traffic light (green → warning at ≥50% → red at ≥80%).
128
+
129
+ ### Bottom border (left)
130
+
131
+ - **agent mode** — current pi-agent-manager agent as a colored pill (optional, no hard dependency).
132
+
133
+ ### Bottom border (right)
134
+
135
+ - **cwd** — working directory (shortened) + git branch and dirty file count.
136
+
137
+ All of these are colored with pi `ThemeColor`s, so they follow kanagawa, dark, etc.
138
+
139
+ ## Source layout
140
+
141
+ ```
142
+ extensions/
143
+ index.ts entry: config, events, editor install
144
+ config/ types, constants, settings loading/normalization
145
+ components/ header, frame, segments, registry (one file per segment)
146
+ editor/ FrameEditor (frame + spinner rendering)
147
+ utils/ agent-mode, git, path, string helpers
148
+ ```
Binary file
@@ -0,0 +1,25 @@
1
+ import { capitalize } from "../utils";
2
+ import type { SegmentDef } from "./types";
3
+
4
+ /** Top-right: the current Agent Mode from pi-agent-manager (when installed).
5
+ * Rendered as a pill using the agent's own color/icon. */
6
+ export const agentModeSegment: SegmentDef = {
7
+ id: "agent-mode",
8
+ slot: "bottomLeft",
9
+ enabled: (_d, cfg) => cfg.showAgentMode !== false,
10
+
11
+ render: (d, { theme, segColor }) => {
12
+ const m = d.agentMode;
13
+ if (!m) return "";
14
+
15
+ // Agent's own color wins; frame.colors.agentMode fills in; zen never mutes.
16
+ const color = m.color ?? segColor("agentMode", d.accentColor);
17
+ const label = theme.bold(` ${m.icon ?? "◆"} ${capitalize(m.name)} `);
18
+
19
+ try {
20
+ return theme.fg(color, label);
21
+ } catch {
22
+ return theme.fg(d.accentColor, label);
23
+ }
24
+ },
25
+ };
@@ -0,0 +1,31 @@
1
+ import type { SegmentDef } from "./types";
2
+ import { getShortCwd } from "../utils";
3
+
4
+ /** Bottom-right: working directory + git branch (with dirty count). */
5
+ export const cwdSegment: SegmentDef = {
6
+ id: "cwd",
7
+ slot: "bottomRight",
8
+ enabled: (_d, cfg) => cfg.showCwd !== false,
9
+ render: (d, { theme, icons, segColor }) => {
10
+ const folder = getShortCwd(d.cwd);
11
+
12
+ let text = theme.fg(
13
+ segColor("cwd", "muted"),
14
+ `${icons.folder} ${folder}`,
15
+ );
16
+
17
+ if (d.gitBranch) {
18
+ let git = ` ${icons.gitBranch} ${d.gitBranch}`;
19
+ text += theme.fg(segColor("cwd", d.accentColor), git);
20
+
21
+ if (d.gitDirty > 0) {
22
+ text += theme.fg(
23
+ segColor("cwd", "error"),
24
+ ` ${icons.gitDirty} ${d.gitDirty}`,
25
+ );
26
+ }
27
+ }
28
+
29
+ return ` ${text} `;
30
+ },
31
+ };
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The box. Wraps the base editor's rows (rendered narrower) with rounded
3
+ * corner glyphs, embeds the segment text into the top/bottom border, and
4
+ * inserts the configurable blank padding lines inside the box.
5
+ *
6
+ * Row budget: content is rendered at `width - 2 - 2*paddingX`, wrapped with
7
+ * `paddingX` spaces + a `│` on each side, so every output row is exactly
8
+ * `width` columns — same contract as the default editor.
9
+ */
10
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
+
12
+ export interface FrameOptions {
13
+ width: number;
14
+ prefix?: string;
15
+ paddingX: number;
16
+ paddingTop: number;
17
+ paddingBottom: number;
18
+ border: (str: string) => string;
19
+ }
20
+
21
+ const SGR_RE = /\x1b\[[0-9;]*m/g;
22
+ const CURSOR_MARKER_RE = /\x1b_pi:c\x07/g;
23
+
24
+ function plain(row: string): string {
25
+ return row.replace(SGR_RE, "").replace(CURSOR_MARKER_RE, "");
26
+ }
27
+
28
+ /** A border row is all `─`, or a `─── ↑/↓ N more ───` scroll indicator. */
29
+ function isBorderRow(row: string): boolean {
30
+ const t = plain(row).trim();
31
+
32
+ if (t === "") return true;
33
+ if (/^─+$/.test(t)) return true;
34
+
35
+ return /^─── [↑↓] \d+ more ─*$/.test(t);
36
+ }
37
+
38
+ /** Lay one border row: cap + leftText + ─fill + rightText + cap, always
39
+ * exactly `width` columns. Right text truncates first, then left text. */
40
+ export function fitFrameRow(
41
+ leftCap: string,
42
+ rightCap: string,
43
+ leftText: string,
44
+ rightText: string,
45
+ width: number,
46
+ border: (str: string) => string,
47
+ ): string {
48
+ const minGap = 3;
49
+ const inner = Math.max(0, width - 2); // space between the two caps
50
+ const rightText2 = truncateToWidth(
51
+ rightText,
52
+ Math.max(0, inner - minGap),
53
+ "",
54
+ );
55
+
56
+ const leftBudget = Math.max(0, inner - minGap - visibleWidth(rightText2));
57
+ const leftText2 = truncateToWidth(leftText, leftBudget, "");
58
+
59
+ const fill = Math.max(
60
+ 0,
61
+ inner - visibleWidth(leftText2) - visibleWidth(rightText2),
62
+ );
63
+
64
+ return (
65
+ border(leftCap) +
66
+ leftText2 +
67
+ border("─".repeat(fill)) +
68
+ rightText2 +
69
+ border(rightCap)
70
+ );
71
+ }
72
+
73
+ /** Lay one info row: leftText + spaces + rightText, exactly `width` cols.
74
+ * No border caps — plain text with gap fill. Right truncates first. */
75
+ export function fitInfoRow(
76
+ leftText: string,
77
+ rightText: string,
78
+ width: number,
79
+ ): string {
80
+ const minGap = 3;
81
+ const rightText2 = truncateToWidth(
82
+ rightText,
83
+ Math.max(0, width - minGap),
84
+ "",
85
+ );
86
+ const leftBudget = Math.max(0, width - minGap - visibleWidth(rightText2));
87
+ const leftText2 = truncateToWidth(leftText, leftBudget, "");
88
+ const fill = Math.max(
89
+ 0,
90
+ width - visibleWidth(leftText2) - visibleWidth(rightText2),
91
+ );
92
+ return leftText2 + " ".repeat(fill) + rightText2;
93
+ }
94
+
95
+ function wrapContent(row: string, paddingX: number): string {
96
+ if (paddingX <= 0) return row;
97
+
98
+ return " ".repeat(paddingX) + row + " ".repeat(paddingX);
99
+ }
100
+
101
+ export function renderFrame(inner: string[], opts: FrameOptions): string[] {
102
+ const { border, width, paddingX, prefix } = opts;
103
+ if (inner.length === 0) return inner;
104
+
105
+ // The real bottom border is the last border-like row (autocomplete rows
106
+ // are appended *after* it, so the last row is NOT necessarily the bottom).
107
+ let bottomIdx = inner.length - 1;
108
+
109
+ for (let i = inner.length - 1; i >= 0; i--) {
110
+ if (isBorderRow(inner[i]!)) {
111
+ bottomIdx = i;
112
+ break;
113
+ }
114
+ }
115
+
116
+ const result: string[] = [];
117
+ const padRow = " ".repeat(Math.max(0, width));
118
+ const fullBorder = border("─".repeat(width));
119
+
120
+ result.push(fullBorder);
121
+
122
+ for (let i = 0; i < opts.paddingTop; i++) result.push(padRow);
123
+
124
+ for (let i = 1; i < bottomIdx; i++) {
125
+ const decorator = i === 1 ? (prefix ?? "") : "";
126
+ const content = decorator + inner[i]!;
127
+
128
+ result.push(wrapContent(content, paddingX - visibleWidth(decorator)));
129
+ }
130
+
131
+ for (let i = 0; i < opts.paddingBottom; i++) result.push(padRow);
132
+
133
+ result.push(fullBorder);
134
+
135
+ for (let i = bottomIdx + 1; i < inner.length; i++) {
136
+ result.push(wrapContent(inner[i]!, paddingX));
137
+ }
138
+
139
+ return result;
140
+ }
@@ -0,0 +1,192 @@
1
+ import type { TUI } from "@earendil-works/pi-tui";
2
+ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import type {
4
+ ExtensionAPI,
5
+ Theme,
6
+ ThemeColor,
7
+ } from "@earendil-works/pi-coding-agent";
8
+
9
+ import {
10
+ DEFAULT_ICONS,
11
+ DEFAULT_SETTINGS,
12
+ HEADER_TIPS,
13
+ } from "../config/constants";
14
+ import type { Settings } from "../config/types";
15
+ import { fitFrameRow } from "./frame";
16
+ import { getShortCwd } from "../utils";
17
+
18
+ /** Below which terminal width the box is skipped (plain logo). */
19
+ const MIN_BOX_WIDTH = 20;
20
+ const LEFT_COL_RATIO = 0.4; // logo column width / total width
21
+
22
+ /** Live env snapshot the header renders in the right column. */
23
+ export interface HeaderEnv {
24
+ gitBranch: string | undefined;
25
+ gitDirty: number;
26
+ cwd: string;
27
+
28
+ /** Combined display name, e.g. `"Model (Provider)"` (provider embedded). */
29
+ modelName: string | undefined;
30
+ }
31
+
32
+ const RANDOM_TIP =
33
+ HEADER_TIPS[Math.floor(Math.random() * HEADER_TIPS.length)]?.text ?? "";
34
+
35
+ export function createHeader(
36
+ _tui: TUI,
37
+ theme: Theme,
38
+ pi: ExtensionAPI,
39
+ settings: Settings,
40
+ getEnv: (pi: ExtensionAPI) => HeaderEnv,
41
+ ) {
42
+ if (!settings.header?.enable) {
43
+ return {
44
+ render(): string[] {
45
+ return [];
46
+ },
47
+
48
+ invalidate() {},
49
+ };
50
+ }
51
+
52
+ const accentColor =
53
+ settings.header?.accentColor ?? settings.accentColor ?? "accent";
54
+
55
+ const logo = {
56
+ lines: settings.header?.logo ?? DEFAULT_SETTINGS.header?.logo ?? [],
57
+ color: settings.header?.logoColor ?? DEFAULT_SETTINGS.header?.logoColor,
58
+ };
59
+
60
+ const leftLines = [
61
+ ...logo.lines.map((l) => theme.fg(logo.color, l)),
62
+
63
+ "",
64
+ "",
65
+
66
+ theme.bold(
67
+ theme.fg(
68
+ "muted",
69
+ settings.header?.heading ?? DEFAULT_SETTINGS.header?.heading,
70
+ ),
71
+ ),
72
+
73
+ "",
74
+
75
+ theme.italic(
76
+ theme.fg(
77
+ "muted",
78
+ settings.header?.subheading ?? DEFAULT_SETTINGS.header?.subheading,
79
+ ),
80
+ ),
81
+ ];
82
+
83
+ const border = (s: string, fg?: ThemeColor) => theme.fg(fg ?? accentColor, s);
84
+
85
+ /** Space-pad `text` so it sits horizontally centered within `inner` cols. */
86
+ function center(text: string, inner: number): string {
87
+ const w = visibleWidth(text);
88
+ const left = Math.max(0, Math.floor((inner - w) / 2));
89
+ const right = Math.max(0, inner - w - left);
90
+
91
+ return " ".repeat(left) + text + " ".repeat(right);
92
+ }
93
+
94
+ /** Claude-style split row: │ logo (leftW) │ info (rightW) │, exactly `width` cols. */
95
+ function splitRow(
96
+ leftW: number,
97
+ rightW: number,
98
+ left: string,
99
+ right: string,
100
+ ): string {
101
+ const left2 = center(left, leftW);
102
+ const right2 = " " + right;
103
+ const pad = " ".repeat(Math.max(0, rightW - visibleWidth(right2)));
104
+
105
+ return border("│") + left2 + border("│") + right2 + pad + border("│");
106
+ }
107
+
108
+ /** Right column halves: top = model(+provider), bottom = cwd + git. */
109
+ function infoRows(env: HeaderEnv): string[] {
110
+ const icons = DEFAULT_ICONS;
111
+ const parts: string[] = [];
112
+
113
+ if (env.cwd) {
114
+ parts.push(theme.fg("muted", `${icons.folder} ${getShortCwd(env.cwd)}`));
115
+ }
116
+ if (env.gitBranch) {
117
+ parts.push(theme.fg(accentColor, `${icons.gitBranch} ${env.gitBranch}`));
118
+
119
+ if (env.gitDirty > 0) {
120
+ parts.push(theme.fg("error", `${icons.gitDirty} ${env.gitDirty}`));
121
+ }
122
+ }
123
+
124
+ return [
125
+ theme.bold(border("Model Info")),
126
+ env.modelName ? theme.fg("muted", `${icons.model} ${env.modelName}`) : "",
127
+ "",
128
+ theme.bold(border("Current Directory")),
129
+ parts.length ? parts.join(" · ") : "",
130
+ "",
131
+ theme.bold(border("Tip")),
132
+ theme.fg("muted", RANDOM_TIP),
133
+ ];
134
+ }
135
+
136
+ return {
137
+ render(width: number): string[] {
138
+ // Too narrow for a box → fall back to a plain centered logo.
139
+ if (width < MIN_BOX_WIDTH) {
140
+ return leftLines;
141
+ }
142
+
143
+ const env = getEnv(pi);
144
+ const rightRows = infoRows(env);
145
+
146
+ const inner = Math.max(0, width - 2);
147
+ const leftW = Math.floor(inner * LEFT_COL_RATIO);
148
+ const rightW = inner - leftW - 1; // divider column
149
+
150
+ // No truncation: overflow wraps onto following lines until it all fits.
151
+ const leftWrapped = leftLines
152
+ .map((l) => wrapTextWithAnsi(l, leftW))
153
+ .flat();
154
+ const rightWrapped = rightRows
155
+ .map((r) => wrapTextWithAnsi(r, Math.max(1, rightW - 2))) // 2-col indent
156
+ .flat();
157
+
158
+ const height = Math.max(leftWrapped.length, rightWrapped.length);
159
+ const logoTop = Math.max(
160
+ 0,
161
+ Math.floor((height - leftWrapped.length) / 2),
162
+ );
163
+ const rightTop = Math.max(
164
+ 0,
165
+ Math.floor((height - rightWrapped.length) / 2),
166
+ );
167
+
168
+ const lines: string[] = [""];
169
+
170
+ lines.push(fitFrameRow("╭", "╮", "", "", width, border));
171
+ lines.push(splitRow(leftW, rightW, "", ""));
172
+
173
+ for (let i = 0; i < height; i++) {
174
+ const l = i - logoTop;
175
+ const leftLine =
176
+ l >= 0 && l < leftWrapped.length ? leftWrapped[l]! : "";
177
+
178
+ const r = i - rightTop;
179
+ const rightLine =
180
+ r >= 0 && r < rightWrapped.length ? rightWrapped[r]! : "";
181
+
182
+ lines.push(splitRow(leftW, rightW, leftLine, rightLine));
183
+ }
184
+
185
+ lines.push(splitRow(leftW, rightW, "", ""));
186
+ lines.push(fitFrameRow("╰", "╯", "", "", width, border));
187
+
188
+ return lines;
189
+ },
190
+ invalidate() {},
191
+ };
192
+ }
@@ -0,0 +1,16 @@
1
+ import type { SegmentDef } from "./types";
2
+
3
+ /** Top-left: the active model name. */
4
+ export const modelSegment: SegmentDef = {
5
+ id: "model",
6
+ slot: "topLeft",
7
+ enabled: (_d, cfg) => cfg.showModel !== false,
8
+ render: (d, { theme, icons, segColor }) => {
9
+ if (!d.modelName) return "";
10
+
11
+ return theme.fg(
12
+ segColor("model", d.accentColor),
13
+ ` ${icons.model} ${d.modelName} `,
14
+ );
15
+ },
16
+ };
@@ -0,0 +1,22 @@
1
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { SegmentDef } from "./types";
4
+ import { THINKING_TOKEN } from "../config/constants";
5
+
6
+ /** Top-left (after model): the current thinking level, tinted with its own
7
+ * theme token — the same color pi applies to the border at that level. */
8
+ export const reasoningSegment: SegmentDef = {
9
+ id: "reasoning",
10
+ slot: "topLeft",
11
+ enabled: (_d, cfg) => cfg.showThinking !== false,
12
+ render: (d, { theme, icons, segColor }) => {
13
+ if (!d.thinkingLevel) return "";
14
+ const token = (THINKING_TOKEN[d.thinkingLevel] ??
15
+ "thinkingText") as ThemeColor;
16
+
17
+ return theme.fg(
18
+ segColor("thinking", token),
19
+ ` ${icons.thinking} ${d.thinkingLevel} `,
20
+ );
21
+ },
22
+ };
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Segment registry. To add a new component:
3
+ * 1. create `extensions/components/<name>.ts` exporting a `SegmentDef`
4
+ * 2. import it here and append to `segments`
5
+ * The frame calls `segmentsFor(slot, ...)` and handles all width/joining.
6
+ */
7
+ import { modelSegment } from "./model";
8
+ import { reasoningSegment } from "./reasoning";
9
+ import { spinnerSegment } from "./spinner";
10
+ import { tokenCountSegment } from "./token-count";
11
+ import { cwdSegment } from "./cwd";
12
+ import { agentModeSegment } from "./agent-mode";
13
+ import type { FrameData, SegmentContext, SegmentDef, Slot } from "./types";
14
+
15
+ export const segments: SegmentDef[] = [
16
+ modelSegment,
17
+ reasoningSegment,
18
+ spinnerSegment,
19
+ tokenCountSegment,
20
+ agentModeSegment,
21
+ cwdSegment,
22
+ ];
23
+
24
+ /** Render all segments for a slot, joined with " · ". A `replaces` segment
25
+ * (e.g. the spinner during an active phase) takes the whole slot. */
26
+ export function segmentsFor(
27
+ slot: Slot,
28
+ d: FrameData,
29
+ ctx: SegmentContext,
30
+ ): string {
31
+ const defs = segments.filter(
32
+ (s) => s.slot === slot && (!s.enabled || s.enabled(d, ctx.cfg)),
33
+ );
34
+
35
+ const replacer = defs.find((s) => s.replaces?.(d, ctx.cfg));
36
+ const active = replacer ? [replacer] : defs;
37
+
38
+ return active
39
+ .map((s) => s.render(d, ctx))
40
+ .filter((t) => t !== "")
41
+ .join(ctx.border("·"));
42
+ }
@@ -0,0 +1,24 @@
1
+ import type { SegmentDef } from "./types";
2
+
3
+ /** Status animation. When a phase is active it REPLACES the top-left slot
4
+ * (model + reasoning) until the phase ends. */
5
+ export const spinnerSegment: SegmentDef = {
6
+ id: "spinner",
7
+ slot: "topLeft",
8
+ enabled: (d, cfg) =>
9
+ cfg.showSpinner === true &&
10
+ d.spinnerPhase !== null &&
11
+ d.spinnerPhase !== "idle",
12
+ render: (d, { theme }) => {
13
+ let phase = d.spinnerPhase ?? "thinking";
14
+
15
+ // No frame.colors key for the spinner; zen mutes it, else accent.
16
+ const spinner = theme.fg(
17
+ d.zenMode ? "muted" : d.accentColor,
18
+ ` ${d.spinnerFrame} `,
19
+ );
20
+ if (phase === "idle") return spinner;
21
+
22
+ return spinner + theme.fg("muted", `${phase} `);
23
+ },
24
+ };