pi-async 0.1.0 → 0.3.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/CHANGELOG.md +188 -0
- package/README.md +93 -19
- package/docs/CONTRACT.md +301 -61
- package/extensions/index.ts +221 -38
- package/package.json +6 -3
- package/src/core/bus.ts +11 -3
- package/src/core/duration.ts +20 -0
- package/src/core/env.ts +134 -0
- package/src/core/notify.ts +82 -18
- package/src/core/output.ts +48 -2
- package/src/core/registry.ts +646 -95
- package/src/core/spawn.ts +95 -20
- package/src/core/task.ts +38 -8
- package/src/core/watch.ts +49 -13
- package/src/guard/settle-guard.ts +152 -46
- package/src/tools/index.ts +221 -27
- package/src/tui/panel.ts +375 -80
- package/src/tui/rail.ts +117 -33
- package/src/tui/status.ts +102 -25
- package/src/tui/width.ts +53 -0
package/src/tui/rail.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import type { PiCtx } from "../core/notify.ts";
|
|
2
2
|
import type { TaskRegistry } from "../core/registry.ts";
|
|
3
3
|
import type { TaskSnapshot } from "../core/task.ts";
|
|
4
|
+
import { visibleText } from "../core/output.ts";
|
|
5
|
+
import { formatDuration } from "../core/duration.ts";
|
|
6
|
+
import { inRailWindow, railLingerMs, railWindowCounts, summarySegments, type SummaryCounts } from "./status.ts";
|
|
7
|
+
import { displayWidth, truncateToWidth } from "./width.ts";
|
|
4
8
|
import type { TUI, TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
5
9
|
import { openPanel } from "./panel.ts";
|
|
6
10
|
import { tuiHost } from "./host.ts";
|
|
@@ -9,6 +13,8 @@ type Theme = { fg(color: string, text: string): string; getFgAnsi?(color: string
|
|
|
9
13
|
export interface RailWidget {
|
|
10
14
|
render(width?: number): string[];
|
|
11
15
|
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined;
|
|
16
|
+
// 0.3.0 §2.9: the rail has no keyboard scrolling (it never receives focus), so
|
|
17
|
+
// `handleInput` / `RAIL_PAGE` were removed. Mouse wheel + click remain.
|
|
12
18
|
dispose?(): void;
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -58,71 +64,143 @@ function typeColor(theme: Theme, mode: string, used: Swatch[]): string {
|
|
|
58
64
|
}
|
|
59
65
|
|
|
60
66
|
const statePriority: Record<TaskSnapshot["status"], number> = { running: 0, pending: 1, completed: 2, failed: 3, killed: 4 };
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
/** Alias for tests / callers still importing elapsed; single source is core/duration. */
|
|
68
|
+
export { formatDuration as elapsed };
|
|
69
|
+
/** Fit a row to at most `width` code points; never exceed the budget (empty when width<=0). */
|
|
70
|
+
/** Truncate a rail row by terminal cell width (CJK/emoji-aware via #7). */
|
|
71
|
+
export function fitRailRow(row: string, width: number): string {
|
|
72
|
+
return truncateToWidth(row, Math.max(0, Math.floor(width)));
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const minutes = Math.floor(seconds / 60);
|
|
69
|
-
return minutes < 60 ? `${minutes}m ${seconds % 60}s` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
70
|
-
}
|
|
75
|
+
export const RAIL_MAX_LINES = 4;
|
|
76
|
+
export const RAIL_TASK_SLOTS = 3;
|
|
77
|
+
export const RAIL_TASK_SLOTS_OVERFLOW = 2;
|
|
71
78
|
|
|
72
|
-
/** Pure, unstyled rail rows; active tasks first and a hard
|
|
79
|
+
/** Pure, unstyled rail rows; active tasks first and a hard three-row limit. */
|
|
73
80
|
export function renderRailRows(snapshots: TaskSnapshot[], width: number, now = Date.now()): string[] {
|
|
74
|
-
const rows = snapshots
|
|
81
|
+
const rows = snapshots
|
|
75
82
|
.slice().sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt))
|
|
76
|
-
.slice(0,
|
|
77
|
-
.map((task) => `▶ ${
|
|
83
|
+
.slice(0, RAIL_TASK_SLOTS)
|
|
84
|
+
.map((task) => `▶ ${visibleText(task.name)} · ${task.status} · ${formatDuration(task, now)}`);
|
|
78
85
|
const limit = Math.max(0, Math.floor(width));
|
|
79
|
-
return rows.map((row) =>
|
|
86
|
+
return rows.map((row) => truncateToWidth(row, limit));
|
|
80
87
|
}
|
|
81
88
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
89
|
+
let railTicker: ReturnType<typeof setInterval> | undefined;
|
|
90
|
+
|
|
91
|
+
function coloredSummary(counts: SummaryCounts, now: number, width: number, theme: Theme): string {
|
|
92
|
+
const plainSegments = summarySegments(counts, now);
|
|
93
|
+
let kept = plainSegments.map((segment) => ({ ...segment }));
|
|
94
|
+
const plain = () => kept.map((segment) => segment.text).join("");
|
|
95
|
+
const removableSegments = [
|
|
96
|
+
(segment: SummarySegmentLike) => segment.token === "error",
|
|
97
|
+
(segment: SummarySegmentLike) => segment.token === "success",
|
|
98
|
+
(segment: SummarySegmentLike) => segment.text.startsWith(" · ") && segment.token === "dim" && !segment.text.includes("pending"),
|
|
99
|
+
(segment: SummarySegmentLike) => segment.text.includes(" pending"),
|
|
100
|
+
];
|
|
101
|
+
while (displayWidth(plain()) > Math.max(0, Math.floor(width))) {
|
|
102
|
+
const predicate = removableSegments.find((remove) => kept.some(remove));
|
|
103
|
+
if (!predicate) break;
|
|
104
|
+
kept = kept.filter((segment) => !predicate(segment));
|
|
105
|
+
}
|
|
106
|
+
const truncated = truncateToWidth(plain(), Math.max(0, Math.floor(width)));
|
|
107
|
+
let offset = 0;
|
|
108
|
+
return kept.map((segment) => {
|
|
109
|
+
const text = truncated.slice(offset, offset + segment.text.length);
|
|
110
|
+
offset += segment.text.length;
|
|
111
|
+
if (!text) return "";
|
|
112
|
+
try { return theme.fg(segment.token, text) ?? text; } catch { return text; }
|
|
113
|
+
}).join("");
|
|
86
114
|
}
|
|
87
115
|
|
|
116
|
+
type SummarySegmentLike = { text: string; token: string };
|
|
117
|
+
|
|
88
118
|
export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onOpenTask?: (taskId: string) => void } = {}): (tui: TUI, theme: Theme) => RailWidget {
|
|
89
119
|
// Factory signature includes the extension context for future host-specific rail integrations.
|
|
90
120
|
void pi;
|
|
91
121
|
return (tui, theme) => {
|
|
122
|
+
if (railTicker) clearInterval(railTicker);
|
|
123
|
+
const ticker = setInterval(() => {
|
|
124
|
+
// A disposed/failing host must not crash the timer (same class as the retired heartbeat).
|
|
125
|
+
try { tui.requestRender(); } catch { /* repaint is best-effort */ }
|
|
126
|
+
}, 1_000);
|
|
127
|
+
railTicker = ticker;
|
|
128
|
+
ticker.unref?.();
|
|
92
129
|
let scroll = 0;
|
|
93
130
|
let hitRows: string[] = [];
|
|
94
131
|
let latchedId: string | undefined;
|
|
95
132
|
// Terminal tasks linger briefly so the user sees the outcome, then drop off
|
|
96
133
|
// the rail (CONTRACT §8: idle sessions render nothing). Live tasks always show.
|
|
97
|
-
const lingerMs =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
134
|
+
const lingerMs = railLingerMs();
|
|
135
|
+
// Rows and summary counts are drawn from one snapshot + one window so `✓N`
|
|
136
|
+
// always has a matching row (0.3.0 §2.9).
|
|
137
|
+
const listAll = (): TaskSnapshot[] => {
|
|
138
|
+
try { return registry.list({ includeHistory: true }); } catch { return []; }
|
|
139
|
+
};
|
|
140
|
+
const ordered = (all: TaskSnapshot[], now = Date.now()): TaskSnapshot[] => all
|
|
141
|
+
.filter((task) => inRailWindow(task, now, lingerMs))
|
|
142
|
+
.sort((a, b) => statePriority[a.status] - statePriority[b.status] || (b.startedAt ?? b.createdAt) - (a.startedAt ?? a.createdAt));
|
|
143
|
+
const clampScroll = (count: number, taskSlots: number): void => {
|
|
144
|
+
scroll = Math.max(0, Math.min(scroll, Math.max(0, count - taskSlots)));
|
|
145
|
+
};
|
|
102
146
|
const render = (width = 120): string[] => {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
hitRows =
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
const all = listAll();
|
|
149
|
+
const tasks = ordered(all, now);
|
|
150
|
+
if (tasks.length === 0) { hitRows = []; return []; }
|
|
151
|
+
const overflow = tasks.length > RAIL_TASK_SLOTS;
|
|
152
|
+
const taskSlots = overflow ? RAIL_TASK_SLOTS_OVERFLOW : RAIL_TASK_SLOTS;
|
|
153
|
+
clampScroll(tasks.length, taskSlots);
|
|
154
|
+
const visible = tasks.slice(scroll, scroll + taskSlots);
|
|
155
|
+
const counts = railWindowCounts(all, now, lingerMs);
|
|
156
|
+
hitRows = ["", ...visible.map((task) => task.taskId)];
|
|
107
157
|
const used = [swatch(theme, "error"), swatch(theme, "dim")];
|
|
108
|
-
|
|
158
|
+
const rows = [coloredSummary(counts, now, width, theme), ...renderRailRows(visible, width).map((row, index) => {
|
|
109
159
|
const task = visible[index];
|
|
110
160
|
if (!task) return row;
|
|
111
161
|
let token = "dim";
|
|
112
162
|
if (task.status === "running") token = typeColor(theme, task.mode, used);
|
|
113
163
|
else if (task.status === "completed") token = "success";
|
|
114
164
|
else if (task.status === "failed") token = "error";
|
|
115
|
-
|
|
116
|
-
|
|
165
|
+
try {
|
|
166
|
+
return theme.fg(token, row) ?? row;
|
|
167
|
+
} catch {
|
|
168
|
+
return row;
|
|
169
|
+
}
|
|
170
|
+
})];
|
|
171
|
+
if (overflow) {
|
|
172
|
+
const hidden = tasks.length - visible.length;
|
|
173
|
+
const above = scroll;
|
|
174
|
+
const below = Math.max(0, tasks.length - scroll - visible.length);
|
|
175
|
+
const label = above > 0 && below > 0
|
|
176
|
+
? `… +${hidden} more (↑${above} ↓${below})`
|
|
177
|
+
: above > 0
|
|
178
|
+
? `… +${above} more ↑`
|
|
179
|
+
: `… +${below} more`;
|
|
180
|
+
const indicator = fitRailRow(label, width);
|
|
181
|
+
try {
|
|
182
|
+
rows.push(theme.fg("dim", indicator) ?? indicator);
|
|
183
|
+
} catch {
|
|
184
|
+
rows.push(indicator);
|
|
185
|
+
}
|
|
186
|
+
hitRows.push(""); // overflow row is not a task hit target
|
|
187
|
+
}
|
|
188
|
+
// Bind the ≤4 invariant to RAIL_MAX_LINES so retuning the slot constants cannot silently break it.
|
|
189
|
+
if (rows.length > RAIL_MAX_LINES) {
|
|
190
|
+
rows.length = RAIL_MAX_LINES;
|
|
191
|
+
hitRows.length = RAIL_MAX_LINES;
|
|
192
|
+
}
|
|
193
|
+
return rows;
|
|
117
194
|
};
|
|
118
195
|
return {
|
|
119
196
|
render,
|
|
120
197
|
handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined {
|
|
121
198
|
const mouse = event as TuiMouseEvent & { type: string; y: number; button?: string; wheelDelta?: number };
|
|
122
199
|
if (mouse.type === "wheel") {
|
|
123
|
-
const count = ordered().length;
|
|
124
|
-
if (count <=
|
|
125
|
-
const
|
|
200
|
+
const count = ordered(listAll()).length;
|
|
201
|
+
if (count <= RAIL_TASK_SLOTS) return undefined;
|
|
202
|
+
const taskSlots = RAIL_TASK_SLOTS_OVERFLOW;
|
|
203
|
+
const next = Math.max(0, Math.min(count - taskSlots, scroll + Math.trunc(mouse.wheelDelta ?? 0)));
|
|
126
204
|
if (next === scroll) return undefined;
|
|
127
205
|
scroll = next; latchedId = undefined; tui.requestRender(); return { handled: true };
|
|
128
206
|
}
|
|
@@ -138,7 +216,13 @@ export function createRailFactory(pi: PiCtx, registry: TaskRegistry, opts: { onO
|
|
|
138
216
|
if (!latchedId || id !== latchedId) { latchedId = undefined; return undefined; }
|
|
139
217
|
const clicked = latchedId; latchedId = undefined; opts.onOpenTask?.(clicked); return { handled: true };
|
|
140
218
|
},
|
|
141
|
-
dispose(): void {
|
|
219
|
+
dispose(): void {
|
|
220
|
+
latchedId = undefined;
|
|
221
|
+
if (railTicker === ticker) {
|
|
222
|
+
clearInterval(ticker);
|
|
223
|
+
railTicker = undefined;
|
|
224
|
+
}
|
|
225
|
+
},
|
|
142
226
|
};
|
|
143
227
|
};
|
|
144
228
|
}
|
package/src/tui/status.ts
CHANGED
|
@@ -1,42 +1,119 @@
|
|
|
1
1
|
import type { PiCtx } from "../core/notify.ts";
|
|
2
2
|
import type { TaskRegistry } from "../core/registry.ts";
|
|
3
|
+
import type { TaskSnapshot } from "../core/task.ts";
|
|
3
4
|
import { reportActivity } from "../core/bus.ts";
|
|
5
|
+
import { envNumber } from "../core/env.ts";
|
|
6
|
+
import { formatElapsedMs } from "../core/duration.ts";
|
|
4
7
|
import { tuiHost } from "./host.ts";
|
|
5
8
|
|
|
6
|
-
export
|
|
7
|
-
|
|
9
|
+
export interface SummaryCounts {
|
|
10
|
+
running: number;
|
|
11
|
+
pending: number;
|
|
12
|
+
completed: number;
|
|
13
|
+
failed: number;
|
|
14
|
+
longestRunningStartedAt?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type SummaryToken = "accent" | "dim" | "success" | "error";
|
|
18
|
+
export interface SummarySegment { text: string; token: SummaryToken }
|
|
19
|
+
|
|
20
|
+
export function summarySegments(c: SummaryCounts, now = Date.now()): SummarySegment[] {
|
|
21
|
+
const elapsed = c.running > 0 && c.longestRunningStartedAt !== undefined
|
|
22
|
+
? formatElapsedMs(now - c.longestRunningStartedAt)
|
|
23
|
+
: "—";
|
|
24
|
+
return [
|
|
25
|
+
{ text: "▶", token: c.running + c.pending > 0 ? "accent" : "dim" },
|
|
26
|
+
{ text: ` ${c.running} running`, token: "accent" },
|
|
27
|
+
...(c.pending > 0 ? [{ text: ` · ${c.pending} pending`, token: "dim" as const }] : []),
|
|
28
|
+
{ text: ` · ${elapsed}`, token: "dim" },
|
|
29
|
+
// ✓ and ✗ are symmetric: both only appear when non-zero, so an all-failed run shows no green 0.
|
|
30
|
+
...(c.completed > 0 ? [{ text: ` · ✓${c.completed}`, token: "success" as const }] : []),
|
|
31
|
+
...(c.failed > 0 ? [{ text: ` · ✗${c.failed}`, token: "error" as const }] : []),
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function formatSummary(c: SummaryCounts, now = Date.now()): string {
|
|
36
|
+
return summarySegments(c, now).map((segment) => segment.text).join("");
|
|
8
37
|
}
|
|
9
38
|
|
|
10
39
|
let clearedAt = 0;
|
|
11
|
-
let refreshCurrent: (() => void) | undefined;
|
|
12
|
-
let refreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
13
40
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
41
|
+
/** Shared /async-clear watermark used by footer and panel (#31). */
|
|
42
|
+
export function clearedAtMs(): number {
|
|
43
|
+
return clearedAt;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Shared clear predicate: running/pending always visible; otherwise compare
|
|
48
|
+
* finishedAt ?? createdAt against the watermark (footer semantics).
|
|
49
|
+
*/
|
|
50
|
+
export function survivesClearCutoff(
|
|
51
|
+
task: { status: string; createdAt: number; finishedAt?: number },
|
|
52
|
+
cutoffMs: number = clearedAt,
|
|
53
|
+
): boolean {
|
|
54
|
+
if (task.status === "running" || task.status === "pending") return true;
|
|
55
|
+
return (task.finishedAt ?? task.createdAt) >= cutoffMs;
|
|
18
56
|
}
|
|
19
57
|
|
|
20
58
|
export function clearDoneStatus(): void {
|
|
21
59
|
clearedAt = Date.now();
|
|
22
|
-
refreshCurrent?.();
|
|
23
60
|
}
|
|
24
61
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
62
|
+
const TERMINAL = new Set(["completed", "failed", "killed"]);
|
|
63
|
+
|
|
64
|
+
/** Terminal-task linger window on the rail, overridable for tests. */
|
|
65
|
+
export function railLingerMs(): number {
|
|
66
|
+
return envNumber("PI_ASYNC_RAIL_LINGER_MS", 30_000, { integer: true, min: 0 });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The single rail visibility window: live tasks always show, terminal tasks linger.
|
|
71
|
+
* Rows and summary counts both go through this predicate so `✓N` can never show a
|
|
72
|
+
* number without a matching row (0.3.0 §2.9).
|
|
73
|
+
*/
|
|
74
|
+
export function inRailWindow(
|
|
75
|
+
task: { status: string; createdAt: number; startedAt?: number; finishedAt?: number },
|
|
76
|
+
now = Date.now(),
|
|
77
|
+
lingerMs = railLingerMs(),
|
|
78
|
+
): boolean {
|
|
79
|
+
if (task.status === "running" || task.status === "pending") return true;
|
|
80
|
+
return now - (task.finishedAt ?? task.startedAt ?? task.createdAt) < lingerMs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Counts drawn from exactly the rows the rail renders (same linger window + clear watermark). */
|
|
84
|
+
export function railWindowCounts(tasks: TaskSnapshot[], now = Date.now(), lingerMs = railLingerMs()): SummaryCounts {
|
|
85
|
+
const window = tasks.filter((task) => inRailWindow(task, now, lingerMs));
|
|
86
|
+
const running = window.filter((task) => task.status === "running");
|
|
87
|
+
const pending = window.filter((task) => task.status === "pending").length;
|
|
88
|
+
const terminal = window.filter((task) => TERMINAL.has(task.status) && survivesClearCutoff(task));
|
|
89
|
+
return {
|
|
90
|
+
running: running.length,
|
|
91
|
+
pending,
|
|
92
|
+
completed: terminal.filter((task) => task.status === "completed").length,
|
|
93
|
+
failed: terminal.filter((task) => task.status === "failed" || task.status === "killed").length,
|
|
94
|
+
...(running.length ? { longestRunningStartedAt: Math.min(...running.map((task) => task.startedAt ?? task.createdAt)) } : {}),
|
|
36
95
|
};
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Opt-in single-line footer (`PI_ASYNC_FOOTER=1`); the rail is the default status surface. */
|
|
99
|
+
export function footerEnabled(): boolean {
|
|
100
|
+
return process.env.PI_ASYNC_FOOTER === "1";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Publish the summary line when the footer is opted in; no timers, callers drive it. */
|
|
104
|
+
export function refreshFooter(registry: TaskRegistry, now = Date.now()): void {
|
|
105
|
+
if (!footerEnabled()) return;
|
|
106
|
+
const ui = tuiHost()?.ui as { setStatus?: (key: string, text: string | undefined) => void } | undefined;
|
|
107
|
+
if (typeof ui?.setStatus !== "function") return;
|
|
108
|
+
try {
|
|
109
|
+
ui.setStatus("pi-async", formatSummary(railWindowCounts(registry.list({ includeHistory: true }), now)));
|
|
110
|
+
} catch {
|
|
111
|
+
// Status rendering is best-effort: a host that rejects setStatus must not break the session.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function initStatus(pi: PiCtx, registry: TaskRegistry): void {
|
|
116
|
+
if (!tuiHost()) return;
|
|
117
|
+
reportActivity(pi, registry.activeCount());
|
|
118
|
+
refreshFooter(registry);
|
|
42
119
|
}
|
package/src/tui/width.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** East-Asian wide / fullwidth / common emoji ranges → 2 cells; else 1. */
|
|
2
|
+
function charWidth(ch: string): number {
|
|
3
|
+
const cp = ch.codePointAt(0)!;
|
|
4
|
+
const wide =
|
|
5
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
6
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
7
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
8
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
9
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
10
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
11
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
12
|
+
(cp >= 0x1f300 && cp <= 0x1f9ff);
|
|
13
|
+
return wide ? 2 : 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Terminal cell width of a string (CJK/emoji ≈ 2 cells). */
|
|
17
|
+
export function displayWidth(text: string): number {
|
|
18
|
+
let w = 0;
|
|
19
|
+
for (const ch of text) w += charWidth(ch);
|
|
20
|
+
return w;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Truncate to a cell budget, never splitting a wide char, appending `…`. */
|
|
24
|
+
export function truncateToWidth(text: string, limit: number): string {
|
|
25
|
+
const budget = Math.max(0, Math.floor(limit));
|
|
26
|
+
if (budget <= 0) return "";
|
|
27
|
+
if (displayWidth(text) <= budget) return text;
|
|
28
|
+
if (budget === 1) return "…";
|
|
29
|
+
let out = "";
|
|
30
|
+
let used = 0;
|
|
31
|
+
for (const ch of text) {
|
|
32
|
+
const cw = charWidth(ch);
|
|
33
|
+
if (used + cw > budget - 1) break;
|
|
34
|
+
out += ch;
|
|
35
|
+
used += cw;
|
|
36
|
+
}
|
|
37
|
+
return `${out}…`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Hard-truncate by cells (no ellipsis) then pad with spaces to exact cell width. */
|
|
41
|
+
export function fitWidth(text: string, limit: number): string {
|
|
42
|
+
const budget = Math.max(0, Math.floor(limit));
|
|
43
|
+
if (budget <= 0) return "";
|
|
44
|
+
let out = "";
|
|
45
|
+
let used = 0;
|
|
46
|
+
for (const ch of text) {
|
|
47
|
+
const cw = charWidth(ch);
|
|
48
|
+
if (used + cw > budget) break;
|
|
49
|
+
out += ch;
|
|
50
|
+
used += cw;
|
|
51
|
+
}
|
|
52
|
+
return out + " ".repeat(budget - used);
|
|
53
|
+
}
|