pi-zentui 0.7.0 → 0.9.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
@@ -35,6 +35,7 @@ Zentui brings two popular aesthetics to Pi:
35
35
  - Configurable model, provider, and thinking-level indicator colors
36
36
  - Prompt-box-style user messages matching the ZentUI input chrome
37
37
  - Copy-friendly mode hides editor and previous-message rail glyphs so terminal selection copies less chrome
38
+ - **Fixed editor** (experimental, opt-in): Pin the editor and footer at the bottom of the terminal while the transcript scrolls above
38
39
 
39
40
  ### Git Status Icons
40
41
 
@@ -143,6 +144,9 @@ Useful slash-command shortcuts:
143
144
  /zentui copy-friendly enable
144
145
  /zentui copy-friendly disable
145
146
  /zentui copy-friendly toggle
147
+ /zentui fixed-editor enable
148
+ /zentui fixed-editor disable
149
+ /zentui fixed-editor toggle
146
150
  /zentui format "$cwd on branch $git_branch$git_status using $runtime $fill $context"
147
151
  /zentui format clear
148
152
  ```
@@ -153,6 +157,7 @@ Default config values — copy this and change any value you want:
153
157
  {
154
158
  "projectRefreshIntervalMs": 30000,
155
159
  "footerFormat": "",
160
+ "separator": "pipe",
156
161
  "contextStyle": "text",
157
162
  "contextThresholds": {
158
163
  "warning": 70,
@@ -256,6 +261,11 @@ Default config values — copy this and change any value you want:
256
261
  "defaultPlacement": "right",
257
262
  "placements": {},
258
263
  "colorModes": {}
264
+ },
265
+ "fixedEditor": {
266
+ "enabled": false,
267
+ "mouseScroll": true,
268
+ "copyNotice": true
259
269
  }
260
270
  }
261
271
  ```
@@ -263,13 +273,14 @@ Default config values — copy this and change any value you want:
263
273
  - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b` / `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`). Short `#rgb` hex values expand to `#rrggbb`.
264
274
  - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling. Values `1..4999` clamp up to `5000` (minimum 5s); invalid/non-finite values fall back to `30000`.
265
275
  - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment.
276
+ - `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Layout** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
266
277
  - `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
267
278
  - `pathDisplay`: controls how the cwd/`$cwd` path is shown. `mode` is `basename` (default, last segment only) or `full` (path with home contracted to `~`). In `full` mode, `depth` keeps only the last N trailing directories (`0` = entire path after `~`, max `5`); when parents are dropped the path is prefixed with `…/` (Starship-style). The `/zentui` **Layout** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
268
279
  - `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `icons.mode` is `auto` | `nerd` | `ascii` (default `auto`, same glyphs as nerd). ASCII mode swaps in plain fallbacks for statusline icons and runtime symbols — useful without a Nerd Font. Custom per-icon strings always win over mode defaults. Custom `icons.os` always wins; when left at the mode default, Zentui maps the OS icon by platform. `rail` sets the vertical glyph drawn as the left rail of the active editor frame and previous user messages when `copyFriendly` is disabled (default `│`; any single Unicode vertical or block glyph). `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
269
280
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
270
281
  - `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line. `copyFriendly` hides editor and previous-message rail glyphs so native terminal selection copies less chrome. All three can be changed from `/zentui` or direct slash-command arguments.
271
282
  - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle them from the `Built-in segments` tab in `/zentui`.
272
- - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Layout** tab configures context style, path display mode/depth, and icon mode; set or clear custom formats with `/zentui format`.
283
+ - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Layout** tab configures context style, separator, path display mode/depth, and icon mode; set or clear custom formats with `/zentui format`.
273
284
  - `gitCommit`: Starship [`git_commit`](https://starship.rs/config/#git-commit)-style options for the `gitCommit` footer segment. `hashLength` (default `7`, clamped to `4`–`40`) controls the short-hash display length. `onlyDetached` (default `true`) shows the hash mainly on detached HEAD. `showTag` (default `true`) appends an exact-match tag (`git describe --tags --exact-match HEAD`). The tag probe piggybacks on the existing git refresh — it only runs when both the segment and `showTag` are on, and misses/failures degrade silently.
274
285
  - `gitMetrics`: Starship [`git_metrics`](https://starship.rs/config/#git-metrics)-style options for the `gitMetrics` footer segment. Uses `git diff HEAD --numstat` (staged + unstaged combined — the Starship “total dirty” view) to show aggregate `+added −deleted` line counts. `onlyNonzero` (default `true`) omits each zero component independently and hides the segment entirely at `0/0`. `ignoreSubmodules` (default `false`) adds `--ignore-submodules=all`. The numstat diff piggybacks on the existing git refresh and uses a hard 2s timeout; a metrics-only failure degrades silently without discarding fresh branch/status data. On very large monorepos the diff may lag or be omitted on timeout.
275
286
  - `extensionStatuses`: controls third-party statuses published by other Pi extensions through `ctx.ui.setStatus()`. `defaultPlacement` and each `placements` value can be `off`, `left`, `middle`, or `right`. The `Extension segments` tab in `/zentui` lists only statuses that are currently active.
@@ -342,9 +353,59 @@ Center the branch between directory and cost:
342
353
  - Unknown `$variables` render empty.
343
354
  - Set or clear at runtime: `/zentui format "<template>"` and `/zentui format clear`.
344
355
 
356
+ ## Fixed editor (experimental, opt-in)
357
+
358
+ The fixed editor pins the Zentui editor and footer at the bottom of the terminal while the transcript scrolls above. This enables composing follow-up messages while referencing earlier conversation history.
359
+
360
+ ### How to enable
361
+
362
+ ```text
363
+ /zentui fixed-editor enable
364
+ ```
365
+
366
+ Or in `~/.pi/agent/zentui.json`:
367
+
368
+ ```json
369
+ {
370
+ "fixedEditor": {
371
+ "enabled": true
372
+ }
373
+ }
374
+ ```
375
+
376
+ ### Keyboard controls
377
+
378
+ | Key | Action |
379
+ | --- | ------ |
380
+ | `PageUp` / `PageDown` | Scroll transcript one viewport up/down |
381
+ | `Ctrl+Shift+↑` / `Ctrl+Shift+↓` | Scroll transcript up/down (Kitty protocol variants supported) |
382
+ | `Enter` | Jump to bottom (and submit message) |
383
+
384
+ ### Mouse scroll (default on)
385
+
386
+ Mouse wheel scrolling is enabled by default when the fixed editor is on. Disable it via `/zentui` Features or:
387
+
388
+ ```json
389
+ {
390
+ "fixedEditor": {
391
+ "enabled": true,
392
+ "mouseScroll": true
393
+ }
394
+ }
395
+ ```
396
+
397
+ **Warning**: Mouse scroll enables SGR mouse reporting, which disables native terminal text selection, URL click-through, and tmux/Herdr scrollback for the Pi session. Toggle off if you need those features.
398
+
399
+ ### Conflicts and limitations
400
+
401
+ - **Incompatible with** `pi-powerline-footer`, `@tifan/pi-fixed-editor`, and `pi-sticky-input`. These packages patch the same Pi TUI internals; only one rendering owner can be active at a time.
402
+ - **Alternate screen**: Uses the terminal's alternate screen buffer. Native scrollback history is not accessible while the fixed editor is active.
403
+ - **Pi version fragility**: Patches internal TUI methods (`doRender`, `render`, `terminal.write`, `terminal.rows`) that may change across Pi versions. If the TUI layout is unsupported, Zentui falls back to normal rendering with a console warning.
404
+ - If your terminal is stuck after a crash, run `reset` or restart the terminal.
405
+
345
406
  ## Requirements
346
407
 
347
- - [Pi](https://pi.dev) coding agent 0.79 or newer
408
+ - [Pi](https://pi.dev) coding agent 0.80 or newer
348
409
  - A [Nerd Font](https://www.nerdfonts.com/) for icons (or set `icons.mode` to `"ascii"`)
349
410
 
350
411
  ## Development
@@ -17,6 +17,7 @@ export type ColorSource = "theme" | "terminal";
17
17
  export type { IconMode } from "./icons";
18
18
 
19
19
  export type ContextStyle = "text" | "gauge" | "text+gauge";
20
+ export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
20
21
 
21
22
  export type ContextThresholds = {
22
23
  warning: number;
@@ -61,6 +62,12 @@ export type FooterSegmentsConfig = {
61
62
  packageVersion: boolean;
62
63
  };
63
64
 
65
+ export type FixedEditorConfig = {
66
+ enabled: boolean;
67
+ mouseScroll: boolean;
68
+ copyNotice: boolean;
69
+ };
70
+
64
71
  export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
65
72
  export type ExtensionStatusColorMode = "zentui" | "original";
66
73
 
@@ -97,6 +104,7 @@ const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
97
104
  export type PolishedTuiConfig = {
98
105
  projectRefreshIntervalMs: number;
99
106
  footerFormat: string;
107
+ separator: SeparatorStyle;
100
108
  contextStyle: ContextStyle;
101
109
  contextThresholds: ContextThresholds;
102
110
  pathDisplay: PathDisplayConfig;
@@ -139,6 +147,7 @@ export type PolishedTuiConfig = {
139
147
  gitCommit: GitCommitConfig;
140
148
  gitMetrics: GitMetricsConfig;
141
149
  extensionStatuses: ExtensionStatusesConfig;
150
+ fixedEditor: FixedEditorConfig;
142
151
  };
143
152
 
144
153
  /**
@@ -188,6 +197,7 @@ export const configPath = join(getAgentDir(), "zentui.json");
188
197
  export const defaultConfig: PolishedTuiConfig = {
189
198
  projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
190
199
  footerFormat: "",
200
+ separator: "pipe",
191
201
  contextStyle: "text",
192
202
  contextThresholds: { warning: 70, error: 90 },
193
203
  pathDisplay: { mode: "basename", depth: 0 },
@@ -257,6 +267,11 @@ export const defaultConfig: PolishedTuiConfig = {
257
267
  placements: {},
258
268
  colorModes: {},
259
269
  },
270
+ fixedEditor: {
271
+ enabled: false,
272
+ mouseScroll: true,
273
+ copyNotice: true,
274
+ },
260
275
  };
261
276
 
262
277
  type ConfigRecord = Record<string, unknown>;
@@ -285,6 +300,14 @@ function parseContextStyle(value: unknown): ContextStyle {
285
300
  return defaultConfig.contextStyle;
286
301
  }
287
302
 
303
+ export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
304
+ return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
305
+ }
306
+
307
+ function parseSeparatorStyle(value: unknown): SeparatorStyle {
308
+ return isSeparatorStyle(value) ? value : defaultConfig.separator;
309
+ }
310
+
288
311
  function parseContextThresholds(value: unknown): ContextThresholds {
289
312
  const defaults = defaultConfig.contextThresholds;
290
313
  if (!isRecord(value)) return { ...defaults };
@@ -508,6 +531,21 @@ function normalizeExtensionStatuses(record: Record<string, unknown>): ExtensionS
508
531
  };
509
532
  }
510
533
 
534
+ function normalizeFixedEditorConfig(record: Record<string, unknown>): FixedEditorConfig {
535
+ return {
536
+ enabled:
537
+ typeof record.enabled === "boolean" ? record.enabled : defaultConfig.fixedEditor.enabled,
538
+ mouseScroll:
539
+ typeof record.mouseScroll === "boolean"
540
+ ? record.mouseScroll
541
+ : defaultConfig.fixedEditor.mouseScroll,
542
+ copyNotice:
543
+ typeof record.copyNotice === "boolean"
544
+ ? record.copyNotice
545
+ : defaultConfig.fixedEditor.copyNotice,
546
+ };
547
+ }
548
+
511
549
  function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
512
550
  return value === "starship" || value === "editor" || value === "userMessages";
513
551
  }
@@ -606,9 +644,13 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
606
644
  const gitMetrics = isRecord(config.gitMetrics)
607
645
  ? normalizeGitMetricsConfig(config.gitMetrics as Record<string, unknown>)
608
646
  : defaultConfig.gitMetrics;
647
+ const fixedEditor = isRecord(config.fixedEditor)
648
+ ? normalizeFixedEditorConfig(config.fixedEditor as Record<string, unknown>)
649
+ : defaultConfig.fixedEditor;
609
650
  return {
610
651
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
611
652
  footerFormat: stringValue(config, "footerFormat") ?? "",
653
+ separator: parseSeparatorStyle(config.separator),
612
654
  contextStyle: parseContextStyle(config.contextStyle),
613
655
  contextThresholds: parseContextThresholds(config.contextThresholds),
614
656
  pathDisplay: parsePathDisplay(config.pathDisplay),
@@ -627,6 +669,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
627
669
  placements: { ...extensionStatuses.placements },
628
670
  colorModes: { ...extensionStatuses.colorModes },
629
671
  },
672
+ fixedEditor,
630
673
  };
631
674
  }
632
675
 
@@ -726,6 +769,16 @@ export function saveContextStylePatch(style: ContextStyle, path = configPath): P
726
769
  return mergeConfig(record);
727
770
  }
728
771
 
772
+ export function saveSeparatorPatch(
773
+ separator: SeparatorStyle,
774
+ path = configPath,
775
+ ): PolishedTuiConfig {
776
+ const record = readConfigRecord(path);
777
+ record.separator = parseSeparatorStyle(separator);
778
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
779
+ return mergeConfig(record);
780
+ }
781
+
729
782
  export function saveContextThresholdsPatch(
730
783
  thresholds: Partial<ContextThresholds>,
731
784
  path = configPath,
@@ -812,3 +865,21 @@ export function saveExtensionStatusColorMode(
812
865
  writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
813
866
  return mergeConfig(record);
814
867
  }
868
+
869
+ export function saveFixedEditorPatch(
870
+ patch: Partial<FixedEditorConfig>,
871
+ path = configPath,
872
+ ): PolishedTuiConfig {
873
+ const record = readConfigRecord(path);
874
+ const existing = isRecord(record.fixedEditor)
875
+ ? { ...(record.fixedEditor as Record<string, unknown>) }
876
+ : {};
877
+ record.fixedEditor = {
878
+ ...existing,
879
+ ...(patch.enabled !== undefined ? { enabled: patch.enabled } : {}),
880
+ ...(patch.mouseScroll !== undefined ? { mouseScroll: patch.mouseScroll } : {}),
881
+ ...(patch.copyNotice !== undefined ? { copyNotice: patch.copyNotice } : {}),
882
+ };
883
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
884
+ return mergeConfig(record);
885
+ }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * Cluster discovery and rendering for the fixed editor.
3
+ *
4
+ * The "cluster" is the set of Pi TUI children around the editor that should be
5
+ * pinned at the bottom: status container, above-editor widget, editor,
6
+ * below-editor widget, and footer.
7
+ *
8
+ * @internal
9
+ */
10
+
11
+ import { CURSOR_MARKER, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
12
+
13
+ import type { ClusterRender } from "./types";
14
+
15
+ /** Minimal Component shape needed for rendering. */
16
+ type Renderable = {
17
+ render(width: number): string[];
18
+ /** Saved original render when the compositor has patched render → [] */
19
+ __zentuiOriginalRender?: (width: number) => string[];
20
+ };
21
+
22
+ /** Minimal Container shape for child scanning. */
23
+ type ContainerLike = Renderable & {
24
+ children: unknown[];
25
+ };
26
+
27
+ /** Check if a value is a container-like object (has children + render). */
28
+ function isContainerLike(value: unknown): value is ContainerLike {
29
+ return (
30
+ typeof value === "object" &&
31
+ value !== null &&
32
+ Array.isArray(Reflect.get(value, "children")) &&
33
+ typeof Reflect.get(value, "render") === "function"
34
+ );
35
+ }
36
+
37
+ /** Check if a value looks like an editor component (duck-typed). */
38
+ function isEditorLike(value: unknown): boolean {
39
+ return (
40
+ typeof value === "object" &&
41
+ value !== null &&
42
+ typeof Reflect.get(value, "getText") === "function" &&
43
+ typeof Reflect.get(value, "setText") === "function" &&
44
+ typeof Reflect.get(value, "handleInput") === "function"
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Find the index in `children` of the container holding the editor.
50
+ * Prefers the focused component's parent; falls back to scanning for
51
+ * an editor-like grandchild.
52
+ */
53
+ export function findEditorContainerIndex(
54
+ children: unknown[],
55
+ focusedComponent?: unknown,
56
+ ): number | undefined {
57
+ // Try focused component first.
58
+ if (focusedComponent && typeof focusedComponent === "object") {
59
+ const idx = children.findIndex(
60
+ (c) => isContainerLike(c) && c.children.includes(focusedComponent),
61
+ );
62
+ if (idx !== -1) return idx;
63
+ }
64
+
65
+ // Scan for a container with an editor-like child.
66
+ const idx = children.findIndex(
67
+ (c) => isContainerLike(c) && c.children.some((gc) => isEditorLike(gc)),
68
+ );
69
+ return idx === -1 ? undefined : idx;
70
+ }
71
+
72
+ /** The 5-component cluster pinned at the bottom. */
73
+ export type FixedCluster = {
74
+ status: Renderable | null;
75
+ aboveWidget: Renderable | null;
76
+ editor: Renderable;
77
+ belowWidget: Renderable | null;
78
+ footer: Renderable | null;
79
+ };
80
+
81
+ /**
82
+ * Patch a cluster component's render to return [] (hide from transcript).
83
+ * Saves the original render for cluster painting.
84
+ */
85
+ export function hideRenderable(component: Renderable | null): void {
86
+ if (!component || component.__zentuiOriginalRender) return;
87
+ component.__zentuiOriginalRender = component.render.bind(component);
88
+ component.render = () => [];
89
+ }
90
+
91
+ /** Restore a cluster component's original render. */
92
+ export function restoreRenderable(component: Renderable | null): void {
93
+ if (!component?.__zentuiOriginalRender) return;
94
+ component.render = component.__zentuiOriginalRender;
95
+ delete component.__zentuiOriginalRender;
96
+ }
97
+
98
+ /** Build the cluster from children around the editor index. */
99
+ export function buildCluster(children: unknown[], editorIdx: number): FixedCluster | null {
100
+ const editor = children[editorIdx];
101
+ if (!editor || typeof (editor as Renderable).render !== "function") return null;
102
+ return {
103
+ status: (children[editorIdx - 2] as Renderable | undefined) ?? null,
104
+ aboveWidget: (children[editorIdx - 1] as Renderable | undefined) ?? null,
105
+ editor: editor as Renderable,
106
+ belowWidget: (children[editorIdx + 1] as Renderable | undefined) ?? null,
107
+ footer: (children[editorIdx + 2] as Renderable | undefined) ?? null,
108
+ };
109
+ }
110
+
111
+ /** Render a component at `width`, using the saved original render if hidden. */
112
+ function renderComponent(component: Renderable | null, width: number): string[] {
113
+ if (!component) return [];
114
+ const renderFn = component.__zentuiOriginalRender ?? component.render;
115
+ const lines = renderFn.call(component, width);
116
+ // Strip only trailing blank lines — internal blank lines (e.g. editor
117
+ // padding in copy-friendly mode) must be preserved.
118
+ let end = lines.length;
119
+ while (end > 0 && visibleWidth(lines[end - 1]) === 0) end--;
120
+ return lines.slice(0, Math.max(end, 1));
121
+ }
122
+
123
+ /**
124
+ * Cap editor lines to `maxLines`, keeping the cursor row visible.
125
+ * If the cursor marker is found, the window centers on it; otherwise
126
+ * the last `maxLines` are kept.
127
+ */
128
+ export function capEditorLines(lines: string[], maxLines: number): string[] {
129
+ if (maxLines <= 0) return [];
130
+ if (lines.length <= maxLines) return lines;
131
+
132
+ const cursorRow = lines.findIndex((line) => line.includes(CURSOR_MARKER));
133
+ if (cursorRow !== -1) {
134
+ const start = Math.max(0, Math.min(cursorRow - maxLines + 1, lines.length - maxLines));
135
+ return lines.slice(start, start + maxLines);
136
+ }
137
+ return lines.slice(lines.length - maxLines);
138
+ }
139
+
140
+ function sanitizeLines(lines: string[], width: number): string[] {
141
+ return lines.map((line) =>
142
+ visibleWidth(line) > width ? truncateToWidth(line, width, "", true) : line,
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Render the full cluster (status + widgets + editor + footer) and extract
148
+ * the cursor position from the CURSOR_MARKER.
149
+ */
150
+ export function renderCluster(
151
+ cluster: FixedCluster,
152
+ width: number,
153
+ maxHeight: number,
154
+ ): ClusterRender {
155
+ const w = Math.max(1, width);
156
+ const maxRows = Math.max(1, maxHeight - 1);
157
+
158
+ const statusLines = sanitizeLines(renderComponent(cluster.status, w), w);
159
+ const aboveLines = sanitizeLines(renderComponent(cluster.aboveWidget, w), w);
160
+ const editorSource = sanitizeLines(renderComponent(cluster.editor, w), w);
161
+ const belowLines = sanitizeLines(renderComponent(cluster.belowWidget, w), w);
162
+ const footerLines = sanitizeLines(renderComponent(cluster.footer, w), w);
163
+
164
+ const editorLines = capEditorLines(editorSource, maxRows);
165
+ let remaining = maxRows - editorLines.length;
166
+
167
+ const footer = footerLines.slice(-remaining);
168
+ remaining -= footer.length;
169
+
170
+ const below = belowLines.slice(-remaining);
171
+ remaining -= below.length;
172
+
173
+ const above = aboveLines.slice(-remaining);
174
+ remaining -= above.length;
175
+
176
+ const status = statusLines.slice(-remaining);
177
+
178
+ let allLines = [...status, ...above, ...editorLines, ...below, ...footer];
179
+
180
+ // Strip leading blank lines (e.g. empty status line above the editor border).
181
+ let start = 0;
182
+ while (start < allLines.length - 1 && visibleWidth(allLines[start]) === 0) start++;
183
+ allLines = allLines.slice(start);
184
+
185
+ let cursor: { row: number; col: number } | null = null;
186
+ const cleaned = allLines.map((line, row) => {
187
+ const markerIndex = line.indexOf(CURSOR_MARKER);
188
+ if (markerIndex === -1) return line;
189
+ cursor ??= { row, col: visibleWidth(line.slice(0, markerIndex)) };
190
+ return line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
191
+ });
192
+
193
+ return { lines: cleaned, cursor };
194
+ }