@leo-alvarenga/pi-zen-frame 0.19.9 → 0.21.2

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
@@ -43,7 +43,7 @@ Configuration is loaded from `~/.pi/agent/pi-zen-frame.json`. All properties are
43
43
  // Fallback accent color for frame border and active segments
44
44
  "accentColor": "accent",
45
45
 
46
- // Editor-frame renderer by registered name (built-in: "blocky", "minimalist")
46
+ // Editor-frame renderer by registered name (built-in: "blocky", "linear", "minimalist")
47
47
  "editorFrame": "blocky",
48
48
 
49
49
  "header": {
@@ -88,14 +88,14 @@ Configuration is loaded from `~/.pi/agent/pi-zen-frame.json`. All properties are
88
88
 
89
89
  The editor frame shows status indicators in two rows below the editor:
90
90
 
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 |
96
- | | **spinner** | Active phase indicator (`thinking`, `outputting`, `toolcall`, `exec`); replaces the row while streaming when `showSpinner` is enabled (default off) |
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 |
91
+ | Location | Segment | Description |
92
+ | -------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
93
+ | Top | **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 |
96
+ | | **spinner** | Active phase indicator (`thinking`, `outputting`, `toolcall`, `exec`); replaces the row while streaming when `showSpinner` is enabled (default off) |
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 |
99
99
 
100
100
  ---
101
101
 
@@ -3,11 +3,11 @@ import { WORKING_MESSAGES } from "./messages";
3
3
 
4
4
  export const DEFAULT_SETTINGS: Settings = {
5
5
  accentColor: "accent",
6
- editorFrame: "blocky",
6
+ editorFrame: "linear",
7
7
 
8
8
  header: {
9
+ type: "box",
9
10
  enable: false,
10
- type: "basic",
11
11
  },
12
12
 
13
13
  workingMessage: {
@@ -1,6 +1,15 @@
1
1
  import type { ThemeColor } from "@earendil-works/pi-coding-agent";
2
2
  import type { FrameIcons, SpinnerPhase } from "../types";
3
3
 
4
+ export const LOGO_LINES = [
5
+ "█████████ ",
6
+ "███ ███ ",
7
+ "██████ ",
8
+ "███ ███",
9
+ ];
10
+
11
+ export const LOGO_COLOR: ThemeColor = "text";
12
+
4
13
  /** Spinner animation frames per phase (pi-editor-shell style) */
5
14
  export const SPINNER_FRAMES: Record<SpinnerPhase, string[]> = {
6
15
  idle: ["⠃", "⠞", "⡵", "⠿", "⢹", "⠄"],
@@ -48,7 +48,7 @@ export interface FrameSettings {
48
48
  export interface HeaderSettings {
49
49
  enable: boolean;
50
50
 
51
- /** Header renderer by registered name. Default "basic". */
51
+ /** Header renderer by registered name. Default "box" */
52
52
  type?: string;
53
53
  }
54
54
 
@@ -4,18 +4,18 @@ import {
4
4
  type ExtensionAPI,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
 
7
- import type { SpinnerPhase } from "../config/types";
8
- import type { ExternalData, FrameData } from "../components/types";
9
- import type { EditorFrameRenderOptions } from "../renderers/types";
7
+ import type { SpinnerPhase } from "../../config/types";
8
+ import type { ExternalData, FrameData } from "../../components/types";
9
+ import type { EditorFrameRenderOptions } from "../../renderers/types";
10
10
 
11
11
  import {
12
12
  handlePaletteInput,
13
13
  loadPaletteItems,
14
14
  renderPalette,
15
15
  type PaletteState,
16
- } from "./palette";
16
+ } from "../palette";
17
17
  import { renderEditorFrame } from "./render";
18
- import { SpinnerController } from "./spinner";
18
+ import { SpinnerController } from "../spinner";
19
19
 
20
20
  export class BlockyEditor extends CustomEditor {
21
21
  private pi: ExtensionAPI;
@@ -0,0 +1 @@
1
+ export { BlockyEditor } from "./blocky-editor";
@@ -1,12 +1,12 @@
1
- import { DEFAULT_ICONS } from "../config/constants";
2
- import { composeBand, fitInfoRow, isBorderRow } from "../components/frame";
3
- import { segmentsFor } from "../components/registry";
1
+ import { DEFAULT_ICONS } from "../../config/constants";
2
+ import { composeBand, fitInfoRow, isBorderRow } from "../../components/frame";
3
+ import { segmentsFor } from "../../components/registry";
4
4
  import type {
5
5
  ExternalData,
6
6
  FrameData,
7
7
  SegmentContext,
8
- } from "../components/types";
9
- import type { EditorFrameRenderOptions } from "../renderers/types";
8
+ } from "../../components/types";
9
+ import type { EditorFrameRenderOptions } from "../../renderers/types";
10
10
 
11
11
  export function renderEditorFrame(
12
12
  getInnerLines: (innerWidth: number) => string[],
@@ -0,0 +1,3 @@
1
+ export { BlockyEditor } from "./blocky";
2
+ export { MinimalistEditor } from "./minimalist";
3
+ export { LinearEditor } from "./linear";
@@ -0,0 +1,44 @@
1
+ import { agentModeSegment } from "../../../components/agent-mode";
2
+ import { cwdSegment } from "../../../components/cwd";
3
+ import { modelSegment } from "../../../components/model";
4
+ import { notifySegment } from "../../../components/notify";
5
+ import { reasoningSegment } from "../../../components/reasoning";
6
+ import { spinnerSegment } from "../../../components/spinner";
7
+ import { tokenCountSegment } from "../../../components/token-count";
8
+ import type {
9
+ FrameData,
10
+ SegmentContext,
11
+ SegmentDef,
12
+ Slot,
13
+ } from "../../../components/types";
14
+
15
+ // Re-wire global segments onto LinearEditor's slot layout:
16
+ // topLeft / topRight → above-band info row
17
+ // bottomLeft → below-band info row
18
+ const segments: SegmentDef[] = [
19
+ { ...agentModeSegment, slot: "topLeft" },
20
+ { ...notifySegment, slot: "topLeft" },
21
+ { ...modelSegment, slot: "topLeft" },
22
+ { ...reasoningSegment, slot: "topLeft" },
23
+ { ...spinnerSegment, slot: "topLeft" },
24
+ { ...tokenCountSegment, slot: "topRight" },
25
+ { ...cwdSegment, slot: "bottomLeft" },
26
+ ];
27
+
28
+ export function linearSegmentsFor(
29
+ slot: Slot,
30
+ d: FrameData,
31
+ ctx: SegmentContext,
32
+ ): string {
33
+ const defs = segments.filter(
34
+ (s) => s.slot === slot && (!s.enabled || s.enabled(d, ctx.cfg)),
35
+ );
36
+
37
+ const replacer = defs.find((s) => s.replaces?.(d, ctx.cfg));
38
+ const active = replacer ? [replacer] : defs;
39
+
40
+ return active
41
+ .map((s) => s.render(d, ctx))
42
+ .filter((t) => t !== "")
43
+ .join(ctx.theme.fg("dim", "·"));
44
+ }
@@ -0,0 +1 @@
1
+ export { LinearEditor } from "./linear-editor";
@@ -0,0 +1,122 @@
1
+ import {
2
+ CustomEditor,
3
+ type ExtensionAPI,
4
+ } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type { SpinnerPhase } from "../../config/types";
7
+ import type { ExternalData, FrameData } from "../../components/types";
8
+ import type {
9
+ EditorFrameRenderer,
10
+ EditorFrameRenderOptions,
11
+ } from "../../renderers/types";
12
+
13
+ import {
14
+ handlePaletteInput,
15
+ loadPaletteItems,
16
+ renderPalette,
17
+ type PaletteState,
18
+ } from "../palette";
19
+ import { SpinnerController } from "../spinner";
20
+ import { renderLinearFrame } from "./render";
21
+
22
+ export class LinearEditor extends CustomEditor implements EditorFrameRenderer {
23
+ private pi: ExtensionAPI;
24
+ private opts: EditorFrameRenderOptions;
25
+ private spinner = new SpinnerController();
26
+ private paletteState: PaletteState | null = null;
27
+ private provider: (pi: ExtensionAPI) => ExternalData;
28
+
29
+ constructor(
30
+ pi: ExtensionAPI,
31
+ provider: (pi: ExtensionAPI) => ExternalData,
32
+ opts: EditorFrameRenderOptions,
33
+ ...args: ConstructorParameters<typeof CustomEditor>
34
+ ) {
35
+ super(...args);
36
+
37
+ this.pi = pi;
38
+ this.opts = opts;
39
+ this.provider = provider;
40
+ }
41
+
42
+ refresh(): void {
43
+ this.tui.requestRender();
44
+ }
45
+
46
+ openPalette(): void {
47
+ const all = loadPaletteItems(this.pi);
48
+ this.paletteState = { query: "", all, items: all, selected: 0, viewTop: 0 };
49
+ this.tui.requestRender();
50
+ }
51
+
52
+ setSpinner(phase: SpinnerPhase | null): void {
53
+ if (phase) {
54
+ this.spinner.start(phase, () => this.tui.requestRender());
55
+ } else {
56
+ this.spinner.stop();
57
+ }
58
+ }
59
+
60
+ stopSpinner(): void {
61
+ this.spinner.stop();
62
+ this.tui.requestRender();
63
+ }
64
+
65
+ override handleInput(data: string): void {
66
+ if (!this.paletteState) {
67
+ super.handleInput(data);
68
+ return;
69
+ }
70
+
71
+ const result = handlePaletteInput(this.paletteState, data);
72
+ if (result.submit) {
73
+ this.paletteState = null;
74
+ this.setText(result.submit);
75
+ super.handleInput("\r");
76
+
77
+ return;
78
+ }
79
+
80
+ this.paletteState = result.next;
81
+ this.tui.requestRender();
82
+ }
83
+
84
+ override render(width: number): string[] {
85
+ if (this.paletteState) {
86
+ const ext = this.provider(this.pi);
87
+ if (ext.theme) return renderPalette(this.paletteState, width, ext.theme);
88
+ }
89
+
90
+ const { frame } = this.opts;
91
+ const marginX = Math.min(1, Math.max(0, Math.floor(width / 2)));
92
+ const contentWidth = width - marginX * 2;
93
+
94
+ if (contentWidth < (frame.minWidth ?? 20)) return super.render(width);
95
+
96
+ const ext = this.provider(this.pi);
97
+ if (!ext.theme || !frame.enable) return super.render(width);
98
+
99
+ const d: FrameData = {
100
+ cwd: ext.cwd,
101
+ context: ext.context,
102
+ gitDirty: ext.gitDirty,
103
+ agentMode: ext.agentMode,
104
+ gitBranch: ext.gitBranch,
105
+ modelName: ext.modelName,
106
+ spinnerPhase: ext.spinnerPhase,
107
+ modelProvider: ext.modelProvider,
108
+ notifyEnabled: ext.notifyEnabled,
109
+ thinkingLevel: ext.thinkingLevel,
110
+ spinnerFrame: this.spinner.frame(),
111
+ accentColor: this.opts.accentColor,
112
+ };
113
+
114
+ return renderLinearFrame(
115
+ (innerWidth) => super.render(innerWidth),
116
+ this.opts,
117
+ ext,
118
+ d,
119
+ width,
120
+ );
121
+ }
122
+ }
@@ -0,0 +1,84 @@
1
+ import { DEFAULT_ICONS } from "../../config/constants";
2
+ import { fitInfoRow, isBorderRow } from "../../components/frame";
3
+ import type {
4
+ ExternalData,
5
+ FrameData,
6
+ SegmentContext,
7
+ } from "../../components/types";
8
+ import type { EditorFrameRenderOptions } from "../../renderers/types";
9
+ import { linearSegmentsFor } from "./components";
10
+
11
+ const LINEAR_PREFIX = "❯";
12
+
13
+ export function renderLinearFrame(
14
+ getInnerLines: (innerWidth: number) => string[],
15
+ opts: EditorFrameRenderOptions,
16
+ ext: ExternalData,
17
+ d: FrameData,
18
+ width: number,
19
+ ): string[] {
20
+ const marginX = Math.min(1, Math.max(0, Math.floor(width / 2)));
21
+ const contentWidth = width - marginX * 2;
22
+
23
+ const innerWidth = contentWidth - LINEAR_PREFIX.length;
24
+
25
+ const inner = getInnerLines(innerWidth);
26
+
27
+ let bottomIdx = inner.length - 1;
28
+ for (let i = inner.length - 1; i >= 0; i--) {
29
+ if (isBorderRow(inner[i]!)) {
30
+ bottomIdx = i;
31
+ break;
32
+ }
33
+ }
34
+
35
+ const content = inner.slice(1, bottomIdx);
36
+ const autocomplete = inner.slice(bottomIdx + 1);
37
+
38
+ const ctx: SegmentContext = {
39
+ cfg: opts.frame,
40
+ theme: ext.theme!,
41
+ icons: DEFAULT_ICONS,
42
+ };
43
+
44
+ const blankRow = " ".repeat(width);
45
+ const regularPrefix = " ".repeat(marginX);
46
+ const borderRow = ext.theme!.fg("dim", "─".repeat(width));
47
+ const accentPrefix = ext.theme!.fg(opts.accentColor, LINEAR_PREFIX);
48
+
49
+ const withMargin = (row: string) => regularPrefix + row + regularPrefix;
50
+
51
+ const rows: string[] = [
52
+ blankRow,
53
+
54
+ withMargin(
55
+ fitInfoRow(
56
+ linearSegmentsFor("topLeft", d, ctx),
57
+ linearSegmentsFor("topRight", d, ctx),
58
+ contentWidth,
59
+ ),
60
+ ),
61
+
62
+ borderRow,
63
+ ];
64
+
65
+ content.forEach((row, i) => {
66
+ rows.push(withMargin((i === 0 ? accentPrefix : regularPrefix) + row));
67
+ });
68
+
69
+ autocomplete.forEach((row) => rows.push(withMargin(regularPrefix + row)));
70
+
71
+ rows.push(
72
+ borderRow,
73
+
74
+ withMargin(
75
+ fitInfoRow(
76
+ linearSegmentsFor("bottomLeft", d, ctx),
77
+ linearSegmentsFor("bottomRight", d, ctx),
78
+ contentWidth,
79
+ ),
80
+ ),
81
+ );
82
+
83
+ return rows;
84
+ }
@@ -3,19 +3,19 @@ import {
3
3
  type ExtensionAPI,
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
 
6
- import type { SpinnerPhase } from "../config/types";
7
- import type { ExternalData } from "../components/types";
6
+ import type { SpinnerPhase } from "../../config/types";
7
+ import type { ExternalData } from "../../components/types";
8
8
  import type {
9
9
  EditorFrameRenderer,
10
10
  EditorFrameRenderOptions,
11
- } from "../renderers/types";
11
+ } from "../../renderers/types";
12
12
 
13
13
  import {
14
14
  handlePaletteInput,
15
15
  loadPaletteItems,
16
16
  renderPalette,
17
17
  type PaletteState,
18
- } from "./palette";
18
+ } from "../palette";
19
19
 
20
20
  export class MinimalistEditor
21
21
  extends CustomEditor
@@ -1,4 +1,4 @@
1
- import { matchesKey } from "@earendil-works/pi-tui";
1
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
2
2
 
3
3
  import { filterPalette, MAX_VISIBLE } from "./items";
4
4
  import type { PaletteState } from "./types";
@@ -14,17 +14,23 @@ export function handlePaletteInput(
14
14
  state: PaletteState,
15
15
  data: string,
16
16
  ): PaletteInputResult {
17
- if (matchesKey(data, "escape")) {
17
+ if (matchesKey(data, Key.esc)) {
18
18
  return { next: null, submit: null };
19
19
  }
20
20
 
21
+ const item = state.items[state.selected];
21
22
  if (data === "\r" || data === "\n") {
22
- const item = state.items[state.selected];
23
-
24
23
  return { next: null, submit: item ? item.name : null };
25
24
  }
26
25
 
27
- if (data === "\x7f" || data === "\b") {
26
+ if (matchesKey(data, Key.tab)) {
27
+ return {
28
+ submit: null,
29
+ next: { ...state, query: item.name.replace("/", "") },
30
+ };
31
+ }
32
+
33
+ if (matchesKey(data, Key.backspace) || matchesKey(data, Key.delete)) {
28
34
  if (state.query.length > 0) {
29
35
  const query = state.query.slice(0, -1);
30
36
 
@@ -48,7 +54,10 @@ export function handlePaletteInput(
48
54
  const selected = Math.max(0, state.selected - 1);
49
55
  const viewTop = selected < state.viewTop ? selected : state.viewTop;
50
56
 
51
- return { next: { ...state, selected, viewTop }, submit: null };
57
+ return {
58
+ submit: null,
59
+ next: { ...state, selected, viewTop },
60
+ };
52
61
  }
53
62
 
54
63
  if (matchesKey(data, "down")) {
@@ -62,7 +71,10 @@ export function handlePaletteInput(
62
71
  viewTop = selected - MAX_VISIBLE + 1;
63
72
  }
64
73
 
65
- return { next: { ...state, selected, viewTop }, submit: null };
74
+ return {
75
+ submit: null,
76
+ next: { ...state, selected, viewTop },
77
+ };
66
78
  }
67
79
 
68
80
  if (data.length === 1 && data >= " ") {
@@ -70,7 +82,6 @@ export function handlePaletteInput(
70
82
 
71
83
  return {
72
84
  submit: null,
73
-
74
85
  next: {
75
86
  ...state,
76
87
  query,
@@ -7,12 +7,29 @@ import type { PaletteState } from "./types";
7
7
  const ACTIVE_PREFIX = " → ";
8
8
  const INACTIVE_PREFIX = " ";
9
9
 
10
+ function getMaxWidth(width: number, offset: number): number {
11
+ let modifier = 0.8;
12
+
13
+ if (width >= 120) {
14
+ modifier = 0.6;
15
+ } else if (width >= 100) {
16
+ modifier = 0.7;
17
+ } else if (width >= 80) {
18
+ modifier = 0.8;
19
+ } else if (width >= 60) {
20
+ modifier = 0.9;
21
+ }
22
+
23
+ const maxSize = Math.floor(Math.max(0, width - offset) * modifier);
24
+ return Math.max(36, maxSize);
25
+ }
26
+
10
27
  export function renderPalette(
11
28
  state: PaletteState,
12
29
  width: number,
13
30
  theme: Theme,
14
31
  ): string[] {
15
- const boxW = Math.min(68, Math.max(36, width - 4));
32
+ const boxW = getMaxWidth(width, 4);
16
33
 
17
34
  const inner = boxW - 2;
18
35
  const lPad = " ".repeat(Math.max(0, Math.floor((width - boxW) / 2)));
@@ -1,52 +1,33 @@
1
1
  import type { TUI } from "@earendil-works/pi-tui";
2
- import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
2
  import type {
4
3
  ExtensionAPI,
5
4
  Theme,
6
5
  ThemeColor,
7
6
  } from "@earendil-works/pi-coding-agent";
8
7
 
9
- import { DEFAULT_ICONS, HEADER_TIPS } from "../config/constants";
10
- import type { Settings } from "../config/types";
11
- import { fitFrameRow } from "./frame";
12
- import { getShortCwd } from "../utils";
13
-
14
- /** Below which terminal width the box is skipped (plain logo). */
8
+ import {
9
+ DEFAULT_ICONS,
10
+ HEADER_TIPS,
11
+ LOGO_COLOR,
12
+ LOGO_LINES,
13
+ } from "../../config/constants";
14
+ import type { Settings } from "../../config/types";
15
+ import { fitFrameRow } from "../../components/frame";
16
+ import { getShortCwd, splitRow, wrapLines } from "../../utils";
17
+ import { HeaderEnv } from "../types";
18
+
19
+ /** Below which terminal width the box is skipped (plain logo) */
15
20
  const MIN_BOX_WIDTH = 20;
16
21
  const LEFT_COL_RATIO = 0.4; // logo column width / total width
17
22
 
18
- /** BasicHeader preset styling — self-contained, not user-configurable. */
19
- const LOGO_LINES = ["█████████ ", "███ ███ ", "██████ ", "███ ███"];
20
- const LOGO_COLOR: ThemeColor = "text";
21
23
  const HEADING = "Welcome back!";
22
24
  const SUBHEADING =
23
25
  "Ready for your next session? Terminal warm, context clean, tools ready to execute";
24
26
 
25
- /** Live env snapshot the header renders in the right column. */
26
- export interface HeaderEnv {
27
- gitBranch: string | undefined;
28
- gitDirty: number;
29
- cwd: string;
30
-
31
- /** Combined display name, e.g. `"Model (Provider)"` (provider embedded). */
32
- modelName: string | undefined;
33
- }
34
-
35
27
  const RANDOM_TIP =
36
28
  HEADER_TIPS[Math.floor(Math.random() * HEADER_TIPS.length)]?.text ?? "";
37
29
 
38
- function wrapLines(
39
- lines: string[],
40
- maxLen: number,
41
- style?: (line: string) => string,
42
- ): string[] {
43
- const wrapped = lines.map((l) => wrapTextWithAnsi(l, maxLen)).flat();
44
- if (style) return wrapped.map((l) => (l.length ? style(l) : l));
45
-
46
- return wrapped;
47
- }
48
-
49
- export function createHeader(
30
+ export function createBoxHeader(
50
31
  _tui: TUI,
51
32
  theme: Theme,
52
33
  pi: ExtensionAPI,
@@ -66,36 +47,13 @@ export function createHeader(
66
47
  const accentColor = settings.accentColor ?? "accent";
67
48
 
68
49
  const logo = {
69
- lines: LOGO_LINES,
70
50
  color: LOGO_COLOR,
51
+ lines: LOGO_LINES,
71
52
  };
72
53
 
73
54
  const border = (s: string, fg?: ThemeColor) => theme.fg(fg ?? accentColor, s);
74
55
 
75
- /** Space-pad `text` so it sits horizontally centered within `inner` cols. */
76
- function center(text: string, inner: number): string {
77
- const w = visibleWidth(text);
78
- const left = Math.max(0, Math.floor((inner - w) / 2));
79
- const right = Math.max(0, inner - w - left);
80
-
81
- return " ".repeat(left) + text + " ".repeat(right);
82
- }
83
-
84
- /** Claude-style split row: │ logo (leftW) │ info (rightW) │, exactly `width` cols. */
85
- function splitRow(
86
- leftW: number,
87
- rightW: number,
88
- left: string,
89
- right: string,
90
- ): string {
91
- const left2 = center(left, leftW);
92
- const right2 = " " + right;
93
- const pad = " ".repeat(Math.max(0, rightW - visibleWidth(right2)));
94
-
95
- return border("│") + left2 + border("│") + right2 + pad + border("│");
96
- }
97
-
98
- /** Right column halves: top = model(+provider), bottom = cwd + git. */
56
+ /** Right column halves: top = model(+provider), bottom = cwd + git */
99
57
  function infoRows(env: HeaderEnv, width: number): string[] {
100
58
  const icons = DEFAULT_ICONS;
101
59
  const parts: string[] = [];
@@ -103,6 +61,7 @@ export function createHeader(
103
61
  if (env.cwd) {
104
62
  parts.push(theme.fg("muted", `${icons.folder} ${getShortCwd(env.cwd)}`));
105
63
  }
64
+
106
65
  if (env.gitBranch) {
107
66
  parts.push(theme.fg(accentColor, `${icons.gitBranch} ${env.gitBranch}`));
108
67
 
@@ -137,6 +96,8 @@ export function createHeader(
137
96
  }
138
97
 
139
98
  return {
99
+ invalidate() {},
100
+
140
101
  render(width: number): string[] {
141
102
  const env = getEnv(pi);
142
103
 
@@ -163,12 +124,12 @@ export function createHeader(
163
124
  ),
164
125
  ];
165
126
 
166
- // Too narrow for a box → fall back to a plain centered logo.
127
+ // Too narrow for a box → fall back to a plain centered logo
167
128
  if (width < MIN_BOX_WIDTH) {
168
129
  return leftLines;
169
130
  }
170
131
 
171
- // No truncation: overflow wraps onto following lines until it all fits.
132
+ // No truncation: overflow wraps onto following lines until it all fits
172
133
  const height = Math.max(leftLines.length, rightLines.length);
173
134
  const logoTop = Math.max(0, Math.floor((height - leftLines.length) / 2));
174
135
 
@@ -180,7 +141,7 @@ export function createHeader(
180
141
  const lines: string[] = [""];
181
142
 
182
143
  lines.push(fitFrameRow("╭", "╮", "", "", width, border));
183
- lines.push(splitRow(leftW, rightW, "", ""));
144
+ lines.push(splitRow(leftW, rightW, "", "", border));
184
145
 
185
146
  for (let i = 0; i < height; i++) {
186
147
  const l = i - logoTop;
@@ -189,14 +150,13 @@ export function createHeader(
189
150
  const r = i - rightTop;
190
151
  const rightLine = r >= 0 && r < rightLines.length ? rightLines[r]! : "";
191
152
 
192
- lines.push(splitRow(leftW, rightW, leftLine, rightLine));
153
+ lines.push(splitRow(leftW, rightW, leftLine, rightLine, border));
193
154
  }
194
155
 
195
- lines.push(splitRow(leftW, rightW, "", ""));
156
+ lines.push(splitRow(leftW, rightW, "", "", border));
196
157
  lines.push(fitFrameRow("╰", "╯", "", "", width, border));
197
158
 
198
159
  return lines;
199
160
  },
200
- invalidate() {},
201
161
  };
202
162
  }
@@ -0,0 +1,2 @@
1
+ export * from "./types";
2
+ export { createBoxHeader } from "./box";
@@ -0,0 +1,9 @@
1
+ /** Live env snapshot the header renders in the right column */
2
+ export interface HeaderEnv {
3
+ cwd: string;
4
+ gitDirty: number;
5
+ gitBranch: string | undefined;
6
+
7
+ /** Combined display name, e.g. `"Model (Provider)"` (provider embedded) */
8
+ modelName: string | undefined;
9
+ }
@@ -31,27 +31,27 @@ import type { Settings, SpinnerPhase } from "./config/types";
31
31
  import type { AgentMode, AgentState, ExternalData } from "./components/types";
32
32
  import { getEditorFrame, getHeader } from "./renderers/registry";
33
33
  import type { EditorFrameRenderer } from "./renderers/types";
34
- import {
35
- capitalize,
36
- readAgentModeFromSession,
37
- readGit,
38
- type GitInfo,
39
- } from "./utils";
40
- import { type HeaderEnv } from "./components/header";
34
+ import { capitalize, getData, type GitInfo } from "./utils";
35
+ import { type HeaderEnv } from "./headers";
41
36
  import { getUsage } from "./utils/token";
42
37
 
43
- let editor: EditorFrameRenderer | null = null;
44
- let currentCtx: ExtensionContext | null = null;
45
38
  let settings: Settings = DEFAULT_SETTINGS;
39
+
40
+ let editor: EditorFrameRenderer | null = null;
46
41
  let spinnerPhase: SpinnerPhase | null = null;
47
- let agentMode: AgentMode = null;
42
+
43
+ let currentCtx: ExtensionContext | null = null;
44
+
48
45
  let notifyEnabled = false;
46
+ let agentMode: AgentMode = null;
47
+
49
48
  let git: GitInfo = {
50
49
  branch: undefined,
51
50
  dirty: 0,
52
51
  };
53
- let workingMessageTimer: ReturnType<typeof setInterval> | null = null;
52
+
54
53
  let workingMessageBag: string[] = [];
54
+ let workingMessageTimer: ReturnType<typeof setInterval> | null = null;
55
55
 
56
56
  /** Pop a random message; reshuffles when the pool is exhausted (no back-to-back repeats). */
57
57
  function nextWorkingMessage(): string {
@@ -91,15 +91,15 @@ function provideExternal(pi: ExtensionAPI): ExternalData {
91
91
  return {
92
92
  agentMode,
93
93
  spinnerPhase,
94
+ notifyEnabled,
94
95
  cwd: ctx?.cwd ?? "",
95
96
  gitDirty: git.dirty,
96
- gitBranch: git.branch,
97
97
  theme: ctx?.ui.theme,
98
+ gitBranch: git.branch,
98
99
  context: getUsage(ctx ?? undefined),
99
100
  thinkingLevel: pi.getThinkingLevel(),
100
101
  modelName: model?.name ?? model?.id ?? "Unknown",
101
102
  modelProvider: capitalize(model?.provider ?? "unknown"),
102
- notifyEnabled,
103
103
  };
104
104
  }
105
105
 
@@ -116,12 +116,21 @@ function paletteKeys(): string[] {
116
116
  readFileSync(join(getAgentDir(), "keybindings.json"), "utf8"),
117
117
  ) as Record<string, unknown>;
118
118
  const v = raw[PALETTE_SHORTCUT_ID];
119
+
119
120
  if (typeof v === "string") return [v];
120
121
  if (Array.isArray(v) && v.every((k) => typeof k === "string")) return v;
121
122
  } catch {}
123
+
122
124
  return [PALETTE_DEFAULT_KEY];
123
125
  }
124
126
 
127
+ function refreshData(ctx: ExtensionContext) {
128
+ const data = getData(ctx);
129
+
130
+ git = data.git;
131
+ agentMode = data.agentMode;
132
+ }
133
+
125
134
  // ── extension entry ──────────────────────────────────────────────────────
126
135
 
127
136
  export default async function (pi: ExtensionAPI) {
@@ -155,14 +164,17 @@ export default async function (pi: ExtensionAPI) {
155
164
  pi.events.on(PI_NOTIFY_TOGGLE_EVENT, (event) => {
156
165
  const enabled = (event as { enabled?: unknown } | undefined)?.enabled;
157
166
  if (typeof enabled === "boolean") notifyEnabled = enabled;
167
+
158
168
  editor?.refresh();
159
169
  });
160
170
 
171
+ pi.on("turn_end", async (_, ctx) => {
172
+ refreshData(ctx);
173
+ });
174
+
161
175
  pi.on("session_start", async (_event, ctx) => {
176
+ refreshData(ctx);
162
177
  currentCtx = ctx;
163
- agentMode = readAgentModeFromSession(ctx) || agentMode;
164
-
165
- git = readGit(ctx.cwd);
166
178
 
167
179
  ctx.ui.setWorkingIndicator({
168
180
  intervalMs: 80,
@@ -183,7 +195,7 @@ export default async function (pi: ExtensionAPI) {
183
195
 
184
196
  if (settings.header?.enable) {
185
197
  const headerFactory =
186
- getHeader(settings.header.type ?? "basic") ?? getHeader("basic")!;
198
+ getHeader(settings.header.type ?? "box") ?? getHeader("box")!;
187
199
  ctx.ui.setHeader((_tui, theme) =>
188
200
  headerFactory(_tui, theme, pi, settings, getHeaderEnv),
189
201
  );
@@ -4,10 +4,9 @@
4
4
  * getEditorFrame/getHeader
5
5
  */
6
6
  import type { EditorFrameFactory, HeaderRendererFactory } from "./types";
7
- import { createHeader } from "../components/header";
8
- import { BlockyEditor } from "../editor/blocky-editor";
9
- import { MinimalistEditor } from "../editor/minimalist-editor";
10
- import { CustomEditor } from "@earendil-works/pi-coding-agent";
7
+
8
+ import { BlockyEditor, LinearEditor, MinimalistEditor } from "../editor";
9
+ import { createBoxHeader } from "../headers";
11
10
 
12
11
  const editorFrames = new Map<string, EditorFrameFactory>();
13
12
  const headers = new Map<string, HeaderRendererFactory>();
@@ -47,4 +46,10 @@ registerEditorFrame(
47
46
  new MinimalistEditor(pi, provider, opts, ...args),
48
47
  );
49
48
 
50
- registerHeader("basic", createHeader);
49
+ registerEditorFrame(
50
+ "linear",
51
+ (pi, provider, opts, ...args) =>
52
+ new LinearEditor(pi, provider, opts, ...args),
53
+ );
54
+
55
+ registerHeader("box", createBoxHeader);
@@ -7,7 +7,8 @@ import type {
7
7
  } from "@earendil-works/pi-coding-agent";
8
8
  import type { FrameSettings, Settings, SpinnerPhase } from "../config/types";
9
9
  import type { ExternalData } from "../components/types";
10
- import type { HeaderEnv } from "../components/header";
10
+
11
+ import type { HeaderEnv } from "../headers";
11
12
 
12
13
  /** What pi's TUI needs from any header implementation */
13
14
  export interface HeaderRenderer {
@@ -0,0 +1,11 @@
1
+ import { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { readGit } from "./git";
4
+ import { readAgentModeFromSession } from "./agent";
5
+
6
+ export function getData(ctx: ExtensionContext) {
7
+ return {
8
+ git: readGit(ctx.cwd),
9
+ agentMode: readAgentModeFromSession(ctx),
10
+ };
11
+ }
@@ -5,9 +5,11 @@ import { THEME_COLORS } from "../config/constants";
5
5
  * Shared helpers, grouped by domain and re-exported from a single barrel.
6
6
  * Import everything with `import { ... } from "../utils"`.
7
7
  */
8
- export * from "./agent";
9
8
  export * from "./git";
9
+ export * from "./tui";
10
+ export * from "./data";
10
11
  export * from "./path";
12
+ export * from "./agent";
11
13
  export { capitalize } from "@leo-alvarenga/pi-ext-core";
12
14
 
13
15
  export function isThemeColor(value: string): value is ThemeColor {
@@ -0,0 +1,36 @@
1
+ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ export function wrapLines(
4
+ lines: string[],
5
+ maxLen: number,
6
+ style?: (line: string) => string,
7
+ ): string[] {
8
+ const wrapped = lines.map((l) => wrapTextWithAnsi(l, maxLen)).flat();
9
+ if (style) return wrapped.map((l) => (l.length ? style(l) : l));
10
+
11
+ return wrapped;
12
+ }
13
+
14
+ /** Space-pad `text` so it sits horizontally centered within `inner` cols. */
15
+ export function center(text: string, inner: number): string {
16
+ const w = visibleWidth(text);
17
+ const left = Math.max(0, Math.floor((inner - w) / 2));
18
+ const right = Math.max(0, inner - w - left);
19
+
20
+ return " ".repeat(left) + text + " ".repeat(right);
21
+ }
22
+
23
+ /** Claude-style split row: │ logo (leftW) │ info (rightW) │ exactly `width` cols */
24
+ export function splitRow(
25
+ leftW: number,
26
+ rightW: number,
27
+ left: string,
28
+ right: string,
29
+ border: (s: string) => string,
30
+ ): string {
31
+ const left2 = center(left, leftW);
32
+ const right2 = " " + right;
33
+ const pad = " ".repeat(Math.max(0, rightW - visibleWidth(right2)));
34
+
35
+ return border("│") + left2 + border("│") + right2 + pad + border("│");
36
+ }
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.19.9",
5
+ "version": "0.21.2",
6
6
  "license": "MIT",
7
7
  "repository": "github.com/leo-alvarenga/pi-mono",
8
8
  "publishConfig": {