@leo-alvarenga/pi-zen-frame 0.17.0 → 0.19.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/README.md CHANGED
@@ -10,6 +10,7 @@ A [`pi-coding-agent`](https://github.com/earendil-works/pi) extension that gives
10
10
  - **Header Box**: Optional welcome panel with a logo and "Welcome back!" heading, model name, current working directory, Git status, and a random tip
11
11
  - **Working Messages**: Randomized loading status messages during response generation, updated on a configurable timer
12
12
  - **Native Theme Support**: Uses pi `ThemeColor` tokens to automatically align with your active theme
13
+ - **Command Palette**: `alt+p` opens a filterable list of built-in and registered slash commands
13
14
 
14
15
  ---
15
16
 
@@ -42,10 +43,7 @@ Configuration is loaded from `~/.pi/agent/pi-zen-frame.json`. All properties are
42
43
  // Fallback accent color for frame border and active segments
43
44
  "accentColor": "accent",
44
45
 
45
- // Master mute: renders all segments in muted tones except agent-mode (default: false)
46
- "zenMode": false,
47
-
48
- // Editor-frame renderer by registered name (built-in: "blocky")
46
+ // Editor-frame renderer by registered name (built-in: "blocky", "minimalist")
49
47
  "editorFrame": "blocky",
50
48
 
51
49
  "header": {
@@ -90,27 +88,27 @@ Configuration is loaded from `~/.pi/agent/pi-zen-frame.json`. All properties are
90
88
 
91
89
  The editor frame shows status indicators in two rows below the editor:
92
90
 
93
- | Location | Segment | Description |
94
- | ------------------ | -------------- | --------------------------------------------------------------------- |
95
- | Editor band (left) | **agent mode** | Active agent indicator pill from `pi-agent-manager` |
96
- | | **model** | Active model name and provider |
97
- | | **reasoning** | Current reasoning effort level, tinted with pi thinking tokens |
91
+ | Location | Segment | Description |
92
+ | ------------------ | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | Editor band (left) | **agent mode** | Active agent indicator pill from `pi-agent-manager` |
94
+ | | **model** | Active model name and provider |
95
+ | | **reasoning** | Current reasoning effort level, tinted with pi thinking tokens |
98
96
  | | **spinner** | Active phase indicator (`thinking`, `outputting`, `toolcall`, `exec`); replaces the row while streaming when `showSpinner` is enabled (default off) |
99
- | Footer (left) | **cwd** | Shortened path, active Git branch, and uncommitted file counts |
100
- | Footer (right) | **ctx** | Context usage percentage and token counts with color alerts |
97
+ | Footer (left) | **cwd** | Shortened path, active Git branch, and uncommitted file counts |
98
+ | Footer (right) | **ctx** | Context usage percentage and token counts with color alerts |
101
99
 
102
100
  ---
103
101
 
104
102
  ## Commands & Keybindings
105
103
 
106
- - **Toggle Command**: `/zen_mode` — Toggles Zen mode on and off.
107
- - **Default Keybinding**: `ctrl+shift+z`
104
+ - **Command Palette**: `alt+p` — filter and insert built-in or registered slash commands.
105
+ - **Palette Keys**: `↑`/`↓` navigate, `Enter` inserts the command, `Esc` closes.
108
106
 
109
107
  To rebind or disable the hotkey, update `~/.pi/agent/keybindings.json`:
110
108
 
111
109
  ```jsonc
112
110
  {
113
- "piZenFrame.zenMode": "ctrl+shift+z", // Set to [] to disable the shortcut
111
+ "piZenFrame.palette": "alt+p", // Set to [] to disable the shortcut
114
112
  }
115
113
  ```
116
114
 
@@ -125,7 +123,7 @@ extensions/
125
123
  ├── index.ts # Extension entry point: config initialization and events
126
124
  ├── config/ # Types, defaults, and settings normalization
127
125
  ├── components/ # Status segments, header, and frame layout helpers
128
- ├── editor/ # BlockyEditor editor-frame renderer
126
+ ├── editor/ # Editor-frame renderers (blocky / minimalist) + palette
129
127
  ├── renderers/ # Renderer registry + swap-in API (blocky / basic)
130
128
  └── utils/ # Helpers for Git status, paths, agent modes, and token usage
131
129
 
@@ -3,7 +3,7 @@ import { capitalize } from "../utils";
3
3
  import type { SegmentDef } from "./types";
4
4
 
5
5
  /** Pseudo-footer (left): the current Agent Mode from pi-agent-manager
6
- * (when installed). Rendered as a pill using the agent's own color/icon. */
6
+ * (when installed). Rendered as a pill using the agent's own color/icon */
7
7
  export const agentModeSegment: SegmentDef = {
8
8
  id: "agent-mode",
9
9
  slot: "topLeft",
@@ -13,7 +13,6 @@ export const agentModeSegment: SegmentDef = {
13
13
  const m = d.agentMode;
14
14
  if (!m) return "";
15
15
 
16
- // Agent's own color wins; accent fills in. Zen never mutes the agent.
17
16
  const color = m.color ?? d.accentColor;
18
17
  const label = ` ${capitalize(m.name)} `;
19
18
 
@@ -15,11 +15,11 @@ export const cwdSegment: SegmentDef = {
15
15
  let git = ` ${icons.gitBranch} ${d.gitBranch}`;
16
16
  text +=
17
17
  theme.fg("dim", " ·") +
18
- theme.fg(d.zenMode ? "muted" : d.accentColor, git);
18
+ theme.fg(d.accentColor, git);
19
19
 
20
20
  if (d.gitDirty > 0) {
21
21
  text += theme.fg(
22
- d.zenMode ? "muted" : "error",
22
+ "error",
23
23
  ` ${icons.gitDirty} ${d.gitDirty}`,
24
24
  );
25
25
  }
@@ -6,10 +6,7 @@ import type {
6
6
  ThemeColor,
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
 
9
- import {
10
- DEFAULT_ICONS,
11
- HEADER_TIPS,
12
- } from "../config/constants";
9
+ import { DEFAULT_ICONS, HEADER_TIPS } from "../config/constants";
13
10
  import type { Settings } from "../config/types";
14
11
  import { fitFrameRow } from "./frame";
15
12
  import { getShortCwd } from "../utils";
@@ -19,12 +16,7 @@ const MIN_BOX_WIDTH = 20;
19
16
  const LEFT_COL_RATIO = 0.4; // logo column width / total width
20
17
 
21
18
  /** BasicHeader preset styling — self-contained, not user-configurable. */
22
- const LOGO_LINES = [
23
- "█████████ ",
24
- "███ ███ ",
25
- "██████ ",
26
- "███ ███",
27
- ];
19
+ const LOGO_LINES = ["█████████ ", "███ ███ ", "██████ ", "███ ███"];
28
20
  const LOGO_COLOR: ThemeColor = "text";
29
21
  const HEADING = "Welcome back!";
30
22
  const SUBHEADING =
@@ -160,17 +152,15 @@ export function createHeader(
160
152
  "",
161
153
  "",
162
154
 
163
- ...wrapLines(
164
- [HEADING],
165
- leftW - 2,
166
- ).map((line) => theme.bold(theme.fg("muted", line))),
155
+ ...wrapLines([HEADING], leftW - 2).map((line) =>
156
+ theme.bold(theme.fg("muted", line)),
157
+ ),
167
158
 
168
159
  "",
169
160
 
170
- ...wrapLines(
171
- [SUBHEADING],
172
- leftW - 2,
173
- ).map((line) => theme.italic(theme.fg("muted", line))),
161
+ ...wrapLines([SUBHEADING], leftW - 2).map((line) =>
162
+ theme.italic(theme.fg("muted", line)),
163
+ ),
174
164
  ];
175
165
 
176
166
  // Too narrow for a box → fall back to a plain centered logo.
@@ -6,7 +6,7 @@ export const notifySegment: SegmentDef = {
6
6
  id: "notify",
7
7
  slot: "topLeft",
8
8
  render: (d, { icons, theme }) => {
9
- const color = d.notifyEnabled && !d.zenMode ? d.accentColor : "muted";
9
+ const color = d.notifyEnabled ? d.accentColor : "muted";
10
10
 
11
11
  return theme.fg(color, ` ${d.notifyEnabled ? icons.bell : icons.bellOff} `);
12
12
  },
@@ -16,7 +16,7 @@ export const reasoningSegment: SegmentDef = {
16
16
  "thinkingText") as ThemeColor;
17
17
 
18
18
  return theme.fg(
19
- d.zenMode ? "muted" : token,
19
+ token,
20
20
  ` ${icons.thinking} ${d.thinkingLevel} `,
21
21
  );
22
22
  },
@@ -12,9 +12,9 @@ export const spinnerSegment: SegmentDef = {
12
12
  render: (d, { theme }) => {
13
13
  let phase = d.spinnerPhase ?? "thinking";
14
14
 
15
- // No frame.colors key for the spinner; zen mutes it, else accent.
15
+ // No frame.colors key for the spinner; accent only.
16
16
  const spinner = theme.fg(
17
- d.zenMode ? "muted" : d.accentColor,
17
+ d.accentColor,
18
18
  ` ${d.spinnerFrame} `,
19
19
  );
20
20
  if (phase === "idle") return spinner;
@@ -44,13 +44,12 @@ export const tokenCountSegment: SegmentDef = {
44
44
  }
45
45
  }
46
46
 
47
- const color2 = d.zenMode ? "muted" : color;
48
47
  const used = tokens === null ? "?" : formatTokens(tokens);
49
48
 
50
49
  return (
51
- theme.fg(color2, ` ${icons.context} ctx ${pct} `) +
50
+ theme.fg(color, ` ${icons.context} ctx ${pct} `) +
52
51
  theme.fg("dim", "·") +
53
- theme.fg(color2, ` ${used}/${formatWindow(window)} `) +
52
+ theme.fg(color, ` ${used}/${formatWindow(window)} `) +
54
53
  theme.fg("dim", "·") +
55
54
  theme.fg(
56
55
  "dim",
@@ -49,9 +49,6 @@ export interface FrameData {
49
49
  spinnerPhase: SpinnerPhase | null;
50
50
  thinkingLevel: string | undefined;
51
51
 
52
- /** Master mute: when true, every segment except agentMode renders muted. */
53
- zenMode: boolean;
54
-
55
52
  /** To render the agent mode (from @leo-alvarenga/pi-agent-manager) segment, if any */
56
53
  agentMode: AgentMode;
57
54
 
@@ -88,7 +85,6 @@ export interface SegmentDef {
88
85
  export interface ExternalData {
89
86
  cwd: string;
90
87
  gitDirty: number;
91
- zenMode: boolean;
92
88
  agentMode: AgentMode;
93
89
  theme: Theme | undefined;
94
90
  gitBranch: string | undefined;
@@ -1,15 +1,10 @@
1
- /** External contract IDs: the zen-mode keybinding and the optional
2
- * pi-agent-manager integration (no hard dependency).
3
- */
4
- /** pi-agent-manager integration (optional — no hard dependency). */
1
+ /** pi-agent-manager integration (optional: no hard dependency) */
5
2
  export const PI_AGENT_MANAGER_AGENT_EVENT = "pi-agent-manager:agent-changed";
6
3
  export const PI_AGENT_MANAGER_AGENT_DATA_KEY = "pi-agent-manager-agent";
7
4
 
8
- /** pi-notify integration (optional — no hard dependency). */
5
+ /** pi-notify integration (optional: no hard dependency) */
9
6
  export const PI_NOTIFY_TOGGLE_EVENT = "pi-notify:toggle-changed";
10
7
 
11
- /** Keybinding id users bind in keybindings.json to toggle zen mode. */
12
- export const ZEN_MODE_SHORTCUT_ID = "piZenFrame.zenMode";
13
- /** Default key when the user hasn't bound one (override or disable via
14
- * keybindings.json; `[]` disables the shortcut, `/zen_mode` still works). */
15
- export const ZEN_MODE_DEFAULT_KEY = "ctrl+shift+z";
8
+ /** Keybinding id users bind in keybindings.json to open the command palett. */
9
+ export const PALETTE_SHORTCUT_ID = "piZenFrame.palette";
10
+ export const PALETTE_DEFAULT_KEY = "alt+p";
@@ -1,7 +1,7 @@
1
1
  import type { Settings } from "../types";
2
2
  import { WORKING_MESSAGES } from "./messages";
3
+
3
4
  export const DEFAULT_SETTINGS: Settings = {
4
- zenMode: false,
5
5
  accentColor: "accent",
6
6
  editorFrame: "blocky",
7
7
 
@@ -1,10 +1,5 @@
1
- /**
2
- * Constants, grouped by concern and re-exported from a single barrel.
3
- * Import everything with `import { ... } from "../config/constants"` —
4
- * resolution keeps working now that this is a directory.
5
- */
6
1
  export * from "./file";
7
2
  export * from "./contracts";
8
3
  export * from "./visuals";
9
4
  export * from "./messages";
10
- export * from "./defaults";
5
+ export * from "./defaults";
@@ -1,6 +1,7 @@
1
1
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import type { FrameIcons, SpinnerPhase } from "../types";
3
- /** Spinner animation frames per phase (pi-editor-shell style). */
3
+
4
+ /** Spinner animation frames per phase (pi-editor-shell style) */
4
5
  export const SPINNER_FRAMES: Record<SpinnerPhase, string[]> = {
5
6
  idle: ["⠃", "⠞", "⡵", "⠿", "⢹", "⠄"],
6
7
  outputting: ["⠋", "⠙", "⠸", "⠴", "⠦", "⠇", "⠏"],
@@ -11,7 +12,7 @@ export const SPINNER_FRAMES: Record<SpinnerPhase, string[]> = {
11
12
  // Sample ["░", "▒", "▓", "█", "▓", "▒"],
12
13
  };
13
14
 
14
- /** Nerd Font glyphs used by the blocky editor preset. */
15
+ /** Nerd Font glyphs used by the blocky editor preset */
15
16
  export const DEFAULT_ICONS: FrameIcons = {
16
17
  model: "󰣖",
17
18
  folder: " ",
@@ -25,15 +26,15 @@ export const DEFAULT_ICONS: FrameIcons = {
25
26
  bellOff: "󰂛",
26
27
  };
27
28
 
28
- /** Thinking level → theme token, mirroring pi's own border-color mapping. */
29
+ /** Thinking level → theme token, mirroring pi's own border-color mapping */
29
30
  export const THINKING_TOKEN: Record<string, string> = {
30
- off: "thinkingOff",
31
- minimal: "thinkingMinimal",
32
31
  low: "thinkingLow",
33
- medium: "thinkingMedium",
32
+ max: "thinkingMax",
33
+ off: "thinkingOff",
34
34
  high: "thinkingHigh",
35
35
  xhigh: "thinkingXhigh",
36
- max: "thinkingMax",
36
+ medium: "thinkingMedium",
37
+ minimal: "thinkingMinimal",
37
38
  };
38
39
 
39
40
  export const THEME_COLORS: Record<ThemeColor, true> = {
@@ -19,7 +19,7 @@ function loadSettingsFile(): Promise<string> {
19
19
  return readFile(getResolvedSettingsFilePath(), "utf8");
20
20
  }
21
21
 
22
- /** Validate a raw (possibly malformed) config against the schema shapes. */
22
+ /** Validate a raw (possibly malformed) config against the schema shapes */
23
23
  function normalize(raw: unknown): Settings {
24
24
  if (!raw || typeof raw !== "object") return DEFAULT_SETTINGS;
25
25
 
@@ -37,7 +37,6 @@ function normalize(raw: unknown): Settings {
37
37
  const out: Settings = {
38
38
  ...DEFAULT_SETTINGS,
39
39
  frame: { ...DEFAULT_SETTINGS.frame },
40
- zenMode: bool(r.zenMode, d.zenMode ?? true),
41
40
  editorFrame:
42
41
  typeof r.editorFrame === "string" ? r.editorFrame : d.editorFrame,
43
42
  };
@@ -95,6 +94,7 @@ function normalize(raw: unknown): Settings {
95
94
  export async function loadSettings(): Promise<Settings> {
96
95
  try {
97
96
  const raw = JSON.parse(await loadSettingsFile()) as unknown;
97
+
98
98
  return normalize(raw);
99
99
  } catch {
100
100
  return DEFAULT_SETTINGS;
@@ -1,6 +1,6 @@
1
1
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
 
3
- /** Streaming phase used to drive the status-animation spinner. */
3
+ /** Streaming phase used to drive the status-animation spinner */
4
4
  export type SpinnerPhase =
5
5
  "thinking" | "outputting" | "toolcall" | "exec" | "idle";
6
6
 
@@ -9,7 +9,7 @@ export interface BannerTip {
9
9
  text: string;
10
10
  }
11
11
 
12
- /** Nerd-font / glyph icons shown in the band and status rows. Overridable. */
12
+ /** Nerd-font / glyph icons shown in the band and status rows. Overridable */
13
13
  export interface FrameIcons {
14
14
  folder: string;
15
15
  model: string;
@@ -24,9 +24,9 @@ export interface FrameIcons {
24
24
  }
25
25
 
26
26
  /**
27
- * ``frame`` — the editor frame preset. Styling and layout (prefix, colors,
27
+ * ``frame``: the editor frame preset. Styling and layout (prefix, colors,
28
28
  * margins, paddings) are owned by the renderer; only these behavioral
29
- * toggles are configurable.
29
+ * toggles are configurable
30
30
  */
31
31
  export interface FrameSettings {
32
32
  enable?: boolean;
@@ -44,7 +44,7 @@ export interface FrameSettings {
44
44
  showSpinner?: boolean;
45
45
  }
46
46
 
47
- /** `header` — the top-line header, which can show a logo or other text */
47
+ /** `header`: the top-line header, which can show a logo or other text */
48
48
  export interface HeaderSettings {
49
49
  enable: boolean;
50
50
 
@@ -52,15 +52,15 @@ export interface HeaderSettings {
52
52
  type?: string;
53
53
  }
54
54
 
55
- /** `workingMessage` — rotating messages in pi's built-in working loader. */
55
+ /** `workingMessage`: rotating messages in pi's built-in working loader */
56
56
  export interface WorkingMessageSettings {
57
- /** Toggle the rotating messages. Default true. */
57
+ /** Toggle the rotating messages. Default true */
58
58
  enable?: boolean;
59
59
 
60
- /** How often (ms) the message is replaced. Default 3000. */
60
+ /** How often (ms) the message is replaced. Default 3000 */
61
61
  intervalMs?: number;
62
62
 
63
- /** Custom message pool; replaces the default 30. */
63
+ /** Custom message pool; replaces the default 30 */
64
64
  messages?: string[];
65
65
  }
66
66
 
@@ -69,14 +69,9 @@ export interface Settings {
69
69
  header?: HeaderSettings;
70
70
  workingMessage?: WorkingMessageSettings;
71
71
 
72
- /** Editor-frame renderer by registered name. Default "blocky". */
72
+ /** Editor-frame renderer by registered name. Default "blocky" */
73
73
  editorFrame?: string;
74
74
 
75
- /** Master mute: every segment except agent-mode renders muted.
76
- * Default true. Toggle at runtime with `/zen_mode` or the `piZenFrame.zenMode`
77
- * keybinding (default `ctrl+shift+z`). */
78
- zenMode?: boolean;
79
-
80
75
  /** Accent color for segment highlights; Defaults to 'accent' */
81
76
  accentColor?: ThemeColor;
82
77
  }
@@ -1,34 +1,29 @@
1
- /**
2
- * BlockyEditor — the editor-frame renderer. Subclasses CustomEditor so all of
3
- * pi's editing/undo/app-keybinding machinery keeps working; `render` layers
4
- * the bg band. Vim behavior was removed — all input passes through to the
5
- * base editor. Appearance lives in `components/` (segments + frame).
6
- */
7
1
  import {
8
2
  CustomEditor,
9
3
  ThemeColor,
10
4
  type ExtensionAPI,
11
5
  } from "@earendil-works/pi-coding-agent";
12
6
 
13
- import { DEFAULT_ICONS, SPINNER_FRAMES } from "../config/constants";
14
7
  import type { SpinnerPhase } from "../config/types";
15
- import { composeBand, fitInfoRow, isBorderRow } from "../components/frame";
16
- import { segmentsFor } from "../components/registry";
17
- import type {
18
- ExternalData,
19
- FrameData,
20
- SegmentContext,
21
- } from "../components/types";
8
+ import type { ExternalData, FrameData } from "../components/types";
22
9
  import type { EditorFrameRenderOptions } from "../renderers/types";
23
10
 
11
+ import {
12
+ handlePaletteInput,
13
+ loadPaletteItems,
14
+ renderPalette,
15
+ type PaletteState,
16
+ } from "./palette";
17
+ import { renderEditorFrame } from "./render";
18
+ import { SpinnerController } from "./spinner";
19
+
24
20
  export class BlockyEditor extends CustomEditor {
25
21
  private pi: ExtensionAPI;
26
22
  private opts: EditorFrameRenderOptions;
27
23
  private provider: (pi: ExtensionAPI) => ExternalData;
28
24
 
29
- private spinnerIdx = 0;
30
- private spinnerPhase: SpinnerPhase | null = null;
31
- private spinnerTimer: ReturnType<typeof setInterval> | null = null;
25
+ private spinner = new SpinnerController();
26
+ private paletteState: PaletteState | null = null;
32
27
 
33
28
  constructor(
34
29
  pi: ExtensionAPI,
@@ -37,149 +32,106 @@ export class BlockyEditor extends CustomEditor {
37
32
  ...args: ConstructorParameters<typeof CustomEditor>
38
33
  ) {
39
34
  super(...args);
40
-
41
35
  this.pi = pi;
42
36
  this.opts = opts;
43
37
  this.provider = provider;
44
38
  }
45
39
 
46
40
  setSpinner(phase: SpinnerPhase | null): void {
47
- if (this.spinnerPhase === phase) return;
48
-
49
- this.spinnerIdx = 0;
50
- this.spinnerPhase = phase;
51
- this.clearSpinnerTimer();
52
-
53
- if (phase && SPINNER_FRAMES[phase]) {
54
- const frames = SPINNER_FRAMES[phase];
55
-
56
- this.spinnerTimer = setInterval(() => {
57
- this.spinnerIdx = (this.spinnerIdx + 1) % frames.length;
58
- this.tui.requestRender();
59
- }, 80);
41
+ if (phase) {
42
+ this.spinner.start(phase, () => this.tui.requestRender());
43
+ } else {
44
+ this.spinner.stop();
45
+ this.tui.requestRender();
60
46
  }
61
-
62
- this.tui.requestRender();
63
47
  }
64
48
 
65
49
  stopSpinner(): void {
66
- this.clearSpinnerTimer();
67
- this.spinnerPhase = null;
50
+ this.spinner.stop();
68
51
  }
69
52
 
70
53
  refresh(): void {
71
54
  this.tui.requestRender();
72
55
  }
73
56
 
74
- // ── rendering ──────────────────────────────────────────────────────────
57
+ openPalette(): void {
58
+ const all = loadPaletteItems(this.pi);
59
+
60
+ this.paletteState = { query: "", all, items: all, selected: 0, viewTop: 0 };
61
+ this.tui.requestRender();
62
+ }
63
+
64
+ override handleInput(data: string): void {
65
+ if (!this.paletteState) {
66
+ super.handleInput(data);
67
+ return;
68
+ }
69
+
70
+ const result = handlePaletteInput(this.paletteState, data);
71
+
72
+ if (result.submit) {
73
+ this.paletteState = null;
74
+ this.setText(result.submit);
75
+ super.handleInput("\r");
76
+ return;
77
+ }
78
+
79
+ this.paletteState = result.next;
80
+ this.tui.requestRender();
81
+ }
75
82
 
76
83
  render(width: number): string[] {
84
+ if (this.paletteState) {
85
+ const ext = this.provider(this.pi);
86
+ if (ext.theme) {
87
+ return renderPalette(this.paletteState, width, ext.theme);
88
+ }
89
+ }
90
+
77
91
  const frame = this.opts.frame;
78
92
 
79
93
  const padX = Math.min(2, Math.max(0, Math.floor(width / 2)));
80
-
81
94
  const marginX = Math.min(1, Math.max(0, Math.floor(width / 2)));
82
95
 
83
96
  const contentWidth = width - marginX * 2;
84
97
  const innerWidth = width - padX * 2 - marginX * 2;
98
+
85
99
  if (innerWidth < 8) return super.render(width);
86
100
 
87
101
  const ext = this.provider(this.pi);
102
+
103
+ // No theme (non-TUI) or frame disabled or too narrow → plain editor.
104
+ if (!ext.theme || !frame.enable || contentWidth < (frame.minWidth ?? 20)) {
105
+ return super.render(width);
106
+ }
107
+
88
108
  const d: FrameData = {
89
109
  cwd: ext.cwd,
90
110
  context: ext.context,
91
- zenMode: ext.zenMode,
92
111
  gitDirty: ext.gitDirty,
93
112
  agentMode: ext.agentMode,
94
113
  gitBranch: ext.gitBranch,
95
114
  modelName: ext.modelName,
96
115
  spinnerPhase: ext.spinnerPhase,
97
116
  modelProvider: ext.modelProvider,
117
+ notifyEnabled: ext.notifyEnabled,
98
118
  thinkingLevel: ext.thinkingLevel,
99
- spinnerFrame: this.spinnerFrame(),
119
+ spinnerFrame: this.spinner.frame(),
100
120
  accentColor: this.opts.accentColor,
101
- notifyEnabled: ext.notifyEnabled,
102
121
  };
103
122
 
104
123
  let prefix = "┃";
105
-
106
- if (ext.theme?.fg) {
107
- const prefixColor: ThemeColor =
108
- ext.agentMode?.color ?? this.opts.accentColor ?? "text";
109
- prefix = ext.theme.fg(prefixColor, prefix);
110
- }
111
-
112
- // No theme (non-TUI) or frame disabled or too narrow → plain editor.
113
- if (!ext.theme || !frame.enable || contentWidth < (frame.minWidth ?? 20)) {
114
- return super.render(width);
115
- }
116
-
117
- const inner = super.render(innerWidth);
118
-
119
- let bottomIdx = inner.length - 1;
120
- for (let i = inner.length - 1; i >= 0; i--) {
121
- if (isBorderRow(inner[i]!)) {
122
- bottomIdx = i;
123
- break;
124
- }
125
- }
126
-
127
- const content = inner.slice(1, bottomIdx);
128
- const autocomplete = inner.slice(bottomIdx + 1);
129
-
130
- const bgSgr = ext.theme.getBgAnsi("customMessageBg");
131
- const paint = (row: string) =>
132
- bgSgr + row.split("\x1b[0m").join(`\x1b[0m${bgSgr}`) + "\x1b[0m";
133
-
134
- const ctx: SegmentContext = {
135
- cfg: frame,
136
- theme: ext.theme,
137
- icons: DEFAULT_ICONS,
138
- };
139
-
140
- const box = composeBand(content, autocomplete, {
141
- padX,
142
- paint,
124
+ const prefixColor: ThemeColor =
125
+ ext.agentMode?.color ?? this.opts.accentColor ?? "text";
126
+ prefix = ext.theme.fg(prefixColor, prefix);
127
+
128
+ return renderEditorFrame(
129
+ (innerWidth) => super.render(innerWidth),
130
+ this.opts,
131
+ ext,
132
+ d,
143
133
  prefix,
144
- width: contentWidth,
145
- marginX: 1,
146
- paddingTop: 1,
147
- paddingBottom: 1,
148
- boxBottom: fitInfoRow(
149
- segmentsFor("topLeft", d, ctx),
150
- segmentsFor("topRight", d, ctx),
151
- innerWidth,
152
- ),
153
- });
154
-
155
- const pseudoFooter = fitInfoRow(
156
- segmentsFor("bottomLeft", d, ctx),
157
- segmentsFor("bottomRight", d, ctx),
158
- contentWidth,
159
- );
160
-
161
- // Blank rows OUTSIDE the band, above/below it.
162
- const marginRow = " ".repeat(contentWidth);
163
- const marginTop: string[] = [];
164
- const marginBottom: string[] = [];
165
-
166
- return [...marginTop, ...box, marginRow, pseudoFooter, ...marginBottom].map(
167
- (row) => " ".repeat(marginX) + row + " ".repeat(marginX),
134
+ width,
168
135
  );
169
136
  }
170
-
171
- private spinnerFrame(): string {
172
- if (!this.spinnerPhase) return "";
173
-
174
- const frames = SPINNER_FRAMES[this.spinnerPhase]!;
175
-
176
- return frames[this.spinnerIdx % frames.length]!;
177
- }
178
-
179
- private clearSpinnerTimer(): void {
180
- if (this.spinnerTimer) {
181
- clearInterval(this.spinnerTimer);
182
- this.spinnerTimer = null;
183
- }
184
- }
185
137
  }