pi-cockpit 0.22.1 → 0.24.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.
Files changed (55) hide show
  1. package/README.md +16 -16
  2. package/node_modules/pi-maestro-settings-core/package.json +3 -2
  3. package/node_modules/pi-maestro-settings-core/src/index.ts +2 -1
  4. package/node_modules/pi-maestro-settings-core/src/public/v1/index.ts +32 -4
  5. package/node_modules/pi-maestro-settings-core/src/ui/ansi-bridge.ts +102 -0
  6. package/node_modules/pi-maestro-settings-core/src/ui/icons.ts +168 -0
  7. package/node_modules/pi-maestro-settings-core/src/ui/index.ts +11 -0
  8. package/node_modules/pi-maestro-settings-core/src/ui/key-labels.ts +10 -0
  9. package/node_modules/pi-maestro-settings-core/src/ui/layout.ts +181 -0
  10. package/node_modules/pi-maestro-settings-core/src/ui/overlay-host.ts +277 -0
  11. package/node_modules/pi-maestro-settings-core/src/ui/overlay-keys.ts +56 -0
  12. package/node_modules/pi-maestro-settings-core/src/ui/overlay-render.ts +233 -0
  13. package/node_modules/pi-maestro-settings-core/src/ui/overlay-spec.ts +107 -0
  14. package/node_modules/pi-maestro-settings-core/src/ui/overlay.ts +205 -0
  15. package/node_modules/pi-maestro-settings-core/src/ui/primitives.ts +86 -0
  16. package/node_modules/pi-maestro-settings-core/src/ui/quiet-state.ts +36 -0
  17. package/package.json +85 -85
  18. package/src/agent-bar.ts +13 -7
  19. package/src/agent-overlay.ts +14 -8
  20. package/src/bash-bg-overlay.ts +4 -10
  21. package/src/bash-bg-store.ts +5 -0
  22. package/src/compaction-style.ts +235 -0
  23. package/src/endpoint-store.ts +294 -10
  24. package/src/footer.ts +66 -2
  25. package/src/icons.ts +1 -168
  26. package/src/index.ts +257 -26
  27. package/src/input-routing.ts +10 -2
  28. package/src/key-labels.ts +1 -10
  29. package/src/layout.ts +1 -181
  30. package/src/maestro-store.ts +10 -0
  31. package/src/patch-health.ts +63 -0
  32. package/src/public/v1/events.ts +8 -0
  33. package/src/quiet-tools.ts +167 -1
  34. package/src/render-memo.ts +145 -0
  35. package/src/session-detail.ts +1 -1
  36. package/src/settings/ui-primitives.ts +23 -59
  37. package/src/settings-view.ts +17 -2
  38. package/src/sidebar-controller.ts +11 -2
  39. package/src/sidebar-render.ts +45 -2
  40. package/src/stack-widget.ts +233 -202
  41. package/src/target-integration.ts +307 -0
  42. package/src/target-routing.ts +673 -0
  43. package/src/thinking-timer.ts +10 -0
  44. package/src/tick-policy.ts +2 -2
  45. package/src/todo-overlay.ts +347 -355
  46. package/src/todo-store.ts +5 -0
  47. package/src/tui-i18n.ts +6 -0
  48. package/src/types.ts +71 -10
  49. package/src/usage/history.ts +221 -216
  50. package/src/usage/sparkline.ts +60 -60
  51. package/src/viewport-stability.ts +73 -5
  52. package/src/window-thread-view.ts +8 -1
  53. package/src/zen-render.ts +31 -22
  54. package/src/zen-sheet.ts +14 -8
  55. package/themes/cockpit-terminal.json +37 -0
package/README.md CHANGED
@@ -62,8 +62,8 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
62
62
  ```json
63
63
  {
64
64
  "enabled": true,
65
- "quietMode": false,
66
- "quietSymbols": "check",
65
+ "quietMode": true,
66
+ "quietSymbols": "dot",
67
67
  "agentsMode": "list",
68
68
  "todoMode": "list",
69
69
  "todoExpanded": false,
@@ -71,21 +71,21 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
71
71
  "hideNativeAgents": true,
72
72
  "icons": { "mode": "auto" },
73
73
  "sidebar": {
74
- "mode": "auto",
74
+ "mode": "off",
75
75
  "width": 40,
76
76
  "density": "comfortable"
77
77
  },
78
78
  "title": {
79
79
  "enabled": true,
80
80
  "showSession": true,
81
- "showCwd": false,
82
- "showModel": false,
83
- "showThinking": false,
84
- "showGit": false,
85
- "showMaestro": false,
81
+ "showCwd": true,
82
+ "showModel": true,
83
+ "showThinking": true,
84
+ "showGit": true,
85
+ "showMaestro": true,
86
86
  "maxLength": 80
87
87
  },
88
- "theme": "",
88
+ "theme": "cockpit-zen",
89
89
  "usage": {
90
90
  "enabled": true,
91
91
  "footer": true,
@@ -108,16 +108,16 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
108
108
  - `icons.mode`: `"auto"` (detect Nerd Font), `"nerd"`, or `"ascii"`.
109
109
  - `title.enabled`: master switch for the terminal tab title (session summary + working state + opt-in tags). On by default.
110
110
  - `title.showSession`: include the session summary (the `session_info` name, else a short session id) right after `pi`. On by default.
111
- - `title.showCwd`: include the working directory after the session. Off by default — the title stays short, since the tab strip has no room for a wall of tags.
112
- - `title.showModel`: include the active model tag (`m:gpt-5.6-sol`). Off by default.
113
- - `title.showThinking`: include the thinking level tag (`t:high`); skipped while off. Off by default.
114
- - `title.showGit`: include the git branch tag (`git:main`, `git:detached`); read synchronously from `.git/HEAD`, so it costs no process spawn. Off by default.
115
- - `title.showMaestro`: include the Maestro workflow status tag (`wf:running`, `wf:done`). Off by default.
111
+ - `title.showCwd`: include the working directory after the session. On by default.
112
+ - `title.showModel`: include the active model tag (`m:gpt-5.6-sol`). On by default.
113
+ - `title.showThinking`: include the thinking level tag (`t:high`); skipped while off. On by default.
114
+ - `title.showGit`: include the git branch tag (`git:main`, `git:detached`); read synchronously from `.git/HEAD`, so it costs no process spawn. On by default.
115
+ - `title.showMaestro`: include the Maestro workflow status tag (`wf:running`, `wf:done`). On by default.
116
116
  - `title.maxLength`: hard cap on the composed title; the middle is ellided to keep the head and the working-state tail. Clamped to `20..200`; default `80`.
117
117
  - `title.generationModel`: `"provider/model"` (e.g. `"maestro-qwen/qwen3.8-max"`) of a model registered with the `/api-manager` command (or any other provider pi resolves). When set, the session title is generated by that model over the first completed turn (user prompt + assistant reply) through its OpenAI-compatible endpoint, with a 10s timeout. Empty (default) uses the offline rule-based extractor instead. Generation is best-effort — on any failure the title silently falls back to the rule-based one. Editable from the `/cockpit` settings overlay (`z` or cursor to the `title gen model` row, Enter, type, Enter to save; empty clears back to rule-based).
118
118
 
119
119
  The tab title is `frame + pi - <session> - <working state>`. The session part follows Claude Code's chain (`sessionTitle ?? agentTitle ?? haikuTitle ?? default`): the `/session name` title wins, otherwise the generated title, otherwise a short session id. The frame is Claude Code's title chrome: `⠂`/`⠐` braille spinner while a turn runs, static `✳` when idle; failure replaces it with `✗`. On quit, Cockpit clears the title so no stale tab label lingers (Claude Code's `CLEAR_TERMINAL_TITLE`).
120
- - `theme`: named theme override; empty string follows the Pi session theme.
120
+ - `theme`: named theme override; defaults to `"cockpit-zen"`. An empty string follows the Pi session theme.
121
121
  - `usage.enabled`: master switch for the usage bars (quota, balance, spend). On by default; off hides the footer segment and disables the `/usage` command. Ports the [`hknet/pi-usage-bars`](https://github.com/hknet/pi-usage-bars) extension.
122
122
  - `usage.footer`: when `true`, render the live quota bar on a dedicated footer line. The `/usage` command works regardless of this toggle. On by default.
123
123
  - `usage.pollIntervalMs`: how often to refresh usage data, in milliseconds. Clamped to `30000`..`1800000` (30s..30min); `0` switches to manual refresh — no background polling and no footer bar; the `/usage` overlay fetches on open and re-fetches with `r`. Default `120000` (2min). Lower values hit provider APIs more often.
@@ -141,7 +141,7 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
141
141
 
142
142
  ## Sidebar compatibility
143
143
 
144
- The split-pane wrapper depends on Pi's current TUI renderer shape and is verified against Pi `0.83.0`. A render integration failure disables the split and retries the original renderer at full width.
144
+ The split-pane wrapper depends on Pi's current TUI renderer shape and is verified against Pi `0.84.4`. A render integration failure disables the split and retries the original renderer at full width. The `/cockpit` panel exposes a read-only "host patches" row showing which pi-internal patches are live; a pi-tui version outside the verified range triggers a one-time warning at session start.
145
145
 
146
146
  Do not enable `pi-cockpit`'s dock and `pi-atelier@0.7.0`'s sidebar together. Both reserve columns by wrapping the same renderer, and `pi-atelier@0.7.0` does not participate in Cockpit's split-owner marker protocol. Use `"sidebar": { "mode": "off" }` when running Atelier.
147
147
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-maestro-settings-core",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Versioned Settings and i18n contracts shared by Pi Maestro plugins.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -17,7 +17,8 @@
17
17
  "./v1/events": "./src/public/v1/events.ts",
18
18
  "./v1/provider": "./src/public/v1/provider.ts",
19
19
  "./v1/schema": "./src/public/v1/schema.ts",
20
- "./v1/i18n": "./src/public/v1/i18n.ts"
20
+ "./v1/i18n": "./src/public/v1/i18n.ts",
21
+ "./ui": "./src/ui/index.ts"
21
22
  },
22
23
  "files": [
23
24
  "src/**/*.ts"
@@ -1 +1,2 @@
1
- export * from "./public/v1/index.ts";
1
+ export * from "./public/v1/index.ts";
2
+ export * from "./ui/index.ts";
@@ -1,4 +1,32 @@
1
- export * from "./events.ts";
2
- export * from "./i18n.ts";
3
- export * from "./provider.ts";
4
- export * from "./schema.ts";
1
+ export * from "./events.ts";
2
+ export * from "./provider.ts";
3
+ export * from "./schema.ts";
4
+
5
+ // The keyboard-shortcut helpers live in the shared UI primitives module so
6
+ // Cockpit, Flow, and Teammate all import the same implementation. Keep the
7
+ // rest of the i18n surface unchanged.
8
+ export {
9
+ SUPPORTED_SETTINGS_LOCALES,
10
+ checkCatalogCompleteness,
11
+ createSettingsTranslator,
12
+ detectSystemSettingsLocale,
13
+ interpolateTranslation,
14
+ mergeTranslationCatalogs,
15
+ normalizeSettingsLocale,
16
+ resolveSettingsLocale,
17
+ translateSettings,
18
+ type CatalogCompletenessIssue,
19
+ type CatalogCompletenessIssueKind,
20
+ type CatalogCompletenessResult,
21
+ type MissingTranslation,
22
+ type MissingTranslationCallback,
23
+ type SettingsTranslator,
24
+ type SettingsTranslatorOptions,
25
+ type SupportedSettingsLocale,
26
+ type SystemSettingsLocaleOptions,
27
+ type TranslationCatalog,
28
+ type TranslationCatalogs,
29
+ type TranslationParams,
30
+ } from "./i18n.ts";
31
+
32
+ export { altKey, altLabel } from "../../ui/key-labels.ts";
@@ -0,0 +1,102 @@
1
+ // ANSI → Span bridge (transitional).
2
+ //
3
+ // Lets a legacy `render(width): string[]` body feed the Frame model without a
4
+ // rewrite: SGR color/style codes are folded onto the closed Role vocabulary,
5
+ // every other escape sequence is stripped. Raw ANSI never crosses the wire —
6
+ // the RPC backend only sees roles, and the TTY backend re-emits them through
7
+ // the theme, which also normalizes plugin styling onto theme slots.
8
+ //
9
+ // Deliberate limits (transitional bridge, not a terminal emulator):
10
+ // - Extended colors (38;5;N / 38;2;R;G;B / bg variants) cannot map to a Role
11
+ // and are dropped — migrate hot paths to Span roles directly.
12
+ // - Italic/underline/blink have no Role and are dropped.
13
+ // - Inverse video (SGR 7) maps to "selected"; a row containing it gets the
14
+ // selected-row background in renderChrome — avoid for in-row cursor cells.
15
+
16
+ import { type Role, type Span } from "./overlay-spec.ts";
17
+
18
+ /** SGR parameter → Role. Bright variants (90-97) share the base-color role. */
19
+ const SGR_ROLE: Record<number, Role> = {
20
+ 2: "dim",
21
+ 7: "selected",
22
+ 30: "muted",
23
+ 31: "error",
24
+ 32: "success",
25
+ 33: "warning",
26
+ 34: "muted",
27
+ 35: "accent",
28
+ 36: "accent",
29
+ 37: "text",
30
+ 90: "dim",
31
+ 91: "error",
32
+ 92: "success",
33
+ 93: "warning",
34
+ 94: "muted",
35
+ 95: "accent",
36
+ 96: "accent",
37
+ 97: "text",
38
+ };
39
+
40
+ /** Parse one rendered line into styled spans. */
41
+ export function ansiToSpans(line: string): Span[] {
42
+ const spans: Span[] = [];
43
+ let role: Role = "text";
44
+ let bold = false;
45
+ let buf = "";
46
+ const flush = (): void => {
47
+ if (!buf) return;
48
+ const s: Span = { text: buf };
49
+ if (role !== "text") s.role = role;
50
+ if (bold) s.bold = true;
51
+ spans.push(s);
52
+ buf = "";
53
+ };
54
+
55
+ let i = 0;
56
+ while (i < line.length) {
57
+ const ch = line[i];
58
+ if (ch === "\x1b") {
59
+ if (line[i + 1] === "]") {
60
+ // OSC: ESC ] … (BEL | ESC \) — strip entirely.
61
+ let j = i + 2;
62
+ while (j < line.length && line[j] !== "\x07" && !(line[j] === "\x1b" && line[j + 1] === "\\")) j++;
63
+ i = line[j] === "\x07" ? j + 1 : j + 2;
64
+ continue;
65
+ }
66
+ if (line[i + 1] === "[") {
67
+ // CSI: ESC [ params final-byte.
68
+ let j = i + 2;
69
+ while (j < line.length && !/[@-~]/.test(line[j])) j++;
70
+ const final = line[j];
71
+ const params = line.slice(i + 2, j);
72
+ i = j + 1;
73
+ if (final !== "m") continue; // non-SGR CSI: strip
74
+ flush();
75
+ for (const p of params.split(";")) {
76
+ const n = p === "" ? 0 : Number.parseInt(p, 10);
77
+ if (!Number.isFinite(n)) continue;
78
+ if (n === 0) { role = "text"; bold = false; }
79
+ else if (n === 1) bold = true;
80
+ else if (n === 22) bold = false;
81
+ else if (n === 39) role = "text";
82
+ else if (n === 27 && role === "selected") role = "text";
83
+ else if (SGR_ROLE[n] !== undefined) role = SGR_ROLE[n];
84
+ // 38/48 extended colors, 40-47/49/100-107 bg, 3/4/5/9 styles: drop.
85
+ }
86
+ continue;
87
+ }
88
+ i++; // lone ESC / other escape introducer: skip the byte.
89
+ continue;
90
+ }
91
+ if (ch < " " && ch !== "\t") { i++; continue; } // C0 controls: strip.
92
+ buf += ch;
93
+ i++;
94
+ }
95
+ flush();
96
+ return spans;
97
+ }
98
+
99
+ /** Convert a whole legacy render output into a Frame. */
100
+ export function ansiToFrame(lines: readonly string[]): Span[][] {
101
+ return lines.map(ansiToSpans);
102
+ }
@@ -0,0 +1,168 @@
1
+ export type IconMode = "auto" | "nerd" | "ascii";
2
+
3
+ export interface IconGlyphs {
4
+ spinFrames: string;
5
+ dotRunning: string;
6
+ dotIdle: string;
7
+ check: string;
8
+ cross: string;
9
+ pending: string;
10
+ blocked: string;
11
+ barDone: string;
12
+ barActive: string;
13
+ barPending: string;
14
+ arrow: string;
15
+ /** Points at a dependency/upstream ("blocked by", "waits on"). */
16
+ depArrow: string;
17
+ /** Creator-to-assignee transfer indicator in actor labels. */
18
+ transferArrow: string;
19
+ /** Tree connectors. Routed through the glyph set so ascii mode stays ascii. */
20
+ treeBranch: string;
21
+ treeLast: string;
22
+ treeVertical: string;
23
+ treeSpace: string;
24
+ ellipsis: string;
25
+ separator: string;
26
+ /** Overlay chrome. Kept in the glyph table so ascii mode draws a real ascii box. */
27
+ box: {
28
+ topLeft: string;
29
+ topRight: string;
30
+ bottomLeft: string;
31
+ bottomRight: string;
32
+ horizontal: string;
33
+ vertical: string;
34
+ };
35
+ /** Marks the focused row. Must survive without the selected background colour. */
36
+ selectMarker: string;
37
+ /** Marks an empty collection. */
38
+ emptyMark: string;
39
+ /** Vertical navigation affordance shown in help lines. */
40
+ upDown: string;
41
+ model: string;
42
+ workspace: string;
43
+ git: string;
44
+ tokensIn: string;
45
+ tokensOut: string;
46
+ cost: string;
47
+ cacheHit: string;
48
+ }
49
+
50
+ const NERD_GLYPHS: IconGlyphs = {
51
+ spinFrames: "⠋⠙⠹⠴⠦⠇",
52
+ dotRunning: "●",
53
+ dotIdle: "·",
54
+ check: "✓",
55
+ cross: "✕",
56
+ // A hollow ring, not the idle dot: "waiting to start" and "running but quiet"
57
+ // are different states and must not share a glyph.
58
+ pending: "○",
59
+ blocked: "!",
60
+ barDone: "█",
61
+ barActive: "▓",
62
+ barPending: "░",
63
+ arrow: "»",
64
+ depArrow: "←",
65
+ transferArrow: "→",
66
+ treeBranch: "├─",
67
+ treeLast: "└─",
68
+ treeVertical: "│ ",
69
+ treeSpace: " ",
70
+ ellipsis: "…",
71
+ separator: " · ",
72
+ box: { topLeft: "╭", topRight: "╮", bottomLeft: "╰", bottomRight: "╯", horizontal: "─", vertical: "│" },
73
+ selectMarker: "›",
74
+ emptyMark: "○",
75
+ upDown: "↑↓",
76
+ model: "⚡",
77
+ workspace: "",
78
+ git: "",
79
+ tokensIn: "↑",
80
+ tokensOut: "↓",
81
+ cost: "$",
82
+ cacheHit: "⚡",
83
+ };
84
+
85
+ const ASCII_GLYPHS: IconGlyphs = {
86
+ spinFrames: "|/-\\",
87
+ dotRunning: "*",
88
+ dotIdle: ".",
89
+ check: "+",
90
+ cross: "x",
91
+ pending: "o",
92
+ blocked: "!",
93
+ barDone: "#",
94
+ barActive: "+",
95
+ barPending: "-",
96
+ arrow: ">",
97
+ depArrow: "<-",
98
+ transferArrow: "->",
99
+ treeBranch: "|",
100
+ treeLast: "`-",
101
+ treeVertical: "| ",
102
+ treeSpace: " ",
103
+ ellipsis: "...",
104
+ separator: " | ",
105
+ box: { topLeft: "+", topRight: "+", bottomLeft: "+", bottomRight: "+", horizontal: "-", vertical: "|" },
106
+ selectMarker: ">",
107
+ emptyMark: "o",
108
+ upDown: "up/dn",
109
+ model: "~",
110
+ workspace: "[]",
111
+ git: "git",
112
+ tokensIn: "^",
113
+ tokensOut: "v",
114
+ cost: "$",
115
+ cacheHit: "c",
116
+ };
117
+
118
+ const NERD_FONT_TERMINALS = new Set([
119
+ "iTerm.app",
120
+ "Ghostty",
121
+ "WezTerm",
122
+ "kitty",
123
+ "rio",
124
+ "tabby",
125
+ "WindowsTerminal",
126
+ "vscode",
127
+ ]);
128
+
129
+ export function detectNerdFont(): boolean {
130
+ const termProgram = process.env.TERM_PROGRAM;
131
+ if (termProgram && NERD_FONT_TERMINALS.has(termProgram)) return true;
132
+ const lcTerminal = process.env.LC_TERMINAL;
133
+ if (lcTerminal && NERD_FONT_TERMINALS.has(lcTerminal)) return true;
134
+ if (process.env.TERM === "xterm-kitty") return true;
135
+ if (process.env.WT_SESSION) return true;
136
+ return false;
137
+ }
138
+
139
+ export function resolveIconMode(mode: IconMode): "nerd" | "ascii" {
140
+ if (mode === "nerd") return "nerd";
141
+ if (mode === "ascii") return "ascii";
142
+ return detectNerdFont() ? "nerd" : "ascii";
143
+ }
144
+
145
+ export function resolveGlyphs(mode: IconMode): IconGlyphs {
146
+ return resolveIconMode(mode) === "nerd" ? NERD_GLYPHS : ASCII_GLYPHS;
147
+ }
148
+
149
+ /**
150
+ * One source of truth for animation cadence.
151
+ *
152
+ * The frame clock and redraw tick stay aligned so terminal animation advances
153
+ * one frame per paint. A half-second cadence remains legible without keeping
154
+ * the terminal renderer busy during long-running work.
155
+ */
156
+ export const ANIMATION_PERIOD_MS = 500;
157
+
158
+ /**
159
+ * The frame to draw at `now`, or a stable glyph when nothing is driving redraws.
160
+ *
161
+ * A frozen mid-cycle spinner claims "busy" while the UI has actually stopped
162
+ * repainting, so an idle surface gets a static marker instead.
163
+ */
164
+ export function spinFrame(glyphs: IconGlyphs, now: number, animating = true): string {
165
+ if (!animating) return glyphs.dotRunning;
166
+ const frames = glyphs.spinFrames;
167
+ return frames[Math.floor(now / ANIMATION_PERIOD_MS) % frames.length];
168
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./ansi-bridge.ts";
2
+ export * from "./icons.ts";
3
+ export * from "./key-labels.ts";
4
+ export * from "./layout.ts";
5
+ export * from "./overlay.ts";
6
+ export * from "./overlay-host.ts";
7
+ export * from "./overlay-keys.ts";
8
+ export * from "./overlay-render.ts";
9
+ export * from "./overlay-spec.ts";
10
+ export * from "./primitives.ts";
11
+ export * from "./quiet-state.ts";
@@ -0,0 +1,10 @@
1
+ import { platform } from "node:os";
2
+
3
+ // Display only: the key-matching token stays "alt" on every platform.
4
+ export function altLabel(): string {
5
+ return platform() === "darwin" ? "Option" : "Alt";
6
+ }
7
+
8
+ export function altKey(letter: string): string {
9
+ return `${altLabel()}+${letter}`;
10
+ }
@@ -0,0 +1,181 @@
1
+ // Shared line-fitting primitives.
2
+ //
3
+ // Every cockpit surface renders "a list of segments on one terminal line". The
4
+ // footer already degraded segment-by-segment while the widgets blind-clipped the
5
+ // whole joined line, which meant the trailing segments (duration, the Alt+J hint,
6
+ // the next-task label) were always the first casualties — exactly the tokens the
7
+ // non-color-accessibility spec says must survive. This module is the single
8
+ // implementation both sides use.
9
+
10
+ // Width helpers are injected so these stay pure and hermetic in unit tests; the
11
+ // real widgets pass pi-tui's visibleWidth / truncateToWidth so east-asian widths
12
+ // are correct on a terminal.
13
+ export interface WidthUtils {
14
+ measure: (text: string) => number;
15
+ clip: (text: string, width: number, ellipsis: string) => string;
16
+ }
17
+
18
+ export interface PrioritizedSegment {
19
+ text: string;
20
+ /** Higher survives longer. The lowest-priority segment is sacrificed first. */
21
+ priority: number;
22
+ /**
23
+ * When false the segment is dropped outright rather than ellipsized, so a
24
+ * segment can never degrade into a bare "…" that occupies space and says
25
+ * nothing. Defaults to true.
26
+ */
27
+ clippable?: boolean;
28
+ /**
29
+ * Narrowest width at which this segment still carries meaning. Below it the
30
+ * segment is dropped instead of clipped. Segments that carry a glyph prefix
31
+ * need this: clipping "{icon} ~/work/project" to two columns leaves the icon
32
+ * and an ellipsis — decoration with the content eaten out of it.
33
+ */
34
+ minWidth?: number;
35
+ }
36
+
37
+ /**
38
+ * Drop and clip segments by ascending priority until the joined line fits.
39
+ *
40
+ * A segment is only ellipsized when the remaining room still leaves visible
41
+ * characters beyond the ellipsis itself; otherwise it is dropped, because a
42
+ * lone "…" costs a column and carries no information.
43
+ */
44
+ export function fitSegmentsByPriority(
45
+ segs: readonly PrioritizedSegment[],
46
+ maxW: number,
47
+ measure: WidthUtils["measure"],
48
+ clip: WidthUtils["clip"],
49
+ ellipsis = "...",
50
+ separatorWidth = 1,
51
+ ): string[] {
52
+ const ellipsisW = measure(ellipsis);
53
+ const items = segs.map((s) => ({
54
+ text: s.text,
55
+ priority: s.priority,
56
+ clippable: s.clippable !== false,
57
+ minWidth: Math.max(s.minWidth ?? 0, ellipsisW + 1),
58
+ w: measure(s.text),
59
+ }));
60
+ const totalW = (): number => {
61
+ const active = items.filter((it) => it.text !== "");
62
+ return active.reduce((a, it) => a + it.w, 0) + Math.max(0, active.length - 1) * separatorWidth;
63
+ };
64
+ while (totalW() > maxW) {
65
+ let target = -1;
66
+ for (let i = 0; i < items.length; i++) {
67
+ if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
68
+ target = i;
69
+ }
70
+ }
71
+ if (target === -1) break;
72
+ const others = items.filter((_, i) => i !== target && items[i].text !== "");
73
+ const otherW = others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1) * separatorWidth;
74
+ const avail = maxW - otherW - (others.length > 0 ? separatorWidth : 0);
75
+ // Ellipsizing down to decoration-plus-ellipsis wastes columns and says
76
+ // nothing, so a segment must clear its minWidth to survive the clip.
77
+ const drop = (): void => {
78
+ items[target].text = "";
79
+ items[target].w = 0;
80
+ };
81
+ if (!items[target].clippable || avail < items[target].minWidth) {
82
+ drop();
83
+ } else if (avail < items[target].w) {
84
+ const clipped = clip(items[target].text, avail, ellipsis);
85
+ const clippedW = measure(clipped);
86
+ if (clippedW < items[target].minWidth) drop();
87
+ else {
88
+ items[target].text = clipped;
89
+ items[target].w = clippedW;
90
+ }
91
+ } else {
92
+ break;
93
+ }
94
+ }
95
+ return items.filter((it) => it.text !== "").map((it) => it.text);
96
+ }
97
+
98
+ /**
99
+ * Join prioritized segments into a single line that fits `width`.
100
+ *
101
+ * The common case at every call site: build segments, fit them, join with the
102
+ * separator. Returns "" when nothing survives.
103
+ */
104
+ export function fitLineByPriority(
105
+ segs: readonly PrioritizedSegment[],
106
+ width: number,
107
+ utils: WidthUtils,
108
+ separator: string,
109
+ ellipsis: string,
110
+ ): string {
111
+ if (width <= 0) return "";
112
+ const fitted = fitSegmentsByPriority(
113
+ segs,
114
+ width,
115
+ utils.measure,
116
+ utils.clip,
117
+ ellipsis,
118
+ utils.measure(separator),
119
+ );
120
+ return fitted.join(separator);
121
+ }
122
+
123
+ /**
124
+ * First visible index of a scrolling window that keeps `selected` centred where
125
+ * it can, and flush against either end where it cannot.
126
+ */
127
+ export function visibleStart(selected: number, length: number, size: number): number {
128
+ return Math.max(0, Math.min(selected - Math.floor(size / 2), Math.max(0, length - size)));
129
+ }
130
+
131
+ // --- Vertical-axis priority composition ---
132
+ //
133
+ // fitSegmentsByPriority handles the horizontal axis (segments on one line).
134
+ // composeByPriority handles the vertical axis (rows across a panel). Both
135
+ // share the same greedy-drop-lowest-first strategy; the vertical variant
136
+ // operates on multi-row groups and accepts a pluggable cost function so
137
+ // callers can account for section headers, separators, or other overhead.
138
+
139
+ /**
140
+ * A prioritized group of rows for vertical budget composition.
141
+ * Lower dropRank = dropped first when the budget is insufficient.
142
+ * required groups are never dropped.
143
+ */
144
+ export interface PriorityGroup {
145
+ name: string;
146
+ rows: string[];
147
+ required: boolean;
148
+ dropRank: number;
149
+ }
150
+
151
+ const defaultCost = (groups: readonly PriorityGroup[]): number =>
152
+ groups.reduce((sum, g) => sum + g.rows.length, 0);
153
+
154
+ /**
155
+ * Iteratively drop lowest-priority groups until the cost fits the budget.
156
+ * Vertical-axis counterpart of fitSegmentsByPriority.
157
+ *
158
+ * @param groups Candidate groups (empty-row groups are pre-filtered).
159
+ * @param budget Maximum allowed cost (e.g. terminal rows).
160
+ * @param cost Optional cost function; defaults to total row count.
161
+ */
162
+ export function composeByPriority(
163
+ groups: PriorityGroup[],
164
+ budget: number,
165
+ cost: (groups: readonly PriorityGroup[]) => number = defaultCost,
166
+ ): PriorityGroup[] {
167
+ let candidate = groups.filter((g) => g.rows.length > 0);
168
+ while (cost(candidate) > budget) {
169
+ let dropIndex = -1;
170
+ let dropRank = Number.POSITIVE_INFINITY;
171
+ for (let i = 0; i < candidate.length; i++) {
172
+ const g = candidate[i];
173
+ if (!g || g.required || g.dropRank >= dropRank) continue;
174
+ dropRank = g.dropRank;
175
+ dropIndex = i;
176
+ }
177
+ if (dropIndex === -1) break;
178
+ candidate = candidate.filter((_, i) => i !== dropIndex);
179
+ }
180
+ return candidate;
181
+ }