pi-open-tui 0.2.13 → 0.2.15

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.
@@ -1,361 +1,365 @@
1
- import { isAbsolute, relative, resolve, sep } from "node:path";
2
- import type { ThinkingLevel } from "@earendil-works/pi-ai";
3
- import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
4
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
-
6
- export { truncateToWidth, visibleWidth };
7
-
8
- export function stripAnsi(text: string): string {
9
- return text
10
- .replace(/\x1b\[[0-9;]*m/g, "")
11
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
12
- .replace(/\x1b_[^\x07]*\x07/g, "");
13
- }
14
-
15
- export function formatCwd(cwd: string): string {
16
- const home = process.env.HOME || process.env.USERPROFILE;
17
- if (!home) return cwd;
18
- const resolvedCwd = resolve(cwd);
19
- const resolvedHome = resolve(home);
20
- const rel = relative(resolvedHome, resolvedCwd);
21
- const insideHome =
22
- rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
23
- if (!insideHome) return cwd;
24
- return rel === "" ? "~" : `~${sep}${rel}`;
25
- }
26
-
27
- export function basenamePath(path: string): string {
28
- return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
29
- }
30
-
31
- /** Truncate branch names from the end so prefixes like `fix/` stay visible. */
32
- export function truncateBranch(branch: string, maxLen: number): string {
33
- if (branch.length <= maxLen) return branch;
34
- if (maxLen <= 3) return "...".slice(0, maxLen);
35
- return `${branch.slice(0, maxLen - 3)}...`;
36
- }
37
-
38
- export function truncatePath(path: string, maxLen: number): string {
39
- if (path.length <= maxLen) return path;
40
- if (maxLen <= 3) return "...".slice(0, maxLen);
41
- const sepChar = path.includes("/") ? "/" : "\\";
42
- const parts = path.split(/[\\/]/);
43
- if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
44
- // Keep first segment (e.g. ~) and as many trailing segments as fit.
45
- const tail: string[] = [];
46
- let tailLen = 0;
47
- for (let i = parts.length - 1; i >= 1; i--) {
48
- const seg = parts[i]!;
49
- if (tailLen + seg.length + 4 > maxLen) break;
50
- tail.unshift(seg);
51
- tailLen += seg.length + 1;
52
- }
53
- const head = parts[0]!;
54
- const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
55
- return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
56
- }
57
-
58
- export function fmtTokens(n: number): string {
59
- if (n < 1000) return n.toString();
60
- if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
61
- if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
62
- if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
63
- return `${Math.round(n / 1_000_000)}M`;
64
- }
65
-
66
- export function formatDuration(ms: number): string {
67
- const totalSeconds = Math.max(0, Math.floor(ms / 1000));
68
- if (totalSeconds < 60) return `${totalSeconds}s`;
69
- const s = totalSeconds % 60;
70
- const totalMinutes = Math.floor(totalSeconds / 60);
71
- if (totalMinutes < 60) return `${totalMinutes}m ${s}s`;
72
- const m = totalMinutes % 60;
73
- const h = Math.floor(totalMinutes / 60);
74
- return `${h}h ${m}m ${s}s`;
75
- }
76
-
77
- export function formatModelLabel(model: { provider?: string; id?: string } | null | undefined): string {
78
- if (!model?.id) return "no-model";
79
- return model.provider ? `${model.provider}/${model.id}` : model.id;
80
- }
81
-
82
- export function formatProviderLabel(provider: string | undefined): string {
83
- if (!provider) return "Unknown";
84
- return provider.charAt(0).toUpperCase() + provider.slice(1);
85
- }
86
-
87
- export function alignRight(left: string, right: string, width: number, theme: Theme): string {
88
- const rightW = visibleWidth(right);
89
- if (rightW > width) {
90
- right = truncateToWidth(right, width, theme.fg("dim", "..."));
91
- }
92
- const leftW = visibleWidth(left);
93
- const rightW2 = visibleWidth(right);
94
- const pad = width - leftW - rightW2;
95
- if (pad >= 1) {
96
- return left + " ".repeat(pad) + right;
97
- }
98
- const availableForLeft = Math.max(0, width - rightW2 - 1);
99
- const truncatedLeft =
100
- availableForLeft > 0 ? truncateToWidth(left, availableForLeft, theme.fg("dim", "...")) : "";
101
- return truncatedLeft ? truncatedLeft + " " + right : right;
102
- }
103
-
104
- export type PrioritizedSegment = {
105
- text: string;
106
- priority: number;
107
- /** Compact form swapped in before any segment is truncated or dropped. */
108
- compactText?: string;
109
- /** Segment-aware truncation replacing the generic truncateToWidth. */
110
- truncate?: (text: string, maxWidth: number, ellipsis: string) => string;
111
- };
112
-
113
- /**
114
- * Pack segments into maxWidth: compact segments first, then shrink/drop the
115
- * lowest-priority segments (higher priority = survives longer). Returns the
116
- * surviving segment texts in original order, space-joined.
117
- */
118
- export function fitSegmentsByPriority(
119
- segs: readonly PrioritizedSegment[],
120
- maxW: number,
121
- ellipsis = "...",
122
- ): string[] {
123
- const items = segs.map((s) => ({
124
- text: s.text,
125
- compactText: s.compactText,
126
- priority: s.priority,
127
- truncate: s.truncate,
128
- w: visibleWidth(s.text),
129
- }));
130
- const totalW = () => {
131
- const active = items.filter((it) => it.text !== "");
132
- return active.reduce((a, it) => a + it.w, 0) + Math.max(0, active.length - 1);
133
- };
134
- // Compact (e.g. cwd -> basename) before sacrificing any segment content.
135
- if (totalW() > maxW) {
136
- for (const item of items) {
137
- if (!item.compactText || visibleWidth(item.compactText) >= item.w) continue;
138
- item.text = item.compactText;
139
- item.w = visibleWidth(item.text);
140
- if (totalW() <= maxW) break;
141
- }
142
- }
143
- while (totalW() > maxW) {
144
- let target = -1;
145
- for (let i = 0; i < items.length; i++) {
146
- if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
147
- target = i;
148
- }
149
- }
150
- if (target === -1) break;
151
- const others = items.filter((_, i) => i !== target && items[i].text !== "");
152
- const otherW = others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1);
153
- const avail = maxW - otherW - (others.length > 0 ? 1 : 0);
154
- if (avail <= visibleWidth(ellipsis)) {
155
- items[target].text = "";
156
- items[target].w = 0;
157
- } else if (avail < items[target].w) {
158
- const truncate = items[target].truncate;
159
- items[target].text = truncate
160
- ? truncate(items[target].text, avail, ellipsis)
161
- : truncateToWidth(items[target].text, avail, ellipsis);
162
- items[target].w = visibleWidth(items[target].text);
163
- } else {
164
- break;
165
- }
166
- }
167
- return items.filter((it) => it.text !== "").map((it) => it.text);
168
- }
169
-
170
- export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
171
- if (value >= danger) return "error";
172
- if (value >= warn) return "warning";
173
- return "accent";
174
- }
175
-
176
- export function cacheHitColor(value: number): ThemeColor {
177
- if (value < 30) return "error";
178
- if (value < 70) return "warning";
179
- return "success";
180
- }
181
-
182
- export function providerColor(provider: string): ThemeColor {
183
- switch (provider.toLowerCase()) {
184
- case "anthropic":
185
- return "accent";
186
- case "openai":
187
- case "openai-codex":
188
- return "success";
189
- case "google":
190
- case "google-vertex":
191
- return "warning";
192
- case "amazon-bedrock":
193
- return "thinkingHigh";
194
- case "github-copilot":
195
- return "mdLink";
196
- case "deepseek":
197
- return "thinkingLow";
198
- case "xai":
199
- case "groq":
200
- return "error";
201
- default:
202
- return "muted";
203
- }
204
- }
205
-
206
- export function effortColor(level: ThinkingLevel | string | undefined): ThemeColor {
207
- switch (level) {
208
- case "minimal":
209
- return "thinkingMinimal";
210
- case "low":
211
- return "thinkingLow";
212
- case "medium":
213
- return "thinkingMedium";
214
- case "high":
215
- return "thinkingHigh";
216
- case "xhigh":
217
- return "thinkingXhigh";
218
- default:
219
- return "thinkingMedium";
220
- }
221
- }
222
-
223
- export function isEditorBorderLine(line: string): boolean {
224
- const plain = stripAnsi(line);
225
- if (/^─+$/.test(plain)) return true;
226
- if (/^─*\s*[↑↓]\s+\d+\s+more\s*─*$/.test(plain)) return true;
227
- return false;
228
- }
229
-
230
- export function findBottomBorderIndex(lines: string[]): number {
231
- for (let i = lines.length - 1; i >= 1; i--) {
232
- if (isEditorBorderLine(lines[i]!)) return i;
233
- }
234
- return Math.max(0, lines.length - 1);
235
- }
236
-
237
- export function padRight(text: string, width: number, ellipsis = ""): string {
238
- const clipped = truncateToWidth(text, width, ellipsis);
239
- return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
240
- }
241
-
242
- export function center(text: string, width: number): string {
243
- if (width <= 0) return "";
244
- const w = visibleWidth(text);
245
- if (w >= width) return truncateToWidth(text, width, "...");
246
- return `${" ".repeat(Math.floor((width - w) / 2))}${text}`;
247
- }
248
-
249
- export function sanitizeStatus(text: string): string {
250
- return stripAnsi(text)
251
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
252
- .replace(/ +/g, " ")
253
- .trim();
254
- }
255
-
256
- export function formatThinkingLabel(level: string): string {
257
- if (level === "off") return "thinking off";
258
- return `${level} effort`;
259
- }
260
-
261
- export const PI_BUILTIN_SLASH_COMMAND_NAMES = [
262
- "settings",
263
- "model",
264
- "scoped-models",
265
- "export",
266
- "import",
267
- "share",
268
- "copy",
269
- "name",
270
- "session",
271
- "changelog",
272
- "hotkeys",
273
- "fork",
274
- "clone",
275
- "tree",
276
- "trust",
277
- "login",
278
- "logout",
279
- "new",
280
- "compact",
281
- "resume",
282
- "reload",
283
- "quit",
284
- ] as const;
285
-
286
- export function collectPiCommandNames(sessionCommands: readonly { name: string }[]): string[] {
287
- const names = new Set<string>(PI_BUILTIN_SLASH_COMMAND_NAMES);
288
- for (const command of sessionCommands) {
289
- if (command.name) names.add(command.name);
290
- }
291
- return [...names];
292
- }
293
-
294
- export function pickSlashCommandTips(
295
- availableNames: readonly string[],
296
- options: {
297
- fixed?: readonly string[];
298
- count?: number;
299
- exclude?: readonly string[];
300
- random?: () => number;
301
- } = {},
302
- ): string[] {
303
- const fixed = [...(options.fixed ?? [])];
304
- const count = options.count ?? 3;
305
- const exclude = new Set<string>([...(options.exclude ?? []), ...fixed]);
306
- const random = options.random ?? Math.random;
307
-
308
- const pool = [...new Set(availableNames.map((n) => n.trim()).filter(Boolean))].filter(
309
- (name) => !exclude.has(name),
310
- );
311
-
312
- for (let i = pool.length - 1; i > 0; i--) {
313
- const j = Math.floor(random() * (i + 1));
314
- const tmp = pool[i]!;
315
- pool[i] = pool[j]!;
316
- pool[j] = tmp;
317
- }
318
-
319
- const picked = pool.slice(0, Math.max(0, count));
320
- return [...fixed, ...picked].map((name) => (name.startsWith("/") ? name : `/${name}`));
321
- }
322
-
323
- export const MIN_LEFT_WIDTH = 28;
324
- export const MIN_TIPS_WIDTH = 16;
325
- export const MAX_TIPS_WIDTH = 28;
326
- const COLUMN_GAP = 3;
327
-
328
- export function headerColumnWidths(
329
- innerWidth: number,
330
- minTipsWidth = MIN_TIPS_WIDTH,
331
- maxTipsWidth = MAX_TIPS_WIDTH,
332
- minLeftWidth = MIN_LEFT_WIDTH,
333
- ): { leftWidth: number; rightWidth: number; useTips: boolean } {
334
- if (innerWidth <= 0) {
335
- return { leftWidth: 0, rightWidth: 0, useTips: false };
336
- }
337
-
338
- const gap = COLUMN_GAP;
339
- if (innerWidth < minLeftWidth + gap + minTipsWidth) {
340
- return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
341
- }
342
-
343
- let rightWidth = Math.min(maxTipsWidth, Math.max(minTipsWidth, Math.round(innerWidth * 0.28)));
344
- let leftWidth = innerWidth - gap - rightWidth;
345
-
346
- if (leftWidth < minLeftWidth) {
347
- leftWidth = minLeftWidth;
348
- rightWidth = innerWidth - gap - leftWidth;
349
- }
350
-
351
- if (leftWidth <= rightWidth) {
352
- leftWidth = Math.ceil((innerWidth - gap) * 0.65);
353
- rightWidth = innerWidth - gap - leftWidth;
354
- }
355
-
356
- if (rightWidth < minTipsWidth || leftWidth < minLeftWidth) {
357
- return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
358
- }
359
-
360
- return { leftWidth, rightWidth, useTips: true };
361
- }
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import type { ThinkingLevel } from "@earendil-works/pi-ai";
3
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
5
+
6
+ export { truncateToWidth, visibleWidth };
7
+
8
+ export function stripAnsi(text: string): string {
9
+ return text
10
+ .replace(/\x1b\[[0-9;]*m/g, "")
11
+ .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
12
+ .replace(/\x1b_[^\x07]*\x07/g, "");
13
+ }
14
+
15
+ export function formatCwd(cwd: string): string {
16
+ const home = process.env.HOME || process.env.USERPROFILE;
17
+ if (!home) return cwd;
18
+ const resolvedCwd = resolve(cwd);
19
+ const resolvedHome = resolve(home);
20
+ const rel = relative(resolvedHome, resolvedCwd);
21
+ const insideHome =
22
+ rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
23
+ if (!insideHome) return cwd;
24
+ return rel === "" ? "~" : `~${sep}${rel}`;
25
+ }
26
+
27
+ export function basenamePath(path: string): string {
28
+ return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
29
+ }
30
+
31
+ /** Truncate branch names from the end so prefixes like `fix/` stay visible. */
32
+ export function truncateBranch(branch: string, maxLen: number): string {
33
+ if (branch.length <= maxLen) return branch;
34
+ if (maxLen <= 3) return "...".slice(0, maxLen);
35
+ return `${branch.slice(0, maxLen - 3)}...`;
36
+ }
37
+
38
+ export function truncatePath(path: string, maxLen: number): string {
39
+ if (path.length <= maxLen) return path;
40
+ if (maxLen <= 3) return "...".slice(0, maxLen);
41
+ const sepChar = path.includes("/") ? "/" : "\\";
42
+ const parts = path.split(/[\\/]/);
43
+ if (parts.length <= 2) return path.slice(0, maxLen - 3) + "...";
44
+ // Keep first segment (e.g. ~) and as many trailing segments as fit.
45
+ const tail: string[] = [];
46
+ let tailLen = 0;
47
+ for (let i = parts.length - 1; i >= 1; i--) {
48
+ const seg = parts[i]!;
49
+ if (tailLen + seg.length + 4 > maxLen) break;
50
+ tail.unshift(seg);
51
+ tailLen += seg.length + 1;
52
+ }
53
+ const head = parts[0]!;
54
+ const result = `${head}${sepChar}...${sepChar}${tail.join(sepChar)}`;
55
+ return result.length > maxLen ? result.slice(0, maxLen - 3) + "..." : result;
56
+ }
57
+
58
+ export function finiteOrZero(value: unknown): number {
59
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
60
+ }
61
+
62
+ export function fmtTokens(n: number): string {
63
+ if (n < 1000) return n.toString();
64
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
65
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
66
+ if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
67
+ return `${Math.round(n / 1_000_000)}M`;
68
+ }
69
+
70
+ export function formatDuration(ms: number): string {
71
+ const totalSeconds = Math.max(0, Math.floor(ms / 1000));
72
+ if (totalSeconds < 60) return `${totalSeconds}s`;
73
+ const s = totalSeconds % 60;
74
+ const totalMinutes = Math.floor(totalSeconds / 60);
75
+ if (totalMinutes < 60) return `${totalMinutes}m ${s}s`;
76
+ const m = totalMinutes % 60;
77
+ const h = Math.floor(totalMinutes / 60);
78
+ return `${h}h ${m}m ${s}s`;
79
+ }
80
+
81
+ export function formatModelLabel(model: { provider?: string; id?: string } | null | undefined): string {
82
+ if (!model?.id) return "no-model";
83
+ return model.provider ? `${model.provider}/${model.id}` : model.id;
84
+ }
85
+
86
+ export function formatProviderLabel(provider: string | undefined): string {
87
+ if (!provider) return "Unknown";
88
+ return provider.charAt(0).toUpperCase() + provider.slice(1);
89
+ }
90
+
91
+ export function alignRight(left: string, right: string, width: number, theme: Theme): string {
92
+ const rightW = visibleWidth(right);
93
+ if (rightW > width) {
94
+ right = truncateToWidth(right, width, theme.fg("dim", "..."));
95
+ }
96
+ const leftW = visibleWidth(left);
97
+ const rightW2 = visibleWidth(right);
98
+ const pad = width - leftW - rightW2;
99
+ if (pad >= 1) {
100
+ return left + " ".repeat(pad) + right;
101
+ }
102
+ const availableForLeft = Math.max(0, width - rightW2 - 1);
103
+ const truncatedLeft =
104
+ availableForLeft > 0 ? truncateToWidth(left, availableForLeft, theme.fg("dim", "...")) : "";
105
+ return truncatedLeft ? truncatedLeft + " " + right : right;
106
+ }
107
+
108
+ export type PrioritizedSegment = {
109
+ text: string;
110
+ priority: number;
111
+ /** Compact form swapped in before any segment is truncated or dropped. */
112
+ compactText?: string;
113
+ /** Segment-aware truncation replacing the generic truncateToWidth. */
114
+ truncate?: (text: string, maxWidth: number, ellipsis: string) => string;
115
+ };
116
+
117
+ /**
118
+ * Pack segments into maxWidth: compact segments first, then shrink/drop the
119
+ * lowest-priority segments (higher priority = survives longer). Returns the
120
+ * surviving segment texts in original order, space-joined.
121
+ */
122
+ export function fitSegmentsByPriority(
123
+ segs: readonly PrioritizedSegment[],
124
+ maxW: number,
125
+ ellipsis = "...",
126
+ ): string[] {
127
+ const items = segs.map((s) => ({
128
+ text: s.text,
129
+ compactText: s.compactText,
130
+ priority: s.priority,
131
+ truncate: s.truncate,
132
+ w: visibleWidth(s.text),
133
+ }));
134
+ const totalW = () => {
135
+ const active = items.filter((it) => it.text !== "");
136
+ return active.reduce((a, it) => a + it.w, 0) + Math.max(0, active.length - 1);
137
+ };
138
+ // Compact (e.g. cwd -> basename) before sacrificing any segment content.
139
+ if (totalW() > maxW) {
140
+ for (const item of items) {
141
+ if (!item.compactText || visibleWidth(item.compactText) >= item.w) continue;
142
+ item.text = item.compactText;
143
+ item.w = visibleWidth(item.text);
144
+ if (totalW() <= maxW) break;
145
+ }
146
+ }
147
+ while (totalW() > maxW) {
148
+ let target = -1;
149
+ for (let i = 0; i < items.length; i++) {
150
+ if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
151
+ target = i;
152
+ }
153
+ }
154
+ if (target === -1) break;
155
+ const others = items.filter((_, i) => i !== target && items[i].text !== "");
156
+ const otherW = others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1);
157
+ const avail = maxW - otherW - (others.length > 0 ? 1 : 0);
158
+ if (avail <= visibleWidth(ellipsis)) {
159
+ items[target].text = "";
160
+ items[target].w = 0;
161
+ } else if (avail < items[target].w) {
162
+ const truncate = items[target].truncate;
163
+ items[target].text = truncate
164
+ ? truncate(items[target].text, avail, ellipsis)
165
+ : truncateToWidth(items[target].text, avail, ellipsis);
166
+ items[target].w = visibleWidth(items[target].text);
167
+ } else {
168
+ break;
169
+ }
170
+ }
171
+ return items.filter((it) => it.text !== "").map((it) => it.text);
172
+ }
173
+
174
+ export function stressColor(value: number, warn = 70, danger = 90): ThemeColor {
175
+ if (value >= danger) return "error";
176
+ if (value >= warn) return "warning";
177
+ return "accent";
178
+ }
179
+
180
+ export function cacheHitColor(value: number): ThemeColor {
181
+ if (value < 30) return "error";
182
+ if (value < 70) return "warning";
183
+ return "success";
184
+ }
185
+
186
+ export function providerColor(provider: string): ThemeColor {
187
+ switch (provider.toLowerCase()) {
188
+ case "anthropic":
189
+ return "accent";
190
+ case "openai":
191
+ case "openai-codex":
192
+ return "success";
193
+ case "google":
194
+ case "google-vertex":
195
+ return "warning";
196
+ case "amazon-bedrock":
197
+ return "thinkingHigh";
198
+ case "github-copilot":
199
+ return "mdLink";
200
+ case "deepseek":
201
+ return "thinkingLow";
202
+ case "xai":
203
+ case "groq":
204
+ return "error";
205
+ default:
206
+ return "muted";
207
+ }
208
+ }
209
+
210
+ export function effortColor(level: ThinkingLevel | string | undefined): ThemeColor {
211
+ switch (level) {
212
+ case "minimal":
213
+ return "thinkingMinimal";
214
+ case "low":
215
+ return "thinkingLow";
216
+ case "medium":
217
+ return "thinkingMedium";
218
+ case "high":
219
+ return "thinkingHigh";
220
+ case "xhigh":
221
+ return "thinkingXhigh";
222
+ default:
223
+ return "thinkingMedium";
224
+ }
225
+ }
226
+
227
+ export function isEditorBorderLine(line: string): boolean {
228
+ const plain = stripAnsi(line);
229
+ if (/^─+$/.test(plain)) return true;
230
+ if (/^─*\s*[↑↓]\s+\d+\s+more\s*─*$/.test(plain)) return true;
231
+ return false;
232
+ }
233
+
234
+ export function findBottomBorderIndex(lines: string[]): number {
235
+ for (let i = lines.length - 1; i >= 1; i--) {
236
+ if (isEditorBorderLine(lines[i]!)) return i;
237
+ }
238
+ return Math.max(0, lines.length - 1);
239
+ }
240
+
241
+ export function padRight(text: string, width: number, ellipsis = ""): string {
242
+ const clipped = truncateToWidth(text, width, ellipsis);
243
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
244
+ }
245
+
246
+ export function center(text: string, width: number): string {
247
+ if (width <= 0) return "";
248
+ const w = visibleWidth(text);
249
+ if (w >= width) return truncateToWidth(text, width, "...");
250
+ return `${" ".repeat(Math.floor((width - w) / 2))}${text}`;
251
+ }
252
+
253
+ export function sanitizeStatus(text: string): string {
254
+ return stripAnsi(text)
255
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
256
+ .replace(/ +/g, " ")
257
+ .trim();
258
+ }
259
+
260
+ export function formatThinkingLabel(level: string): string {
261
+ if (level === "off") return "thinking off";
262
+ return `${level} effort`;
263
+ }
264
+
265
+ export const PI_BUILTIN_SLASH_COMMAND_NAMES = [
266
+ "settings",
267
+ "model",
268
+ "scoped-models",
269
+ "export",
270
+ "import",
271
+ "share",
272
+ "copy",
273
+ "name",
274
+ "session",
275
+ "changelog",
276
+ "hotkeys",
277
+ "fork",
278
+ "clone",
279
+ "tree",
280
+ "trust",
281
+ "login",
282
+ "logout",
283
+ "new",
284
+ "compact",
285
+ "resume",
286
+ "reload",
287
+ "quit",
288
+ ] as const;
289
+
290
+ export function collectPiCommandNames(sessionCommands: readonly { name: string }[]): string[] {
291
+ const names = new Set<string>(PI_BUILTIN_SLASH_COMMAND_NAMES);
292
+ for (const command of sessionCommands) {
293
+ if (command.name) names.add(command.name);
294
+ }
295
+ return [...names];
296
+ }
297
+
298
+ export function pickSlashCommandTips(
299
+ availableNames: readonly string[],
300
+ options: {
301
+ fixed?: readonly string[];
302
+ count?: number;
303
+ exclude?: readonly string[];
304
+ random?: () => number;
305
+ } = {},
306
+ ): string[] {
307
+ const fixed = [...(options.fixed ?? [])];
308
+ const count = options.count ?? 3;
309
+ const exclude = new Set<string>([...(options.exclude ?? []), ...fixed]);
310
+ const random = options.random ?? Math.random;
311
+
312
+ const pool = [...new Set(availableNames.map((n) => n.trim()).filter(Boolean))].filter(
313
+ (name) => !exclude.has(name),
314
+ );
315
+
316
+ for (let i = pool.length - 1; i > 0; i--) {
317
+ const j = Math.floor(random() * (i + 1));
318
+ const tmp = pool[i]!;
319
+ pool[i] = pool[j]!;
320
+ pool[j] = tmp;
321
+ }
322
+
323
+ const picked = pool.slice(0, Math.max(0, count));
324
+ return [...fixed, ...picked].map((name) => (name.startsWith("/") ? name : `/${name}`));
325
+ }
326
+
327
+ export const MIN_LEFT_WIDTH = 28;
328
+ export const MIN_TIPS_WIDTH = 16;
329
+ export const MAX_TIPS_WIDTH = 28;
330
+ const COLUMN_GAP = 3;
331
+
332
+ export function headerColumnWidths(
333
+ innerWidth: number,
334
+ minTipsWidth = MIN_TIPS_WIDTH,
335
+ maxTipsWidth = MAX_TIPS_WIDTH,
336
+ minLeftWidth = MIN_LEFT_WIDTH,
337
+ ): { leftWidth: number; rightWidth: number; useTips: boolean } {
338
+ if (innerWidth <= 0) {
339
+ return { leftWidth: 0, rightWidth: 0, useTips: false };
340
+ }
341
+
342
+ const gap = COLUMN_GAP;
343
+ if (innerWidth < minLeftWidth + gap + minTipsWidth) {
344
+ return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
345
+ }
346
+
347
+ let rightWidth = Math.min(maxTipsWidth, Math.max(minTipsWidth, Math.round(innerWidth * 0.28)));
348
+ let leftWidth = innerWidth - gap - rightWidth;
349
+
350
+ if (leftWidth < minLeftWidth) {
351
+ leftWidth = minLeftWidth;
352
+ rightWidth = innerWidth - gap - leftWidth;
353
+ }
354
+
355
+ if (leftWidth <= rightWidth) {
356
+ leftWidth = Math.ceil((innerWidth - gap) * 0.65);
357
+ rightWidth = innerWidth - gap - leftWidth;
358
+ }
359
+
360
+ if (rightWidth < minTipsWidth || leftWidth < minLeftWidth) {
361
+ return { leftWidth: innerWidth, rightWidth: 0, useTips: false };
362
+ }
363
+
364
+ return { leftWidth, rightWidth, useTips: true };
365
+ }