pi-zentui 0.15.0 → 0.16.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 +22 -10
- package/extensions/zentui/config.ts +28 -0
- package/extensions/zentui/editor-transfer.ts +73 -0
- package/extensions/zentui/footer.ts +65 -9
- package/extensions/zentui/format.ts +116 -37
- package/extensions/zentui/index.ts +184 -68
- package/extensions/zentui/prototype-patch-registry.ts +8 -1
- package/extensions/zentui/selector-border.ts +11 -5
- package/extensions/zentui/settings-command.ts +40 -7
- package/extensions/zentui/state.ts +16 -0
- package/extensions/zentui/telemetry.ts +82 -0
- package/extensions/zentui/ui.ts +191 -98
- package/extensions/zentui/user-message.ts +24 -18
- 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):
|
|
24
|
+
- Optional segments (off by default): selected model/provider, `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)
|
|
@@ -133,9 +133,9 @@ User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or
|
|
|
133
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
134
|
|
|
135
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`).
|
|
136
|
+
2. **Editor** — editor enabled (`features.editor`); editor border color mode (`editorBorderColorMode`); 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
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`.
|
|
138
|
+
4. **Segments** — visibility toggles for every non-Git built-in segment under `footerSegments`: `cwd`, `sessionName`, `runtime`, `modelInfo`, `context`, `tokens`, `cost`, `sessionDuration`, `username`, `time`, `os`, and `packageVersion`.
|
|
139
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
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
141
|
|
|
@@ -176,6 +176,7 @@ Default config values — copy this and change any value you want:
|
|
|
176
176
|
"separator": "pipe",
|
|
177
177
|
"contextStyle": "text",
|
|
178
178
|
"editorModelLabel": "id",
|
|
179
|
+
"editorBorderColorMode": "static",
|
|
179
180
|
"contextThresholds": {
|
|
180
181
|
"warning": 70,
|
|
181
182
|
"error": 90
|
|
@@ -260,6 +261,7 @@ Default config values — copy this and change any value you want:
|
|
|
260
261
|
"gitStatus": true,
|
|
261
262
|
"gitCounts": false,
|
|
262
263
|
"runtime": true,
|
|
264
|
+
"modelInfo": false,
|
|
263
265
|
"context": true,
|
|
264
266
|
"tokens": true,
|
|
265
267
|
"cost": true,
|
|
@@ -295,8 +297,9 @@ Default config values — copy this and change any value you want:
|
|
|
295
297
|
|
|
296
298
|
- 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`.
|
|
297
299
|
- `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`.
|
|
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.
|
|
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.
|
|
300
|
+
- `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment. Finite context percentages use one decimal place. Context usage refreshes during assistant streaming; token and cost totals remain canonical and finalize at turn boundaries.
|
|
301
|
+
- `editorModelLabel`: controls the model shown in the editor frame, built-in model-info footer segment, and footer `$model` variable. `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.
|
|
302
|
+
- `editorBorderColorMode`: `static` (default) uses `colors.editorBorder`; `adaptive` follows Pi's current shell-mode and thinking-level editor border color. Cycle it from the `/zentui` **Editor** tab.
|
|
300
303
|
- `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.
|
|
301
304
|
- `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.
|
|
302
305
|
- `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
|
|
@@ -305,7 +308,7 @@ Default config values — copy this and change any value you want:
|
|
|
305
308
|
- `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.
|
|
306
309
|
- `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
|
|
307
310
|
- `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`.
|
|
311
|
+
- `footerSegments`: show or hide individual built-in footer segments (`cwd`, `sessionName`, `gitBranch`, `gitStatus`, `gitCounts`, `gitCommit`, `gitMetrics`, `runtime`, `modelInfo`, `packageVersion`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). `modelInfo` is off by default and shows the selected model plus the provider unless the model label already contains it. Toggle non-Git segments from **Segments** and Git segments from **Git** in `/zentui`.
|
|
309
312
|
- `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`.
|
|
310
313
|
- `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.
|
|
311
314
|
- `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.
|
|
@@ -316,7 +319,7 @@ Default config values — copy this and change any value you want:
|
|
|
316
319
|
- 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`.
|
|
317
320
|
- `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
|
|
318
321
|
- `editorPrompt` styles the copy-friendly editor prompt glyph. Omit it to use `editorAccent`, then the default accent fallback.
|
|
319
|
-
- `editorBorder` styles
|
|
322
|
+
- `editorBorder` styles previous user-message top/bottom borders and the active editor in static border color mode; the border glyph stays `─`.
|
|
320
323
|
- `editorModel`, `editorProvider`, and `editorThinking*` style the editor metadata. `editorThinking` applies to every non-`off` thinking level unless a level-specific key is set.
|
|
321
324
|
|
|
322
325
|
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.
|
|
@@ -381,16 +384,22 @@ Center the branch between directory and cost:
|
|
|
381
384
|
| `$git_added` | | added line count (`+N`) |
|
|
382
385
|
| `$git_deleted` | | deleted line count (`−N`) |
|
|
383
386
|
| `$runtime` | | runtime icon + version |
|
|
387
|
+
| `$model` | | selected model label (`editorModelLabel`) |
|
|
388
|
+
| `$provider` | | formatted provider label |
|
|
384
389
|
| `$package` | | project package version, `is <glyph> <version>` (manifest-derived) |
|
|
385
390
|
| `$package_version` | | raw project package version (no icon) |
|
|
386
391
|
| `$session_duration` | `$duration` | session running time |
|
|
387
392
|
| `$username` | | `user@host` |
|
|
388
393
|
| `$os` | | operating-system icon |
|
|
389
394
|
| `$time` | | current time `HH:MM` |
|
|
390
|
-
| `$context` | | context usage (text and/or gauge
|
|
391
|
-
| `$tokens` | | input/output
|
|
395
|
+
| `$context` | | context usage (text and/or gauge; finite percentages use one decimal) |
|
|
396
|
+
| `$tokens` | | input/output counts and existing cache-hit percentage |
|
|
397
|
+
| `$cache_read` | | cache-read total (`R1.2k`); empty at zero or when unavailable |
|
|
398
|
+
| `$cache_write` | | cache-write total (`W300`); empty at zero or when unavailable |
|
|
392
399
|
| `$cost` | | session cost |
|
|
393
|
-
| `$
|
|
400
|
+
| `$subscription` | | `(sub)` in subscription mode; otherwise empty |
|
|
401
|
+
| `$auto_compaction` | | `(auto)` when automatic compaction is enabled; otherwise empty |
|
|
402
|
+
| `$sep` | `$separator` | themed `\|` using `colors.separator` |
|
|
394
403
|
| `$fill` | — | special: splits zones |
|
|
395
404
|
|
|
396
405
|
### `$fill` behavior
|
|
@@ -406,6 +415,9 @@ Center the branch between directory and cost:
|
|
|
406
415
|
- Each variable renders its core value only (no `on`/`via` prefixes); add those words as literal text.
|
|
407
416
|
- 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.
|
|
408
417
|
- `$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.
|
|
418
|
+
- The built-in wide footer appends cache totals to the token segment, `(sub)` to cost, and `(auto)` to context when available. Custom formats keep `$tokens`, `$cost`, and `$context` backward-compatible and include telemetry only through the atomic variables above.
|
|
419
|
+
- `DEFAULT_COMPACT_FOOTER_FORMAT` omits model/provider and atomic telemetry. Add their variables explicitly to `compactFooterFormat` to opt in at narrow widths.
|
|
420
|
+
- Auto-compaction settings refresh on the next normal footer synchronization event. Unsupported Pi capabilities or settings-read errors safely omit optional markers.
|
|
409
421
|
- Unknown `$variables` render empty.
|
|
410
422
|
- Set or clear at runtime: `/zentui format "<template>"` and `/zentui format clear`.
|
|
411
423
|
|
|
@@ -33,6 +33,7 @@ export type { IconMode } from "./icons";
|
|
|
33
33
|
export type ContextStyle = "text" | "gauge" | "text+gauge";
|
|
34
34
|
export type SeparatorStyle = "pipe" | "dot" | "chevron" | "none";
|
|
35
35
|
export type ModelLabelSource = "id" | "name";
|
|
36
|
+
export type EditorBorderColorMode = "static" | "adaptive";
|
|
36
37
|
export type CompactFooterMaxLines = 1 | 2 | 3 | "unlimited";
|
|
37
38
|
|
|
38
39
|
export const DEFAULT_COMPACT_FOOTER_FORMAT =
|
|
@@ -79,6 +80,7 @@ export type FooterSegmentsConfig = {
|
|
|
79
80
|
gitCommit: boolean;
|
|
80
81
|
gitMetrics: boolean;
|
|
81
82
|
runtime: boolean;
|
|
83
|
+
modelInfo: boolean;
|
|
82
84
|
context: boolean;
|
|
83
85
|
tokens: boolean;
|
|
84
86
|
cost: boolean;
|
|
@@ -139,6 +141,7 @@ export type PolishedTuiConfig = {
|
|
|
139
141
|
separator: SeparatorStyle;
|
|
140
142
|
contextStyle: ContextStyle;
|
|
141
143
|
editorModelLabel: ModelLabelSource;
|
|
144
|
+
editorBorderColorMode: EditorBorderColorMode;
|
|
142
145
|
contextThresholds: ContextThresholds;
|
|
143
146
|
pathDisplay: PathDisplayConfig;
|
|
144
147
|
gitBranch: GitBranchConfig;
|
|
@@ -196,13 +199,19 @@ export const FOOTER_FORMAT_VARIABLES = [
|
|
|
196
199
|
"git_status",
|
|
197
200
|
"git_state",
|
|
198
201
|
"runtime",
|
|
202
|
+
"model",
|
|
203
|
+
"provider",
|
|
199
204
|
"session_duration",
|
|
200
205
|
"username",
|
|
201
206
|
"os",
|
|
202
207
|
"time",
|
|
203
208
|
"context",
|
|
204
209
|
"tokens",
|
|
210
|
+
"cache_read",
|
|
211
|
+
"cache_write",
|
|
205
212
|
"cost",
|
|
213
|
+
"subscription",
|
|
214
|
+
"auto_compaction",
|
|
206
215
|
"package",
|
|
207
216
|
"package_version",
|
|
208
217
|
"git_commit",
|
|
@@ -240,6 +249,7 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
240
249
|
separator: "pipe",
|
|
241
250
|
contextStyle: "text",
|
|
242
251
|
editorModelLabel: "id",
|
|
252
|
+
editorBorderColorMode: "static",
|
|
243
253
|
contextThresholds: { warning: 70, error: 90 },
|
|
244
254
|
pathDisplay: { mode: "basename", depth: 0 },
|
|
245
255
|
gitBranch: { maxLength: "full" },
|
|
@@ -287,6 +297,7 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
287
297
|
gitStatus: true,
|
|
288
298
|
gitCounts: false,
|
|
289
299
|
runtime: true,
|
|
300
|
+
modelInfo: false,
|
|
290
301
|
context: true,
|
|
291
302
|
tokens: true,
|
|
292
303
|
cost: true,
|
|
@@ -350,6 +361,11 @@ function parseEditorModelLabel(value: unknown): ModelLabelSource {
|
|
|
350
361
|
return defaultConfig.editorModelLabel;
|
|
351
362
|
}
|
|
352
363
|
|
|
364
|
+
function parseEditorBorderColorMode(value: unknown): EditorBorderColorMode {
|
|
365
|
+
if (value === "static" || value === "adaptive") return value;
|
|
366
|
+
return defaultConfig.editorBorderColorMode;
|
|
367
|
+
}
|
|
368
|
+
|
|
353
369
|
export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
|
|
354
370
|
return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
|
|
355
371
|
}
|
|
@@ -521,6 +537,7 @@ function normalizeFooterSegments(record: Record<string, unknown>): FooterSegment
|
|
|
521
537
|
gitStatus: footerSegmentValue(record, "gitStatus"),
|
|
522
538
|
gitCounts: footerSegmentValue(record, "gitCounts"),
|
|
523
539
|
runtime: footerSegmentValue(record, "runtime"),
|
|
540
|
+
modelInfo: footerSegmentValue(record, "modelInfo"),
|
|
524
541
|
context: footerSegmentValue(record, "context"),
|
|
525
542
|
tokens: footerSegmentValue(record, "tokens"),
|
|
526
543
|
cost: footerSegmentValue(record, "cost"),
|
|
@@ -638,6 +655,7 @@ function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig
|
|
|
638
655
|
value === "gitStatus" ||
|
|
639
656
|
value === "gitCounts" ||
|
|
640
657
|
value === "runtime" ||
|
|
658
|
+
value === "modelInfo" ||
|
|
641
659
|
value === "context" ||
|
|
642
660
|
value === "tokens" ||
|
|
643
661
|
value === "cost" ||
|
|
@@ -815,6 +833,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
815
833
|
separator: parseSeparatorStyle(config.separator),
|
|
816
834
|
contextStyle: parseContextStyle(config.contextStyle),
|
|
817
835
|
editorModelLabel: parseEditorModelLabel(config.editorModelLabel),
|
|
836
|
+
editorBorderColorMode: parseEditorBorderColorMode(config.editorBorderColorMode),
|
|
818
837
|
contextThresholds: parseContextThresholds(config.contextThresholds),
|
|
819
838
|
pathDisplay: parsePathDisplay(config.pathDisplay),
|
|
820
839
|
gitBranch,
|
|
@@ -1007,6 +1026,15 @@ export function saveEditorModelLabel(
|
|
|
1007
1026
|
});
|
|
1008
1027
|
}
|
|
1009
1028
|
|
|
1029
|
+
export function saveEditorBorderColorMode(
|
|
1030
|
+
value: EditorBorderColorMode,
|
|
1031
|
+
path = configPath,
|
|
1032
|
+
): PolishedTuiConfig {
|
|
1033
|
+
return mutateConfig(path, (record) => {
|
|
1034
|
+
record.editorBorderColorMode = parseEditorBorderColorMode(value);
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1010
1038
|
export function saveGitCommitPatch(
|
|
1011
1039
|
patch: Partial<Pick<GitCommitConfig, "onlyDetached" | "showTag">>,
|
|
1012
1040
|
path = configPath,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export type EditorComponentFactory = (...args: never[]) => unknown;
|
|
2
|
+
|
|
3
|
+
export type EditorTransferUi<Factory = EditorComponentFactory> = {
|
|
4
|
+
getEditorText?: () => unknown;
|
|
5
|
+
setEditorText?: (text: string) => void;
|
|
6
|
+
setEditorComponent?: (factory: Factory | undefined) => void;
|
|
7
|
+
getEditorComponent?: () => Factory | undefined;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type EditorTransferFailureReason =
|
|
11
|
+
| "unsupported-transfer-api"
|
|
12
|
+
| "editor-factory-snapshot-failed"
|
|
13
|
+
| "editor-text-snapshot-failed"
|
|
14
|
+
| "editor-text-preparation-failed"
|
|
15
|
+
| "editor-replacement-failed-with-rollback"
|
|
16
|
+
| "editor-replacement-rollback-failed";
|
|
17
|
+
|
|
18
|
+
export type EditorTransferResult =
|
|
19
|
+
| { ok: true }
|
|
20
|
+
| { ok: false; reason: EditorTransferFailureReason };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Prepare the active editor's public expanded text before Pi replaces its factory.
|
|
24
|
+
* This intentionally transfers prompt contents only; editor-private state is never inspected.
|
|
25
|
+
*/
|
|
26
|
+
export function replaceEditorComponentWithExpandedText<Factory>(
|
|
27
|
+
ui: EditorTransferUi<Factory>,
|
|
28
|
+
factory: Factory | undefined,
|
|
29
|
+
): EditorTransferResult {
|
|
30
|
+
if (
|
|
31
|
+
typeof ui.getEditorText !== "function" ||
|
|
32
|
+
typeof ui.setEditorText !== "function" ||
|
|
33
|
+
typeof ui.setEditorComponent !== "function" ||
|
|
34
|
+
typeof ui.getEditorComponent !== "function"
|
|
35
|
+
) {
|
|
36
|
+
return { ok: false, reason: "unsupported-transfer-api" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let previousFactory: Factory | undefined;
|
|
40
|
+
try {
|
|
41
|
+
previousFactory = ui.getEditorComponent();
|
|
42
|
+
} catch {
|
|
43
|
+
return { ok: false, reason: "editor-factory-snapshot-failed" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let expandedText: unknown;
|
|
47
|
+
try {
|
|
48
|
+
expandedText = ui.getEditorText();
|
|
49
|
+
} catch {
|
|
50
|
+
return { ok: false, reason: "editor-text-snapshot-failed" };
|
|
51
|
+
}
|
|
52
|
+
if (typeof expandedText !== "string") {
|
|
53
|
+
return { ok: false, reason: "editor-text-snapshot-failed" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
ui.setEditorText(expandedText);
|
|
58
|
+
} catch {
|
|
59
|
+
return { ok: false, reason: "editor-text-preparation-failed" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
ui.setEditorComponent(factory);
|
|
64
|
+
} catch {
|
|
65
|
+
try {
|
|
66
|
+
ui.setEditorComponent(previousFactory);
|
|
67
|
+
return { ok: false, reason: "editor-replacement-failed-with-rollback" };
|
|
68
|
+
} catch {
|
|
69
|
+
return { ok: false, reason: "editor-replacement-rollback-failed" };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { ok: true };
|
|
73
|
+
}
|
|
@@ -51,6 +51,18 @@ function joinStatusTexts(statusTexts: string[], separator: string): string {
|
|
|
51
51
|
return statusTexts.filter(Boolean).join(separator);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function normalizeModelInfoPart(value: string): string {
|
|
55
|
+
return value.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function composeModelInfoLabel(model: string, provider: string): string {
|
|
59
|
+
const normalizedModel = normalizeModelInfoPart(model);
|
|
60
|
+
const normalizedProvider = normalizeModelInfoPart(provider);
|
|
61
|
+
const providerIsDuplicated =
|
|
62
|
+
normalizedProvider.length > 0 && normalizedModel.includes(normalizedProvider);
|
|
63
|
+
return [model, providerIsDuplicated ? "" : provider].filter(Boolean).join(" ");
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
function fitStatusTexts(statusTexts: string[], maxWidth: number, separator: string): string {
|
|
55
67
|
if (maxWidth <= 0) return "";
|
|
56
68
|
|
|
@@ -260,6 +272,18 @@ export function installFooter(
|
|
|
260
272
|
: tier === "warning"
|
|
261
273
|
? config.colors.contextWarning
|
|
262
274
|
: config.colors.contextNormal;
|
|
275
|
+
const cacheReadLabel = state.cacheReadLabel
|
|
276
|
+
? renderStyleForSource(theme, colorSource, config.colors.tokens, state.cacheReadLabel)
|
|
277
|
+
: "";
|
|
278
|
+
const cacheWriteLabel = state.cacheWriteLabel
|
|
279
|
+
? renderStyleForSource(theme, colorSource, config.colors.tokens, state.cacheWriteLabel)
|
|
280
|
+
: "";
|
|
281
|
+
const subscriptionLabel = state.subscription
|
|
282
|
+
? renderStyleForSource(theme, colorSource, config.colors.cost, "(sub)")
|
|
283
|
+
: "";
|
|
284
|
+
const autoCompactionLabel = state.autoCompaction
|
|
285
|
+
? renderStyleForSource(theme, colorSource, contextColor, "(auto)")
|
|
286
|
+
: "";
|
|
263
287
|
const gitColor = (text: string) =>
|
|
264
288
|
renderStyleForSource(theme, colorSource, config.colors.gitBranch, text);
|
|
265
289
|
const gitStatusColor = (text: string) =>
|
|
@@ -325,6 +349,10 @@ export function installFooter(
|
|
|
325
349
|
const label = state.runtime.version ? `${symbol} ${state.runtime.version}` : symbol;
|
|
326
350
|
return renderStyleForSource(theme, colorSource, state.runtime.style, label);
|
|
327
351
|
}
|
|
352
|
+
case "model":
|
|
353
|
+
return sanitizeExtensionStatusText(state.modelLabel);
|
|
354
|
+
case "provider":
|
|
355
|
+
return sanitizeExtensionStatusText(state.providerLabel);
|
|
328
356
|
case "session_duration":
|
|
329
357
|
return state.sessionStartEpoch
|
|
330
358
|
? renderStyleForSource(
|
|
@@ -364,8 +392,16 @@ export function installFooter(
|
|
|
364
392
|
config.colors.tokens,
|
|
365
393
|
state.tokenLabel,
|
|
366
394
|
);
|
|
395
|
+
case "cache_read":
|
|
396
|
+
return cacheReadLabel;
|
|
397
|
+
case "cache_write":
|
|
398
|
+
return cacheWriteLabel;
|
|
367
399
|
case "cost":
|
|
368
400
|
return renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel);
|
|
401
|
+
case "subscription":
|
|
402
|
+
return subscriptionLabel;
|
|
403
|
+
case "auto_compaction":
|
|
404
|
+
return autoCompactionLabel;
|
|
369
405
|
case "package":
|
|
370
406
|
return formatPackageVersionSegment(
|
|
371
407
|
theme,
|
|
@@ -544,6 +580,12 @@ export function installFooter(
|
|
|
544
580
|
.filter(Boolean)
|
|
545
581
|
.join(" ");
|
|
546
582
|
|
|
583
|
+
const modelInfoSegment = config.footerSegments.modelInfo
|
|
584
|
+
? composeModelInfoLabel(
|
|
585
|
+
sanitizeExtensionStatusText(state.modelLabel),
|
|
586
|
+
sanitizeExtensionStatusText(state.providerLabel),
|
|
587
|
+
)
|
|
588
|
+
: "";
|
|
547
589
|
const timeSegment = config.footerSegments.time
|
|
548
590
|
? renderStyleForSource(
|
|
549
591
|
theme,
|
|
@@ -552,16 +594,30 @@ export function installFooter(
|
|
|
552
594
|
formatTimeLabel(config.icons.time),
|
|
553
595
|
)
|
|
554
596
|
: "";
|
|
597
|
+
const builtInContextLabel = [
|
|
598
|
+
renderStyleForSource(theme, colorSource, contextColor, contextLabel),
|
|
599
|
+
autoCompactionLabel,
|
|
600
|
+
]
|
|
601
|
+
.filter(Boolean)
|
|
602
|
+
.join(" ");
|
|
603
|
+
const builtInTokenLabel = [
|
|
604
|
+
renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel),
|
|
605
|
+
cacheReadLabel,
|
|
606
|
+
cacheWriteLabel,
|
|
607
|
+
]
|
|
608
|
+
.filter(Boolean)
|
|
609
|
+
.join(" ");
|
|
610
|
+
const builtInCostLabel = [
|
|
611
|
+
renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel),
|
|
612
|
+
subscriptionLabel,
|
|
613
|
+
]
|
|
614
|
+
.filter(Boolean)
|
|
615
|
+
.join(" ");
|
|
555
616
|
const right = [
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
config.footerSegments.
|
|
560
|
-
? renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel)
|
|
561
|
-
: "",
|
|
562
|
-
config.footerSegments.cost
|
|
563
|
-
? renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel)
|
|
564
|
-
: "",
|
|
617
|
+
modelInfoSegment,
|
|
618
|
+
config.footerSegments.context ? builtInContextLabel : "",
|
|
619
|
+
config.footerSegments.tokens ? builtInTokenLabel : "",
|
|
620
|
+
config.footerSegments.cost ? builtInCostLabel : "",
|
|
565
621
|
timeSegment,
|
|
566
622
|
]
|
|
567
623
|
.filter(Boolean)
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { homedir, hostname, userInfo } from "node:os";
|
|
2
|
-
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
3
2
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
5
4
|
import type {
|
|
@@ -81,23 +80,40 @@ export type UsageTotals = {
|
|
|
81
80
|
|
|
82
81
|
export type ContextColorTier = "normal" | "warning" | "error";
|
|
83
82
|
|
|
83
|
+
type SessionUsage = {
|
|
84
|
+
input?: unknown;
|
|
85
|
+
output?: unknown;
|
|
86
|
+
cacheRead?: unknown;
|
|
87
|
+
cacheWrite?: unknown;
|
|
88
|
+
cost?: unknown;
|
|
89
|
+
};
|
|
90
|
+
|
|
84
91
|
type SessionEntry = {
|
|
85
92
|
type?: string;
|
|
86
93
|
id?: string | number;
|
|
87
94
|
timestamp?: string | number;
|
|
95
|
+
usage?: SessionUsage;
|
|
88
96
|
message?: {
|
|
89
97
|
role?: string;
|
|
90
|
-
usage?:
|
|
98
|
+
usage?: SessionUsage;
|
|
91
99
|
};
|
|
92
100
|
};
|
|
93
101
|
|
|
102
|
+
type SelectedUsage = {
|
|
103
|
+
usage: SessionUsage | undefined;
|
|
104
|
+
location: "message" | "entry";
|
|
105
|
+
isAssistant: boolean;
|
|
106
|
+
};
|
|
107
|
+
|
|
94
108
|
type UsageCacheEntry = {
|
|
95
109
|
key: string;
|
|
96
110
|
totals: UsageTotals;
|
|
97
111
|
};
|
|
98
112
|
|
|
113
|
+
const MAX_USAGE_TOTAL = Number.MAX_VALUE;
|
|
114
|
+
|
|
99
115
|
let usageTotalsCache: UsageCacheEntry | undefined;
|
|
100
|
-
let
|
|
116
|
+
let usageTotalsAggregationPassCount = 0;
|
|
101
117
|
|
|
102
118
|
export function formatCount(value: number): string {
|
|
103
119
|
if (value < 1000) return value.toString();
|
|
@@ -130,26 +146,78 @@ function calculateCacheHitRate(
|
|
|
130
146
|
cacheWrite: number,
|
|
131
147
|
): number | undefined {
|
|
132
148
|
const promptTokens = input + cacheRead + cacheWrite;
|
|
133
|
-
|
|
149
|
+
if (promptTokens === 0) return undefined;
|
|
150
|
+
if (Number.isFinite(promptTokens)) return (cacheRead / promptTokens) * 100;
|
|
151
|
+
|
|
152
|
+
const scale = Math.max(input, cacheRead, cacheWrite);
|
|
153
|
+
const scaledPromptTokens = input / scale + cacheRead / scale + cacheWrite / scale;
|
|
154
|
+
return (cacheRead / scale / scaledPromptTokens) * 100;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function normalizeUsageNumber(value: unknown): number {
|
|
158
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function usageCostTotal(usage: SessionUsage | undefined): number {
|
|
162
|
+
if (typeof usage?.cost !== "object" || usage.cost === null) return 0;
|
|
163
|
+
return normalizeUsageNumber((usage.cost as { total?: unknown }).total);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function addUsageTotal(total: number, value: number): number {
|
|
167
|
+
const sum = total + value;
|
|
168
|
+
return Number.isFinite(sum) ? sum : MAX_USAGE_TOTAL;
|
|
134
169
|
}
|
|
135
170
|
|
|
136
|
-
function
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
171
|
+
function usageForEntry(entry: SessionEntry): SelectedUsage | undefined {
|
|
172
|
+
if (entry.type === "message") {
|
|
173
|
+
const role = entry.message?.role;
|
|
174
|
+
if (role !== "assistant" && role !== "toolResult") return undefined;
|
|
175
|
+
return {
|
|
176
|
+
usage: entry.message?.usage,
|
|
177
|
+
location: "message",
|
|
178
|
+
isAssistant: role === "assistant",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
182
|
+
return { usage: entry.usage, location: "entry", isAssistant: false };
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function normalizedUsage(usage: SessionUsage | undefined) {
|
|
188
|
+
return {
|
|
189
|
+
input: normalizeUsageNumber(usage?.input),
|
|
190
|
+
output: normalizeUsageNumber(usage?.output),
|
|
191
|
+
cacheRead: normalizeUsageNumber(usage?.cacheRead),
|
|
192
|
+
cacheWrite: normalizeUsageNumber(usage?.cacheWrite),
|
|
193
|
+
cost: usageCostTotal(usage),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function entryIdentity(entry: SessionEntry): string {
|
|
198
|
+
const selected = usageForEntry(entry);
|
|
199
|
+
if (!selected) return "unsupported";
|
|
200
|
+
const usage = normalizedUsage(selected.usage);
|
|
201
|
+
return JSON.stringify([
|
|
202
|
+
entry.id ?? null,
|
|
203
|
+
entry.timestamp ?? null,
|
|
204
|
+
entry.type ?? null,
|
|
205
|
+
entry.message?.role ?? null,
|
|
206
|
+
selected.location,
|
|
207
|
+
usage.input,
|
|
208
|
+
usage.output,
|
|
209
|
+
usage.cacheRead,
|
|
210
|
+
usage.cacheWrite,
|
|
211
|
+
usage.cost,
|
|
212
|
+
]);
|
|
143
213
|
}
|
|
144
214
|
|
|
145
215
|
function buildUsageFingerprint(entries: readonly SessionEntry[]): string {
|
|
146
|
-
|
|
147
|
-
const last = entries[entries.length - 1];
|
|
148
|
-
return `${entries.length}\0${entryIdentity(first)}\0${entryIdentity(last)}`;
|
|
216
|
+
return entries.map(entryIdentity).join("\0");
|
|
149
217
|
}
|
|
150
218
|
|
|
151
219
|
function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
|
|
152
|
-
|
|
220
|
+
usageTotalsAggregationPassCount += 1;
|
|
153
221
|
let input = 0;
|
|
154
222
|
let output = 0;
|
|
155
223
|
let cacheRead = 0;
|
|
@@ -158,18 +226,18 @@ function computeUsageTotals(entries: readonly SessionEntry[]): UsageTotals {
|
|
|
158
226
|
let cost = 0;
|
|
159
227
|
|
|
160
228
|
for (const entry of entries) {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
229
|
+
const selected = usageForEntry(entry);
|
|
230
|
+
if (!selected) continue;
|
|
231
|
+
const usage = normalizedUsage(selected.usage);
|
|
232
|
+
|
|
233
|
+
input = addUsageTotal(input, usage.input);
|
|
234
|
+
output = addUsageTotal(output, usage.output);
|
|
235
|
+
cacheRead = addUsageTotal(cacheRead, usage.cacheRead);
|
|
236
|
+
cacheWrite = addUsageTotal(cacheWrite, usage.cacheWrite);
|
|
237
|
+
cost = addUsageTotal(cost, usage.cost);
|
|
238
|
+
if (selected.isAssistant) {
|
|
239
|
+
latestCacheHitRate = calculateCacheHitRate(usage.input, usage.cacheRead, usage.cacheWrite);
|
|
240
|
+
}
|
|
173
241
|
}
|
|
174
242
|
|
|
175
243
|
return Object.freeze({ input, output, cacheRead, cacheWrite, latestCacheHitRate, cost });
|
|
@@ -179,23 +247,26 @@ export function invalidateUsageTotalsCache(): void {
|
|
|
179
247
|
usageTotalsCache = undefined;
|
|
180
248
|
}
|
|
181
249
|
|
|
182
|
-
/** Test helper:
|
|
183
|
-
export function
|
|
184
|
-
return
|
|
250
|
+
/** Test helper: counts aggregation passes only; every cache lookup still fingerprints all entries. */
|
|
251
|
+
export function __usageTotalsAggregationPassCount(): number {
|
|
252
|
+
return usageTotalsAggregationPassCount;
|
|
185
253
|
}
|
|
186
254
|
|
|
187
|
-
/** Test helper: reset
|
|
255
|
+
/** Test helper: reset the aggregation-pass counter and cached totals. */
|
|
188
256
|
export function __resetUsageTotalsCacheForTests(): void {
|
|
189
257
|
usageTotalsCache = undefined;
|
|
190
|
-
|
|
258
|
+
usageTotalsAggregationPassCount = 0;
|
|
191
259
|
}
|
|
192
260
|
|
|
193
261
|
export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
194
262
|
const sessionManager = ctx.sessionManager as {
|
|
195
|
-
getEntries?: () => SessionEntry[];
|
|
196
|
-
getBranch: () => SessionEntry[];
|
|
263
|
+
getEntries?: () => readonly SessionEntry[];
|
|
264
|
+
getBranch: () => readonly SessionEntry[];
|
|
197
265
|
};
|
|
198
|
-
const entries =
|
|
266
|
+
const entries =
|
|
267
|
+
typeof sessionManager.getEntries === "function"
|
|
268
|
+
? sessionManager.getEntries()
|
|
269
|
+
: sessionManager.getBranch();
|
|
199
270
|
const key = buildUsageFingerprint(entries);
|
|
200
271
|
if (usageTotalsCache?.key === key) return usageTotalsCache.totals;
|
|
201
272
|
|
|
@@ -204,6 +275,14 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
|
204
275
|
return totals;
|
|
205
276
|
}
|
|
206
277
|
|
|
278
|
+
export function buildCacheReadLabel(cacheRead: number): string {
|
|
279
|
+
return cacheRead > 0 ? `R${formatCount(cacheRead)}` : "";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function buildCacheWriteLabel(cacheWrite: number): string {
|
|
283
|
+
return cacheWrite > 0 ? `W${formatCount(cacheWrite)}` : "";
|
|
284
|
+
}
|
|
285
|
+
|
|
207
286
|
export function buildTokenLabel(totals: UsageTotals, cacheHitIcon = ""): string {
|
|
208
287
|
const parts: string[] = [];
|
|
209
288
|
if (totals.input) parts.push(`↑${formatCount(totals.input)}`);
|
|
@@ -255,9 +334,9 @@ export function formatContextPercentLabel(
|
|
|
255
334
|
): string {
|
|
256
335
|
if (!contextWindow || contextWindow <= 0) return "--";
|
|
257
336
|
const percentLabel =
|
|
258
|
-
percent === null || percent === undefined
|
|
337
|
+
percent === null || percent === undefined || !Number.isFinite(percent)
|
|
259
338
|
? "?"
|
|
260
|
-
: `${Math.max(0, Math.min(999,
|
|
339
|
+
: `${Math.max(0, Math.min(999, percent)).toFixed(1)}%`;
|
|
261
340
|
return `${percentLabel}/${formatCount(contextWindow)}`;
|
|
262
341
|
}
|
|
263
342
|
|