@vigent/cli 0.2.3 → 0.3.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/dist/attach.js ADDED
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `@path` in a prompt attaches a file from the agent's folder.
3
+ *
4
+ * Each engine gets it its own way: Claude Code reads `@path` in a prompt
5
+ * itself; OpenCode takes the files with `-f`; for Codex and pi, vigent adds
6
+ * the file's contents under the prompt. The transcript shows what you typed.
7
+ */
8
+ import { execFile } from "node:child_process";
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
11
+ /** files larger than this aren't inlined (Codex, pi) */
12
+ export const MAX_INLINE_BYTES = 100 * 1024;
13
+ /** The files an agent can be pointed at: tracked and new ones, without what .gitignore hides. */
14
+ export function listFiles(cwd) {
15
+ return new Promise((resolve) => {
16
+ execFile("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], { cwd, maxBuffer: 64 * 1024 * 1024 }, (err, stdout) => {
17
+ if (err)
18
+ return resolve(walk(cwd));
19
+ const files = [...new Set(String(stdout).split("\0").filter(Boolean))].filter((f) => fs.existsSync(path.join(cwd, f)));
20
+ resolve(files.sort(byDepthThenName));
21
+ });
22
+ });
23
+ }
24
+ /** Not a git repository: a shallow walk, skipping the usual heavy folders. */
25
+ function walk(cwd, max = 5000) {
26
+ const out = [];
27
+ const skip = new Set(["node_modules", ".git", "dist", "build", "target", ".next", ".venv", "venv"]);
28
+ const visit = (dir, depth) => {
29
+ if (out.length >= max || depth > 6)
30
+ return;
31
+ let entries = [];
32
+ try {
33
+ entries = fs.readdirSync(path.join(cwd, dir), { withFileTypes: true });
34
+ }
35
+ catch {
36
+ return;
37
+ }
38
+ for (const e of entries) {
39
+ const rel = dir ? `${dir}/${e.name}` : e.name;
40
+ if (e.isDirectory()) {
41
+ if (!skip.has(e.name) && !e.name.startsWith("."))
42
+ visit(rel, depth + 1);
43
+ }
44
+ else if (e.isFile())
45
+ out.push(rel);
46
+ }
47
+ };
48
+ visit("", 0);
49
+ return out.sort(byDepthThenName);
50
+ }
51
+ function byDepthThenName(a, b) {
52
+ return a.split("/").length - b.split("/").length || a.localeCompare(b);
53
+ }
54
+ /** a path that can follow `@`: letters, digits, `_ . / @ + -`, not ending in `.` or `@` (so "see @a.js." works) */
55
+ const PATH = String.raw `[\p{L}\p{N}_./@+-]*[\p{L}\p{N}_/+-]`;
56
+ /** `@path` at the start of a word; group 1 is the path */
57
+ export const MENTION = new RegExp(String.raw `(?:^|(?<=\s))@(${PATH})`, "gu");
58
+ /** Whether `@file` would read back as this file (names with spaces can't). */
59
+ export function mentionable(file) {
60
+ return new RegExp(`^${PATH}$`, "u").test(file);
61
+ }
62
+ /** The `@path` mentions in a prompt that name files in `cwd` (in order, once each). */
63
+ export function mentions(prompt, cwd) {
64
+ const out = [];
65
+ for (const m of prompt.matchAll(MENTION)) {
66
+ const rel = m[1];
67
+ const abs = path.resolve(cwd, rel);
68
+ if (!abs.startsWith(path.resolve(cwd) + path.sep) && abs !== path.resolve(cwd))
69
+ continue; // stays inside the folder
70
+ if (!out.includes(rel) && fs.existsSync(abs) && fs.statSync(abs).isFile())
71
+ out.push(rel);
72
+ }
73
+ return out;
74
+ }
75
+ /** Turn a prompt with `@path` mentions into what this engine needs. */
76
+ export function expandPrompt(prompt, engine, cwd) {
77
+ const files = mentions(prompt, cwd);
78
+ if (files.length === 0)
79
+ return { prompt, files: [], skipped: [] };
80
+ if (engine === "claude")
81
+ return { prompt, files: [], skipped: [] }; // Claude Code reads @path itself
82
+ if (engine === "opencode")
83
+ return { prompt, files: files.map((f) => path.join(cwd, f)), skipped: [] };
84
+ const blocks = [];
85
+ const skipped = [];
86
+ for (const f of files) {
87
+ const buf = fs.readFileSync(path.join(cwd, f));
88
+ if (buf.includes(0)) {
89
+ skipped.push(`${f} (binary)`);
90
+ continue;
91
+ }
92
+ if (buf.length > MAX_INLINE_BYTES) {
93
+ skipped.push(`${f} (${Math.round(buf.length / 1024)} KB, over ${MAX_INLINE_BYTES / 1024} KB)`);
94
+ continue;
95
+ }
96
+ const text = buf.toString("utf8");
97
+ blocks.push(`<file path="${f}">\n${text}${text.endsWith("\n") ? "" : "\n"}</file>`);
98
+ }
99
+ if (blocks.length === 0)
100
+ return { prompt, files: [], skipped };
101
+ return { prompt: `${prompt}\n\nAttached files:\n\n${blocks.join("\n\n")}`, files: [], skipped };
102
+ }
package/dist/choices.js CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { execFile } from "node:child_process";
8
8
  import { ENGINE_BINS } from "./engines.js";
9
+ import { loadPiAi } from "./pi-sdk.js";
9
10
  /** Reasoning effort / thinking levels each engine accepts. */
10
11
  export const EFFORTS = {
11
12
  pi: ["off", "minimal", "low", "medium", "high", "xhigh"],
@@ -46,7 +47,9 @@ export async function modelChoices(engine, env = process.env) {
46
47
  case "opencode":
47
48
  return [{ value: "default", note: "OpenCode's own choice" }, ...(await opencodeModels(env)).map((value) => ({ value }))];
48
49
  case "pi": {
49
- const ai = await import("@mariozechner/pi-ai");
50
+ const ai = await loadPiAi().catch(() => undefined);
51
+ if (!ai)
52
+ return [{ value: "default", note: "pi isn't installed" }];
50
53
  const providers = Object.keys(PI_KEYS).filter((p) => PI_KEYS[p].some((k) => env[k]));
51
54
  const out = [];
52
55
  for (const provider of providers.length ? providers : ["anthropic"]) {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { spawn } from "node:child_process";
12
12
  import * as fs from "node:fs";
13
+ import { expandPrompt } from "./attach.js";
13
14
  import { ENGINE_BINS, ENGINE_NAMES } from "./engines.js";
14
15
  import { Session } from "./session.js";
15
16
  // ---------------------------------------------------------------------------
@@ -76,8 +77,9 @@ export const claudeAdapter = {
76
77
  case "result": {
77
78
  const u = e.usage ?? {};
78
79
  const tokens = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0) + (u.output_tokens ?? 0);
79
- if (tokens)
80
- out.push({ type: "usage", tokens });
80
+ const cost = typeof e.total_cost_usd === "number" ? e.total_cost_usd : undefined;
81
+ if (tokens || cost !== undefined)
82
+ out.push({ type: "usage", tokens, ...(cost !== undefined ? { cost } : {}) });
81
83
  if (e.session_id)
82
84
  out.push({ type: "session", id: e.session_id });
83
85
  const ok = !e.is_error && e.subtype === "success";
@@ -209,6 +211,7 @@ export const opencodeAdapter = {
209
211
  ...(o.model ? ["-m", o.model] : []),
210
212
  ...(o.effort ? ["--variant", o.effort] : []),
211
213
  ...(o.resumeId ? ["--session", o.resumeId] : []),
214
+ ...(o.files ?? []).flatMap((f) => ["-f", f]),
212
215
  "--",
213
216
  prompt,
214
217
  ];
@@ -238,8 +241,9 @@ export const opencodeAdapter = {
238
241
  case "step_finish": {
239
242
  const t = part.tokens ?? {};
240
243
  const tokens = (t.input ?? 0) + (t.output ?? 0) + (t.reasoning ?? 0) + (t.cache?.read ?? 0) + (t.cache?.write ?? 0);
241
- if (tokens)
242
- out.push({ type: "usage", tokens });
244
+ const cost = typeof part.cost === "number" ? part.cost : undefined;
245
+ if (tokens || cost !== undefined)
246
+ out.push({ type: "usage", tokens, ...(cost !== undefined ? { cost } : {}) });
243
247
  break;
244
248
  }
245
249
  case "error": {
@@ -302,8 +306,12 @@ export class CliSession extends Session {
302
306
  this.pushUser(text);
303
307
  this.setState("running");
304
308
  this.aborted = false;
305
- const args = this.adapter.args(text, {
309
+ const attached = expandPrompt(text, this.adapter.id, this.cwd);
310
+ if (attached.skipped.length)
311
+ this.pushInfo(`not attached: ${attached.skipped.join(", ")}`);
312
+ const args = this.adapter.args(attached.prompt, {
306
313
  cwd: this.cwd,
314
+ files: attached.files,
307
315
  model: this.requestedModel,
308
316
  effort: this.effort,
309
317
  resumeId: this.resumeId,
@@ -381,6 +389,8 @@ export class CliSession extends Session {
381
389
  }
382
390
  case "usage":
383
391
  this.tokens += ev.tokens;
392
+ if (ev.cost !== undefined)
393
+ this.cost = (this.cost ?? 0) + ev.cost;
384
394
  this.emit();
385
395
  break;
386
396
  case "error":
package/dist/cli.js CHANGED
@@ -3,9 +3,10 @@
3
3
  import { execFileSync } from "node:child_process";
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
- import { ProcessTerminal, TUI } from "@mariozechner/pi-tui";
7
- import { loadAgentMeta, restorableAgents, saveAgentMeta, sessionDir } from "./agents.js";
6
+ import { ProcessTerminal, TUI } from "./tui.js";
7
+ import { loadAgentMeta, reopenableAgents, saveAgentMeta, sessionDir } from "./agents.js";
8
8
  import { App } from "./app.js";
9
+ import { FOCUS_REPORTING_OFF, FOCUS_REPORTING_ON } from "./notify.js";
9
10
  import { staleBuild, VERSION_TEXT } from "./version.js";
10
11
  import { CliSession } from "./cli-engines.js";
11
12
  import { defaultEngine, detectEngines, ENGINE_BINS, isEngineId, onPath, piConnected, quickEngines } from "./engines.js";
@@ -23,6 +24,7 @@ const HELP = `vigent — vim-native agent orchestrator (pi engine)
23
24
  usage:
24
25
  vigent --version which vigent this is (and the commit, from a clone)
25
26
  vigent [folder] [--demo] [--theme <name>] [--model <id>] [--engine pi|claude|codex|opencode]
27
+ vigent --serve [--local] also follow your agents from your phone (:serve)
26
28
 
27
29
  engines (what runs new agents; :checkhealth shows which are signed in):
28
30
  pi built in, uses ANTHROPIC_API_KEY / OPENAI_API_KEY / ... or pi's login
@@ -313,6 +315,7 @@ async function main() {
313
315
  },
314
316
  });
315
317
  // model and effort for new agents, per engine, from [engines.<id>]
318
+ app.templates = config.templates;
316
319
  for (const [id, opts] of Object.entries(config.engines)) {
317
320
  if (opts?.model)
318
321
  app.newAgentModel[id] = opts.model;
@@ -331,7 +334,7 @@ async function main() {
331
334
  saveAgentMeta(wt, {
332
335
  ...meta,
333
336
  model: s instanceof CliSession ? (requested ?? "") : s.model,
334
- ...(s instanceof CliSession ? { requestedModel: requested } : {}),
337
+ ...(s instanceof CliSession ? { requestedModel: requested, usage: { tokens: s.tokens, ...(s.cost !== undefined ? { cost: s.cost } : {}) } } : {}),
335
338
  effort: s.effort,
336
339
  });
337
340
  };
@@ -365,8 +368,13 @@ async function main() {
365
368
  /** Reopen a saved agent with the engine it was started with. */
366
369
  const reopen = (meta, worktree) => {
367
370
  const engine = meta.engine && isEngineId(meta.engine) ? meta.engine : "pi";
368
- if (engine !== "pi")
369
- return cliSession(meta.name, engine, meta.requestedModel, worktree.path, worktree, meta.resumeId, meta.effort);
371
+ if (engine !== "pi") {
372
+ const s = cliSession(meta.name, engine, meta.requestedModel, worktree.path, worktree, meta.resumeId, meta.effort);
373
+ // the CLI keeps the conversation; vigent keeps the running totals
374
+ s.tokens = meta.usage?.tokens ?? 0;
375
+ s.cost = meta.usage?.cost;
376
+ return s;
377
+ }
370
378
  return new PiSession(meta.name, meta.model, { cwd: worktree.path, worktree, sessionDir: sessionDir(worktree.path), effort: meta.effort });
371
379
  };
372
380
  app.attachSession = (wt) => {
@@ -389,6 +397,7 @@ async function main() {
389
397
  s.abort();
390
398
  s.dispose();
391
399
  }
400
+ terminal.write(FOCUS_REPORTING_OFF);
392
401
  tui.stop();
393
402
  process.exit(0);
394
403
  };
@@ -403,17 +412,13 @@ async function main() {
403
412
  }
404
413
  else if (root && config.worktree.enabled) {
405
414
  // reopen the agents that were running when vigent last quit
406
- const agents = restorableAgents(root, config.worktree);
407
- // riftri views on OverlayFS lose their mount on reboot: repair them before an agent runs there
408
- const views = prepareViews(root, agents.map((a) => a.worktree.path));
409
- for (const { meta, worktree } of agents) {
410
- const view = views.get(worktree.path);
411
- if (view && !view.ready) {
412
- warnings.push(`couldn't reopen '${meta.name}': ${view.reason}`);
413
- continue;
414
- }
415
+ // riftri views on OverlayFS lose their mount on reboot: they're repaired before any agent's record is read
416
+ const { agents, blocked } = reopenableAgents(root, config.worktree, prepareViews);
417
+ for (const b of blocked)
418
+ warnings.push(`couldn't reopen the agent in ${b.path}: ${b.reason}`);
419
+ for (const { meta, worktree, backend } of agents) {
415
420
  try {
416
- app.addSession(reopen(meta, { ...worktree, riftri: view?.backend }), false);
421
+ app.addSession(reopen(meta, { ...worktree, riftri: backend }), false);
417
422
  }
418
423
  catch (err) {
419
424
  warnings.push(`couldn't reopen '${meta.name}': ${err.message}`);
@@ -428,6 +433,8 @@ async function main() {
428
433
  tui.setFocus(app);
429
434
  terminal.setTitle("vigent");
430
435
  tui.start();
436
+ // focus reports let a notification skip the agent you're looking at
437
+ terminal.write(FOCUS_REPORTING_ON);
431
438
  if (warnings.length > 0)
432
439
  app.notify(warnings.join(" · "));
433
440
  if (isFollow(themeName)) {
@@ -438,6 +445,9 @@ async function main() {
438
445
  });
439
446
  }
440
447
  tui.requestRender(true);
448
+ // follow from your phone from the start (same as :serve)
449
+ if (argv.includes("--serve"))
450
+ void app.execServe(argv.includes("--local") ? "local" : "");
441
451
  return undefined;
442
452
  }
443
453
  main()
package/dist/config.js CHANGED
@@ -15,10 +15,12 @@
15
15
  * CursorLine = "guibg=#2a2f3a"
16
16
  * VigentSidebar = { link = "NormalFloat" }
17
17
  */
18
+ import { NOTIFY_MODES } from "./notify.js";
18
19
  import * as fs from "node:fs";
19
20
  import * as path from "node:path";
20
21
  import { parse as parseToml } from "smol-toml";
21
22
  import { EFFORTS } from "./choices.js";
23
+ import { parseTemplates } from "./templates.js";
22
24
  import { ICON_SETS } from "./icons.js";
23
25
  import { isEngineId } from "./engines.js";
24
26
  import { WORKTREE_BACKENDS } from "./riftri.js";
@@ -36,13 +38,14 @@ export const DEFAULT_UI = {
36
38
  termguicolors: process.env.TERM_PROGRAM !== "Apple_Terminal",
37
39
  transparent: "auto",
38
40
  whichkey: true,
41
+ notify: "auto",
39
42
  };
40
43
  export function configPath() {
41
44
  return path.join(configDir(), "config.toml");
42
45
  }
43
46
  /** Load the config file. Problems become warnings so a typo never stops vigent from starting. */
44
47
  export function loadConfig(file = configPath()) {
45
- const config = { ui: { ...DEFAULT_UI }, highlights: {}, keys: {}, worktree: { ...DEFAULT_WORKTREE }, engines: {} };
48
+ const config = { ui: { ...DEFAULT_UI }, highlights: {}, keys: {}, worktree: { ...DEFAULT_WORKTREE }, engines: {}, templates: {} };
46
49
  const warnings = [];
47
50
  if (!fs.existsSync(file))
48
51
  return { config, warnings };
@@ -104,6 +107,9 @@ export function loadConfig(file = configPath()) {
104
107
  config.engines[id] = e;
105
108
  }
106
109
  break;
110
+ case "templates":
111
+ config.templates = parseTemplates(value, warnings);
112
+ break;
107
113
  case "keys":
108
114
  if (!value || typeof value !== "object" || Array.isArray(value)) {
109
115
  warnings.push("config: [keys] should be a table");
@@ -200,6 +206,7 @@ const ALIASES = {
200
206
  transp: "transparent",
201
207
  whichkey: "whichkey",
202
208
  wk: "whichkey",
209
+ notify: "notify",
203
210
  };
204
211
  /** Set one option from a config value. Returns an error message, or undefined on success. */
205
212
  export function setOption(ui, name, value) {
@@ -223,6 +230,11 @@ export function setOption(ui, name, value) {
223
230
  ui.sidebar_width = n;
224
231
  return;
225
232
  }
233
+ case "notify":
234
+ if (!NOTIFY_MODES.includes(value))
235
+ return `'${name}' should be "auto", "bell", "desktop" or "off"`;
236
+ ui.notify = value;
237
+ return;
226
238
  case "icons":
227
239
  if (!ICON_SETS.includes(value))
228
240
  return `'${name}' should be "unicode", "nerd" or "ascii"`;
@@ -327,7 +339,7 @@ theme = "default"
327
339
  # --- Engines ---------------------------------------------------------------
328
340
  # What runs new agents: "pi" (built in), or a coding-agent CLI you already
329
341
  # have installed and signed in: "claude" (Claude Code), "codex", "opencode".
330
- # "auto" picks pi if it has an API key, else the first signed-in CLI.
342
+ # "auto" picks the first signed-in CLI (your own login), else pi if it has an API key.
331
343
  # Per agent: :new fix the race --engine codex. Status: :checkhealth
332
344
  engine = "auto"
333
345
 
@@ -340,6 +352,17 @@ engine = "auto"
340
352
  # [engines.opencode]
341
353
  # model = "anthropic/claude-sonnet-4-5"
342
354
 
355
+ # --- Templates -------------------------------------------------------------
356
+ # Prompts you start agents with often. :new -t fix <task> starts an agent and
357
+ # sends the prompt, {task} replaced by what you typed; :templates picks one.
358
+ # Built in: fix, test, review, explain (one of yours with the same name
359
+ # replaces it). engine, model, effort and worktree are optional.
360
+ # [templates.migrate]
361
+ # description = "a database migration, with a rollback"
362
+ # prompt = "Write a migration that {task}. Include the rollback, and test both."
363
+ # engine = "claude"
364
+ # effort = "high"
365
+
343
366
  # --- Leader ----------------------------------------------------------------
344
367
  # What <leader> means in [keys]. Vim's default is "\\".
345
368
  leader = "<Space>"
@@ -355,6 +378,7 @@ timeoutlen = 1000 # ms to wait for the rest of a multi-key mapping
355
378
  # termguicolors = false # for terminals without 24-bit color: use the nearest of 256
356
379
  # transparent = true # use the terminal's background with any theme ("auto": the theme decides)
357
380
  whichkey = true # after <leader>, show what each next key does (like which-key.nvim)
381
+ notify = "auto" # an agent finished while you looked elsewhere: "auto", "bell", "desktop", "off"
358
382
 
359
383
  # --- Colors (also live: :hi Group guifg=#... guibg=#...) -------------------
360
384
  # Applied on top of whichever theme is active. A value is a table, a vim
package/dist/diffview.js CHANGED
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Both return the line indices of hunk headers so ]c / [c can jump.
9
9
  */
10
- import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
10
+ import { truncateToWidth, visibleWidth } from "./tui.js";
11
11
  import { fileIcon } from "./icons.js";
12
12
  function lineGroup(line) {
13
13
  if (line.startsWith("+++") || line.startsWith("---"))
package/dist/engines.js CHANGED
@@ -10,6 +10,7 @@ import { execFile } from "node:child_process";
10
10
  import * as fs from "node:fs";
11
11
  import * as os from "node:os";
12
12
  import * as path from "node:path";
13
+ import { PI_INSTALL, piInstalled } from "./pi-sdk.js";
13
14
  export const ENGINE_IDS = ["pi", "claude", "codex", "opencode"];
14
15
  export const ENGINE_NAMES = {
15
16
  pi: "pi",
@@ -53,22 +54,25 @@ export function onPath(bin, env = process.env) {
53
54
  export function piConnected(env = process.env) {
54
55
  return detectPi(env).connected;
55
56
  }
57
+ const CLI_IDS = ["claude", "codex", "opencode"];
56
58
  /**
57
- * The engine new agents use when none is given: pi if it has a key, else the
58
- * first signed-in CLI (Claude Code, Codex, OpenCode). Before the sign-in checks
59
- * finish, the first of those that is installed.
59
+ * The engine new agents use when none is given ("auto"): the first signed-in
60
+ * CLI (Claude Code, Codex, OpenCode), so your own login and settings are used;
61
+ * pi only when no CLI is ready and pi has a key. While the sign-in checks run,
62
+ * an installed CLI still wins over pi. With nothing ready, the first installed
63
+ * CLI, else pi (starting an agent then says how to connect one).
60
64
  */
61
65
  export function defaultEngine(engines, env = process.env) {
62
66
  if (engines) {
63
- const ready = engines.find((e) => e.connected);
67
+ const cli = (ok) => CLI_IDS.map((id) => engines.find((e) => e.id === id)).find((e) => e && ok(e))?.id;
68
+ const ready = cli((e) => e.connected) ?? cli((e) => e.installed && Boolean(e.checking));
64
69
  if (ready)
65
- return ready.id;
66
- const installed = engines.find((e) => e.id !== "pi" && e.installed);
67
- return installed?.id ?? "pi";
70
+ return ready;
71
+ if (engines.find((e) => e.id === "pi")?.connected)
72
+ return "pi";
73
+ return cli((e) => e.installed) ?? "pi";
68
74
  }
69
- if (piConnected(env))
70
- return "pi";
71
- for (const id of ["claude", "codex", "opencode"])
75
+ for (const id of CLI_IDS)
72
76
  if (onPath(ENGINE_BINS[id], env))
73
77
  return id;
74
78
  return "pi";
@@ -88,7 +92,10 @@ function strip(s) {
88
92
  function firstVersion(s) {
89
93
  return /\d+\.\d+\.\d+[\w.-]*/.exec(s)?.[0];
90
94
  }
91
- function detectPi(env) {
95
+ export function detectPi(env, installed = piInstalled()) {
96
+ if (!installed) {
97
+ return { id: "pi", name: ENGINE_NAMES.pi, installed: false, connected: false, detail: "not installed (light install)", install: PI_INSTALL };
98
+ }
92
99
  const keys = PI_KEYS.filter((k) => env[k]);
93
100
  let stored = false;
94
101
  try {
package/dist/float.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * optionally with selectable rows (`:dash`) and jump points (`]c` / `[c`
4
4
  * between diff hunks).
5
5
  */
6
- import { parseKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
6
+ import { parseKey, truncateToWidth, visibleWidth } from "./tui.js";
7
7
  import { RESET } from "./theme.js";
8
8
  export class TextFloat {
9
9
  painter;