pi-maestro-teammate 2.6.2 → 2.7.1
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/node_modules/pi-maestro-settings-core/package.json +3 -2
- package/node_modules/pi-maestro-settings-core/src/index.ts +2 -1
- package/node_modules/pi-maestro-settings-core/src/public/v1/index.ts +32 -4
- package/node_modules/pi-maestro-settings-core/src/ui/ansi-bridge.ts +102 -0
- package/node_modules/pi-maestro-settings-core/src/ui/icons.ts +168 -0
- package/node_modules/pi-maestro-settings-core/src/ui/index.ts +11 -0
- package/node_modules/pi-maestro-settings-core/src/ui/key-labels.ts +10 -0
- package/node_modules/pi-maestro-settings-core/src/ui/layout.ts +181 -0
- package/node_modules/pi-maestro-settings-core/src/ui/overlay-host.ts +277 -0
- package/node_modules/pi-maestro-settings-core/src/ui/overlay-keys.ts +56 -0
- package/node_modules/pi-maestro-settings-core/src/ui/overlay-render.ts +233 -0
- package/node_modules/pi-maestro-settings-core/src/ui/overlay-spec.ts +107 -0
- package/node_modules/pi-maestro-settings-core/src/ui/overlay.ts +205 -0
- package/node_modules/pi-maestro-settings-core/src/ui/primitives.ts +86 -0
- package/{src → node_modules/pi-maestro-settings-core/src/ui}/quiet-state.ts +36 -35
- package/package.json +21 -8
- package/src/backends/acp-registry.ts +2 -2
- package/src/backends/registry-host.ts +122 -5
- package/src/classify/client.ts +159 -0
- package/src/classify/domains.ts +146 -0
- package/src/classify/engine.ts +343 -0
- package/src/classify/types.ts +147 -0
- package/src/extension/background-status-heartbeat.ts +223 -0
- package/src/extension/index.ts +350 -34
- package/src/extension/schemas.ts +15 -0
- package/src/extension/teammate-core.ts +2333 -2319
- package/src/extension/teammate-helpers.ts +2356 -2356
- package/src/extension/teammate-proxy.ts +3533 -3528
- package/src/key-labels.ts +1 -10
- package/src/models/model-registry.ts +42 -1
- package/src/models/model-routing.ts +28 -1
- package/src/public/v1/backends.ts +1 -0
- package/src/public/v1/classify.ts +64 -0
- package/src/public/v1/external-agent-projections.ts +278 -0
- package/src/public/v1/fabric-runtime.ts +473 -0
- package/src/public/v1/index.ts +2 -0
- package/src/runs/execution-infra.ts +52 -2
- package/src/runs/execution.ts +506 -100
- package/src/runs/fork-snapshot.ts +36 -13
- package/src/runs/retry.ts +57 -20
- package/src/settings/teammate-settings-provider.ts +130 -22
- package/src/shared/agent-directory.ts +15 -0
- package/src/shared/agent-status.ts +5 -2
- package/src/shared/cockpit-events.ts +2 -0
- package/src/shared/diagnostic-log.ts +41 -1
- package/src/shared/limits.ts +11 -0
- package/src/shared/types.ts +3 -0
- package/src/transcript/session-history.ts +48 -6
- package/src/tui/attach-overlay.ts +135 -121
- package/src/tui/render.ts +1431 -1431
- package/src/tui/session-send-overlay.ts +57 -33
- package/types/backends/registry-host.d.ts +34 -2
- package/types/classify/client.d.ts +38 -0
- package/types/classify/domains.d.ts +33 -0
- package/types/classify/engine.d.ts +86 -0
- package/types/classify/types.d.ts +137 -0
- package/types/extension/background-status-heartbeat.d.ts +56 -0
- package/types/extension/schemas.d.ts +11 -7
- package/types/extension/teammate-core.d.ts +1 -1
- package/types/key-labels.d.ts +1 -2
- package/types/models/model-registry.d.ts +13 -0
- package/types/models/model-routing.d.ts +4 -0
- package/types/public/v1/backends.d.ts +1 -1
- package/types/public/v1/classify.d.ts +8 -0
- package/types/public/v1/external-agent-projections.d.ts +70 -0
- package/types/public/v1/fabric-runtime.d.ts +131 -0
- package/types/public/v1/index.d.ts +2 -0
- package/types/runs/execution-infra.d.ts +23 -0
- package/types/runs/execution.d.ts +30 -1
- package/types/runs/retry.d.ts +19 -3
- package/types/settings/teammate-settings-provider.d.ts +3 -0
- package/types/shared/agent-directory.d.ts +3 -0
- package/types/shared/cockpit-events.d.ts +2 -0
- package/types/shared/diagnostic-log.d.ts +7 -0
- package/types/shared/limits.d.ts +10 -0
- package/types/shared/types.d.ts +3 -0
- package/types/tui/attach-overlay.d.ts +4 -1
- package/types/tui/session-send-overlay.d.ts +5 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-maestro-settings-core",
|
|
3
|
-
"version": "0.2.
|
|
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 "./
|
|
3
|
-
export * from "./
|
|
4
|
-
|
|
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
|
+
}
|