pi-zentui 0.14.0 → 0.15.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
@@ -130,7 +130,16 @@ pi install git:github.com/lmilojevicc/pi-zentui
130
130
 
131
131
  User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or invalid known values fall back to Zentui defaults, unknown keys are ignored at runtime, and `/zentui` can patch color-source settings, UI feature toggles, built-in footer segment visibility, and active third-party status placements.
132
132
 
133
- The interactive `/zentui` menu is split into five sections. Use `Tab` and `Shift+Tab` to switch between `Coloring`, `Features`, `Layout`, `Built-in segments`, and `Extension segments`.
133
+ The interactive `/zentui` menu is split into exactly six sections, in this order. Use `Tab` and `Shift+Tab` to switch sections. Every listed control patches the shown JSON equivalent:
134
+
135
+ 1. **Appearance** — Starship/footer colors (`colorSources.starship`); editor + previous-message colors (`colorSources.editor` and `colorSources.userMessages`); separator (`separator`); icon mode (`icons.mode`).
136
+ 2. **Editor** — editor enabled (`features.editor`); editor model label (`editorModelLabel`); copy-friendly mode (`features.copyFriendly`); viewport indicators (`features.viewportIndicators`); fixed editor (`fixedEditor.enabled`); and, while fixed editor is enabled, mouse scroll (`fixedEditor.mouseScroll`) and copy notice (`fixedEditor.copyNotice`).
137
+ 3. **Footer** — status line enabled (`features.statusLine`); responsive footer (`responsiveFooter`); compact footer rows (`compactFooterMaxLines`); context style (`contextStyle`); path display (`pathDisplay.mode`); path depth (`pathDisplay.depth`).
138
+ 4. **Segments** — visibility toggles for every non-Git built-in segment under `footerSegments`: `cwd`, `sessionName`, `runtime`, `context`, `tokens`, `cost`, `sessionDuration`, `username`, `time`, `os`, and `packageVersion`.
139
+ 5. **Git** — Git branch visibility (`footerSegments.gitBranch`); branch length (`gitBranch.maxLength`); Git status visibility (`footerSegments.gitStatus`); Git counts visibility (`footerSegments.gitCounts`); Git commit visibility (`footerSegments.gitCommit`); commit-only-detached (`gitCommit.onlyDetached`); exact-match tag (`gitCommit.showTag`); Git metrics visibility (`footerSegments.gitMetrics`); hide zero metrics (`gitMetrics.onlyNonzero`); ignore submodules (`gitMetrics.ignoreSubmodules`).
140
+ 6. **Extensions** — default placement (`extensionStatuses.defaultPlacement`) first, followed by placement (`extensionStatuses.placements[key]`) and color (`extensionStatuses.colorModes[key]`) controls for currently active status keys.
141
+
142
+ Free-form values such as custom formats, raw colors/styles, numeric values outside the shown presets, and inactive extension keys remain JSON-only.
134
143
 
135
144
  Useful slash-command shortcuts:
136
145
 
@@ -144,6 +153,9 @@ Useful slash-command shortcuts:
144
153
  /zentui copy-friendly enable
145
154
  /zentui copy-friendly disable
146
155
  /zentui copy-friendly toggle
156
+ /zentui viewport-indicators enable
157
+ /zentui viewport-indicators disable
158
+ /zentui viewport-indicators toggle
147
159
  /zentui fixed-editor enable
148
160
  /zentui fixed-editor disable
149
161
  /zentui fixed-editor toggle
@@ -238,7 +250,8 @@ Default config values — copy this and change any value you want:
238
250
  "features": {
239
251
  "editor": true,
240
252
  "statusLine": true,
241
- "copyFriendly": false
253
+ "copyFriendly": false,
254
+ "viewportIndicators": true
242
255
  },
243
256
  "footerSegments": {
244
257
  "cwd": true,
@@ -285,21 +298,21 @@ Default config values — copy this and change any value you want:
285
298
  - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment. Context usage refreshes during assistant streaming; token and cost totals remain canonical and finalize at turn boundaries.
286
299
  - `editorModelLabel`: controls the model shown in the editor frame. `id` (default) shows the model id; `name` shows the model's display name (including custom `name` values set in `models.json`), falling back to the id when no name is set.
287
300
  - `editorMetadataFormat`: JSON-only template for the left side of the editor metadata row. Missing, non-string, or empty values restore the default `$model $provider( $thinking)` layout; non-empty strings, including whitespace-only strings, are preserved. See [Editor Metadata Format](#editor-metadata-format) below.
288
- - `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.
301
+ - `separator`: controls the default footer layout and extension-status connectors: `pipe` (default, ` | `), `dot` (` · `), `chevron` (` › `), or `none` (one space). Cycle it from the `/zentui` **Appearance** tab. This selects the separator glyph; `colors.separator` controls its color. Custom `footerFormat` literals and `$sep` keep their existing behavior.
289
302
  - `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
290
- - `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`.
291
- - `gitBranch.maxLength`: visible width of the built-in branch name and `$git_branch` / `$branch`. The default `full` preserves the complete name; any positive integer uses that width including the trailing `…`. `/zentui` **Layout** cycles `full`, `10`, `20`, `30`, `40`, and `50`; custom positive integers can be set in JSON.
303
+ - `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` **Footer** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
304
+ - `gitBranch.maxLength`: visible width of the built-in branch name and `$git_branch` / `$branch`. The default `full` preserves the complete name; any positive integer uses that width including the trailing `…`. `/zentui` **Git** cycles `full`, `10`, `20`, `30`, `40`, and `50`; custom positive integers can be set in JSON.
292
305
  - `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.
293
306
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
294
- - `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.
295
- - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `sessionName`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle them from the `Built-in segments` tab in `/zentui`.
296
- - `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 responsive behavior, compact rows, context style, separator, path display mode/depth, branch length, and icon mode; set or clear custom formats with `/zentui format`.
307
+ - `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. `viewportIndicators` preserves Pi's native `↑ N more` / `↓ N more` wrapped-row counts in Zentui's editor borders (default `true`). All four can be changed from `/zentui` or direct slash-command arguments.
308
+ - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `sessionName`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle non-Git segments from **Segments** and Git segments from **Git** in `/zentui`.
309
+ - `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` **Footer** tab configures responsive behavior, compact rows, context style, and path display mode/depth; **Appearance** configures separator and icon mode; **Git** configures branch length; set or clear custom formats with `/zentui format`.
297
310
  - `responsiveFooter`: enabled by default. Zentui keeps the current aligned one-row footer while every settings-resolved left/middle/right zone fits without layout truncation. Otherwise it tries two complete left-aligned rows, preferring `left` / `middle right` and then `left middle` / `right`. Only when neither split fits does it use `compactFooterFormat`. Set `false` to restore the legacy one-row fitting behavior. Selection uses measured terminal-cell width, not fixed device breakpoints.
298
311
  - `compactFooterFormat`: JSON-only template used by the compact stage. The default keeps cwd, session name, git branch/status, context, and abbreviated token/cache metrics. A top-level `$wrap` is an automatic wrap opportunity: one space on the same row or no space at a row break. `$wrap_sep` is the same kind of boundary but renders the styled ` | ` divider only when its adjacent chunks share a row. Nested uses of either boundary remain empty variables. `$fill` is ignored. A standalone `$extensions` chunk inserts active non-`off` extension statuses in left/middle/right placement order; embedded uses render empty. Custom `footerFormat` values use this built-in compact fallback unless this key is also customized.
299
312
  - `compactFooterMaxLines`: `1`, `2`, `3`, or `"unlimited"` (default `2`). Finite limits crop remaining chunks with exactly one trailing `…`. Compact cwd always uses basename mode; cwd/session/branch chunks target half the available row width before final ANSI-aware clamping. `/zentui` exposes the responsive toggle and row limit, while compact format editing remains JSON-only. Pi supplies footer width but no supported viewport-height budget, so `"unlimited"` is explicit.
300
313
  - `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.
301
314
  - `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.
302
- - `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.
315
+ - `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 **Extensions** tab in `/zentui` lists only statuses that are currently active. `defaultPlacement` applies only when a status key has no entry in `placements`; keyed overrides always win.
303
316
  - The shown `editor*` values match the default `theme` source. Omit those keys to keep Zentui's source-aware defaults when switching between `theme` and `terminal`.
304
317
  - `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
305
318
  - `editorPrompt` styles the copy-friendly editor prompt glyph. Omit it to use `editorAccent`, then the default accent fallback.
@@ -67,6 +67,7 @@ export type UiFeaturesConfig = {
67
67
  editor: boolean;
68
68
  statusLine: boolean;
69
69
  copyFriendly: boolean;
70
+ viewportIndicators: boolean;
70
71
  };
71
72
 
72
73
  export type FooterSegmentsConfig = {
@@ -277,6 +278,7 @@ export const defaultConfig: PolishedTuiConfig = {
277
278
  editor: true,
278
279
  statusLine: true,
279
280
  copyFriendly: false,
281
+ viewportIndicators: true,
280
282
  },
281
283
  footerSegments: {
282
284
  cwd: true,
@@ -507,6 +509,7 @@ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig
507
509
  editor: booleanValue(record, "editor"),
508
510
  statusLine: booleanValue(record, "statusLine"),
509
511
  copyFriendly: booleanValue(record, "copyFriendly"),
512
+ viewportIndicators: booleanValue(record, "viewportIndicators"),
510
513
  };
511
514
  }
512
515
 
@@ -619,7 +622,12 @@ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
619
622
  }
620
623
 
621
624
  function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
622
- return value === "editor" || value === "statusLine" || value === "copyFriendly";
625
+ return (
626
+ value === "editor" ||
627
+ value === "statusLine" ||
628
+ value === "copyFriendly" ||
629
+ value === "viewportIndicators"
630
+ );
623
631
  }
624
632
 
625
633
  function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig {
@@ -990,6 +998,62 @@ export function saveGitBranchPatch(
990
998
  });
991
999
  }
992
1000
 
1001
+ export function saveEditorModelLabel(
1002
+ value: ModelLabelSource,
1003
+ path = configPath,
1004
+ ): PolishedTuiConfig {
1005
+ return mutateConfig(path, (record) => {
1006
+ record.editorModelLabel = parseEditorModelLabel(value);
1007
+ });
1008
+ }
1009
+
1010
+ export function saveGitCommitPatch(
1011
+ patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
1012
+ path = configPath,
1013
+ ): PolishedTuiConfig {
1014
+ return mutateConfig(path, (record) => {
1015
+ const existing = isRecord(record.gitCommit)
1016
+ ? { ...(record.gitCommit as Record<string, unknown>) }
1017
+ : {};
1018
+ if (typeof patch.onlyDetached === "boolean") existing.onlyDetached = patch.onlyDetached;
1019
+ if (typeof patch.showTag === "boolean") existing.showTag = patch.showTag;
1020
+ record.gitCommit = existing;
1021
+ });
1022
+ }
1023
+
1024
+ export function saveGitMetricsPatch(
1025
+ patch: Partial<GitMetricsConfig>,
1026
+ path = configPath,
1027
+ ): PolishedTuiConfig {
1028
+ return mutateConfig(path, (record) => {
1029
+ const existing = isRecord(record.gitMetrics)
1030
+ ? { ...(record.gitMetrics as Record<string, unknown>) }
1031
+ : {};
1032
+ if (typeof patch.onlyNonzero === "boolean") existing.onlyNonzero = patch.onlyNonzero;
1033
+ if (typeof patch.ignoreSubmodules === "boolean") {
1034
+ existing.ignoreSubmodules = patch.ignoreSubmodules;
1035
+ }
1036
+ record.gitMetrics = existing;
1037
+ });
1038
+ }
1039
+
1040
+ export function saveExtensionStatusDefaultPlacement(
1041
+ placement: ExtensionStatusPlacement,
1042
+ path = configPath,
1043
+ ): PolishedTuiConfig {
1044
+ return mutateConfig(path, (record) => {
1045
+ const existing = isRecord(record.extensionStatuses)
1046
+ ? { ...(record.extensionStatuses as Record<string, unknown>) }
1047
+ : {};
1048
+ record.extensionStatuses = {
1049
+ ...existing,
1050
+ defaultPlacement: isExtensionStatusPlacement(placement)
1051
+ ? placement
1052
+ : defaultConfig.extensionStatuses.defaultPlacement,
1053
+ };
1054
+ });
1055
+ }
1056
+
993
1057
  export function saveExtensionStatusPlacement(
994
1058
  key: string,
995
1059
  placement: ExtensionStatusPlacement,
@@ -15,19 +15,26 @@ import {
15
15
  FOOTER_FORMAT_ALIASES,
16
16
  type FooterSegmentsConfig,
17
17
  type GitBranchConfig,
18
+ type GitCommitConfig,
19
+ type GitMetricsConfig,
18
20
  type IconMode,
19
21
  loadConfig,
22
+ type ModelLabelSource,
20
23
  type PathDisplayConfig,
21
24
  type PolishedTuiConfig,
22
25
  type SeparatorStyle,
23
26
  saveColorSourcesPatch,
24
27
  saveContextStylePatch,
28
+ saveEditorModelLabel,
25
29
  saveExtensionStatusColorMode,
30
+ saveExtensionStatusDefaultPlacement,
26
31
  saveExtensionStatusPlacement,
27
32
  saveFixedEditorPatch,
28
33
  saveFooterFormatPatch,
29
34
  saveFooterSegmentsPatch,
30
35
  saveGitBranchPatch,
36
+ saveGitCommitPatch,
37
+ saveGitMetricsPatch,
31
38
  saveIconsModePatch,
32
39
  savePathDisplayPatch,
33
40
  saveResponsiveFooterPatch,
@@ -545,9 +552,31 @@ export default function (pi: ExtensionAPI) {
545
552
  setGitBranch(patch: Partial<GitBranchConfig>) {
546
553
  currentConfig = saveGitBranchPatch(patch);
547
554
  },
555
+ setEditorModelLabel(value: ModelLabelSource, ctx: ExtensionContext) {
556
+ currentConfig = saveEditorModelLabel(value);
557
+ syncFooterState(ctx);
558
+ },
559
+ setGitCommit(
560
+ patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
561
+ ctx: ExtensionContext,
562
+ ) {
563
+ currentConfig = saveGitCommitPatch(patch);
564
+ if (patch.showTag !== undefined && footerInstalled) {
565
+ scheduleProjectRefresh(ctx, { force: true });
566
+ }
567
+ },
568
+ setGitMetrics(patch: Partial<GitMetricsConfig>, ctx: ExtensionContext) {
569
+ currentConfig = saveGitMetricsPatch(patch);
570
+ if (patch.ignoreSubmodules !== undefined && footerInstalled) {
571
+ scheduleProjectRefresh(ctx, { force: true });
572
+ }
573
+ },
548
574
  getActiveExtensionStatuses() {
549
575
  return getActiveExtensionStatuses();
550
576
  },
577
+ setExtensionStatusDefaultPlacement(placement: ExtensionStatusPlacement) {
578
+ currentConfig = saveExtensionStatusDefaultPlacement(placement);
579
+ },
551
580
  setExtensionStatusPlacement(key: string, placement: ExtensionStatusPlacement) {
552
581
  currentConfig = saveExtensionStatusPlacement(key, placement);
553
582
  },
@@ -8,6 +8,7 @@ import {
8
8
  SettingsList,
9
9
  type SettingsListTheme,
10
10
  truncateToWidth,
11
+ visibleWidth,
11
12
  } from "@earendil-works/pi-tui";
12
13
  import {
13
14
  type ColorSource,
@@ -20,12 +21,15 @@ import {
20
21
  type FooterSegmentsConfig,
21
22
  type GitBranchConfig,
22
23
  type GitBranchMaxLength,
24
+ type GitCommitConfig,
25
+ type GitMetricsConfig,
23
26
  getExtensionStatusColorMode,
24
27
  getExtensionStatusPlacement,
25
28
  type IconMode,
26
29
  isExtensionStatusColorMode,
27
30
  isExtensionStatusPlacement,
28
31
  isSeparatorStyle,
32
+ type ModelLabelSource,
29
33
  type PathDisplayConfig,
30
34
  type PathDisplayMode,
31
35
  type PolishedTuiConfig,
@@ -51,23 +55,25 @@ const pathDisplayModeValues: PathDisplayMode[] = ["basename", "full"];
51
55
  const pathDepthValues = ["0", "1", "2", "3", "4", "5"] as const;
52
56
  const branchLengthPresetValues = ["full", "10", "20", "30", "40", "50"] as const;
53
57
  const iconModeValues: IconMode[] = ["auto", "nerd", "ascii"];
58
+ const modelLabelValues: ModelLabelSource[] = ["id", "name"];
54
59
  const compactFooterMaxLineValues = ["1", "2", "3", "unlimited"] as const;
55
60
  type FeatureState = "enabled" | "disabled";
56
61
 
57
62
  const featureStateValues: FeatureState[] = ["enabled", "disabled"];
58
63
  const settingsSections = [
59
- "coloring",
60
- "features",
61
- "layout",
62
- "builtinSegments",
63
- "extensionSegments",
64
+ "appearance",
65
+ "editor",
66
+ "footer",
67
+ "segments",
68
+ "git",
69
+ "extensions",
64
70
  ] as const;
65
71
 
66
72
  type ColorSettingId = "starship" | "editorMessages";
67
73
  type FeatureSettingId = keyof UiFeaturesConfig;
68
74
  type FooterSegmentSettingId = keyof FooterSegmentsConfig;
69
75
  type SettingsSection = (typeof settingsSections)[number];
70
- type LayoutSettingId =
76
+ type BoundedSettingId =
71
77
  | "responsiveFooter"
72
78
  | "compactFooterMaxLines"
73
79
  | "contextStyle"
@@ -75,7 +81,13 @@ type LayoutSettingId =
75
81
  | "pathDisplay"
76
82
  | "pathDepth"
77
83
  | "branchLength"
78
- | "iconMode";
84
+ | "iconMode"
85
+ | "editorModelLabel"
86
+ | "gitCommitOnlyDetached"
87
+ | "gitCommitShowTag"
88
+ | "gitMetricsOnlyNonzero"
89
+ | "gitMetricsIgnoreSubmodules"
90
+ | "extensionStatusDefaultPlacement";
79
91
 
80
92
  type SettingsCommandDeps = {
81
93
  sessionLifecycle: SessionLifecycle;
@@ -96,7 +108,14 @@ type SettingsCommandDeps = {
96
108
  setSeparator: (separator: SeparatorStyle) => void;
97
109
  setPathDisplay: (patch: Partial<PathDisplayConfig>) => void;
98
110
  setGitBranch: (patch: Partial<GitBranchConfig>) => void;
111
+ setEditorModelLabel?: (value: ModelLabelSource, ctx: ExtensionContext) => void;
112
+ setGitCommit?: (
113
+ patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
114
+ ctx: ExtensionContext,
115
+ ) => void;
116
+ setGitMetrics?: (patch: Partial<GitMetricsConfig>, ctx: ExtensionContext) => void;
99
117
  getActiveExtensionStatuses: () => ReadonlyMap<string, string>;
118
+ setExtensionStatusDefaultPlacement?: (placement: ExtensionStatusPlacement) => void;
100
119
  setExtensionStatusPlacement: (key: string, placement: ExtensionStatusPlacement) => void;
101
120
  setExtensionStatusColorMode: (key: string, colorMode: ExtensionStatusColorMode) => void;
102
121
  setFixedEditor: (patch: Partial<FixedEditorConfig>, ctx: ExtensionContext) => void;
@@ -120,6 +139,7 @@ const featureSettingLabels: Record<FeatureSettingId, string> = {
120
139
  editor: "Editor",
121
140
  statusLine: "Status line",
122
141
  copyFriendly: "Copy-friendly mode",
142
+ viewportIndicators: "Editor viewport indicators",
123
143
  };
124
144
 
125
145
  const featureSettingDescriptions: Record<FeatureSettingId, string> = {
@@ -128,6 +148,7 @@ const featureSettingDescriptions: Record<FeatureSettingId, string> = {
128
148
  statusLine: "Enable or disable Zentui's custom footer/status line.",
129
149
  copyFriendly:
130
150
  "Hide editor and previous-message rail glyphs for cleaner native terminal selection.",
151
+ viewportIndicators: "Show Pi's native wrapped-row counts in the editor's top and bottom borders.",
131
152
  };
132
153
 
133
154
  const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
@@ -182,6 +203,9 @@ const directCommandSuggestions = [
182
203
  "copy-friendly enable",
183
204
  "copy-friendly disable",
184
205
  "copy-friendly toggle",
206
+ "viewport-indicators enable",
207
+ "viewport-indicators disable",
208
+ "viewport-indicators toggle",
185
209
  "fixed-editor enable",
186
210
  "fixed-editor disable",
187
211
  "fixed-editor toggle",
@@ -191,11 +215,12 @@ const directCommandSuggestions = [
191
215
  ];
192
216
 
193
217
  const sectionLabels: Record<SettingsSection, string> = {
194
- coloring: "Coloring",
195
- features: "Features",
196
- layout: "Layout",
197
- builtinSegments: "Built-in segments",
198
- extensionSegments: "Extension segments",
218
+ appearance: "Appearance",
219
+ editor: "Editor",
220
+ footer: "Footer",
221
+ segments: "Segments",
222
+ git: "Git",
223
+ extensions: "Extensions",
199
224
  };
200
225
 
201
226
  const thirdPartyStatusSettingPrefix = "thirdPartyStatus:";
@@ -211,7 +236,12 @@ function isColorSettingId(value: string): value is ColorSettingId {
211
236
  }
212
237
 
213
238
  function isFeatureSettingId(value: string): value is FeatureSettingId {
214
- return value === "editor" || value === "statusLine" || value === "copyFriendly";
239
+ return (
240
+ value === "editor" ||
241
+ value === "statusLine" ||
242
+ value === "copyFriendly" ||
243
+ value === "viewportIndicators"
244
+ );
215
245
  }
216
246
 
217
247
  function isFooterSegmentSettingId(value: string): value is FooterSegmentSettingId {
@@ -273,7 +303,7 @@ function parseCompactFooterMaxLines(value: string): CompactFooterMaxLines | unde
273
303
  return value === "unlimited" ? value : (Number(value) as 1 | 2 | 3);
274
304
  }
275
305
 
276
- function isLayoutSettingId(value: string): value is LayoutSettingId {
306
+ function isBoundedSettingId(value: string): value is BoundedSettingId {
277
307
  return (
278
308
  value === "responsiveFooter" ||
279
309
  value === "compactFooterMaxLines" ||
@@ -282,7 +312,13 @@ function isLayoutSettingId(value: string): value is LayoutSettingId {
282
312
  value === "pathDisplay" ||
283
313
  value === "pathDepth" ||
284
314
  value === "branchLength" ||
285
- value === "iconMode"
315
+ value === "iconMode" ||
316
+ value === "editorModelLabel" ||
317
+ value === "gitCommitOnlyDetached" ||
318
+ value === "gitCommitShowTag" ||
319
+ value === "gitMetricsOnlyNonzero" ||
320
+ value === "gitMetricsIgnoreSubmodules" ||
321
+ value === "extensionStatusDefaultPlacement"
286
322
  );
287
323
  }
288
324
 
@@ -322,7 +358,7 @@ function footerSegmentPatch(
322
358
  }
323
359
 
324
360
  function usageText(): string {
325
- return 'Usage: /zentui [editor|statusline|copy-friendly] [enable|disable|toggle] or /zentui format "<template>"';
361
+ return 'Usage: /zentui [editor|statusline|copy-friendly|viewport-indicators] [enable|disable|toggle] or /zentui format "<template>"';
326
362
  }
327
363
 
328
364
  function featureNotification(
@@ -343,13 +379,16 @@ function parseDirectFeatureCommand(
343
379
 
344
380
  const words = normalized.split(/\s+/g).filter(Boolean);
345
381
  const hasWord = (value: string) => words.includes(value);
346
- const feature = hasWord("editor")
347
- ? "editor"
348
- : hasWord("footer") || hasWord("statusline") || hasWord("status")
349
- ? "statusLine"
350
- : hasWord("copyfriendly") || hasWord("copy")
351
- ? "copyFriendly"
352
- : undefined;
382
+ const feature =
383
+ hasWord("viewportindicators") || (hasWord("viewport") && hasWord("indicators"))
384
+ ? "viewportIndicators"
385
+ : hasWord("editor")
386
+ ? "editor"
387
+ : hasWord("footer") || hasWord("statusline") || hasWord("status")
388
+ ? "statusLine"
389
+ : hasWord("copyfriendly") || hasWord("copy")
390
+ ? "copyFriendly"
391
+ : undefined;
353
392
  const action = hasWord("toggle")
354
393
  ? "toggle"
355
394
  : hasWord("enable") || hasWord("enabled") || hasWord("on")
@@ -433,26 +472,49 @@ function buildItems(
433
472
  config: PolishedTuiConfig,
434
473
  activeStatuses: ReadonlyMap<string, string>,
435
474
  ): SettingItem[] {
436
- if (section === "coloring") {
437
- return (Object.keys(colorSettingLabels) as ColorSettingId[]).map((key) => ({
475
+ if (section === "appearance") {
476
+ const items = (Object.keys(colorSettingLabels) as ColorSettingId[]).map((key) => ({
438
477
  id: key,
439
478
  label: colorSettingLabels[key],
440
479
  description: colorSettingDescriptions[key],
441
480
  currentValue: key === "starship" ? config.colorSources.starship : editorMessageValue(config),
442
481
  values: colorSourceValues,
443
482
  }));
483
+ return [
484
+ ...items,
485
+ {
486
+ id: "separator",
487
+ label: "Separator",
488
+ description: "Choose the separator between default footer segments.",
489
+ currentValue: config.separator,
490
+ values: separatorStyleValues,
491
+ },
492
+ {
493
+ id: "iconMode",
494
+ label: "Icon mode",
495
+ description: "auto/nerd use Nerd Font glyphs; ascii uses plain fallbacks.",
496
+ currentValue: config.icons.mode,
497
+ values: iconModeValues,
498
+ },
499
+ ];
444
500
  }
445
501
 
446
- if (section === "features") {
447
- const items: SettingItem[] = (Object.keys(featureSettingLabels) as FeatureSettingId[]).map(
448
- (key) => ({
449
- id: key,
450
- label: featureSettingLabels[key],
451
- description: featureSettingDescriptions[key],
452
- currentValue: featureValue(config.features[key]),
453
- values: featureStateValues,
454
- }),
455
- );
502
+ if (section === "editor") {
503
+ const editorFeatures: FeatureSettingId[] = ["editor", "copyFriendly", "viewportIndicators"];
504
+ const items: SettingItem[] = editorFeatures.map((key) => ({
505
+ id: key,
506
+ label: featureSettingLabels[key],
507
+ description: featureSettingDescriptions[key],
508
+ currentValue: featureValue(config.features[key]),
509
+ values: featureStateValues,
510
+ }));
511
+ items.splice(1, 0, {
512
+ id: "editorModelLabel",
513
+ label: "Editor model label",
514
+ description: "Show the model id or display name in the editor frame.",
515
+ currentValue: config.editorModelLabel,
516
+ values: modelLabelValues,
517
+ });
456
518
  items.push({
457
519
  id: "fixedEditor",
458
520
  label: "Fixed editor (experimental)",
@@ -481,8 +543,15 @@ function buildItems(
481
543
  return items;
482
544
  }
483
545
 
484
- if (section === "layout") {
546
+ if (section === "footer") {
485
547
  return [
548
+ {
549
+ id: "statusLine",
550
+ label: featureSettingLabels.statusLine,
551
+ description: featureSettingDescriptions.statusLine,
552
+ currentValue: featureValue(config.features.statusLine),
553
+ values: featureStateValues,
554
+ },
486
555
  {
487
556
  id: "responsiveFooter",
488
557
  label: "Responsive footer",
@@ -505,13 +574,6 @@ function buildItems(
505
574
  currentValue: config.contextStyle,
506
575
  values: contextStyleValues,
507
576
  },
508
- {
509
- id: "separator",
510
- label: "Separator",
511
- description: "Choose the separator between default footer segments.",
512
- currentValue: config.separator,
513
- values: separatorStyleValues,
514
- },
515
577
  {
516
578
  id: "pathDisplay",
517
579
  label: "Path display",
@@ -527,6 +589,42 @@ function buildItems(
527
589
  currentValue: String(config.pathDisplay.depth),
528
590
  values: [...pathDepthValues],
529
591
  },
592
+ ];
593
+ }
594
+
595
+ const nonGitSegmentKeys: FooterSegmentSettingId[] = [
596
+ "cwd",
597
+ "sessionName",
598
+ "runtime",
599
+ "context",
600
+ "tokens",
601
+ "cost",
602
+ "sessionDuration",
603
+ "username",
604
+ "time",
605
+ "os",
606
+ "packageVersion",
607
+ ];
608
+ if (section === "segments") {
609
+ return nonGitSegmentKeys.map((key) => ({
610
+ id: footerSegmentSettingId(key),
611
+ label: footerSegmentSettingLabels[key],
612
+ description: footerSegmentSettingDescriptions[key],
613
+ currentValue: featureValue(config.footerSegments[key]),
614
+ values: featureStateValues,
615
+ }));
616
+ }
617
+
618
+ if (section === "git") {
619
+ const segmentItem = (key: FooterSegmentSettingId): SettingItem => ({
620
+ id: footerSegmentSettingId(key),
621
+ label: footerSegmentSettingLabels[key],
622
+ description: footerSegmentSettingDescriptions[key],
623
+ currentValue: featureValue(config.footerSegments[key]),
624
+ values: featureStateValues,
625
+ });
626
+ return [
627
+ segmentItem("gitBranch"),
530
628
  {
531
629
  id: "branchLength",
532
630
  label: "Branch length",
@@ -534,31 +632,54 @@ function buildItems(
534
632
  currentValue: String(config.gitBranch.maxLength),
535
633
  values: branchLengthValues(config.gitBranch.maxLength),
536
634
  },
635
+ segmentItem("gitStatus"),
636
+ segmentItem("gitCounts"),
637
+ segmentItem("gitCommit"),
537
638
  {
538
- id: "iconMode",
539
- label: "Icon mode",
540
- description: "auto/nerd use Nerd Font glyphs; ascii uses plain fallbacks.",
541
- currentValue: config.icons.mode,
542
- values: iconModeValues,
639
+ id: "gitCommitOnlyDetached",
640
+ label: "Commit only on detached HEAD",
641
+ description: "Show the commit segment only when HEAD is detached.",
642
+ currentValue: featureValue(config.gitCommit.onlyDetached),
643
+ values: featureStateValues,
644
+ },
645
+ {
646
+ id: "gitCommitShowTag",
647
+ label: "Show exact-match tag",
648
+ description: "Append an exact-match tag to the commit hash.",
649
+ currentValue: featureValue(config.gitCommit.showTag),
650
+ values: featureStateValues,
651
+ },
652
+ segmentItem("gitMetrics"),
653
+ {
654
+ id: "gitMetricsOnlyNonzero",
655
+ label: "Hide zero metrics",
656
+ description: "Hide zero added/deleted components and an all-zero metrics segment.",
657
+ currentValue: featureValue(config.gitMetrics.onlyNonzero),
658
+ values: featureStateValues,
659
+ },
660
+ {
661
+ id: "gitMetricsIgnoreSubmodules",
662
+ label: "Ignore submodules",
663
+ description: "Exclude submodule changes from Git line metrics.",
664
+ currentValue: featureValue(config.gitMetrics.ignoreSubmodules),
665
+ values: featureStateValues,
543
666
  },
544
667
  ];
545
668
  }
546
669
 
547
- if (section === "builtinSegments") {
548
- return (Object.keys(footerSegmentSettingLabels) as FooterSegmentSettingId[]).map((key) => ({
549
- id: footerSegmentSettingId(key),
550
- label: footerSegmentSettingLabels[key],
551
- description: footerSegmentSettingDescriptions[key],
552
- currentValue: featureValue(config.footerSegments[key]),
553
- values: featureStateValues,
554
- }));
555
- }
556
-
670
+ const defaultPlacementItem: SettingItem = {
671
+ id: "extensionStatusDefaultPlacement",
672
+ label: "Default placement",
673
+ description: "Placement used for active statuses without a keyed override.",
674
+ currentValue: config.extensionStatuses.defaultPlacement,
675
+ values: extensionStatusPlacementValues,
676
+ };
557
677
  const statuses = Array.from(activeStatuses.entries()).sort(([a], [b]) =>
558
678
  a < b ? -1 : a > b ? 1 : 0,
559
679
  );
560
680
  if (statuses.length === 0) {
561
681
  return [
682
+ defaultPlacementItem,
562
683
  {
563
684
  id: "noThirdPartyStatuses",
564
685
  label: "No active statuses",
@@ -568,50 +689,58 @@ function buildItems(
568
689
  ];
569
690
  }
570
691
 
571
- return statuses.flatMap(([key, value]) => {
572
- const sanitizedText = sanitizeExtensionStatusText(value);
573
- const description = sanitizedText ? `Current status: ${sanitizedText}` : undefined;
574
- return [
575
- {
576
- id: thirdPartyStatusSettingId(key, "placement"),
577
- label: `${key} placement`,
578
- description,
579
- currentValue: getExtensionStatusPlacement(config, key),
580
- values: extensionStatusPlacementValues,
581
- },
582
- {
583
- id: thirdPartyStatusSettingId(key, "colorMode"),
584
- label: `${key} color`,
585
- description,
586
- currentValue: getExtensionStatusColorMode(config, key),
587
- values: extensionStatusColorModeValues,
588
- },
589
- ];
590
- });
692
+ return [
693
+ defaultPlacementItem,
694
+ ...statuses.flatMap(([key, value]) => {
695
+ const sanitizedText = sanitizeExtensionStatusText(value);
696
+ const description = sanitizedText ? `Current status: ${sanitizedText}` : undefined;
697
+ return [
698
+ {
699
+ id: thirdPartyStatusSettingId(key, "placement"),
700
+ label: `${key} placement`,
701
+ description,
702
+ currentValue: getExtensionStatusPlacement(config, key),
703
+ values: extensionStatusPlacementValues,
704
+ },
705
+ {
706
+ id: thirdPartyStatusSettingId(key, "colorMode"),
707
+ label: `${key} color`,
708
+ description,
709
+ currentValue: getExtensionStatusColorMode(config, key),
710
+ values: extensionStatusColorModeValues,
711
+ },
712
+ ];
713
+ }),
714
+ ];
591
715
  }
592
716
 
593
717
  function nextSection(section: SettingsSection): SettingsSection {
594
718
  const currentIndex = settingsSections.indexOf(section);
595
- return settingsSections[(currentIndex + 1) % settingsSections.length] ?? "coloring";
719
+ return settingsSections[(currentIndex + 1) % settingsSections.length] ?? "appearance";
596
720
  }
597
721
 
598
722
  function previousSection(section: SettingsSection): SettingsSection {
599
723
  const currentIndex = settingsSections.indexOf(section);
600
724
  return (
601
725
  settingsSections[(currentIndex - 1 + settingsSections.length) % settingsSections.length] ??
602
- "coloring"
726
+ "appearance"
603
727
  );
604
728
  }
605
729
 
606
730
  function formatSectionTabs(
607
731
  activeSection: SettingsSection,
608
732
  theme: ExtensionContext["ui"]["theme"],
733
+ width: number,
609
734
  ): string {
610
735
  const rendered = settingsSections.map((section) => {
611
736
  const label = sectionLabels[section];
612
737
  return section === activeSection ? theme.bold(label) : safeThemeFg(theme, "muted", label);
613
738
  });
614
- return ` ${rendered.join(safeThemeFg(theme, "muted", " / "))}`;
739
+ const full = ` ${rendered.join(safeThemeFg(theme, "muted", " / "))}`;
740
+ if (visibleWidth(full) <= width) return full;
741
+
742
+ const activeIndex = settingsSections.indexOf(activeSection);
743
+ return ` ${theme.bold(sectionLabels[activeSection])} (${activeIndex + 1}/${settingsSections.length})`;
615
744
  }
616
745
 
617
746
  function withSectionFooter(lines: string[], theme: ExtensionContext["ui"]["theme"]): string[] {
@@ -705,7 +834,7 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
705
834
 
706
835
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
707
836
  const settingsListTheme = deps.settingsListTheme ?? getSettingsListTheme();
708
- let activeSection: SettingsSection = "coloring";
837
+ let activeSection: SettingsSection = "appearance";
709
838
  const applyFeatureChange = (id: FeatureSettingId, newValue: FeatureState) => {
710
839
  const result = deps.setUiFeatures(featurePatch(id, newValue), ctx);
711
840
  deps.requestRender();
@@ -752,7 +881,17 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
752
881
  return;
753
882
  }
754
883
 
755
- if (isLayoutSettingId(id)) {
884
+ if (isBoundedSettingId(id)) {
885
+ if (id === "editorModelLabel" && (newValue === "id" || newValue === "name")) {
886
+ if (!deps.setEditorModelLabel) return;
887
+ deps.setEditorModelLabel(newValue, ctx);
888
+ settingsList.updateValue(id, newValue);
889
+ deps.requestRender();
890
+ ctx.ui.notify(`Editor model label: ${newValue}`, "info");
891
+ tui.requestRender();
892
+ return;
893
+ }
894
+
756
895
  if (id === "responsiveFooter" && isFeatureState(newValue)) {
757
896
  deps.setResponsiveFooter?.({ responsiveFooter: newValue === "enabled" }, ctx);
758
897
  settingsList.updateValue(id, newValue);
@@ -826,6 +965,59 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
826
965
  deps.requestRender();
827
966
  ctx.ui.notify(`Icon mode: ${newValue}`, "info");
828
967
  tui.requestRender();
968
+ return;
969
+ }
970
+
971
+ if (id === "gitCommitOnlyDetached" && isFeatureState(newValue)) {
972
+ if (!deps.setGitCommit) return;
973
+ deps.setGitCommit({ onlyDetached: newValue === "enabled" }, ctx);
974
+ settingsList.updateValue(id, newValue);
975
+ deps.requestRender();
976
+ ctx.ui.notify(`Commit only on detached HEAD: ${newValue}`, "info");
977
+ tui.requestRender();
978
+ return;
979
+ }
980
+
981
+ if (id === "gitCommitShowTag" && isFeatureState(newValue)) {
982
+ if (!deps.setGitCommit) return;
983
+ deps.setGitCommit({ showTag: newValue === "enabled" }, ctx);
984
+ settingsList.updateValue(id, newValue);
985
+ deps.requestRender();
986
+ ctx.ui.notify(`Show exact-match tag: ${newValue}`, "info");
987
+ tui.requestRender();
988
+ return;
989
+ }
990
+
991
+ if (id === "gitMetricsOnlyNonzero" && isFeatureState(newValue)) {
992
+ if (!deps.setGitMetrics) return;
993
+ deps.setGitMetrics({ onlyNonzero: newValue === "enabled" }, ctx);
994
+ settingsList.updateValue(id, newValue);
995
+ deps.requestRender();
996
+ ctx.ui.notify(`Hide zero metrics: ${newValue}`, "info");
997
+ tui.requestRender();
998
+ return;
999
+ }
1000
+
1001
+ if (id === "gitMetricsIgnoreSubmodules" && isFeatureState(newValue)) {
1002
+ if (!deps.setGitMetrics) return;
1003
+ deps.setGitMetrics({ ignoreSubmodules: newValue === "enabled" }, ctx);
1004
+ settingsList.updateValue(id, newValue);
1005
+ deps.requestRender();
1006
+ ctx.ui.notify(`Ignore submodules: ${newValue}`, "info");
1007
+ tui.requestRender();
1008
+ return;
1009
+ }
1010
+
1011
+ if (
1012
+ id === "extensionStatusDefaultPlacement" &&
1013
+ isExtensionStatusPlacement(newValue)
1014
+ ) {
1015
+ if (!deps.setExtensionStatusDefaultPlacement) return;
1016
+ deps.setExtensionStatusDefaultPlacement(newValue);
1017
+ settingsList = makeSettingsList();
1018
+ deps.requestRender();
1019
+ ctx.ui.notify(`Default extension status placement: ${newValue}`, "info");
1020
+ tui.requestRender();
829
1021
  }
830
1022
  return;
831
1023
  }
@@ -925,7 +1117,7 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
925
1117
  );
926
1118
  return [
927
1119
  truncateToWidth(border, width, ""),
928
- truncateToWidth(formatSectionTabs(activeSection, theme), width, ""),
1120
+ truncateToWidth(formatSectionTabs(activeSection, theme, width), width, ""),
929
1121
  truncateToWidth(border, width, ""),
930
1122
  ...withSectionFooter(settingsList.render(width), theme).map((line) =>
931
1123
  truncateToWidth(line, width, ""),
@@ -19,11 +19,19 @@ import {
19
19
 
20
20
  const SPLIT_POLISHED_FRAME: unique symbol = Symbol.for("pi-zentui.polished-frame");
21
21
 
22
+ type ViewportCounts = {
23
+ above?: string;
24
+ below?: string;
25
+ };
26
+
22
27
  type PolishedFrameSplit = {
23
28
  editorLines: string[];
24
29
  trailingLines: string[];
30
+ viewport: ViewportCounts;
25
31
  };
26
32
 
33
+ const POLISHED_FRAME_SPLITS = new WeakMap<string[], PolishedFrameSplit>();
34
+
27
35
  type AutocompleteEditorInternals = {
28
36
  autocompleteList?: Pick<Component, "render">;
29
37
  isShowingAutocomplete?: () => boolean;
@@ -128,30 +136,48 @@ function composeMetadataLine(left: string, right: string | undefined, width: num
128
136
  return `${leftText}${gap}${right}`;
129
137
  }
130
138
 
139
+ function ansiStrippedText(line: string): string {
140
+ return line.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
141
+ }
142
+
131
143
  function plainRenderedText(line: string): string {
132
- return line
133
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
134
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
135
- .replace(/\[\/?[^\]]+\]/g, "");
144
+ return ansiStrippedText(line).replace(/\[\/?[^\]]+\]/g, "");
145
+ }
146
+
147
+ function parseEditorBorder(
148
+ line: string,
149
+ direction: keyof ViewportCounts,
150
+ ): { count?: string } | undefined {
151
+ const plain = ansiStrippedText(line);
152
+ if (/^─+$/.test(plain)) return {};
153
+
154
+ const arrow = direction === "above" ? "↑" : "↓";
155
+ const match = new RegExp(`^─── ${arrow} ([1-9]\\d*) more ─*$`).exec(plain);
156
+ return match?.[1] ? { count: match[1] } : undefined;
136
157
  }
137
158
 
138
- function isHorizontalBorder(line: string): boolean {
139
- const plain = plainRenderedText(line).trim();
140
- return plain.length > 0 && /^─+$/.test(plain);
159
+ function renderEditorBorder(
160
+ width: number,
161
+ direction: keyof ViewportCounts,
162
+ count: string | undefined,
163
+ ): string {
164
+ if (!count) return "─".repeat(width);
165
+ const arrow = direction === "above" ? "↑" : "↓";
166
+ const indicator = `─── ${arrow} ${count} more `;
167
+ return `${indicator}${"─".repeat(Math.max(0, width - visibleWidth(indicator)))}`;
141
168
  }
142
169
 
143
170
  function unwrapPolishedFrameOnly(
144
171
  lines: string[],
145
172
  config: PolishedTuiConfig,
146
173
  uiTheme: Theme,
147
- ): string[] | undefined {
148
- if (
149
- lines.length < 5 ||
150
- !isHorizontalBorder(lines[0] ?? "") ||
151
- !isHorizontalBorder(lines.at(-1) ?? "")
152
- )
153
- return undefined;
174
+ ): { editorLines: string[]; viewport: ViewportCounts } | undefined {
175
+ if (lines.length < 5) return undefined;
176
+ const top = parseEditorBorder(lines[0] ?? "", "above");
177
+ const bottom = parseEditorBorder(lines.at(-1) ?? "", "below");
178
+ if (!top || !bottom) return undefined;
154
179
 
180
+ const viewport = { above: top.count, below: bottom.count };
155
181
  const interior = lines.slice(1, -1);
156
182
  if (interior.length < 3) return undefined;
157
183
 
@@ -173,7 +199,7 @@ function unwrapPolishedFrameOnly(
173
199
  if (prefix && !line.startsWith(prefix)) return undefined;
174
200
  unwrapped.push(prefix ? line.slice(prefix.length) : line);
175
201
  }
176
- return unwrapped;
202
+ return { editorLines: unwrapped, viewport };
177
203
  }
178
204
 
179
205
  const { rail } = getEditorChromeWidths(config, uiTheme, "\x1b[0m");
@@ -184,7 +210,7 @@ function unwrapPolishedFrameOnly(
184
210
  plainRenderedText(unrailed.at(-2) ?? "").trim() !== ""
185
211
  )
186
212
  return undefined;
187
- return unrailed.slice(1, -2);
213
+ return { editorLines: unrailed.slice(1, -2), viewport };
188
214
  }
189
215
 
190
216
  function splitPolishedFrame(
@@ -192,12 +218,12 @@ function splitPolishedFrame(
192
218
  config: PolishedTuiConfig,
193
219
  uiTheme: Theme,
194
220
  ): PolishedFrameSplit | undefined {
195
- if (!isHorizontalBorder(lines[0] ?? "")) return undefined;
221
+ if (!parseEditorBorder(lines[0] ?? "", "above")) return undefined;
196
222
  for (let bottomIndex = lines.length - 1; bottomIndex >= 4; bottomIndex--) {
197
- if (!isHorizontalBorder(lines[bottomIndex] ?? "")) continue;
198
- const editorLines = unwrapPolishedFrameOnly(lines.slice(0, bottomIndex + 1), config, uiTheme);
199
- if (editorLines) {
200
- return { editorLines, trailingLines: lines.slice(bottomIndex + 1) };
223
+ if (!parseEditorBorder(lines[bottomIndex] ?? "", "below")) continue;
224
+ const frame = unwrapPolishedFrameOnly(lines.slice(0, bottomIndex + 1), config, uiTheme);
225
+ if (frame) {
226
+ return { ...frame, trailingLines: lines.slice(bottomIndex + 1) };
201
227
  }
202
228
  }
203
229
  return undefined;
@@ -272,6 +298,12 @@ function renderPolishedFrame({
272
298
  if (editorFrame.length < 2) return clampRenderedLines(baseRendered, width);
273
299
 
274
300
  const editorLines = ownedFrame?.editorLines ?? editorFrame.slice(1, -1);
301
+ const parsedTop = parseEditorBorder(editorFrame[0] ?? "", "above");
302
+ const parsedBottom = parseEditorBorder(editorFrame.at(-1) ?? "", "below");
303
+ const viewport = ownedFrame?.viewport ?? {
304
+ above: parsedTop?.count,
305
+ below: parsedBottom?.count,
306
+ };
275
307
  const meta = renderEditorMetadataFormat(
276
308
  config.editorMetadataFormat,
277
309
  {
@@ -293,14 +325,22 @@ function renderPolishedFrame({
293
325
  colorSource,
294
326
  config.colors.editorBorder,
295
327
  EDITOR_BORDER_FALLBACK,
296
- "─".repeat(width),
328
+ renderEditorBorder(
329
+ width,
330
+ "above",
331
+ config.features.viewportIndicators ? viewport.above : undefined,
332
+ ),
297
333
  );
298
334
  const bottom = renderStyleForSourceOrFallback(
299
335
  uiTheme,
300
336
  colorSource,
301
337
  config.colors.editorBorder,
302
338
  EDITOR_BORDER_FALLBACK,
303
- "─".repeat(width),
339
+ renderEditorBorder(
340
+ width,
341
+ "below",
342
+ config.features.viewportIndicators ? viewport.below : undefined,
343
+ ),
304
344
  );
305
345
  const lines = ["", ...editorLines, "", railedMeta];
306
346
  const renderedLines = config.features.copyFriendly
@@ -323,7 +363,13 @@ function renderPolishedFrame({
323
363
  ...autocompleteLines,
324
364
  ];
325
365
 
326
- return clampRenderedLines(renderedLines, width);
366
+ const clamped = clampRenderedLines(renderedLines, width);
367
+ POLISHED_FRAME_SPLITS.set(clamped, {
368
+ editorLines,
369
+ trailingLines: autocompleteLines.length > 0 ? clamped.slice(-autocompleteLines.length) : [],
370
+ viewport,
371
+ });
372
+ return clamped;
327
373
  }
328
374
 
329
375
  export class PolishedEditor extends CustomEditor {
@@ -372,7 +418,9 @@ export class PolishedEditor extends CustomEditor {
372
418
  }
373
419
 
374
420
  [SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
375
- return splitPolishedFrame(lines, this.getConfig(), this.uiTheme);
421
+ return (
422
+ POLISHED_FRAME_SPLITS.get(lines) ?? splitPolishedFrame(lines, this.getConfig(), this.uiTheme)
423
+ );
376
424
  }
377
425
  }
378
426
 
@@ -485,7 +533,9 @@ export class WrappedPolishedEditor implements EditorComponent {
485
533
  }
486
534
 
487
535
  [SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
488
- return splitPolishedFrame(lines, this.getConfig(), this.uiTheme);
536
+ return (
537
+ POLISHED_FRAME_SPLITS.get(lines) ?? splitPolishedFrame(lines, this.getConfig(), this.uiTheme)
538
+ );
489
539
  }
490
540
 
491
541
  invalidate(): void {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",