pi-zentui 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -4
- package/extensions/zentui/config.ts +25 -0
- package/extensions/zentui/editor-metadata-format.ts +259 -0
- package/extensions/zentui/footer.ts +18 -1
- package/extensions/zentui/index.ts +8 -1
- package/extensions/zentui/settings-command.ts +3 -0
- package/extensions/zentui/state.ts +15 -2
- package/extensions/zentui/ui.ts +100 -126
- package/package.json +4 -4
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ Zentui brings two popular aesthetics to Pi:
|
|
|
21
21
|
- `on branch` — git branch with icon
|
|
22
22
|
- `[!?↑]` — git status indicators (modified, untracked, ahead/behind, stashed, etc.)
|
|
23
23
|
- `via v5.5.0` — runtime detection with version and Starship-style Nerd Font runtime/language modules
|
|
24
|
-
- Optional segments (off by default): `user@host`, current time, OS icon, session duration, and the **project package version** (e.g. `package.json` → `0.6.0`) — distinct from the runtime segment, which shows the installed toolchain
|
|
24
|
+
- Optional segments (off by default): session name, `user@host`, current time, OS icon, session duration, and the **project package version** (e.g. `package.json` → `0.6.0`) — distinct from the runtime segment, which shows the installed toolchain
|
|
25
25
|
- Right side shows context usage, token counts, and cost
|
|
26
26
|
- Built-in footer segments can be shown or hidden individually from `/zentui`
|
|
27
27
|
- Fully custom Starship-style layout via a `footerFormat` template string — see [Footer Format Template](#footer-format-template)
|
|
@@ -157,8 +157,10 @@ Default config values — copy this and change any value you want:
|
|
|
157
157
|
{
|
|
158
158
|
"projectRefreshIntervalMs": 30000,
|
|
159
159
|
"footerFormat": "",
|
|
160
|
+
"editorMetadataFormat": "$model $provider( $thinking)",
|
|
160
161
|
"separator": "pipe",
|
|
161
162
|
"contextStyle": "text",
|
|
163
|
+
"editorModelLabel": "id",
|
|
162
164
|
"contextThresholds": {
|
|
163
165
|
"warning": 70,
|
|
164
166
|
"error": 90
|
|
@@ -194,6 +196,7 @@ Default config values — copy this and change any value you want:
|
|
|
194
196
|
},
|
|
195
197
|
"colors": {
|
|
196
198
|
"cwd": "bold cyan",
|
|
199
|
+
"sessionName": "bold green",
|
|
197
200
|
"gitBranch": "bold purple",
|
|
198
201
|
"gitStatus": "bold red",
|
|
199
202
|
"contextNormal": "bright-black",
|
|
@@ -236,6 +239,7 @@ Default config values — copy this and change any value you want:
|
|
|
236
239
|
},
|
|
237
240
|
"footerSegments": {
|
|
238
241
|
"cwd": true,
|
|
242
|
+
"sessionName": true,
|
|
239
243
|
"gitBranch": true,
|
|
240
244
|
"gitStatus": true,
|
|
241
245
|
"gitCounts": false,
|
|
@@ -276,6 +280,8 @@ Default config values — copy this and change any value you want:
|
|
|
276
280
|
- 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`.
|
|
277
281
|
- `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`.
|
|
278
282
|
- `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.
|
|
283
|
+
- `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.
|
|
284
|
+
- `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.
|
|
279
285
|
- `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.
|
|
280
286
|
- `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
|
|
281
287
|
- `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`.
|
|
@@ -283,7 +289,7 @@ Default config values — copy this and change any value you want:
|
|
|
283
289
|
- `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.
|
|
284
290
|
- `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
|
|
285
291
|
- `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.
|
|
286
|
-
- `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`.
|
|
292
|
+
- `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`.
|
|
287
293
|
- `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, branch length, and icon mode; set or clear custom formats with `/zentui format`.
|
|
288
294
|
- `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.
|
|
289
295
|
- `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.
|
|
@@ -296,6 +302,31 @@ Default config values — copy this and change any value you want:
|
|
|
296
302
|
|
|
297
303
|
Tip: when using copy-friendly mode, setting Pi's `editorPaddingX` to `1` in `~/.pi/agent/settings.json` keeps a small left gutter without copying a rail glyph.
|
|
298
304
|
|
|
305
|
+
## Editor Metadata Format
|
|
306
|
+
|
|
307
|
+
Set `editorMetadataFormat` in `~/.pi/agent/zentui.json` to customize the left side of the editor metadata row:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"editorMetadataFormat": "$model_name ($model_id)( · $provider)( · $thinking)( · $session_name)"
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The syntax follows the relevant `footerFormat` conventions: `$variable` and `${variable}` references, literal text and spaces, and conditional groups `( ... )` that disappear when all variables inside are empty. Unknown variables and `$fill` render empty; `$fill` never creates an editor layout zone because the right side remains reserved for structural Vim status.
|
|
316
|
+
|
|
317
|
+
| Token | Renders |
|
|
318
|
+
| --------------- | --------------------------------------------------------------------------------------------- |
|
|
319
|
+
| `$model` | label selected by `editorModelLabel` (`id`, or name with ID fallback) |
|
|
320
|
+
| `$model_id` | active Pi model ID |
|
|
321
|
+
| `$model_name` | active Pi model display name; empty when no name is set |
|
|
322
|
+
| `$provider` | provider label using Zentui's existing formatting |
|
|
323
|
+
| `$thinking` | current thinking level; empty when thinking is `off` |
|
|
324
|
+
| `$session_name` | current Pi session name; empty when unnamed |
|
|
325
|
+
|
|
326
|
+
Model variables use `editorModel`, provider uses `editorProvider`, and thinking uses the matching `editorThinking*` style. Literal text and `$session_name` use the neutral editor border theme style. The template controls spacing. ANSI/VT sequences, control characters, and line-breaking whitespace are sanitized before rendering without collapsing ordinary spaces.
|
|
327
|
+
|
|
328
|
+
Missing, non-string, or empty values use the default `$model $provider( $thinking)`. A non-empty format that resolves to no visible metadata keeps the normal blank spacer and metadata rows so the editor frame height remains stable. This option is configured only through JSON in its first version; `/zentui format` continues to control the footer only.
|
|
329
|
+
|
|
299
330
|
## Footer Format Template
|
|
300
331
|
|
|
301
332
|
For full control, set a Starship-style `footerFormat` template string. It supports `$variable` and `${variable}` tokens, a special `$fill` token that splits the line into left and right zones, and conditional groups `( ... )` that drop entirely when every nested variable is empty. When set, it overrides the built-in `footerSegments` layout; when empty or omitted, the segment layout above is used.
|
|
@@ -304,7 +335,7 @@ A second `$fill` creates a **centered middle zone** — content between the two
|
|
|
304
335
|
|
|
305
336
|
```json
|
|
306
337
|
{
|
|
307
|
-
"footerFormat": "$os $username $cwd( on $git_branch)( $git_status)( via $runtime)$fill($context)($sep$tokens)($sep$cost)($sep$time)"
|
|
338
|
+
"footerFormat": "$os $username $cwd($sep$session_name)( on $git_branch)( $git_status)( via $runtime)$fill($context)($sep$tokens)($sep$cost)($sep$time)"
|
|
308
339
|
}
|
|
309
340
|
```
|
|
310
341
|
|
|
@@ -321,6 +352,7 @@ Center the branch between directory and cost:
|
|
|
321
352
|
| Token | Aliases | Renders |
|
|
322
353
|
| ------------------- | ------------ | ------------------------------------------------------------------- |
|
|
323
354
|
| `$cwd` | `$directory` | current directory |
|
|
355
|
+
| `$session_name` | | current Pi session name |
|
|
324
356
|
| `$git_branch` | `$branch` | git branch with icon |
|
|
325
357
|
| `$git_status` | `$status` | `[!?↑]` status block |
|
|
326
358
|
| `$git_state` | `$state` | `REBASING` / `MERGING` / … (optional `n/m`) |
|
|
@@ -354,6 +386,7 @@ Center the branch between directory and cost:
|
|
|
354
386
|
- Literal text (`on branch`, `using`, `\|`, spaces) is rendered verbatim — you control all spacing.
|
|
355
387
|
- Each variable renders its core value only (no `on`/`via` prefixes); add those words as literal text.
|
|
356
388
|
- Conditional groups: wrap optional pieces in parentheses, e.g. `$cwd( on $git_branch)($git_status)$fill($context)`. If every `$var` inside a group is empty, the whole group (including its literals) is dropped.
|
|
389
|
+
- `$session_name` is available whenever `footerFormat` is set, independently of `footerSegments.sessionName`; use a conditional group such as `($sep$session_name)` so unnamed sessions leave no separator.
|
|
357
390
|
- Unknown `$variables` render empty.
|
|
358
391
|
- Set or clear at runtime: `/zentui format "<template>"` and `/zentui format clear`.
|
|
359
392
|
|
|
@@ -409,7 +442,7 @@ Mouse wheel scrolling is enabled by default when the fixed editor is on. Disable
|
|
|
409
442
|
|
|
410
443
|
## Requirements
|
|
411
444
|
|
|
412
|
-
- [Pi](https://pi.dev) coding agent 0.80 or newer
|
|
445
|
+
- [Pi](https://pi.dev) coding agent 0.80.3 or newer
|
|
413
446
|
- A [Nerd Font](https://www.nerdfonts.com/) for icons (or set `icons.mode` to `"ascii"`)
|
|
414
447
|
|
|
415
448
|
## Development
|
|
@@ -32,6 +32,7 @@ export type { IconMode } from "./icons";
|
|
|
32
32
|
|
|
33
33
|
export type ContextStyle = "text" | "gauge" | "text+gauge";
|
|
34
34
|
export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
|
|
35
|
+
export type ModelLabelSource = "id" | "name";
|
|
35
36
|
|
|
36
37
|
export type ContextThresholds = {
|
|
37
38
|
warning: number;
|
|
@@ -66,6 +67,7 @@ export type UiFeaturesConfig = {
|
|
|
66
67
|
|
|
67
68
|
export type FooterSegmentsConfig = {
|
|
68
69
|
cwd: boolean;
|
|
70
|
+
sessionName: boolean;
|
|
69
71
|
gitBranch: boolean;
|
|
70
72
|
gitStatus: boolean;
|
|
71
73
|
gitCounts: boolean;
|
|
@@ -120,18 +122,22 @@ export type ExtensionStatusesConfig = {
|
|
|
120
122
|
|
|
121
123
|
const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
|
|
122
124
|
const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
|
|
125
|
+
export const DEFAULT_EDITOR_METADATA_FORMAT = "$model $provider( $thinking)";
|
|
123
126
|
|
|
124
127
|
export type PolishedTuiConfig = {
|
|
125
128
|
projectRefreshIntervalMs: number;
|
|
126
129
|
footerFormat: string;
|
|
130
|
+
editorMetadataFormat: string;
|
|
127
131
|
separator: SeparatorStyle;
|
|
128
132
|
contextStyle: ContextStyle;
|
|
133
|
+
editorModelLabel: ModelLabelSource;
|
|
129
134
|
contextThresholds: ContextThresholds;
|
|
130
135
|
pathDisplay: PathDisplayConfig;
|
|
131
136
|
gitBranch: GitBranchConfig;
|
|
132
137
|
icons: ResolvedIcons;
|
|
133
138
|
colors: {
|
|
134
139
|
cwd: ColorSpec;
|
|
140
|
+
sessionName: ColorSpec;
|
|
135
141
|
gitBranch: ColorSpec;
|
|
136
142
|
gitStatus: ColorSpec;
|
|
137
143
|
contextNormal: ColorSpec;
|
|
@@ -177,6 +183,7 @@ export type PolishedTuiConfig = {
|
|
|
177
183
|
*/
|
|
178
184
|
export const FOOTER_FORMAT_VARIABLES = [
|
|
179
185
|
"cwd",
|
|
186
|
+
"session_name",
|
|
180
187
|
"git_branch",
|
|
181
188
|
"git_status",
|
|
182
189
|
"git_state",
|
|
@@ -218,8 +225,10 @@ export const configPath = join(getAgentDir(), "zentui.json");
|
|
|
218
225
|
export const defaultConfig: PolishedTuiConfig = {
|
|
219
226
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
220
227
|
footerFormat: "",
|
|
228
|
+
editorMetadataFormat: DEFAULT_EDITOR_METADATA_FORMAT,
|
|
221
229
|
separator: "pipe",
|
|
222
230
|
contextStyle: "text",
|
|
231
|
+
editorModelLabel: "id",
|
|
223
232
|
contextThresholds: { warning: 70, error: 90 },
|
|
224
233
|
pathDisplay: { mode: "basename", depth: 0 },
|
|
225
234
|
gitBranch: { maxLength: "full" },
|
|
@@ -229,6 +238,7 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
229
238
|
},
|
|
230
239
|
colors: {
|
|
231
240
|
cwd: "bold cyan",
|
|
241
|
+
sessionName: "bold green",
|
|
232
242
|
gitBranch: "bold purple",
|
|
233
243
|
gitStatus: "bold red",
|
|
234
244
|
contextNormal: "bright-black",
|
|
@@ -260,6 +270,7 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
260
270
|
},
|
|
261
271
|
footerSegments: {
|
|
262
272
|
cwd: true,
|
|
273
|
+
sessionName: true,
|
|
263
274
|
gitBranch: true,
|
|
264
275
|
gitStatus: true,
|
|
265
276
|
gitCounts: false,
|
|
@@ -322,6 +333,11 @@ function parseContextStyle(value: unknown): ContextStyle {
|
|
|
322
333
|
return defaultConfig.contextStyle;
|
|
323
334
|
}
|
|
324
335
|
|
|
336
|
+
function parseEditorModelLabel(value: unknown): ModelLabelSource {
|
|
337
|
+
if (value === "id" || value === "name") return value;
|
|
338
|
+
return defaultConfig.editorModelLabel;
|
|
339
|
+
}
|
|
340
|
+
|
|
325
341
|
export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
|
|
326
342
|
return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
|
|
327
343
|
}
|
|
@@ -431,6 +447,7 @@ function normalizeIconOverrides(record: Record<string, unknown>): Partial<IconGl
|
|
|
431
447
|
function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
|
|
432
448
|
return definedColors({
|
|
433
449
|
cwd: colorValue(record, "cwd") ?? colorValue(record, "cwdText"),
|
|
450
|
+
sessionName: colorValue(record, "sessionName"),
|
|
434
451
|
gitBranch: colorValue(record, "gitBranch") ?? colorValue(record, "git"),
|
|
435
452
|
gitStatus: colorValue(record, "gitStatus"),
|
|
436
453
|
contextNormal: colorValue(record, "contextNormal"),
|
|
@@ -482,6 +499,7 @@ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig
|
|
|
482
499
|
function normalizeFooterSegments(record: Record<string, unknown>): FooterSegmentsConfig {
|
|
483
500
|
return {
|
|
484
501
|
cwd: footerSegmentValue(record, "cwd"),
|
|
502
|
+
sessionName: footerSegmentValue(record, "sessionName"),
|
|
485
503
|
gitBranch: footerSegmentValue(record, "gitBranch"),
|
|
486
504
|
gitStatus: footerSegmentValue(record, "gitStatus"),
|
|
487
505
|
gitCounts: footerSegmentValue(record, "gitCounts"),
|
|
@@ -593,6 +611,7 @@ function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
|
|
|
593
611
|
function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig {
|
|
594
612
|
return (
|
|
595
613
|
value === "cwd" ||
|
|
614
|
+
value === "sessionName" ||
|
|
596
615
|
value === "gitBranch" ||
|
|
597
616
|
value === "gitStatus" ||
|
|
598
617
|
value === "gitCounts" ||
|
|
@@ -753,11 +772,17 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
753
772
|
const fixedEditor = isRecord(config.fixedEditor)
|
|
754
773
|
? normalizeFixedEditorConfig(config.fixedEditor as Record<string, unknown>)
|
|
755
774
|
: defaultConfig.fixedEditor;
|
|
775
|
+
const editorMetadataFormat = stringValue(config, "editorMetadataFormat");
|
|
756
776
|
return {
|
|
757
777
|
projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
|
|
758
778
|
footerFormat: stringValue(config, "footerFormat") ?? "",
|
|
779
|
+
editorMetadataFormat:
|
|
780
|
+
editorMetadataFormat && editorMetadataFormat.length > 0
|
|
781
|
+
? editorMetadataFormat
|
|
782
|
+
: DEFAULT_EDITOR_METADATA_FORMAT,
|
|
759
783
|
separator: parseSeparatorStyle(config.separator),
|
|
760
784
|
contextStyle: parseContextStyle(config.contextStyle),
|
|
785
|
+
editorModelLabel: parseEditorModelLabel(config.editorModelLabel),
|
|
761
786
|
contextThresholds: parseContextThresholds(config.contextThresholds),
|
|
762
787
|
pathDisplay: parsePathDisplay(config.pathDisplay),
|
|
763
788
|
gitBranch,
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { PolishedTuiConfig } from "./config";
|
|
3
|
+
import { type FormatToken, parseFooterFormat } from "./footer-format";
|
|
4
|
+
import { EDITOR_ACCENT_FALLBACK, renderStyleForSourceOrFallback, safeThemeFg } from "./style";
|
|
5
|
+
|
|
6
|
+
export type EditorMetadataValues = {
|
|
7
|
+
model: string;
|
|
8
|
+
modelId: string;
|
|
9
|
+
modelName: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
thinking: string;
|
|
12
|
+
sessionName: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type RenderedTokens = {
|
|
16
|
+
styled: string;
|
|
17
|
+
hasDynamic: boolean;
|
|
18
|
+
hasNonEmptyDynamic: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ESC = 0x1b;
|
|
22
|
+
const BEL = 0x07;
|
|
23
|
+
const CAN = 0x18;
|
|
24
|
+
const SUB = 0x1a;
|
|
25
|
+
const C1_DCS = 0x90;
|
|
26
|
+
const C1_CSI = 0x9b;
|
|
27
|
+
const C1_ST = 0x9c;
|
|
28
|
+
const C1_OSC = 0x9d;
|
|
29
|
+
const C1_SOS = 0x98;
|
|
30
|
+
const C1_PM = 0x9e;
|
|
31
|
+
const C1_APC = 0x9f;
|
|
32
|
+
|
|
33
|
+
function consumeCsi(value: string, start: number): number {
|
|
34
|
+
for (let index = start; index < value.length; index++) {
|
|
35
|
+
const code = value.charCodeAt(index);
|
|
36
|
+
if (code === CAN || code === SUB) return index + 1;
|
|
37
|
+
if (code >= 0x40 && code <= 0x7e) return index + 1;
|
|
38
|
+
}
|
|
39
|
+
return value.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function consumeControlString(value: string, start: number, allowBel: boolean): number {
|
|
43
|
+
for (let index = start; index < value.length; index++) {
|
|
44
|
+
const code = value.charCodeAt(index);
|
|
45
|
+
if (code === CAN || code === SUB) return index + 1;
|
|
46
|
+
if (allowBel && code === BEL) return index + 1;
|
|
47
|
+
if (code === C1_ST) return index + 1;
|
|
48
|
+
if (code === ESC && value.charCodeAt(index + 1) === 0x5c) return index + 2;
|
|
49
|
+
}
|
|
50
|
+
return value.length;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function consumeEscape(value: string, start: number): number {
|
|
54
|
+
if (start + 1 >= value.length) return value.length;
|
|
55
|
+
const next = value.charCodeAt(start + 1);
|
|
56
|
+
if (next === 0x5b) return consumeCsi(value, start + 2);
|
|
57
|
+
if (next === 0x5d) return consumeControlString(value, start + 2, true);
|
|
58
|
+
if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
|
|
59
|
+
return consumeControlString(value, start + 2, false);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let index = start + 1;
|
|
63
|
+
while (index < value.length) {
|
|
64
|
+
const code = value.charCodeAt(index);
|
|
65
|
+
if (code >= 0x20 && code <= 0x2f) {
|
|
66
|
+
index += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
return code >= 0x30 && code <= 0x7e ? index + 1 : index;
|
|
70
|
+
}
|
|
71
|
+
return value.length;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isNormalizedWhitespace(code: number): boolean {
|
|
75
|
+
return (
|
|
76
|
+
code === 0x09 ||
|
|
77
|
+
code === 0x0a ||
|
|
78
|
+
code === 0x0b ||
|
|
79
|
+
code === 0x0c ||
|
|
80
|
+
code === 0x0d ||
|
|
81
|
+
code === 0x85 ||
|
|
82
|
+
code === 0x2028 ||
|
|
83
|
+
code === 0x2029
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function sanitizeEditorMetadataText(value: string): string {
|
|
88
|
+
let sanitized = "";
|
|
89
|
+
for (let index = 0; index < value.length; ) {
|
|
90
|
+
const code = value.charCodeAt(index);
|
|
91
|
+
if (code === ESC) {
|
|
92
|
+
index = consumeEscape(value, index);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (code === C1_CSI) {
|
|
96
|
+
index = consumeCsi(value, index + 1);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (code === C1_OSC) {
|
|
100
|
+
index = consumeControlString(value, index + 1, true);
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (code === C1_DCS || code === C1_SOS || code === C1_PM || code === C1_APC) {
|
|
104
|
+
index = consumeControlString(value, index + 1, false);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (isNormalizedWhitespace(code)) {
|
|
108
|
+
sanitized += " ";
|
|
109
|
+
do index += 1;
|
|
110
|
+
while (index < value.length && isNormalizedWhitespace(value.charCodeAt(index)));
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) {
|
|
114
|
+
index += 1;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
sanitized += value[index];
|
|
118
|
+
index += 1;
|
|
119
|
+
}
|
|
120
|
+
return sanitized;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function editorThinkingStyle(config: PolishedTuiConfig, level: string): string | undefined {
|
|
124
|
+
switch (level.toLowerCase()) {
|
|
125
|
+
case "minimal":
|
|
126
|
+
return config.colors.editorThinkingMinimal ?? config.colors.editorThinking;
|
|
127
|
+
case "low":
|
|
128
|
+
return config.colors.editorThinkingLow ?? config.colors.editorThinking;
|
|
129
|
+
case "medium":
|
|
130
|
+
return config.colors.editorThinkingMedium ?? config.colors.editorThinking;
|
|
131
|
+
case "high":
|
|
132
|
+
return config.colors.editorThinkingHigh ?? config.colors.editorThinking;
|
|
133
|
+
case "xhigh":
|
|
134
|
+
return config.colors.editorThinkingXhigh ?? config.colors.editorThinking;
|
|
135
|
+
default:
|
|
136
|
+
return config.colors.editorThinking;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function renderVariable(
|
|
141
|
+
name: string,
|
|
142
|
+
values: EditorMetadataValues,
|
|
143
|
+
uiTheme: Theme,
|
|
144
|
+
config: PolishedTuiConfig,
|
|
145
|
+
): { plain: string; styled: string } {
|
|
146
|
+
const colorSource = config.colorSources.editor;
|
|
147
|
+
const thinking = values.thinking.toLowerCase() === "off" ? "" : values.thinking;
|
|
148
|
+
const raw =
|
|
149
|
+
name === "model"
|
|
150
|
+
? values.model
|
|
151
|
+
: name === "model_id"
|
|
152
|
+
? values.modelId
|
|
153
|
+
: name === "model_name"
|
|
154
|
+
? values.modelName
|
|
155
|
+
: name === "provider"
|
|
156
|
+
? values.provider
|
|
157
|
+
: name === "thinking"
|
|
158
|
+
? thinking
|
|
159
|
+
: name === "session_name"
|
|
160
|
+
? values.sessionName
|
|
161
|
+
: "";
|
|
162
|
+
const plain = sanitizeEditorMetadataText(raw);
|
|
163
|
+
if (!plain) return { plain: "", styled: "" };
|
|
164
|
+
|
|
165
|
+
if (name === "model" || name === "model_id" || name === "model_name") {
|
|
166
|
+
return {
|
|
167
|
+
plain,
|
|
168
|
+
styled: renderStyleForSourceOrFallback(
|
|
169
|
+
uiTheme,
|
|
170
|
+
colorSource,
|
|
171
|
+
config.colors.editorModel,
|
|
172
|
+
EDITOR_ACCENT_FALLBACK,
|
|
173
|
+
plain,
|
|
174
|
+
),
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
if (name === "provider") {
|
|
178
|
+
return {
|
|
179
|
+
plain,
|
|
180
|
+
styled: renderStyleForSourceOrFallback(
|
|
181
|
+
uiTheme,
|
|
182
|
+
colorSource,
|
|
183
|
+
config.colors.editorProvider,
|
|
184
|
+
"text",
|
|
185
|
+
plain,
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (name === "thinking") {
|
|
190
|
+
return {
|
|
191
|
+
plain,
|
|
192
|
+
styled: renderStyleForSourceOrFallback(
|
|
193
|
+
uiTheme,
|
|
194
|
+
colorSource,
|
|
195
|
+
editorThinkingStyle(config, plain),
|
|
196
|
+
"muted",
|
|
197
|
+
plain,
|
|
198
|
+
),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (name === "session_name") {
|
|
202
|
+
return { plain, styled: safeThemeFg(uiTheme, "border", plain) };
|
|
203
|
+
}
|
|
204
|
+
return { plain: "", styled: "" };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function renderTokens(
|
|
208
|
+
tokens: FormatToken[],
|
|
209
|
+
values: EditorMetadataValues,
|
|
210
|
+
uiTheme: Theme,
|
|
211
|
+
config: PolishedTuiConfig,
|
|
212
|
+
): RenderedTokens {
|
|
213
|
+
let styled = "";
|
|
214
|
+
let hasDynamic = false;
|
|
215
|
+
let hasNonEmptyDynamic = false;
|
|
216
|
+
|
|
217
|
+
for (const token of tokens) {
|
|
218
|
+
if (token.kind === "text") {
|
|
219
|
+
const plain = sanitizeEditorMetadataText(token.value);
|
|
220
|
+
if (plain) styled += safeThemeFg(uiTheme, "border", plain);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (token.kind === "fill") {
|
|
224
|
+
hasDynamic = true;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (token.kind === "var") {
|
|
228
|
+
hasDynamic = true;
|
|
229
|
+
const rendered = renderVariable(token.name, values, uiTheme, config);
|
|
230
|
+
styled += rendered.styled;
|
|
231
|
+
if (rendered.plain) hasNonEmptyDynamic = true;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const rendered = renderTokens(token.tokens, values, uiTheme, config);
|
|
236
|
+
const visible = !rendered.hasDynamic || rendered.hasNonEmptyDynamic;
|
|
237
|
+
hasDynamic = true;
|
|
238
|
+
if (visible) {
|
|
239
|
+
styled += rendered.styled;
|
|
240
|
+
hasNonEmptyDynamic = true;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return { styled, hasDynamic, hasNonEmptyDynamic };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function renderEditorMetadataFormat(
|
|
248
|
+
format: string,
|
|
249
|
+
values: EditorMetadataValues,
|
|
250
|
+
uiTheme: Theme,
|
|
251
|
+
config: PolishedTuiConfig,
|
|
252
|
+
): string {
|
|
253
|
+
return renderTokens(
|
|
254
|
+
parseFooterFormat(sanitizeEditorMetadataText(format)),
|
|
255
|
+
values,
|
|
256
|
+
uiTheme,
|
|
257
|
+
config,
|
|
258
|
+
).styled;
|
|
259
|
+
}
|
|
@@ -2,7 +2,11 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { PolishedTuiConfig, SeparatorStyle } from "./config";
|
|
4
4
|
import { FOOTER_FORMAT_ALIASES } from "./config";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
collectExtensionStatusSegments,
|
|
7
|
+
type ExtensionStatusSegment,
|
|
8
|
+
sanitizeExtensionStatusText,
|
|
9
|
+
} from "./extension-status";
|
|
6
10
|
import { parseFooterFormat, renderFormatSplit, stripOrphanSeparators } from "./footer-format";
|
|
7
11
|
import {
|
|
8
12
|
buildContextDisplayLabel,
|
|
@@ -195,6 +199,16 @@ export function installFooter(
|
|
|
195
199
|
depth: config.pathDisplay.depth,
|
|
196
200
|
}),
|
|
197
201
|
);
|
|
202
|
+
const needsSessionName = config.footerFormat
|
|
203
|
+
? /(?:\$session_name\b|\$\{session_name\})/.test(config.footerFormat)
|
|
204
|
+
: config.footerSegments.sessionName;
|
|
205
|
+
const sessionName = needsSessionName
|
|
206
|
+
? sanitizeExtensionStatusText(ctx.sessionManager.getSessionName() ?? "")
|
|
207
|
+
: "";
|
|
208
|
+
const sessionNameLabel = sessionName
|
|
209
|
+
? renderStyleForSource(theme, colorSource, config.colors.sessionName, sessionName)
|
|
210
|
+
: "";
|
|
211
|
+
const builtInSessionNameLabel = sessionNameLabel ? `in ${sessionNameLabel}` : "";
|
|
198
212
|
const branch = state.branch;
|
|
199
213
|
const branchText = branch
|
|
200
214
|
? formatGitBranchText(branch, config.gitBranch.maxLength)
|
|
@@ -263,6 +277,8 @@ export function installFooter(
|
|
|
263
277
|
switch (canonical) {
|
|
264
278
|
case "cwd":
|
|
265
279
|
return cwdLabel;
|
|
280
|
+
case "session_name":
|
|
281
|
+
return sessionNameLabel;
|
|
266
282
|
case "git_branch":
|
|
267
283
|
return branchText
|
|
268
284
|
? gitIcon
|
|
@@ -491,6 +507,7 @@ export function installFooter(
|
|
|
491
507
|
osSegment,
|
|
492
508
|
usernameSegment,
|
|
493
509
|
config.footerSegments.cwd ? cwdLabel : "",
|
|
510
|
+
config.footerSegments.sessionName ? builtInSessionNameLabel : "",
|
|
494
511
|
branchLabel,
|
|
495
512
|
gitCommitLabel,
|
|
496
513
|
gitMetricsLabel,
|
|
@@ -120,7 +120,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
120
120
|
const getThinkingLevel = () =>
|
|
121
121
|
sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : ("off" as const);
|
|
122
122
|
const syncFooterState = (ctx: ExtensionContext) =>
|
|
123
|
-
syncState(state, ctx, currentConfig.icons.cacheHit);
|
|
123
|
+
syncState(state, ctx, currentConfig.icons.cacheHit, currentConfig.editorModelLabel);
|
|
124
124
|
|
|
125
125
|
type ProjectRefreshTarget = { cwd: string; generation: number };
|
|
126
126
|
const refreshProjectState = async ({ cwd, generation }: ProjectRefreshTarget) => {
|
|
@@ -244,7 +244,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
244
244
|
getCurrentConfig,
|
|
245
245
|
() => ({
|
|
246
246
|
modelLabel: state.modelLabel,
|
|
247
|
+
modelId: state.modelId,
|
|
248
|
+
modelName: state.modelName,
|
|
247
249
|
providerLabel: state.providerLabel,
|
|
250
|
+
sessionName: ctx.sessionManager.getSessionName() ?? "",
|
|
248
251
|
}),
|
|
249
252
|
getThinkingLevel,
|
|
250
253
|
)) as ZentuiEditorFactory;
|
|
@@ -264,7 +267,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
264
267
|
getCurrentConfig,
|
|
265
268
|
() => ({
|
|
266
269
|
modelLabel: state.modelLabel,
|
|
270
|
+
modelId: state.modelId,
|
|
271
|
+
modelName: state.modelName,
|
|
267
272
|
providerLabel: state.providerLabel,
|
|
273
|
+
sessionName: ctx.sessionManager.getSessionName() ?? "",
|
|
268
274
|
}),
|
|
269
275
|
getThinkingLevel,
|
|
270
276
|
)) as ZentuiEditorFactory;
|
|
@@ -536,6 +542,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
536
542
|
syncInteractiveState(event, ctx);
|
|
537
543
|
});
|
|
538
544
|
pi.on("thinking_level_select", syncInteractiveState);
|
|
545
|
+
pi.on("session_info_changed", syncInteractiveState);
|
|
539
546
|
pi.on("message_update", (event) => {
|
|
540
547
|
liveContext.update(event.message);
|
|
541
548
|
});
|
|
@@ -124,6 +124,7 @@ const featureSettingDescriptions: Record<FeatureSettingId, string> = {
|
|
|
124
124
|
|
|
125
125
|
const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
|
|
126
126
|
cwd: "Current directory",
|
|
127
|
+
sessionName: "Session name",
|
|
127
128
|
gitBranch: "Git branch",
|
|
128
129
|
gitStatus: "Git status",
|
|
129
130
|
gitCounts: "Git counts",
|
|
@@ -142,6 +143,7 @@ const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
|
|
|
142
143
|
|
|
143
144
|
const footerSegmentSettingDescriptions: Record<FooterSegmentSettingId, string> = {
|
|
144
145
|
cwd: "Show or hide the current working directory segment on the left.",
|
|
146
|
+
sessionName: "Show or hide the current Pi session name on the left, after the current directory.",
|
|
145
147
|
gitBranch: "Show or hide the git branch name on the left.",
|
|
146
148
|
gitStatus: "Show or hide git status icons and ahead/behind markers.",
|
|
147
149
|
gitCounts:
|
|
@@ -207,6 +209,7 @@ function isFeatureSettingId(value: string): value is FeatureSettingId {
|
|
|
207
209
|
function isFooterSegmentSettingId(value: string): value is FooterSegmentSettingId {
|
|
208
210
|
return (
|
|
209
211
|
value === "cwd" ||
|
|
212
|
+
value === "sessionName" ||
|
|
210
213
|
value === "gitBranch" ||
|
|
211
214
|
value === "gitStatus" ||
|
|
212
215
|
value === "gitCounts" ||
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ModelLabelSource } from "./config";
|
|
2
3
|
import {
|
|
3
4
|
buildContextLabel,
|
|
4
5
|
buildCostLabel,
|
|
@@ -12,6 +13,8 @@ import type { RuntimeInfo } from "./runtime";
|
|
|
12
13
|
|
|
13
14
|
export type FooterState = GitStatusSummary & {
|
|
14
15
|
modelLabel: string;
|
|
16
|
+
modelId: string;
|
|
17
|
+
modelName: string;
|
|
15
18
|
providerLabel: string;
|
|
16
19
|
contextLabel: string;
|
|
17
20
|
tokenLabel: string;
|
|
@@ -24,6 +27,8 @@ export type FooterState = GitStatusSummary & {
|
|
|
24
27
|
export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
|
|
25
28
|
return {
|
|
26
29
|
modelLabel: "no-model",
|
|
30
|
+
modelId: "",
|
|
31
|
+
modelName: "",
|
|
27
32
|
providerLabel: "Unknown",
|
|
28
33
|
contextLabel: "--",
|
|
29
34
|
tokenLabel: "↑0 ↓0",
|
|
@@ -35,9 +40,17 @@ export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
|
|
|
35
40
|
};
|
|
36
41
|
}
|
|
37
42
|
|
|
38
|
-
export function syncState(
|
|
43
|
+
export function syncState(
|
|
44
|
+
state: FooterState,
|
|
45
|
+
ctx: ExtensionContext,
|
|
46
|
+
cacheHitIcon: string,
|
|
47
|
+
modelLabelSource: ModelLabelSource,
|
|
48
|
+
): void {
|
|
39
49
|
const totals = getUsageTotals(ctx);
|
|
40
|
-
|
|
50
|
+
const m = ctx.model;
|
|
51
|
+
state.modelId = m?.id ?? "";
|
|
52
|
+
state.modelName = m?.name ?? "";
|
|
53
|
+
state.modelLabel = (modelLabelSource === "name" ? m?.name || m?.id : m?.id) ?? "no-model";
|
|
41
54
|
state.providerLabel = formatProviderLabel(ctx.model?.provider);
|
|
42
55
|
state.contextLabel = buildContextLabel(ctx);
|
|
43
56
|
state.tokenLabel = buildTokenLabel(totals, cacheHitIcon);
|
package/extensions/zentui/ui.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
visibleWidth,
|
|
10
10
|
} from "@earendil-works/pi-tui";
|
|
11
11
|
import type { PolishedTuiConfig } from "./config";
|
|
12
|
+
import { renderEditorMetadataFormat } from "./editor-metadata-format";
|
|
12
13
|
import {
|
|
13
14
|
EDITOR_ACCENT_FALLBACK,
|
|
14
15
|
EDITOR_BORDER_FALLBACK,
|
|
@@ -16,6 +17,13 @@ import {
|
|
|
16
17
|
safeThemeFg,
|
|
17
18
|
} from "./style";
|
|
18
19
|
|
|
20
|
+
const SPLIT_POLISHED_FRAME: unique symbol = Symbol.for("pi-zentui.polished-frame");
|
|
21
|
+
|
|
22
|
+
type PolishedFrameSplit = {
|
|
23
|
+
editorLines: string[];
|
|
24
|
+
trailingLines: string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
19
27
|
type AutocompleteEditorInternals = {
|
|
20
28
|
autocompleteList?: Pick<Component, "render">;
|
|
21
29
|
isShowingAutocomplete?: () => boolean;
|
|
@@ -42,11 +50,15 @@ type WrappedEditor = EditorComponent &
|
|
|
42
50
|
setAutocompleteProvider?: (provider: AutocompleteProvider) => void;
|
|
43
51
|
setPaddingX?: (padding: number) => void;
|
|
44
52
|
setAutocompleteMaxVisible?: (maxVisible: number) => void;
|
|
53
|
+
[SPLIT_POLISHED_FRAME]?: (lines: string[]) => PolishedFrameSplit | undefined;
|
|
45
54
|
};
|
|
46
55
|
|
|
47
56
|
type EditorMeta = {
|
|
48
57
|
modelLabel: string;
|
|
58
|
+
modelId?: string;
|
|
59
|
+
modelName?: string;
|
|
49
60
|
providerLabel: string;
|
|
61
|
+
sessionName?: string;
|
|
50
62
|
};
|
|
51
63
|
|
|
52
64
|
type PolishedFrameOptions = {
|
|
@@ -56,9 +68,9 @@ type PolishedFrameOptions = {
|
|
|
56
68
|
uiTheme: Theme;
|
|
57
69
|
config: PolishedTuiConfig;
|
|
58
70
|
modelMeta: EditorMeta;
|
|
59
|
-
previousModelMeta?: EditorMeta;
|
|
60
71
|
thinkingLevel: string | undefined;
|
|
61
72
|
rightStatus?: string;
|
|
73
|
+
splitBaseFrame?: (lines: string[]) => PolishedFrameSplit | undefined;
|
|
62
74
|
};
|
|
63
75
|
|
|
64
76
|
function clampRenderedLines(lines: string[], width: number): string[] {
|
|
@@ -72,23 +84,6 @@ function fillLine(content: string, width: number): string {
|
|
|
72
84
|
return `${truncated}${pad}`;
|
|
73
85
|
}
|
|
74
86
|
|
|
75
|
-
function editorThinkingStyle(config: PolishedTuiConfig, level: string): string | undefined {
|
|
76
|
-
switch (level.toLowerCase()) {
|
|
77
|
-
case "minimal":
|
|
78
|
-
return config.colors.editorThinkingMinimal ?? config.colors.editorThinking;
|
|
79
|
-
case "low":
|
|
80
|
-
return config.colors.editorThinkingLow ?? config.colors.editorThinking;
|
|
81
|
-
case "medium":
|
|
82
|
-
return config.colors.editorThinkingMedium ?? config.colors.editorThinking;
|
|
83
|
-
case "high":
|
|
84
|
-
return config.colors.editorThinkingHigh ?? config.colors.editorThinking;
|
|
85
|
-
case "xhigh":
|
|
86
|
-
return config.colors.editorThinkingXhigh ?? config.colors.editorThinking;
|
|
87
|
-
default:
|
|
88
|
-
return config.colors.editorThinking;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
87
|
function copyFriendlyPrompt(config: PolishedTuiConfig, uiTheme: Theme, reset: string): string {
|
|
93
88
|
const promptIcon = config.icons.editorPrompt;
|
|
94
89
|
return promptIcon
|
|
@@ -137,7 +132,7 @@ function plainRenderedText(line: string): string {
|
|
|
137
132
|
return line
|
|
138
133
|
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
139
134
|
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
140
|
-
.replace(/\[[
|
|
135
|
+
.replace(/\[\/?[^\]]+\]/g, "");
|
|
141
136
|
}
|
|
142
137
|
|
|
143
138
|
function isHorizontalBorder(line: string): boolean {
|
|
@@ -145,70 +140,67 @@ function isHorizontalBorder(line: string): boolean {
|
|
|
145
140
|
return plain.length > 0 && /^─+$/.test(plain);
|
|
146
141
|
}
|
|
147
142
|
|
|
148
|
-
function
|
|
149
|
-
const plain = plainRenderedText(line);
|
|
150
|
-
return plain.includes(modelMeta.modelLabel) && plain.includes(modelMeta.providerLabel);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function matchesAnyModelMeta(
|
|
154
|
-
line: string,
|
|
155
|
-
modelMeta: EditorMeta,
|
|
156
|
-
previousMeta?: EditorMeta,
|
|
157
|
-
): boolean {
|
|
158
|
-
if (isRenderedModelMetaLine(line, modelMeta)) return true;
|
|
159
|
-
if (previousMeta && isRenderedModelMetaLine(line, previousMeta)) return true;
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function hasRenderedModelMetaLine(
|
|
164
|
-
lines: string[],
|
|
165
|
-
modelMeta: EditorMeta,
|
|
166
|
-
previousMeta?: EditorMeta,
|
|
167
|
-
): boolean {
|
|
168
|
-
return lines.some((line) => matchesAnyModelMeta(line, modelMeta, previousMeta));
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function isAlreadyPolishedFrame(
|
|
143
|
+
function unwrapPolishedFrameOnly(
|
|
172
144
|
lines: string[],
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
):
|
|
176
|
-
|
|
177
|
-
lines.length
|
|
178
|
-
isHorizontalBorder(lines[0] ?? "")
|
|
179
|
-
isHorizontalBorder(lines.at(-1) ?? "")
|
|
180
|
-
|
|
181
|
-
|
|
145
|
+
config: PolishedTuiConfig,
|
|
146
|
+
uiTheme: Theme,
|
|
147
|
+
): string[] | undefined {
|
|
148
|
+
if (
|
|
149
|
+
lines.length < 5 ||
|
|
150
|
+
!isHorizontalBorder(lines[0] ?? "") ||
|
|
151
|
+
!isHorizontalBorder(lines.at(-1) ?? "")
|
|
152
|
+
)
|
|
153
|
+
return undefined;
|
|
154
|
+
|
|
155
|
+
const interior = lines.slice(1, -1);
|
|
156
|
+
if (interior.length < 3) return undefined;
|
|
157
|
+
|
|
158
|
+
if (config.features.copyFriendly) {
|
|
159
|
+
if (
|
|
160
|
+
plainRenderedText(interior[0] ?? "").trim() !== "" ||
|
|
161
|
+
plainRenderedText(interior.at(-2) ?? "").trim() !== "" ||
|
|
162
|
+
!(interior.at(-1) ?? "").startsWith(" ")
|
|
163
|
+
)
|
|
164
|
+
return undefined;
|
|
165
|
+
|
|
166
|
+
const { prompt, promptWidth } = getEditorChromeWidths(config, uiTheme, "\x1b[0m");
|
|
167
|
+
const continuation = " ".repeat(promptWidth);
|
|
168
|
+
const content = interior.slice(1, -2);
|
|
169
|
+
const unwrapped: string[] = [];
|
|
170
|
+
for (let index = 0; index < content.length; index++) {
|
|
171
|
+
const prefix = index === 0 ? prompt : continuation;
|
|
172
|
+
const line = content[index] ?? "";
|
|
173
|
+
if (prefix && !line.startsWith(prefix)) return undefined;
|
|
174
|
+
unwrapped.push(prefix ? line.slice(prefix.length) : line);
|
|
175
|
+
}
|
|
176
|
+
return unwrapped;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { rail } = getEditorChromeWidths(config, uiTheme, "\x1b[0m");
|
|
180
|
+
if (!rail || interior.some((line) => !line.startsWith(rail))) return undefined;
|
|
181
|
+
const unrailed = interior.map((line) => line.slice(rail.length));
|
|
182
|
+
if (
|
|
183
|
+
plainRenderedText(unrailed[0] ?? "").trim() !== "" ||
|
|
184
|
+
plainRenderedText(unrailed.at(-2) ?? "").trim() !== ""
|
|
185
|
+
)
|
|
186
|
+
return undefined;
|
|
187
|
+
return unrailed.slice(1, -2);
|
|
182
188
|
}
|
|
183
189
|
|
|
184
|
-
function
|
|
190
|
+
function splitPolishedFrame(
|
|
185
191
|
lines: string[],
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
):
|
|
189
|
-
|
|
190
|
-
for (let
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
index < lines.length - 1 &&
|
|
199
|
-
matchesAnyModelMeta(lines[index + 1] ?? "", modelMeta, previousMeta);
|
|
200
|
-
if (!plain && (previousWasMeta || nextIsMeta)) continue;
|
|
201
|
-
|
|
202
|
-
result.push(line);
|
|
203
|
-
}
|
|
204
|
-
return result;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
function removeStalePolishedLeadingSpacer(lines: string[], shouldRemove: boolean): string[] {
|
|
208
|
-
if (!shouldRemove || lines.length === 0) return lines;
|
|
209
|
-
const firstLine = lines[0] ?? "";
|
|
210
|
-
if (plainRenderedText(firstLine).trim()) return lines;
|
|
211
|
-
return lines.slice(1);
|
|
192
|
+
config: PolishedTuiConfig,
|
|
193
|
+
uiTheme: Theme,
|
|
194
|
+
): PolishedFrameSplit | undefined {
|
|
195
|
+
if (!isHorizontalBorder(lines[0] ?? "")) return undefined;
|
|
196
|
+
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) };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return undefined;
|
|
212
204
|
}
|
|
213
205
|
|
|
214
206
|
function vimModeColor(mode: string): string {
|
|
@@ -244,9 +236,9 @@ function renderPolishedFrame({
|
|
|
244
236
|
uiTheme,
|
|
245
237
|
config,
|
|
246
238
|
modelMeta,
|
|
247
|
-
previousModelMeta,
|
|
248
239
|
thinkingLevel,
|
|
249
240
|
rightStatus,
|
|
241
|
+
splitBaseFrame,
|
|
250
242
|
}: PolishedFrameOptions): string[] {
|
|
251
243
|
if (width <= 2) return clampRenderedLines(baseRendered, width);
|
|
252
244
|
|
|
@@ -262,56 +254,37 @@ function renderPolishedFrame({
|
|
|
262
254
|
|
|
263
255
|
if (baseRendered.length < 2) return clampRenderedLines(baseRendered, width);
|
|
264
256
|
|
|
257
|
+
const ownedFrame = splitBaseFrame?.(baseRendered);
|
|
265
258
|
const { autocompleteList } = autocompleteSource;
|
|
266
259
|
const autocompleteCount =
|
|
267
|
-
isShowingAutocomplete && typeof autocompleteList?.render === "function"
|
|
260
|
+
!ownedFrame && isShowingAutocomplete && typeof autocompleteList?.render === "function"
|
|
268
261
|
? autocompleteList.render(innerWidth).length
|
|
269
262
|
: 0;
|
|
270
263
|
const editorFrame =
|
|
271
|
-
autocompleteCount > 0 && autocompleteCount < baseRendered.length
|
|
264
|
+
!ownedFrame && autocompleteCount > 0 && autocompleteCount < baseRendered.length
|
|
272
265
|
? baseRendered.slice(0, -autocompleteCount)
|
|
273
266
|
: baseRendered;
|
|
274
|
-
const autocompleteLines =
|
|
275
|
-
|
|
267
|
+
const autocompleteLines = ownedFrame
|
|
268
|
+
? ownedFrame.trailingLines
|
|
269
|
+
: autocompleteCount > 0 && autocompleteCount < baseRendered.length
|
|
276
270
|
? baseRendered.slice(-autocompleteCount)
|
|
277
271
|
: [];
|
|
278
272
|
if (editorFrame.length < 2) return clampRenderedLines(baseRendered, width);
|
|
279
273
|
|
|
280
|
-
const
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
274
|
+
const editorLines = ownedFrame?.editorLines ?? editorFrame.slice(1, -1);
|
|
275
|
+
const meta = renderEditorMetadataFormat(
|
|
276
|
+
config.editorMetadataFormat,
|
|
277
|
+
{
|
|
278
|
+
model: modelMeta.modelLabel,
|
|
279
|
+
modelId: modelMeta.modelId ?? "",
|
|
280
|
+
modelName: modelMeta.modelName ?? "",
|
|
281
|
+
provider: modelMeta.providerLabel,
|
|
282
|
+
thinking: thinkingLevel ?? "",
|
|
283
|
+
sessionName: modelMeta.sessionName ?? "",
|
|
284
|
+
},
|
|
286
285
|
uiTheme,
|
|
287
|
-
|
|
288
|
-
config.colors.editorModel,
|
|
289
|
-
EDITOR_ACCENT_FALLBACK,
|
|
290
|
-
modelMeta.modelLabel,
|
|
291
|
-
);
|
|
292
|
-
const provider = renderStyleForSourceOrFallback(
|
|
293
|
-
uiTheme,
|
|
294
|
-
colorSource,
|
|
295
|
-
config.colors.editorProvider,
|
|
296
|
-
"text",
|
|
297
|
-
modelMeta.providerLabel,
|
|
286
|
+
config,
|
|
298
287
|
);
|
|
299
|
-
const renderedModelMeta = [model, provider]
|
|
300
|
-
.filter(Boolean)
|
|
301
|
-
.join(safeThemeFg(uiTheme, "borderMuted", " "));
|
|
302
|
-
const metaParts = [renderedModelMeta];
|
|
303
|
-
if (thinkingLevel && thinkingLevel !== "off") {
|
|
304
|
-
metaParts.push(
|
|
305
|
-
renderStyleForSourceOrFallback(
|
|
306
|
-
uiTheme,
|
|
307
|
-
colorSource,
|
|
308
|
-
editorThinkingStyle(config, thinkingLevel),
|
|
309
|
-
"muted",
|
|
310
|
-
thinkingLevel,
|
|
311
|
-
),
|
|
312
|
-
);
|
|
313
|
-
}
|
|
314
|
-
const meta = metaParts.filter(Boolean).join(safeThemeFg(uiTheme, "border", " "));
|
|
315
288
|
const copyFriendlyMeta = composeMetadataLine(meta, rightStatus, Math.max(0, width - 1));
|
|
316
289
|
const railedMeta = composeMetadataLine(meta, rightStatus, innerWidth);
|
|
317
290
|
|
|
@@ -358,7 +331,6 @@ export class PolishedEditor extends CustomEditor {
|
|
|
358
331
|
private readonly getThinkingLevel: () => string | undefined;
|
|
359
332
|
private readonly getConfig: () => PolishedTuiConfig;
|
|
360
333
|
private readonly uiTheme: Theme;
|
|
361
|
-
private previousModelMeta?: EditorMeta;
|
|
362
334
|
|
|
363
335
|
constructor(
|
|
364
336
|
tui: TUI,
|
|
@@ -394,17 +366,17 @@ export class PolishedEditor extends CustomEditor {
|
|
|
394
366
|
uiTheme: this.uiTheme,
|
|
395
367
|
config,
|
|
396
368
|
modelMeta,
|
|
397
|
-
previousModelMeta: this.previousModelMeta,
|
|
398
369
|
thinkingLevel: this.getThinkingLevel(),
|
|
399
370
|
});
|
|
400
|
-
this.previousModelMeta = modelMeta;
|
|
401
371
|
return result;
|
|
402
372
|
}
|
|
373
|
+
|
|
374
|
+
[SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
|
|
375
|
+
return splitPolishedFrame(lines, this.getConfig(), this.uiTheme);
|
|
376
|
+
}
|
|
403
377
|
}
|
|
404
378
|
|
|
405
379
|
export class WrappedPolishedEditor implements EditorComponent {
|
|
406
|
-
private previousModelMeta?: EditorMeta;
|
|
407
|
-
|
|
408
380
|
constructor(
|
|
409
381
|
private readonly base: WrappedEditor,
|
|
410
382
|
private readonly uiTheme: Theme,
|
|
@@ -499,19 +471,21 @@ export class WrappedPolishedEditor implements EditorComponent {
|
|
|
499
471
|
const rendered = this.base.render(innerWidth);
|
|
500
472
|
const vimStatus = readVimStatus(this.base, this.uiTheme);
|
|
501
473
|
const modelMeta = this.getModelMeta();
|
|
502
|
-
|
|
474
|
+
return renderPolishedFrame({
|
|
503
475
|
width,
|
|
504
476
|
baseRendered: rendered,
|
|
505
477
|
autocompleteSource: this.base,
|
|
506
478
|
uiTheme: this.uiTheme,
|
|
507
479
|
config,
|
|
508
480
|
modelMeta,
|
|
509
|
-
previousModelMeta: this.previousModelMeta,
|
|
510
481
|
thinkingLevel: this.getThinkingLevel(),
|
|
511
482
|
rightStatus: vimStatus,
|
|
483
|
+
splitBaseFrame: this.base[SPLIT_POLISHED_FRAME]?.bind(this.base),
|
|
512
484
|
});
|
|
513
|
-
|
|
514
|
-
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
[SPLIT_POLISHED_FRAME](lines: string[]): PolishedFrameSplit | undefined {
|
|
488
|
+
return splitPolishedFrame(lines, this.getConfig(), this.uiTheme);
|
|
515
489
|
}
|
|
516
490
|
|
|
517
491
|
invalidate(): void {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-zentui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,9 +35,9 @@
|
|
|
35
35
|
"prepublishOnly": "npm run verify && npm run pack:check"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@earendil-works/pi-ai": ">=0.80",
|
|
39
|
-
"@earendil-works/pi-coding-agent": ">=0.80",
|
|
40
|
-
"@earendil-works/pi-tui": ">=0.80"
|
|
38
|
+
"@earendil-works/pi-ai": ">=0.80.3",
|
|
39
|
+
"@earendil-works/pi-coding-agent": ">=0.80.3",
|
|
40
|
+
"@earendil-works/pi-tui": ">=0.80.3"
|
|
41
41
|
},
|
|
42
42
|
"pi": {
|
|
43
43
|
"extensions": [
|