@leo-alvarenga/pi-zen-frame 0.12.2 → 0.13.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
@@ -45,8 +45,13 @@ Configuration is loaded from `~/.pi/agent/pi-zen-frame.json`. All properties are
45
45
  // Master mute: renders all segments in muted tones except agent-mode
46
46
  "zenMode": true,
47
47
 
48
+ // Editor-frame renderer by registered name (built-in: "blocky")
49
+ "editorFrame": "blocky",
50
+
48
51
  "header": {
49
52
  "enable": true,
53
+ // Header renderer by registered name (built-in: "basic")
54
+ "type": "basic",
50
55
  "logo": ["█████████ ", "███ ███ ", "██████ ", "███ ███"],
51
56
  "heading": "Welcome back!",
52
57
  "subheading": "Ready for your next session? Terminal warm, context clean, tools ready to execute",
@@ -63,7 +68,7 @@ Configuration is loaded from `~/.pi/agent/pi-zen-frame.json`. All properties are
63
68
  // Padding inside the frame
64
69
  "paddingTop": 1,
65
70
  "paddingBottom": 1,
66
- "paddingX": 1,
71
+ "paddingX": 2,
67
72
 
68
73
  // Outer margin around the frame
69
74
  "marginTop": 0,
@@ -81,7 +86,7 @@ Configuration is loaded from `~/.pi/agent/pi-zen-frame.json`. All properties are
81
86
  "borderColor": "border",
82
87
 
83
88
  // Input prompt prefix glyph and color settings
84
- "prefix": "",
89
+ "prefix": "",
85
90
  "prefixColor": "muted", // Options: "agentMode", "frameBorder", or any ThemeColor
86
91
 
87
92
  // Override default Nerd-Font glyphs
@@ -149,8 +154,9 @@ Run `/reload` after modifying your keybindings.
149
154
  extensions/
150
155
  ├── index.ts # Extension entry point: config initialization and events
151
156
  ├── config/ # Types, defaults, and settings normalization
152
- ├── components/ # Header, frame border, and status segment modules
153
- ├── editor/ # FrameEditor component for layout rendering
157
+ ├── components/ # Header (BasicHeader), frame border, and status segment modules
158
+ ├── editor/ # BlockyEditor editor-frame renderer
159
+ ├── renderers/ # Renderer registry + swap-in API (blocky / basic)
154
160
  └── utils/ # Helpers for Git status, paths, and agent modes
155
161
 
156
162
  ```
@@ -1,20 +1,21 @@
1
1
  import { capitalize } from "../utils";
2
+
2
3
  import type { SegmentDef } from "./types";
3
4
 
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. */
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
7
  export const agentModeSegment: SegmentDef = {
7
8
  id: "agent-mode",
8
- slot: "bottomLeft",
9
+ slot: "topLeft",
9
10
  enabled: (_d, cfg) => cfg.showAgentMode !== false,
10
11
 
11
- render: (d, { theme, segColor }) => {
12
+ render: (d, { theme }) => {
12
13
  const m = d.agentMode;
13
14
  if (!m) return "";
14
15
 
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)} `);
16
+ // Agent's own color wins; accent fills in. Zen never mutes the agent.
17
+ const color = m.color ?? d.accentColor;
18
+ const label = ` ${capitalize(m.name)} `;
18
19
 
19
20
  try {
20
21
  return theme.fg(color, label);
@@ -1,26 +1,23 @@
1
1
  import type { SegmentDef } from "./types";
2
2
  import { getShortCwd } from "../utils";
3
3
 
4
- /** Bottom-right: working directory + git branch (with dirty count). */
4
+ /** Pseudo-footer (right): working directory + git branch (with dirty count). */
5
5
  export const cwdSegment: SegmentDef = {
6
6
  id: "cwd",
7
- slot: "bottomRight",
7
+ slot: "bottomLeft",
8
8
  enabled: (_d, cfg) => cfg.showCwd !== false,
9
- render: (d, { theme, icons, segColor }) => {
9
+ render: (d, { theme, icons }) => {
10
10
  const folder = getShortCwd(d.cwd);
11
11
 
12
- let text = theme.fg(
13
- segColor("cwd", "muted"),
14
- `${icons.folder} ${folder}`,
15
- );
12
+ let text = theme.fg("muted", `${icons.folder} ${folder}`);
16
13
 
17
14
  if (d.gitBranch) {
18
15
  let git = ` ${icons.gitBranch} ${d.gitBranch}`;
19
- text += theme.fg(segColor("cwd", d.accentColor), git);
16
+ text += theme.fg(d.zenMode ? "muted" : d.accentColor, git);
20
17
 
21
18
  if (d.gitDirty > 0) {
22
19
  text += theme.fg(
23
- segColor("cwd", "error"),
20
+ d.zenMode ? "muted" : "error",
24
21
  ` ${icons.gitDirty} ${d.gitDirty}`,
25
22
  );
26
23
  }
@@ -1,23 +1,13 @@
1
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.
2
+ * Row layout for the borderless band editor.
5
3
  *
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.
4
+ * composeBand assembles the bg-filled band: padding rows, content (prefix on
5
+ * the first row), autocomplete, one blank row, then the box-bottom segments.
6
+ * Every output row is exactly `width` columns. fitFrameRow/fitInfoRow lay out
7
+ * the header and status rows.
9
8
  */
10
9
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
11
10
 
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
11
  const SGR_RE = /\x1b\[[0-9;]*m/g;
22
12
  const CURSOR_MARKER_RE = /\x1b_pi:c\x07/g;
23
13
 
@@ -26,7 +16,7 @@ function plain(row: string): string {
26
16
  }
27
17
 
28
18
  /** A border row is all `─`, or a `─── ↑/↓ N more ───` scroll indicator. */
29
- function isBorderRow(row: string): boolean {
19
+ export function isBorderRow(row: string): boolean {
30
20
  const t = plain(row).trim();
31
21
 
32
22
  if (t === "") return true;
@@ -35,6 +25,58 @@ function isBorderRow(row: string): boolean {
35
25
  return /^─── [↑↓] \d+ more ─*$/.test(t);
36
26
  }
37
27
 
28
+ export interface BandOptions {
29
+ padX: number;
30
+ width: number;
31
+ prefix: string;
32
+ marginX: number;
33
+ boxBottom: string;
34
+ paddingTop: number;
35
+ paddingBottom: number;
36
+ paint: (row: string) => string;
37
+ }
38
+
39
+ /** Borderless bg band: padding, content (prefix on first row), autocomplete,
40
+ * blank row, box-bottom segments. Every row is exactly `width` columns. */
41
+ export function composeBand(
42
+ content: string[],
43
+ autocomplete: string[],
44
+ opts: BandOptions,
45
+ ): string[] {
46
+ const {
47
+ padX,
48
+ paint,
49
+ width,
50
+ prefix,
51
+ marginX,
52
+ boxBottom,
53
+ paddingTop,
54
+ paddingBottom,
55
+ } = opts;
56
+
57
+ const lead = Math.max(0, padX - visibleWidth(prefix));
58
+
59
+ const inset = (row: string) =>
60
+ paint(prefix + " ".repeat(lead) + row + " ".repeat(padX));
61
+
62
+ const fill = () => inset(paint(" ".repeat(Math.max(0, width - 2 * padX))));
63
+
64
+ const rows: string[] = [];
65
+ for (let i = 0; i < paddingTop; i++) rows.push(fill());
66
+
67
+ content.forEach((row) => {
68
+ rows.push(inset(row));
69
+ });
70
+
71
+ autocomplete.forEach((row) => rows.push(inset(row)));
72
+
73
+ rows.push(fill());
74
+ rows.push(inset(boxBottom));
75
+
76
+ for (let i = 0; i < paddingBottom; i++) rows.push(fill());
77
+ return rows;
78
+ }
79
+
38
80
  /** Lay one border row: cap + leftText + ─fill + rightText + cap, always
39
81
  * exactly `width` columns. Right text truncates first, then left text. */
40
82
  export function fitFrameRow(
@@ -91,50 +133,3 @@ export function fitInfoRow(
91
133
  );
92
134
  return leftText2 + " ".repeat(fill) + rightText2;
93
135
  }
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
- }
@@ -1,16 +1,13 @@
1
1
  import type { SegmentDef } from "./types";
2
2
 
3
- /** Top-left: the active model name. */
3
+ /** Box bottom (left): the active model name. */
4
4
  export const modelSegment: SegmentDef = {
5
5
  id: "model",
6
6
  slot: "topLeft",
7
7
  enabled: (_d, cfg) => cfg.showModel !== false,
8
- render: (d, { theme, icons, segColor }) => {
8
+ render: (d, { theme }) => {
9
9
  if (!d.modelName) return "";
10
10
 
11
- return theme.fg(
12
- segColor("model", d.accentColor),
13
- ` ${icons.model} ${d.modelName} `,
14
- );
11
+ return ` ${theme.fg("text", d.modelName)} ${theme.fg("muted", `(${d.modelProvider})`)} `;
15
12
  },
16
13
  };
@@ -3,19 +3,20 @@ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
3
3
  import type { SegmentDef } from "./types";
4
4
  import { THINKING_TOKEN } from "../config/constants";
5
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. */
6
+ /** Box bottom (left): current thinking level, tinted with its own theme
7
+ * token — the same color pi applies to the border at that level. */
8
8
  export const reasoningSegment: SegmentDef = {
9
9
  id: "reasoning",
10
10
  slot: "topLeft",
11
11
  enabled: (_d, cfg) => cfg.showThinking !== false,
12
- render: (d, { theme, icons, segColor }) => {
12
+ render: (d, { theme, icons }) => {
13
13
  if (!d.thinkingLevel) return "";
14
+
14
15
  const token = (THINKING_TOKEN[d.thinkingLevel] ??
15
16
  "thinkingText") as ThemeColor;
16
17
 
17
18
  return theme.fg(
18
- segColor("thinking", token),
19
+ d.zenMode ? "muted" : token,
19
20
  ` ${icons.thinking} ${d.thinkingLevel} `,
20
21
  );
21
22
  },
@@ -13,11 +13,11 @@ import { agentModeSegment } from "./agent-mode";
13
13
  import type { FrameData, SegmentContext, SegmentDef, Slot } from "./types";
14
14
 
15
15
  export const segments: SegmentDef[] = [
16
+ agentModeSegment,
16
17
  modelSegment,
17
18
  reasoningSegment,
18
19
  spinnerSegment,
19
20
  tokenCountSegment,
20
- agentModeSegment,
21
21
  cwdSegment,
22
22
  ];
23
23
 
@@ -38,5 +38,5 @@ export function segmentsFor(
38
38
  return active
39
39
  .map((s) => s.render(d, ctx))
40
40
  .filter((t) => t !== "")
41
- .join(ctx.border("·"));
41
+ .join(ctx.theme.fg("dim", "·"));
42
42
  }
@@ -19,12 +19,12 @@ function formatWindow(n: number): string {
19
19
  return `${(n / 1_000).toFixed(0)}k`;
20
20
  }
21
21
 
22
- /** Bottom-left: context window usage (percent + used/window tokens). */
22
+ /** Box bottom (right): context window usage (percent + used/window tokens). */
23
23
  export const tokenCountSegment: SegmentDef = {
24
24
  id: "token-count",
25
- slot: "topRight",
25
+ slot: "bottomRight",
26
26
  enabled: (_d, cfg) => cfg.showContext !== false,
27
- render: (d, { border, theme, icons, segColor }) => {
27
+ render: (d, { theme, icons }) => {
28
28
  const c = d.context;
29
29
  if (!c) return "";
30
30
 
@@ -41,15 +41,13 @@ export const tokenCountSegment: SegmentDef = {
41
41
  }
42
42
  }
43
43
 
44
+ const color2 = d.zenMode ? "muted" : color;
44
45
  const used = c.tokens === null ? "?" : formatTokens(c.tokens);
45
46
 
46
47
  return (
47
- theme.fg(segColor("context", color), ` ${icons.context} ctx ${pct} `) +
48
- border("·") +
49
- theme.fg(
50
- segColor("context", color),
51
- ` ${used}/${formatWindow(c.window)} `,
52
- )
48
+ theme.fg(color2, ` ${icons.context} ctx ${pct} `) +
49
+ theme.fg("dim", "·") +
50
+ theme.fg(color2, ` ${used}/${formatWindow(c.window)} `)
53
51
  );
54
52
  },
55
53
  };
@@ -10,14 +10,11 @@
10
10
  */
11
11
  import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
12
12
 
13
- import type {
14
- FrameColors,
15
- FrameIcons,
16
- FrameSettings,
17
- SpinnerPhase,
18
- } from "../config/types";
13
+ import type { FrameIcons, FrameSettings, SpinnerPhase } from "../config/types";
19
14
 
20
- /** Where in the frame border a segment renders. */
15
+ /** Where a segment renders. "top*" → the box-bottom row inside the band,
16
+ * "bottom*" → the pseudo-footer row below it (names kept from the old
17
+ * border layout). */
21
18
  export type Slot = "topLeft" | "topRight" | "bottomLeft" | "bottomRight";
22
19
 
23
20
  export type AgentConfig = {
@@ -47,6 +44,7 @@ export interface FrameData {
47
44
  accentColor: ThemeColor;
48
45
  gitBranch: string | undefined;
49
46
  modelName: string | undefined;
47
+ modelProvider: string | undefined;
50
48
  spinnerPhase: SpinnerPhase | null;
51
49
  thinkingLevel: string | undefined;
52
50
 
@@ -68,11 +66,6 @@ export interface SegmentContext {
68
66
  theme: Theme;
69
67
  icons: FrameIcons;
70
68
  cfg: FrameSettings;
71
- border: (str: string) => string;
72
-
73
- /** Resolve a segment's fg: zen-mode mutes everything except agentMode;
74
- * else `frame.colors.<key>` supersedes the segment default. */
75
- segColor: (key: keyof FrameColors, fallback: ThemeColor) => ThemeColor;
76
69
  }
77
70
 
78
71
  /** A segment renders an already-ANSI-styled string, or "" to render nothing. */
@@ -93,14 +86,15 @@ export interface SegmentDef {
93
86
  * this with its own live state (mode / count / spinner frame) in render().
94
87
  */
95
88
  export interface ExternalData {
96
- modelName: string | undefined;
97
- thinkingLevel: string | undefined;
98
- spinnerPhase: SpinnerPhase | null;
99
- context: FrameData["context"];
100
89
  cwd: string;
101
- gitBranch: string | undefined;
102
90
  gitDirty: number;
91
+ zenMode: boolean;
103
92
  agentMode: AgentMode;
104
93
  theme: Theme | undefined;
105
- zenMode: boolean;
94
+ gitBranch: string | undefined;
95
+ modelName: string | undefined;
96
+ context: FrameData["context"];
97
+ modelProvider: string | undefined;
98
+ thinkingLevel: string | undefined;
99
+ spinnerPhase: SpinnerPhase | null;
106
100
  }
@@ -3,9 +3,11 @@ import { WORKING_MESSAGES } from "./messages";
3
3
  export const DEFAULT_SETTINGS: Settings = {
4
4
  zenMode: false,
5
5
  accentColor: "accent",
6
+ editorFrame: "blocky",
6
7
 
7
8
  header: {
8
9
  enable: false,
10
+ type: "basic",
9
11
  logoColor: "text",
10
12
  accentColor: "accent",
11
13
 
@@ -24,7 +26,9 @@ export const DEFAULT_SETTINGS: Settings = {
24
26
 
25
27
  frame: {
26
28
  icons: {},
27
- paddingX: 1,
29
+ marginX: 1,
30
+ paddingX: 2,
31
+ prefix: "┃",
28
32
  enable: true,
29
33
  minWidth: 20,
30
34
  marginTop: 0,
@@ -37,11 +41,6 @@ export const DEFAULT_SETTINGS: Settings = {
37
41
  showContext: true,
38
42
  showThinking: true,
39
43
  showAgentMode: true,
40
- borderColor: "text",
41
-
42
- colors: {},
43
- // ponytail: colors were dormant (nothing read them); now wired — keep
44
- // defaults unset so segments keep their natural colors and zen-mode is
45
- // the mute switch.
44
+ prefixColor: "agentMode",
46
45
  },
47
46
  };
@@ -8,7 +8,7 @@ import {
8
8
  DEFAULT_SETTINGS,
9
9
  WORKING_MESSAGES,
10
10
  } from "./constants";
11
- import { Settings, FrameColors } from "./types";
11
+ import type { Settings } from "./types";
12
12
  import { isThemeColor } from "../utils";
13
13
 
14
14
  function getResolvedSettingsFilePath(): string {
@@ -41,6 +41,8 @@ function normalize(raw: unknown): Settings {
41
41
  ...DEFAULT_SETTINGS,
42
42
  frame: { ...DEFAULT_SETTINGS.frame },
43
43
  zenMode: bool(r.zenMode, d.zenMode ?? true),
44
+ editorFrame:
45
+ typeof r.editorFrame === "string" ? r.editorFrame : d.editorFrame,
44
46
  };
45
47
 
46
48
  if (typeof r.frame === "object" && r.frame) {
@@ -48,10 +50,11 @@ function normalize(raw: unknown): Settings {
48
50
 
49
51
  out.frame = {
50
52
  enable: bool(f.enable, d.frame?.enable),
53
+ marginX: num(f.marginX, d.frame?.marginX ?? 1),
54
+ paddingX: num(f.paddingX, d.frame?.paddingX ?? 1),
51
55
  minWidth: num(f.minWidth, d.frame?.minWidth ?? 20),
52
- paddingTop: num(f.paddingTop, d.frame?.paddingTop ?? 1),
56
+ paddingTop: num(f.paddingTop, d.frame?.paddingTop ?? 0),
53
57
  paddingBottom: num(f.paddingBottom, d.frame?.paddingBottom ?? 1),
54
- paddingX: num(f.paddingX, d.frame?.paddingX ?? 1),
55
58
  marginTop: num(f.marginTop, d.frame?.marginTop ?? 0),
56
59
  marginBottom: num(f.marginBottom, d.frame?.marginBottom ?? 0),
57
60
  showModel: bool(f.showModel, d.frame?.showModel),
@@ -64,29 +67,12 @@ function normalize(raw: unknown): Settings {
64
67
  typeof f.icons === "object" && f.icons
65
68
  ? { ...d.frame?.icons, ...(f.icons as Record<string, unknown>) }
66
69
  : d.frame?.icons,
67
- borderColor:
68
- typeof f.borderColor === "string" &&
69
- (isThemeColor(f.borderColor) || f.borderColor === "agentMode")
70
- ? f.borderColor
71
- : (d.frame?.borderColor ?? "border"),
72
-
73
- colors:
74
- typeof f.colors === "object" && f.colors
75
- ? (Object.fromEntries(
76
- Object.entries(f.colors as Record<string, unknown>).filter(
77
- ([, v]) => typeof v === "string" && isThemeColor(v),
78
- ),
79
- ) as Partial<FrameColors>)
80
- : d.frame?.colors,
81
-
82
70
  prefix:
83
- typeof f.prefix === "string" ? f.prefix : (d.frame?.prefix ?? ""),
71
+ typeof f.prefix === "string" ? f.prefix : (d.frame?.prefix ?? ""),
84
72
 
85
73
  prefixColor:
86
74
  typeof f.prefixColor === "string" &&
87
- (f.prefixColor === "agentMode" ||
88
- f.prefixColor === "frameBorder" ||
89
- isThemeColor(f.prefixColor))
75
+ (f.prefixColor === "agentMode" || isThemeColor(f.prefixColor))
90
76
  ? f.prefixColor
91
77
  : d.frame?.prefixColor, // unset → text
92
78
  };
@@ -96,6 +82,7 @@ function normalize(raw: unknown): Settings {
96
82
  const h = r.header as Record<string, unknown>;
97
83
 
98
84
  out.header = {
85
+ type: typeof h.type === "string" ? h.type : (d.header?.type ?? "basic"),
99
86
  enable: bool(h.enable, d.header?.enable),
100
87
  heading: str(h.heading, d.header?.heading ?? ""),
101
88
  subheading: str(h.subheading, d.header?.subheading ?? ""),
@@ -9,17 +9,7 @@ export interface BannerTip {
9
9
  text: string;
10
10
  }
11
11
 
12
- export interface FrameColors {
13
- border: ThemeColor;
14
- background: ThemeColor;
15
- cwd: ThemeColor;
16
- model: ThemeColor;
17
- context: ThemeColor;
18
- thinking: ThemeColor;
19
- agentMode: ThemeColor;
20
- }
21
-
22
- /** Nerd-font / glyph icons shown in the frame border. Each is overridable. */
12
+ /** Nerd-font / glyph icons shown in the band and status rows. Overridable. */
23
13
  export interface FrameIcons {
24
14
  folder: string;
25
15
  model: string;
@@ -30,9 +20,9 @@ export interface FrameIcons {
30
20
  }
31
21
 
32
22
  /**
33
- * ``frame`` — the rounded-corner shell look. Padding values are plain
23
+ * ``frame`` — the borderless band look. Padding values are plain
34
24
  * numbers (>= 0): paddingTop/paddingBottom are blank lines shown *inside*
35
- * the box above/below the content.
25
+ * the band above/below the content.
36
26
  */
37
27
  export interface FrameSettings {
38
28
  enable?: boolean;
@@ -40,19 +30,22 @@ export interface FrameSettings {
40
30
  /** Below this terminal width the frame is skipped entirely (passthrough) */
41
31
  minWidth?: number;
42
32
 
43
- /** Blank lines inside the box above the content. Default 1. */
33
+ /** Blank rows inside the band above the content. Default 1 */
44
34
  paddingTop?: number;
45
35
 
46
- /** Blank lines inside the box below the content. Default 1. */
36
+ /** Blank rows inside the band below the content. Default 1 */
47
37
  paddingBottom?: number;
48
38
 
49
- /** Horizontal inner padding for the content rows. Default 1. */
39
+ /** Horizontal inner padding for the content rows. Default 2 */
50
40
  paddingX?: number;
51
41
 
52
- /** Blank rows OUTSIDE the box, above its top border. Default 0. */
42
+ /** Horizontal outer margin for the content rows. Default 1 */
43
+ marginX?: number;
44
+
45
+ /** Blank rows OUTSIDE the band, above it. Default 0 */
53
46
  marginTop?: number;
54
47
 
55
- /** Blank rows OUTSIDE the box, below its bottom border. Default 0. */
48
+ /** Blank rows OUTSIDE the band, below the pseudo-footer. Default 0 */
56
49
  marginBottom?: number;
57
50
 
58
51
  showCwd?: boolean;
@@ -61,21 +54,16 @@ export interface FrameSettings {
61
54
  showThinking?: boolean;
62
55
  showAgentMode?: boolean;
63
56
 
64
- /** Show the status-animation spinner segment while streaming. Default false. */
57
+ /** Show the status-animation spinner segment while streaming. Default false */
65
58
  showSpinner?: boolean;
66
59
 
67
60
  icons?: Partial<FrameIcons>;
68
- colors?: Partial<FrameColors>;
69
61
 
70
- /** ThemeColor used as the fg for all editor border characters; If set to `"agentMode"`, will follow the current Agent color. Default "border". */
71
- borderColor?: ThemeColor | "agentMode";
72
-
73
- /** Text shown before the editor content. Default "❯". */
62
+ /** Text before the editor content. Default "" */
74
63
  prefix?: string;
75
64
 
76
- /** ThemeColor used as the fg for the prefix text; If set to `"agentMode"`, will follow the current Agent color;
77
- * If set to `"frameBorder"`, will follow the border color. Default: text color. */
78
- prefixColor?: "agentMode" | "frameBorder" | ThemeColor;
65
+ /** ThemeColor for the prefix; "agentMode" follows the agent's color if (@leo-alvarenga/pi-agent-manager is installed, defaults to "text otherwise"). Default: "agentMode" */
66
+ prefixColor?: "agentMode" | ThemeColor;
79
67
  }
80
68
 
81
69
  /** `header` — the top-line header, which can show a logo or other text */
@@ -86,6 +74,9 @@ export interface HeaderSettings {
86
74
  subheading: string;
87
75
  logoColor: ThemeColor;
88
76
  accentColor: ThemeColor;
77
+
78
+ /** Header renderer by registered name. Default "basic". */
79
+ type?: string;
89
80
  }
90
81
 
91
82
  /** `workingMessage` — rotating messages in pi's built-in working loader. */
@@ -105,11 +96,14 @@ export interface Settings {
105
96
  header?: HeaderSettings;
106
97
  workingMessage?: WorkingMessageSettings;
107
98
 
99
+ /** Editor-frame renderer by registered name. Default "blocky". */
100
+ editorFrame?: string;
101
+
108
102
  /** Master mute: every segment except agent-mode renders muted.
109
103
  * Default true. Toggle at runtime with `/zen_mode` or the `piZenFrame.zenMode`
110
104
  * keybinding (default `ctrl+shift+z`). */
111
105
  zenMode?: boolean;
112
106
 
113
- /** Accent color for the frame border and other highlights; Defaults to 'accent' */
107
+ /** Accent color for segment highlights; Defaults to 'accent' */
114
108
  accentColor?: ThemeColor;
115
109
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * FrameEditor — the editor component. Subclasses CustomEditor so all of
2
+ * BlockyEditor — the editor-frame renderer. Subclasses CustomEditor so all of
3
3
  * pi's editing/undo/app-keybinding machinery keeps working; `render` layers
4
- * the box frame. Vim behavior was removed — all input passes through to the
4
+ * the bg band. Vim behavior was removed — all input passes through to the
5
5
  * base editor. Appearance lives in `components/` (segments + frame).
6
6
  */
7
7
  import {
@@ -11,24 +11,20 @@ import {
11
11
  } from "@earendil-works/pi-coding-agent";
12
12
 
13
13
  import { DEFAULT_ICONS, SPINNER_FRAMES } from "../config/constants";
14
- import type { FrameSettings, SpinnerPhase } from "../config/types";
15
- import { renderFrame, fitInfoRow } from "../components/frame";
14
+ import type { SpinnerPhase } from "../config/types";
15
+ import { composeBand, fitInfoRow, isBorderRow } from "../components/frame";
16
16
  import { segmentsFor } from "../components/registry";
17
17
  import type {
18
18
  ExternalData,
19
19
  FrameData,
20
20
  SegmentContext,
21
21
  } from "../components/types";
22
+ import type { EditorFrameRenderOptions } from "../renderers/types";
22
23
  import { isThemeColor } from "../utils";
23
24
 
24
- export interface FrameEditorOptions {
25
- frame: FrameSettings;
26
- accentColor: ThemeColor;
27
- }
28
-
29
- export class FrameEditor extends CustomEditor {
25
+ export class BlockyEditor extends CustomEditor {
30
26
  private pi: ExtensionAPI;
31
- private opts: FrameEditorOptions;
27
+ private opts: EditorFrameRenderOptions;
32
28
  private provider: (pi: ExtensionAPI) => ExternalData;
33
29
 
34
30
  private spinnerIdx = 0;
@@ -38,7 +34,7 @@ export class FrameEditor extends CustomEditor {
38
34
  constructor(
39
35
  pi: ExtensionAPI,
40
36
  provider: (pi: ExtensionAPI) => ExternalData,
41
- opts: FrameEditorOptions,
37
+ opts: EditorFrameRenderOptions,
42
38
  ...args: ConstructorParameters<typeof CustomEditor>
43
39
  ) {
44
40
  super(...args);
@@ -83,101 +79,109 @@ export class FrameEditor extends CustomEditor {
83
79
 
84
80
  const padX = Math.min(
85
81
  frame.paddingX ?? 1,
86
- Math.max(0, Math.floor((width - 2) / 2)),
82
+ Math.max(0, Math.floor(width / 2)),
83
+ );
84
+
85
+ const marginX = Math.min(
86
+ frame.marginX ?? 0,
87
+ Math.max(0, Math.floor(width / 2)),
87
88
  );
88
89
 
89
- const innerWidth = width - 2 - padX * 2;
90
+ const contentWidth = width - marginX * 2;
91
+ const innerWidth = width - padX * 2 - marginX * 2;
90
92
  if (innerWidth < 8) return super.render(width);
91
93
 
92
94
  const ext = this.provider(this.pi);
93
95
  const d: FrameData = {
94
96
  cwd: ext.cwd,
95
97
  context: ext.context,
98
+ zenMode: ext.zenMode,
96
99
  gitDirty: ext.gitDirty,
97
100
  agentMode: ext.agentMode,
98
101
  gitBranch: ext.gitBranch,
99
102
  modelName: ext.modelName,
100
103
  spinnerPhase: ext.spinnerPhase,
104
+ modelProvider: ext.modelProvider,
101
105
  thinkingLevel: ext.thinkingLevel,
102
106
  spinnerFrame: this.spinnerFrame(),
103
107
  accentColor: this.opts.accentColor,
104
- zenMode: ext.zenMode,
105
108
  };
106
109
 
107
- let prefix = frame.prefix ?? "";
110
+ let prefix = frame.prefix ?? "";
108
111
 
109
112
  if (ext.theme?.fg) {
110
- let borderColor: ThemeColor = "accent";
111
-
112
- if (frame.borderColor === "agentMode") {
113
- borderColor = ext.agentMode?.color ?? "accent";
114
- } else {
115
- borderColor = frame.borderColor ?? "accent";
116
- }
117
-
118
113
  let prefixColor: ThemeColor = "text";
119
114
  if (frame.prefixColor === "agentMode") {
120
- prefixColor = ext.agentMode?.color ?? "accent";
121
- } else if (frame.prefixColor === "frameBorder") {
122
- prefixColor = borderColor;
115
+ prefixColor = ext.agentMode?.color ?? "text";
123
116
  } else if (frame.prefixColor && isThemeColor(frame.prefixColor)) {
124
117
  prefixColor = frame.prefixColor;
125
118
  }
126
119
 
127
120
  prefix = ext.theme.fg(prefixColor, prefix);
128
-
129
- this.borderColor = (s: string) => ext.theme!.fg(borderColor, s);
130
121
  }
131
122
 
132
123
  // No theme (non-TUI) or frame disabled or too narrow → plain editor.
133
- if (!ext.theme || !frame.enable || width < (frame.minWidth ?? 20)) {
124
+ if (!ext.theme || !frame.enable || contentWidth < (frame.minWidth ?? 20)) {
134
125
  return super.render(width);
135
126
  }
136
127
 
137
128
  const inner = super.render(innerWidth);
129
+
130
+ let bottomIdx = inner.length - 1;
131
+ for (let i = inner.length - 1; i >= 0; i--) {
132
+ if (isBorderRow(inner[i]!)) {
133
+ bottomIdx = i;
134
+ break;
135
+ }
136
+ }
137
+
138
+ const content = inner.slice(1, bottomIdx);
139
+ const autocomplete = inner.slice(bottomIdx + 1);
140
+
141
+ const bgSgr = ext.theme.getBgAnsi("customMessageBg");
142
+ const paint = (row: string) =>
143
+ bgSgr + row.split("\x1b[0m").join(`\x1b[0m${bgSgr}`) + "\x1b[0m";
144
+
138
145
  const ctx: SegmentContext = {
139
146
  cfg: frame,
140
147
  theme: ext.theme,
141
- border: this.borderColor,
142
148
  icons: {
143
149
  ...DEFAULT_ICONS,
144
150
  ...frame.icons,
145
151
  },
146
- segColor: (key, fallback) =>
147
- d.zenMode && key !== "agentMode"
148
- ? "muted"
149
- : (frame.colors?.[key] ?? fallback),
150
152
  };
151
153
 
152
- const topLine = fitInfoRow(
153
- segmentsFor("topLeft", d, ctx),
154
- segmentsFor("topRight", d, ctx),
155
- width,
156
- );
157
-
158
- const bottomLine = fitInfoRow(
159
- segmentsFor("bottomLeft", d, ctx),
160
- segmentsFor("bottomRight", d, ctx),
161
- width,
162
- );
163
-
164
- const box = renderFrame(inner, {
165
- width,
154
+ const box = composeBand(content, autocomplete, {
155
+ padX,
156
+ paint,
166
157
  prefix,
167
- paddingX: padX,
168
- border: this.borderColor,
158
+ width: contentWidth,
159
+ marginX: frame.marginX ?? 0,
169
160
  paddingTop: frame.paddingTop ?? 1,
170
161
  paddingBottom: frame.paddingBottom ?? 1,
162
+ boxBottom: fitInfoRow(
163
+ segmentsFor("topLeft", d, ctx),
164
+ segmentsFor("topRight", d, ctx),
165
+ innerWidth,
166
+ ),
171
167
  });
172
168
 
173
- // Blank rows OUTSIDE the box, above/below its borders.
174
- const marginRow = " ".repeat(width);
169
+ const pseudoFooter = fitInfoRow(
170
+ segmentsFor("bottomLeft", d, ctx),
171
+ segmentsFor("bottomRight", d, ctx),
172
+ contentWidth,
173
+ );
174
+
175
+ // Blank rows OUTSIDE the band, above/below it.
176
+ const marginRow = " ".repeat(contentWidth);
175
177
  const marginTop = Array(Math.max(0, frame.marginTop ?? 0)).fill(marginRow);
176
178
  const marginBottom = Array(Math.max(0, frame.marginBottom ?? 0)).fill(
177
179
  marginRow,
178
180
  );
179
181
 
180
- return [...marginTop, topLine, ...box, bottomLine, ...marginBottom];
182
+ return [...marginTop, ...box, marginRow, pseudoFooter, ...marginBottom].map(
183
+ (row) => " ".repeat(marginX) + row + " ".repeat(marginX),
184
+ );
181
185
  }
182
186
 
183
187
  private spinnerFrame(): string {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * pi-zen-frame entry. Thin wiring layer:
3
3
  * - loads config, subscribes to pi lifecycle + mode/agent-mode events
4
- * - installs FrameEditor via setEditorComponent (factory is re-run on
5
- * model switch — the old instance's spinner timer is stopped first)
4
+ * - installs the configured editor-frame renderer via setEditorComponent
5
+ * (factory is re-run on model switch — the old instance's spinner
6
+ * timer is stopped first)
6
7
  * - assembles ExternalData per paint (model, thinking, context, git, ...)
7
8
  *
8
9
  * All appearance logic lives in components/ (segments + frame); the editor
@@ -27,16 +28,17 @@ import {
27
28
  import { loadSettings } from "./config/settings";
28
29
  import type { Settings, SpinnerPhase } from "./config/types";
29
30
  import type { AgentMode, AgentState, ExternalData } from "./components/types";
30
- import { FrameEditor } from "./editor/frame-editor";
31
+ import { getEditorFrame, getHeader } from "./renderers/registry";
32
+ import type { EditorFrameRenderer } from "./renderers/types";
31
33
  import {
32
34
  capitalize,
33
35
  readAgentModeFromSession,
34
36
  readGit,
35
37
  type GitInfo,
36
38
  } from "./utils";
37
- import { createHeader, type HeaderEnv } from "./components/header";
39
+ import { type HeaderEnv } from "./components/header";
38
40
 
39
- let editor: FrameEditor | null = null;
41
+ let editor: EditorFrameRenderer | null = null;
40
42
  let currentCtx: ExtensionContext | null = null;
41
43
  let settings: Settings = DEFAULT_SETTINGS;
42
44
  let spinnerPhase: SpinnerPhase | null = null;
@@ -94,7 +96,8 @@ function provideExternal(pi: ExtensionAPI): ExternalData {
94
96
  gitBranch: git.branch,
95
97
  theme: ctx?.ui.theme,
96
98
  thinkingLevel: pi.getThinkingLevel(),
97
- modelName: `${model?.name ?? model?.id ?? "Unknown"} (${capitalize(model?.provider ?? "unknown")})`,
99
+ modelName: model?.name ?? model?.id ?? "Unknown",
100
+ modelProvider: capitalize(model?.provider ?? "unknown"),
98
101
  context: usage
99
102
  ? {
100
103
  tokens: usage.tokens,
@@ -201,8 +204,10 @@ export default async function (pi: ExtensionAPI) {
201
204
  }
202
205
 
203
206
  if (settings.header?.enable) {
207
+ const headerFactory =
208
+ getHeader(settings.header.type ?? "basic") ?? getHeader("basic")!;
204
209
  ctx.ui.setHeader((_tui, theme) =>
205
- createHeader(_tui, theme, pi, settings, getHeaderEnv),
210
+ headerFactory(_tui, theme, pi, settings, getHeaderEnv),
206
211
  );
207
212
  }
208
213
 
@@ -217,13 +222,19 @@ export default async function (pi: ExtensionAPI) {
217
222
  ctx.ui.setEditorComponent((...args) => {
218
223
  editor?.stopSpinner();
219
224
 
220
- editor = new FrameEditor(
225
+ const frameFactory =
226
+ getEditorFrame(settings.editorFrame ?? "blocky") ??
227
+ getEditorFrame("blocky")!;
228
+
229
+ editor = frameFactory(
221
230
  pi,
222
231
  provideExternal,
223
232
  {
224
233
  frame: settings.frame ?? DEFAULT_SETTINGS.frame!,
225
234
  accentColor:
226
- settings.accentColor ?? DEFAULT_SETTINGS.accentColor ?? "accent",
235
+ settings.accentColor ??
236
+ DEFAULT_SETTINGS.accentColor ??
237
+ "accent",
227
238
  },
228
239
  ...args,
229
240
  );
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Renderer registry — the swap-in point. Register an editor-frame or header
3
+ * implementation by name; the entry point resolves the configured name via
4
+ * getEditorFrame/getHeader. Built-ins are registered here so the entry point
5
+ * only ever talks to the registry — add a new look by registering it.
6
+ */
7
+ import type { EditorFrameFactory, HeaderRendererFactory } from "./types";
8
+ import { createHeader } from "../components/header";
9
+ import { BlockyEditor } from "../editor/blocky-editor";
10
+
11
+ const editorFrames = new Map<string, EditorFrameFactory>();
12
+ const headers = new Map<string, HeaderRendererFactory>();
13
+
14
+ export function registerEditorFrame(
15
+ name: string,
16
+ factory: EditorFrameFactory,
17
+ ): void {
18
+ editorFrames.set(name, factory);
19
+ }
20
+
21
+ export function registerHeader(
22
+ name: string,
23
+ factory: HeaderRendererFactory,
24
+ ): void {
25
+ headers.set(name, factory);
26
+ }
27
+
28
+ export function getEditorFrame(name: string): EditorFrameFactory | undefined {
29
+ return editorFrames.get(name);
30
+ }
31
+
32
+ export function getHeader(name: string): HeaderRendererFactory | undefined {
33
+ return headers.get(name);
34
+ }
35
+
36
+ // ── built-ins ─────────────────────────────────────────────────────────────
37
+ registerEditorFrame("blocky", (pi, provider, opts, ...args) =>
38
+ new BlockyEditor(pi, provider, opts, ...args),
39
+ );
40
+
41
+ registerHeader("basic", createHeader);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Renderer contracts. A "renderer" is a named, swappable implementation of
3
+ * the editor frame (the blocky band) or the header (the welcome panel).
4
+ * Implementations register by name in registry.ts; the entry point resolves
5
+ * the configured name and instantiates. Signatures mirror pi's
6
+ * setEditorComponent / setHeader factories.
7
+ */
8
+ import type { TUI } from "@earendil-works/pi-tui";
9
+ import type {
10
+ CustomEditor,
11
+ ExtensionAPI,
12
+ Theme,
13
+ ThemeColor,
14
+ } from "@earendil-works/pi-coding-agent";
15
+ import type { FrameSettings, Settings, SpinnerPhase } from "../config/types";
16
+ import type { ExternalData } from "../components/types";
17
+ import type { HeaderEnv } from "../components/header";
18
+
19
+ // ── Header ────────────────────────────────────────────────────────────────
20
+
21
+ /** What pi's TUI needs from any header implementation. */
22
+ export interface HeaderRenderer {
23
+ render(width: number): string[];
24
+ invalidate(): void;
25
+ }
26
+
27
+ /** Builds a HeaderRenderer; matches ctx.ui.setHeader's factory signature. */
28
+ export type HeaderRendererFactory = (
29
+ tui: TUI,
30
+ theme: Theme,
31
+ pi: ExtensionAPI,
32
+ settings: Settings,
33
+ getEnv: (pi: ExtensionAPI) => HeaderEnv,
34
+ ) => HeaderRenderer;
35
+
36
+ // ── Editor frame ──────────────────────────────────────────────────────────
37
+
38
+ /** Per-renderer options handed to the frame factory (frame config + accent). */
39
+ export interface EditorFrameRenderOptions {
40
+ frame: FrameSettings;
41
+ accentColor: ThemeColor;
42
+ }
43
+
44
+ /** Surface the entry point drives on the active editor-frame renderer. */
45
+ export interface EditorFrameRenderer extends CustomEditor {
46
+ setSpinner(phase: SpinnerPhase | null): void;
47
+ stopSpinner(): void;
48
+ refresh(): void;
49
+ }
50
+
51
+ /** Builds an EditorFrameRenderer; `...args` forward to CustomEditor. */
52
+ export type EditorFrameFactory = (
53
+ pi: ExtensionAPI,
54
+ provider: (pi: ExtensionAPI) => ExternalData,
55
+ opts: EditorFrameRenderOptions,
56
+ ...args: ConstructorParameters<typeof CustomEditor>
57
+ ) => EditorFrameRenderer;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@leo-alvarenga/pi-zen-frame",
3
3
  "description": "A pi-coding-agent extension that gives the TUI editor a simple yet polished look and feel",
4
4
  "author": "Leonardo A. Alvarenga",
5
- "version": "0.12.2",
5
+ "version": "0.13.0",
6
6
  "license": "MIT",
7
7
  "repository": "github.com/leo-alvarenga/pi-mono",
8
8
  "publishConfig": {