@a-t-h-i/bot-lobby 0.6.2 → 0.6.3

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/src/pi/zen.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * arrive on its run. Everything here is pure: expression frames and the spinner
8
8
  * tick arrive from the caller, and every timestamp arrives as `now`.
9
9
  */
10
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
10
+ import { clip, textWidth } from "../width.ts";
11
11
  import type { AgentRun } from "../schemas/findings.ts";
12
12
  import { TERMINAL_STATES, type Task, type TaskState } from "../schemas/task.ts";
13
13
  import { truncate } from "../text.ts";
@@ -399,9 +399,9 @@ const BANNER_MIN_WIDTH = 60;
399
399
  /** Boxed title for wide terminals, one-line title otherwise; never wider than `width`. */
400
400
  export function bannerLines(width: number): string[] {
401
401
  if (width < 1) return [];
402
- if (width < BANNER_MIN_WIDTH) return [truncateToWidth(BANNER_NARROW, width, "")];
402
+ if (width < BANNER_MIN_WIDTH) return [clip(BANNER_NARROW, width, "")];
403
403
  const inner = BANNER_WIDTH - 2;
404
- const titleWidth = visibleWidth(BANNER_TITLE);
404
+ const titleWidth = textWidth(BANNER_TITLE);
405
405
  const left = Math.floor((inner - titleWidth) / 2);
406
406
  const border = "─".repeat(inner);
407
407
  return [`┌${border}┐`, `│${" ".repeat(left)}${BANNER_TITLE}${" ".repeat(inner - titleWidth - left)}│`, `└${border}┘`];
@@ -476,7 +476,7 @@ function compactFrame(slot: SlotView, expressions: ExpressionFrames): string {
476
476
  }
477
477
 
478
478
  function compactRow(content: string, color: PanelColor, theme?: PanelTheme): string {
479
- const body = truncateToWidth(content, COMPACT_INNER, "", true);
479
+ const body = clip(content, COMPACT_INNER, "", true);
480
480
  return `|${theme ? theme.fg(color, body) : body}|`;
481
481
  }
482
482
 
@@ -524,7 +524,7 @@ function compactPanel(
524
524
  const fixed = [...bannerLines(width), headerLine(task, now, quiet), ...strip];
525
525
  const tail = tailLines(task, runs, now, tick, steps, opts.theme);
526
526
  const room = Math.max(0, MAX_PANEL_LINES - fixed.length - tail.length);
527
- return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => truncateToWidth(line, width));
527
+ return [...fixed, ...tail, ...checklistLines(steps, room)].map((line) => clip(line, width));
528
528
  }
529
529
 
530
530
  /* -------------------------------------------------------------------------
@@ -646,7 +646,7 @@ export function panelLines(
646
646
  // The still scene has no tower or agent strip to fit, so any height keeps the large tier's status box.
647
647
  if (width >= LARGE_MIN_WIDTH && (budget >= MIN_LARGE_LINES || opts.still)) {
648
648
  const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts), width, budget, opts.theme, opts.still);
649
- return scene.map((line) => truncateToWidth(line, width));
649
+ return scene.map((line) => clip(line, width));
650
650
  }
651
651
  return compactPanel(task, runs, now, quiet, tick, steps, opts, width);
652
652
  }
@@ -66,6 +66,8 @@ export interface WorkflowConfig {
66
66
  wrapUpAt: number;
67
67
  /** Workers that may run at once when the Master delegates several domains together. */
68
68
  maxParallelWorkers: number;
69
+ /** The oracle starts each task with a clean context: its model is sent only the conversation since the task started, or since the last one ended. */
70
+ freshContext: boolean;
69
71
  }
70
72
 
71
73
  export interface KnowledgeConfig {
@@ -201,6 +203,7 @@ export const DEFAULT_CONFIG: BotLobbyConfig = {
201
203
  toolStallTimeoutMs: 10 * 60 * 1000,
202
204
  wrapUpAt: 0.75,
203
205
  maxParallelWorkers: 3,
206
+ freshContext: true,
204
207
  },
205
208
  knowledge: {
206
209
  compactionThreshold: 20000,
@@ -336,6 +339,7 @@ function normalizeClassifier(value: unknown): ClassifierConfig {
336
339
  export function resolveConfig(partial: unknown): BotLobbyConfig {
337
340
  const src = (partial ?? {}) as Record<string, unknown>;
338
341
  const workflow = { ...DEFAULT_CONFIG.workflow, ...(src.workflow as Partial<WorkflowConfig> | undefined) };
342
+ workflow.freshContext = workflow.freshContext !== false;
339
343
  const knowledge = { ...DEFAULT_CONFIG.knowledge, ...(src.knowledge as Partial<KnowledgeConfig> | undefined) };
340
344
  const srcAgents = (src.agents ?? {}) as Partial<BotLobbyConfig["agents"]>;
341
345
  return {
@@ -6,11 +6,12 @@
6
6
  * abandoned before it is archived, so it never comes back half-driven.
7
7
  */
8
8
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
9
- import { dirname, join } from "node:path";
9
+ import { dirname, join, sep } from "node:path";
10
10
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
11
11
  import { taskDir, tasksRoot } from "../knowledge/paths.ts";
12
12
  import { transition } from "./task-state.ts";
13
13
  import { dataRoot, readDataRoots } from "./project.ts";
14
+ import { forgetCachedUnder, readJsonCached } from "./file-cache.ts";
14
15
 
15
16
  export function archiveRoot(root: string, configDir: string): string {
16
17
  return join(dataRoot(root, configDir), "archive", "tasks");
@@ -43,15 +44,23 @@ function archivedDir(root: string, configDir: string, taskId: string): string {
43
44
  return join(archiveRoot(root, configDir), checkId(taskId));
44
45
  }
45
46
 
46
- /** Archived tasks, most recently archived first. */
47
+ function isObject(value: unknown): value is Task {
48
+ return typeof value === "object" && value !== null;
49
+ }
50
+
51
+ /** Archived tasks, most recently archived first (parsed again only when a file changes; shared, for reading). */
47
52
  export function listArchivedTasks(root: string, configDir: string): Task[] {
48
53
  const dir = archiveRoot(root, configDir);
49
54
  if (!existsSync(dir)) return [];
50
55
  const tasks: Task[] = [];
56
+ const seen = new Set<string>();
51
57
  for (const entry of readdirSync(dir)) {
52
- const task = readAt(join(dir, entry));
58
+ const path = join(dir, entry, "state.json");
59
+ seen.add(path);
60
+ const task = readJsonCached(path, isObject);
53
61
  if (task) tasks.push(task);
54
62
  }
63
+ forgetCachedUnder(`${dir}${sep}`, seen);
55
64
  return tasks.sort((a, b) => (b.archivedAt ?? b.updatedAt).localeCompare(a.archivedAt ?? a.updatedAt));
56
65
  }
57
66
 
@@ -6,8 +6,9 @@
6
6
  * saving at once cannot overwrite each other.
7
7
  */
8
8
  import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
9
- import { join } from "node:path";
9
+ import { join, sep } from "node:path";
10
10
  import { writeFileEnsured } from "../knowledge/store.ts";
11
+ import { forgetCachedUnder, readJsonCached } from "./file-cache.ts";
11
12
  import { dataRoot } from "./project.ts";
12
13
  import { taskSlug } from "./persistence.ts";
13
14
 
@@ -71,16 +72,25 @@ export function loadPlannedTask(root: string, configDir: string, id: string): Pl
71
72
  }
72
73
  }
73
74
 
74
- /** Every saved entry, pending first, newest first within each group; unreadable files are skipped. */
75
+ /**
76
+ * Every saved entry, pending first, newest first within each group;
77
+ * unreadable files are skipped. Files are parsed again only when they change,
78
+ * and the entries are shared: for reading (the lobby lists them every few
79
+ * seconds); `loadPlannedTask` gives a copy of one's own.
80
+ */
75
81
  export function listPlannedTasks(root: string, configDir: string): PlannedTask[] {
76
82
  const dir = backlogDir(root, configDir);
77
83
  if (!existsSync(dir)) return [];
78
84
  const entries: PlannedTask[] = [];
85
+ const seen = new Set<string>();
79
86
  for (const file of readdirSync(dir)) {
80
87
  if (!file.endsWith(".json")) continue;
81
- const entry = loadPlannedTask(root, configDir, file.slice(0, -".json".length));
88
+ const path = join(dir, file);
89
+ seen.add(path);
90
+ const entry = readJsonCached(path, isPlannedTask);
82
91
  if (entry) entries.push(entry);
83
92
  }
93
+ forgetCachedUnder(`${dir}${sep}`, seen);
84
94
  return entries.sort((a, b) => {
85
95
  if (a.status !== b.status) return a.status === "pending" ? -1 : 1;
86
96
  return b.updatedAt.localeCompare(a.updatedAt);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * JSON files as last parsed, kept while each file's stat is unchanged. The
3
+ * owner's clock and the lobby look at every task (and planned or archived
4
+ * task) every few seconds in every session; a file is parsed again only when
5
+ * it changed. A file written a moment ago is not kept (two writes that close
6
+ * together can share a stat), and this process's own writes drop their entry
7
+ * at once. What comes back is shared with every other caller: for reading
8
+ * only.
9
+ */
10
+ import { readFileSync, statSync } from "node:fs";
11
+
12
+ /** How long ago a file must have been written for its parse to be kept. */
13
+ export const SETTLE_MS = 1000;
14
+
15
+ /** Files kept at most; the least recently read are let go past this (archived tasks listed once, say). */
16
+ export const CACHE_LIMIT = 512;
17
+
18
+ const parsed = new Map<string, { stamp: string; value: unknown }>();
19
+
20
+ /** The file at `path` parsed as JSON and accepted by `valid`; undefined when missing, unreadable or rejected. */
21
+ export function readJsonCached<T>(path: string, valid: (value: unknown) => value is T): T | undefined {
22
+ let stamp: string;
23
+ let settled: boolean;
24
+ try {
25
+ const stat = statSync(path, { bigint: true });
26
+ stamp = `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
27
+ settled = Date.now() - Number(stat.mtimeMs) >= SETTLE_MS;
28
+ } catch {
29
+ parsed.delete(path);
30
+ return undefined;
31
+ }
32
+ const hit = parsed.get(path);
33
+ if (hit?.stamp === stamp) {
34
+ // Most recently read last, so the one let go is the longest unread.
35
+ parsed.delete(path);
36
+ parsed.set(path, hit);
37
+ return valid(hit.value) ? hit.value : undefined;
38
+ }
39
+ let value: unknown;
40
+ try {
41
+ value = JSON.parse(readFileSync(path, "utf8"));
42
+ } catch {
43
+ parsed.delete(path);
44
+ return undefined;
45
+ }
46
+ parsed.delete(path);
47
+ if (settled) {
48
+ parsed.set(path, { stamp, value });
49
+ if (parsed.size > CACHE_LIMIT) parsed.delete(parsed.keys().next().value!);
50
+ }
51
+ return valid(value) ? value : undefined;
52
+ }
53
+
54
+ /** Drop a file's entry (this process is about to write it). */
55
+ export function forgetCached(path: string): void {
56
+ parsed.delete(path);
57
+ }
58
+
59
+ /** Drop the entries under `dir` other than `keep` (files that left the folder). */
60
+ export function forgetCachedUnder(dir: string, keep: ReadonlySet<string>): void {
61
+ for (const path of parsed.keys()) if (path.startsWith(dir) && !keep.has(path)) parsed.delete(path);
62
+ }
@@ -5,7 +5,7 @@
5
5
  * thinking level, how often it succeeds and what it costs. Append-only JSON
6
6
  * lines per project; reads keep the newest `MAX_READ` records.
7
7
  */
8
- import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
8
+ import { appendFileSync, closeSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
10
  import type { AgentRun } from "../schemas/findings.ts";
11
11
  import type { RunLogEntry, Task } from "../schemas/task.ts";
@@ -119,6 +119,20 @@ function isRecord(value: unknown): value is MetricRecord {
119
119
  return Boolean(record && typeof record.id === "string" && typeof record.kind === "string" && typeof record.durationMs === "number");
120
120
  }
121
121
 
122
+ /** How much of a long log's end the first read takes: far more than `MAX_READ` records need. */
123
+ const TAIL_BYTES = 4 * 1024 * 1024;
124
+
125
+ /** A metrics log as followed so far: the file it was, how far it was read, and its newest records. */
126
+ interface Followed {
127
+ ino: bigint;
128
+ offset: number;
129
+ records: MetricRecord[];
130
+ /** The first read starts inside the file, part way through a line. */
131
+ midLine: boolean;
132
+ }
133
+
134
+ const followed = new Map<string, Followed>();
135
+
122
136
  /** Agent runs for the Metrics tab; classifier calls, a few hundred milliseconds each, are read apart. */
123
137
  export function readMetrics(root: string, configDir: string, limit = MAX_READ): MetricRecord[] {
124
138
  return readRecords(root, configDir, limit).filter((record) => record.kind !== "classifier");
@@ -129,26 +143,62 @@ export function readClassifierMetrics(root: string, configDir: string, limit = M
129
143
  return readRecords(root, configDir, limit).filter((record) => record.kind === "classifier");
130
144
  }
131
145
 
146
+ /**
147
+ * The newest records of the project's log, agent runs and classifier calls
148
+ * alike. The log only grows, so after the first read (of its end only) each
149
+ * read parses just the lines appended since; a log that shrank or was
150
+ * replaced is read afresh.
151
+ */
132
152
  function readRecords(root: string, configDir: string, limit: number): MetricRecord[] {
133
153
  const path = metricsPath(root, configDir);
134
- if (!existsSync(path)) return [];
135
- let text: string;
154
+ let ino: bigint;
155
+ let size: number;
136
156
  try {
137
- text = readFileSync(path, "utf8");
157
+ const stat = statSync(path, { bigint: true });
158
+ ino = stat.ino;
159
+ size = Number(stat.size);
138
160
  } catch {
161
+ followed.delete(path);
139
162
  return [];
140
163
  }
141
- const records: MetricRecord[] = [];
142
- for (const line of text.split("\n").slice(-limit - 1)) {
143
- if (!line.trim()) continue;
144
- try {
145
- const value = JSON.parse(line) as unknown;
146
- if (isRecord(value)) records.push(value);
147
- } catch {
148
- // Torn lines are skipped.
164
+ let log = followed.get(path);
165
+ if (!log || log.ino !== ino || size < log.offset) {
166
+ const offset = Math.max(0, size - TAIL_BYTES);
167
+ log = { ino, offset, records: [], midLine: offset > 0 };
168
+ followed.set(path, log);
169
+ }
170
+ if (size > log.offset) readAppended(path, log, size);
171
+ return log.records.length > limit ? log.records.slice(-limit) : [...log.records];
172
+ }
173
+
174
+ function readAppended(path: string, log: Followed, size: number): void {
175
+ let fd: number | undefined;
176
+ try {
177
+ fd = openSync(path, "r");
178
+ const buffer = Buffer.alloc(size - log.offset);
179
+ const read = readSync(fd, buffer, 0, buffer.length, log.offset);
180
+ // Whole lines only: one still being appended is read next time.
181
+ const end = buffer.lastIndexOf(0x0a, read - 1);
182
+ if (end < 0) return;
183
+ const lines = buffer.toString("utf8", 0, end).split("\n");
184
+ if (log.midLine) lines.shift();
185
+ log.midLine = false;
186
+ for (const line of lines) {
187
+ if (!line.trim()) continue;
188
+ try {
189
+ const value = JSON.parse(line) as unknown;
190
+ if (isRecord(value)) log.records.push(value);
191
+ } catch {
192
+ // Torn lines are skipped.
193
+ }
149
194
  }
195
+ if (log.records.length > MAX_READ) log.records.splice(0, log.records.length - MAX_READ);
196
+ log.offset += end + 1;
197
+ } catch {
198
+ // Unreadable for now; the next read tries again.
199
+ } finally {
200
+ if (fd !== undefined) closeSync(fd);
150
201
  }
151
- return records.slice(-limit);
152
202
  }
153
203
 
154
204
  const AGENT_NAMES: Record<string, string> = { backend: "DEV", designer: "DESIGN", qa: "QA" };
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { join, sep } from "node:path";
3
3
  import type { KnowledgeConfig } from "../schemas/configuration.ts";
4
4
  import type { Domain } from "../schemas/agent.ts";
5
5
  import { TERMINAL_STATES, isTaskState, type Task } from "../schemas/task.ts";
@@ -14,6 +14,7 @@ import {
14
14
  type KnowledgeAgent,
15
15
  } from "../knowledge/paths.ts";
16
16
  import { DEFAULT_KNOWLEDGE_CONTENT, ensureFile, readFileOr, writeFileEnsured } from "../knowledge/store.ts";
17
+ import { forgetCached, forgetCachedUnder, readJsonCached } from "./file-cache.ts";
17
18
 
18
19
  /** Idempotently create the full knowledge + tasks layout with seed files. */
19
20
  export function ensureProjectStructure(root: string, configDir: string): void {
@@ -36,6 +37,7 @@ export function ensureProjectStructure(root: string, configDir: string): void {
36
37
  export function createTaskDir(root: string, configDir: string, task: Task): void {
37
38
  const dir = taskDir(dataRoot(root, configDir), task.id);
38
39
  mkdirSync(dir, { recursive: true });
40
+ forgetCached(join(dir, "state.json"));
39
41
  writeFileEnsured(join(dir, "state.json"), JSON.stringify(task, null, 2));
40
42
  ensureFile(join(dir, "proposal.md"), "");
41
43
  ensureFile(join(dir, "plan.md"), "");
@@ -77,7 +79,9 @@ export function readTaskArtifact(
77
79
  }
78
80
 
79
81
  export function saveTask(root: string, configDir: string, task: Task): void {
80
- writeFileEnsured(join(taskDir(dataRoot(root, configDir), task.id), "state.json"), JSON.stringify(task, null, 2));
82
+ const path = join(taskDir(dataRoot(root, configDir), task.id), "state.json");
83
+ forgetCached(path);
84
+ writeFileEnsured(path, JSON.stringify(task, null, 2));
81
85
  }
82
86
 
83
87
  /** Read a task state: the bot-lobby copy wins, else the newest pre-rename copy. */
@@ -147,6 +151,36 @@ export function listTasks(root: string, configDir: string): Task[] {
147
151
  return tasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
148
152
  }
149
153
 
154
+ /** Any parsed JSON object counts as a task, as `readTaskAt` has it. */
155
+ function isObject(value: unknown): value is Task {
156
+ return typeof value === "object" && value !== null;
157
+ }
158
+
159
+ /**
160
+ * All tasks on disk, newest first, like `listTasks`, but a file is read only
161
+ * when it changed since the last look. The tasks are shared with every other
162
+ * caller, so they are for reading (the owner's clock, the lobby): whatever
163
+ * changes a task loads its own copy with `loadTask` and saves that.
164
+ */
165
+ export function peekTasks(root: string, configDir: string): Task[] {
166
+ const tasks: Task[] = [];
167
+ const seen = new Set<string>();
168
+ for (const { dir } of taskEntries(root, configDir)) {
169
+ const path = join(dir, "state.json");
170
+ seen.add(path);
171
+ const task = readJsonCached(path, isObject);
172
+ if (task) tasks.push(task);
173
+ }
174
+ // Forget tasks that left these folders (deleted or archived).
175
+ for (const dr of readDataRoots(root, configDir)) forgetCachedUnder(`${tasksRoot(dr)}${sep}`, seen);
176
+ return tasks.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
177
+ }
178
+
179
+ /** The non-terminal task a session owns, from `peekTasks` (for reading only). */
180
+ export function peekOwnedTask(root: string, configDir: string, sessionId: string): Task | undefined {
181
+ return peekTasks(root, configDir).find((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId === sessionId);
182
+ }
183
+
150
184
  /** Tasks on disk plus the ids whose state.json could not be read (§59). */
151
185
  export function taskHealth(root: string, configDir: string): { tasks: Task[]; corrupted: string[] } {
152
186
  const tasks: Task[] = [];
package/src/width.ts ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Terminal width of styled text, fast. pi-tui's `visibleWidth` segments every
3
+ * string into grapheme clusters, which is exact but slow, and its cache only
4
+ * helps strings seen before: the lobby's rows are new strings every frame
5
+ * (boxes side by side, a clock, a spinner). This scans them instead: ASCII
6
+ * counts one column, escape sequences none, and every other character in the
7
+ * ranges below is measured once with pi-tui (on its own, as the cluster it
8
+ * always is there) and remembered. Anything else — combining marks, variation
9
+ * selectors, joiners, emoji or CJK outside those ranges — goes to pi-tui for
10
+ * the whole string, so the answer is always pi-tui's.
11
+ */
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+
14
+ /** Characters that always stand alone as a cluster: Latin, punctuation, arrows, symbols, box drawing, shapes, braille. */
15
+ function standsAlone(code: number): boolean {
16
+ return (code >= 0xa0 && code < 0x300 && code !== 0xad)
17
+ || (code >= 0x2010 && code <= 0x2027)
18
+ || (code >= 0x2030 && code <= 0x205e)
19
+ || (code >= 0x2190 && code <= 0x23ff)
20
+ || (code >= 0x2500 && code <= 0x27bf)
21
+ || (code >= 0x2800 && code <= 0x29ff);
22
+ }
23
+
24
+ /**
25
+ * The length of the escape sequence at `index` that takes no columns, exactly
26
+ * as pi-tui reads them — CSI ending in m, G, K, H or J (parameters only
27
+ * before it), OSC and APC ending in BEL or ST — or 0 for any other.
28
+ */
29
+ function escapeLength(text: string, index: number): number {
30
+ const kind = text.charCodeAt(index + 1);
31
+ if (kind === 0x5b) {
32
+ for (let at = index + 2; at < text.length; at += 1) {
33
+ const code = text.charCodeAt(at);
34
+ // m G K H J end it; digits, ; : ? are its parameters; anything else is read differently by pi-tui.
35
+ if (code === 0x6d || code === 0x47 || code === 0x4b || code === 0x48 || code === 0x4a) return at + 1 - index;
36
+ if (!((code >= 0x30 && code <= 0x3b) || code === 0x3f)) return 0;
37
+ }
38
+ return 0;
39
+ }
40
+ if (kind === 0x5d || kind === 0x5f) {
41
+ for (let at = index + 2; at < text.length; at += 1) {
42
+ const code = text.charCodeAt(at);
43
+ if (code === 0x07) return at + 1 - index;
44
+ if (code === 0x1b) return text.charCodeAt(at + 1) === 0x5c ? at + 2 - index : 0;
45
+ }
46
+ }
47
+ return 0;
48
+ }
49
+
50
+ const charWidths = new Map<number, number>();
51
+
52
+ function scan(text: string): number {
53
+ let width = 0;
54
+ for (let index = 0; index < text.length; ) {
55
+ const code = text.charCodeAt(index);
56
+ if (code >= 0x20 && code < 0x7f) {
57
+ width += 1;
58
+ index += 1;
59
+ } else if (code === 0x1b) {
60
+ const length = escapeLength(text, index);
61
+ if (length === 0) return visibleWidth(text);
62
+ index += length;
63
+ } else {
64
+ if (!standsAlone(code)) return visibleWidth(text);
65
+ let columns = charWidths.get(code);
66
+ if (columns === undefined) {
67
+ columns = visibleWidth(String.fromCharCode(code));
68
+ charWidths.set(code, columns);
69
+ }
70
+ width += columns;
71
+ index += 1;
72
+ }
73
+ }
74
+ return width;
75
+ }
76
+
77
+ /** Widths of recent strings: a frame mostly repeats the last one's lines (a few hundred of them). */
78
+ const recent = new Map<string, number>();
79
+ const RECENT_LIMIT = 1024;
80
+
81
+ /** Columns `text` takes in a terminal; the same as pi-tui's `visibleWidth`. */
82
+ export function textWidth(text: string): number {
83
+ if (text.length < 16) return scan(text);
84
+ const known = recent.get(text);
85
+ if (known !== undefined) return known;
86
+ const width = scan(text);
87
+ if (recent.size >= RECENT_LIMIT) recent.delete(recent.keys().next().value!);
88
+ recent.set(text, width);
89
+ return width;
90
+ }
91
+
92
+ /**
93
+ * `text` cut to `width` columns (ending in `ellipsis`), padded to it when
94
+ * `pad`: pi-tui's `truncateToWidth`, which walks every character even when
95
+ * the text already fits — checked first here, since most lines do.
96
+ */
97
+ export function clip(text: string, width: number, ellipsis = "...", pad = false): string {
98
+ if (width <= 0) return "";
99
+ const columns = textWidth(text);
100
+ if (columns <= width) return pad && columns < width ? `${text}${" ".repeat(width - columns)}` : text;
101
+ return truncateToWidth(text, width, ellipsis, pad);
102
+ }