@thedevlabs-io/labd 0.7.0 → 0.8.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/dist/labd.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // packages/cli/src/index.ts
4
- import { resolve } from "node:path";
4
+ import { resolve as resolve2 } from "node:path";
5
5
 
6
6
  // vendor/labd/packages/protocol/src/activity.ts
7
7
  var ACTIVITY_WEEKS = 53;
@@ -105,6 +105,8 @@ function surfaceLabel(surface) {
105
105
  return "memory (background)";
106
106
  case "api":
107
107
  return "API";
108
+ case "help":
109
+ return "plans & billing help";
108
110
  default:
109
111
  return surface;
110
112
  }
@@ -186,7 +188,7 @@ function rankPaths(query, paths, limit = 12) {
186
188
  }
187
189
 
188
190
  // vendor/labd/packages/protocol/src/workspace.ts
189
- var SECRETISH = /(^|\/)(\.env(\..*)?|.*\.(pem|key|p12|pfx)|id_(rsa|ed25519)|.*credentials.*|.*secrets?\.(json|ya?ml|toml))$/i;
191
+ var SECRETISH = /(^|\/)(\.env(\..*)?|.*\.(pem|key|p12|pfx)|id_(rsa|ed25519)|.*credentials.*|.*secrets?\.(json|ya?ml|toml)|\.dev\.vars|\.npmrc|\.netrc|\.pypirc|\.yarnrc\.yml|\.git\/config)$/i;
190
192
  var EXCLUDED_DIRS = [
191
193
  ".git",
192
194
  "node_modules",
@@ -201,6 +203,7 @@ var OFF_LIMITS = new RegExp(
201
203
  `(^|/)(${EXCLUDED_DIRS.map((d) => d.replace(/\./g, "\\.")).join("|")})(/|$)`,
202
204
  "i"
203
205
  );
206
+ var EDITOR_SETTINGS = /(^|\/)\.vscode\/(settings|tasks|launch|mcp)\.json$|(^|\/)\.devcontainer(\/|$)|(^|\/)\.devcontainer\.json$|\.code-workspace$/i;
204
207
  var EXCLUDED_GLOB = `**/{${EXCLUDED_DIRS.join(",")}}/**`;
205
208
  var ASSIGNMENT = /^(\s*(?:export\s+)?["']?[\w.-]+["']?\s*[:=]\s*)(.+)$/;
206
209
  function redactValues(text) {
@@ -293,8 +296,12 @@ function hasControlChars(command) {
293
296
  }
294
297
  var INTERPRETERS = /^(sh|bash|zsh|fish|dash|ksh|python[\d.]*|node|deno|bun|ruby|perl|php|osascript)$/;
295
298
  var DOWNLOADERS = /^(curl|wget|fetch|httpie|http)$/;
296
- var WRAPPERS = /^(command|exec|xargs|time|nice|builtin)$/;
299
+ var WRAPPERS = /^(command|exec|xargs|time|nice|builtin|env)$/;
300
+ var WRAPPER_VALUED = /^(-u|-C|-S|-I|-n|-P|-L|-d|-s|--unset|--chdir)$/;
301
+ var ASSIGNMENT_WORD = /^[A-Za-z_][A-Za-z0-9_]*=/;
297
302
  var SHELLS_WITH_C = /^(sh|bash|zsh|dash|ksh|fish)$/;
303
+ var PROTECTED_REASON = "it would write a file labd never changes: the editor's settings, task or container files, or something inside .git. Those run commands or change rules outside any approval. Tell the learner what to change and let them do it.";
304
+ var SECRET_REASON = "it would read a file that holds credentials in the clear. labd only ever sees which keys are set, never their values, and a shell command is not a way around that - tell the learner what to change instead.";
298
305
  var REFUSALS = [
299
306
  {
300
307
  // Everything after this runs as a different user, which puts it outside
@@ -362,9 +369,17 @@ var REFUSALS = [
362
369
  test: (argv) => /^(vi|vim|nvim|nano|emacs|less|more|top|htop|watch|ssh|telnet|ftp|man)$/.test(argv[0] ?? "") || argv[0] === "tail" && argv.includes("-f"),
363
370
  reason: "it waits for input or never exits, so there would be no output to read. Ask for a command that finishes."
364
371
  },
372
+ {
373
+ test: (argv) => writesAProtectedFile(argv),
374
+ reason: PROTECTED_REASON
375
+ },
376
+ {
377
+ test: (argv) => changesGitExecution(argv),
378
+ reason: "it changes a git setting that decides which programs git runs (a hook path, pager, editor, alias, credential helper, include and the like), which would run code later outside any approval. Tell the learner what to set and why."
379
+ },
365
380
  {
366
381
  test: (argv) => namesASecret(argv),
367
- reason: "it would read a file that holds credentials in the clear. labd only ever sees which keys are set, never their values, and a shell command is not a way around that - tell the learner what to change instead."
382
+ reason: SECRET_REASON
368
383
  },
369
384
  {
370
385
  test: (argv) => argv.length === 1 && /^(env|printenv|set)$/.test(argv[0] ?? ""),
@@ -385,9 +400,13 @@ function isBigTarget(t) {
385
400
  }
386
401
  function unwrap(argv) {
387
402
  let out = argv;
388
- while (out.length > 1 && WRAPPERS.test(bare(out[0]))) {
403
+ for (; ; ) {
404
+ while (out.length > 1 && ASSIGNMENT_WORD.test(out[0])) out = out.slice(1);
405
+ if (!(out.length > 1 && WRAPPERS.test(bare(out[0])))) break;
389
406
  out = out.slice(1);
390
- while (out.length > 1 && (out[0].startsWith("-") || /^\d+$/.test(out[0]))) out = out.slice(1);
407
+ while (out.length > 1 && (out[0].startsWith("-") || /^\d+$/.test(out[0]) || ASSIGNMENT_WORD.test(out[0]))) {
408
+ out = WRAPPER_VALUED.test(out[0]) && out.length > 2 ? out.slice(2) : out.slice(1);
409
+ }
391
410
  }
392
411
  return out.length ? [bare(out[0]), ...out.slice(1)] : out;
393
412
  }
@@ -420,14 +439,115 @@ function pipesNetworkIntoShell(argv) {
420
439
  return downloader && interpreter;
421
440
  }
422
441
  function namesASecret(argv) {
423
- return argv.slice(1).some((a) => SECRETISH.test(bare(a)));
442
+ return argv.slice(1).some((a) => SECRETISH.test(bare(unredirect(a))) || argPaths(a).some((p) => SECRETISH.test(p)));
443
+ }
444
+ var REDIRECTION = /^\d*(<>|>\||[<>]+)&?/;
445
+ var unredirect = (arg) => arg.replace(/^["']|["']$/g, "").replace(REDIRECTION, "");
446
+ function argPath(arg) {
447
+ return unredirect(arg).replace(/\\/g, "/");
448
+ }
449
+ function argPaths(arg) {
450
+ const whole = argPath(arg);
451
+ const value = whole.includes("=") ? whole.slice(whole.indexOf("=") + 1) : null;
452
+ return [whole, ...value ? [value] : []].map((p) => {
453
+ const out = [];
454
+ for (const seg of p.split("/")) {
455
+ if (seg === "" || seg === ".") continue;
456
+ if (seg === ".." && out.length && out[out.length - 1] !== "..") out.pop();
457
+ else out.push(seg);
458
+ }
459
+ return out.join("/");
460
+ });
461
+ }
462
+ var WRITERS = /^(rm|rmdir|mv|cp|tee|touch|mkdir|ln|chmod|chown|install|rsync|truncate|patch|unzip|dd|copy|xcopy|robocopy|move|del|erase|ren|rename|rd|md)$/i;
463
+ var EXCLUDING = /^(-path|-ipath|-prune|--exclude|--exclude-dir|--exclude-from)$/;
464
+ var PROTECTED_PATH = (p) => EDITOR_SETTINGS.test(p) || /(^|\/)\.vscode$/i.test(p) || /(^|\/)\.git(\/|$)/i.test(p);
465
+ function writtenPaths(argv) {
466
+ const out = [];
467
+ const writer = WRITERS.test(argv[0] ?? "") || argv[0] === "sed" && argv.some((a) => /^-i/.test(a) || a === "--in-place");
468
+ for (let i = 0; i < argv.length; i++) {
469
+ const a = argv[i].replace(/^["']|["']$/g, "");
470
+ if (EXCLUDING.test(a)) {
471
+ i++;
472
+ continue;
473
+ }
474
+ if (/^--exclude/.test(a)) continue;
475
+ const op = REDIRECT_OP.exec(a);
476
+ if (op) {
477
+ const writes = a.includes(">");
478
+ const duplicates = /&(\d+|-)$/.test(a);
479
+ if (!duplicates && i + 1 < argv.length) {
480
+ i++;
481
+ if (writes) out.push(...argPaths(argv[i]));
482
+ }
483
+ continue;
484
+ }
485
+ if (i > 0 && writer && !a.startsWith("-")) out.push(...argPaths(a));
486
+ }
487
+ return out;
488
+ }
489
+ var REDIRECT_OP = /^\d*(<>|>\||>>|>|<<<|<<|<)(&(\d+|-)?)?$/;
490
+ function writesAProtectedFile(argv) {
491
+ return writtenPaths(argv).some(PROTECTED_PATH);
492
+ }
493
+ var PROGRAM_KEYS = /^(core\.(hookspath|fsmonitor|pager|editor|sshcommand|askpass|gitproxy|alternaterefscommand)|diff\.external|credential\..*|filter\..*|include\..*|includeif\..*|alias\..*|sequence\.editor|gpg\..*program|pager\..*|diff\..*\.(command|textconv)|merge\..*\.driver|url\..*|hook\..*|submodule\..*\.update|remote\..*\.(uploadpack|receivepack)|mergetool\..*|difftool\..*)$/i;
494
+ var SAFE_CONFIG_KEYS = /^(user\.[\w.-]+|init\.defaultbranch|pull\.rebase|push\.default|color\.[\w.-]+|core\.autocrlf|core\.quotepath|merge\.conflictstyle)$/i;
495
+ var GIT_VALUED_OPTIONS = /^(-C|-c|--git-dir|--work-tree|--namespace|--config-env|--exec-path)$/;
496
+ var CONFIG_READ_FLAGS = /^(--get|--get-all|--get-regexp|--get-urlmatch|-l|--list)$/;
497
+ var CONFIG_WRITE_FLAGS = /^(--unset|--unset-all|--add|--replace-all|--rename-section|--remove-section|-e|--edit)$/;
498
+ function opensEditor(arg) {
499
+ if (/^--ed(i|it)?$/.test(arg)) return true;
500
+ return /^-[a-zA-Z]+$/.test(arg) && !arg.startsWith("-f") && arg.includes("e");
501
+ }
502
+ var CONFIG_READ_VERBS = /^(get|list)$/;
503
+ var CONFIG_VALUED = /^(-f|--file|--blob|--type|--default|--comment)$/;
504
+ function programSetting(pair) {
505
+ const eq = pair.indexOf("=");
506
+ const key = eq === -1 ? pair : pair.slice(0, eq);
507
+ const value = eq === -1 ? "" : pair.slice(eq + 1);
508
+ if (/^(core\.pager|pager\..*)$/i.test(key) && /^(cat)?$/.test(value)) return false;
509
+ return PROGRAM_KEYS.test(key);
510
+ }
511
+ function changesGitExecution(argv) {
512
+ if (bare(argv[0] ?? "") !== "git") return false;
513
+ const args = argv.slice(1).map((a) => a.replace(/^["']|["']$/g, ""));
514
+ let i = 0;
515
+ for (; i < args.length && args[i].startsWith("-"); i++) {
516
+ const a = args[i];
517
+ if (a === "-c" || a === "--config-env") {
518
+ if (programSetting(args[i + 1] ?? "")) return true;
519
+ i++;
520
+ continue;
521
+ }
522
+ if (/^-c./.test(a) && programSetting(a.slice(2))) return true;
523
+ if (/^--config-env=/.test(a) && programSetting(a.slice("--config-env=".length))) return true;
524
+ if (GIT_VALUED_OPTIONS.test(a)) i++;
525
+ }
526
+ if (args[i] !== "config") return false;
527
+ const rest = args.slice(i + 1);
528
+ if (rest.some(opensEditor) || rest.find((a) => !a.startsWith("-")) === "edit") return true;
529
+ if (rest.some((a) => CONFIG_READ_FLAGS.test(a))) return false;
530
+ const words = [];
531
+ for (let j = 0; j < rest.length; j++) {
532
+ const a = rest[j];
533
+ if (CONFIG_VALUED.test(a)) {
534
+ j++;
535
+ continue;
536
+ }
537
+ if (a.startsWith("-")) continue;
538
+ words.push(a);
539
+ }
540
+ if (CONFIG_READ_VERBS.test(words[0] ?? "")) return false;
541
+ if (words.length === 1 && !rest.some((a) => CONFIG_WRITE_FLAGS.test(a))) return false;
542
+ const key = /^(set|unset)$/.test(words[0] ?? "") ? words[1] : words[0];
543
+ return key !== void 0 && !SAFE_CONFIG_KEYS.test(key);
424
544
  }
425
545
  function bare(arg) {
426
546
  const unquoted = arg.replace(/^["']|["']$/g, "");
427
547
  const slash = Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\"));
428
548
  return slash === -1 ? unquoted : unquoted.slice(slash + 1);
429
549
  }
430
- function segments(command) {
550
+ function segments(command, literalBackslash = false) {
431
551
  const out = [];
432
552
  let argv = [];
433
553
  let token = "";
@@ -452,7 +572,7 @@ function segments(command) {
452
572
  quote = c;
453
573
  continue;
454
574
  }
455
- if (c === "\\" && i + 1 < command.length) {
575
+ if (c === "\\" && i + 1 < command.length && !literalBackslash) {
456
576
  token += command[++i];
457
577
  continue;
458
578
  }
@@ -470,6 +590,20 @@ function segments(command) {
470
590
  argv.push(c);
471
591
  continue;
472
592
  }
593
+ if (c === "<" || c === ">") {
594
+ let op = /^\d+$/.test(token) ? token : "";
595
+ if (!op) endToken();
596
+ token = "";
597
+ op += c;
598
+ while (i + 1 < command.length && /[<>]/.test(command[i + 1])) op += command[++i];
599
+ if (c === ">" && command[i + 1] === "|") op += command[++i];
600
+ if (command[i + 1] === "&") {
601
+ op += command[++i];
602
+ while (i + 1 < command.length && /[\d-]/.test(command[i + 1])) op += command[++i];
603
+ }
604
+ argv.push(op);
605
+ continue;
606
+ }
473
607
  if (c === "&") {
474
608
  const standalone = token === "" && (i === 0 || command[i - 1] === " " || command[i - 1] === " ");
475
609
  if (standalone) argv.push("&");
@@ -502,6 +636,12 @@ function classifyCommand(command) {
502
636
  }
503
637
  for (const segment of segments(raw)) {
504
638
  const argv = unwrap(segment);
639
+ if (segment.some((word) => /^(export\s+)?GIT_CONFIG\w*=/.test(word))) {
640
+ return {
641
+ ok: false,
642
+ reason: "it passes git configuration through the environment (GIT_CONFIG_*), which can make git run other programs. Tell the learner what to set and why."
643
+ };
644
+ }
505
645
  if (argv.includes("&")) {
506
646
  return {
507
647
  ok: false,
@@ -519,6 +659,13 @@ function classifyCommand(command) {
519
659
  }
520
660
  }
521
661
  }
662
+ for (const segment of segments(raw, true)) {
663
+ const argv = unwrap(segment);
664
+ for (const candidate of [argv, ...stages(argv)]) {
665
+ if (writesAProtectedFile(candidate)) return { ok: false, reason: PROTECTED_REASON };
666
+ if (namesASecret(candidate)) return { ok: false, reason: SECRET_REASON };
667
+ }
668
+ }
522
669
  return { ok: true };
523
670
  }
524
671
 
@@ -613,6 +760,21 @@ function forExtraction(messages) {
613
760
  };
614
761
  });
615
762
  }
763
+ var LOW_CREDITS_PERCENT = 15;
764
+ function safeTopUpUrl(url) {
765
+ const m = /^(https?):\/\/((?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(:[1-9]\d{0,4})?(\/settings\/labd)(?:[?#]|$)/.exec(
766
+ (url ?? "").replace(/^[a-z]+:\/\/[^/?#]*/i, (origin) => origin.toLowerCase())
767
+ );
768
+ if (!m) return null;
769
+ const [, scheme, host, port = "", path] = m;
770
+ if (port && Number(port.slice(1)) > 65535) return null;
771
+ const local = host === "localhost" || host === "127.0.0.1";
772
+ const ours = host === "thedevlabs.io" || host.endsWith(".thedevlabs.io");
773
+ if (!(ours && scheme === "https" || local && scheme === "http")) return null;
774
+ return `${scheme}://${host}${port}${path}`;
775
+ }
776
+ var isOutOfCredits = (credits) => credits?.percentLeft === 0;
777
+ var isLowOnCredits = (credits) => credits != null && credits.percentLeft > 0 && credits.percentLeft <= LOW_CREDITS_PERCENT;
616
778
 
617
779
  // packages/core/src/agent.ts
618
780
  import { randomUUID as randomUUID2 } from "node:crypto";
@@ -1381,7 +1543,7 @@ async function runCommand(input, ctx) {
1381
1543
  ].join("\n");
1382
1544
  }
1383
1545
  function execute(command, ctx) {
1384
- return new Promise((resolve2) => {
1546
+ return new Promise((resolve3) => {
1385
1547
  const windows = process.platform === "win32";
1386
1548
  const shell = windows ? process.env.ComSpec || "cmd.exe" : process.env.SHELL || "/bin/bash";
1387
1549
  const args = windows ? ["/d", "/s", "/c", command] : ["-lc", command];
@@ -1436,7 +1598,7 @@ ${tail}` : head + tail;
1436
1598
  if (settled) return;
1437
1599
  settled = true;
1438
1600
  clearTimeout(timer);
1439
- resolve2({ code, output: output(), timedOut });
1601
+ resolve3({ code, output: output(), timedOut });
1440
1602
  };
1441
1603
  child.on("close", done);
1442
1604
  child.on("error", (err) => {
@@ -1666,11 +1828,14 @@ async function runTurn(question, opts) {
1666
1828
  case "need_tools":
1667
1829
  needTools = true;
1668
1830
  return "go";
1831
+ case "ready":
1832
+ ui.ready?.({ credits: event.credits });
1833
+ return "go";
1669
1834
  case "done":
1670
1835
  ui.done?.({ credits: event.credits, context: event.context });
1671
1836
  return "go";
1672
1837
  case "error":
1673
- ui.error(event.message);
1838
+ ui.error(event.message, { code: event.code, ...event.credits ? { credits: event.credits } : {} });
1674
1839
  return "stop";
1675
1840
  default:
1676
1841
  return "go";
@@ -2100,9 +2265,14 @@ var AgentClient = class {
2100
2265
  if (this.opts.token) headers.Authorization = `Bearer ${this.opts.token}`;
2101
2266
  return headers;
2102
2267
  }
2103
- async credits() {
2268
+ /**
2269
+ * What is left of this month's allowance, or null when the server cannot say.
2270
+ * Bounded, because the sessions ask for it as they open and a slow server
2271
+ * must not hold the first frame.
2272
+ */
2273
+ async credits(timeoutMs = 5e3) {
2104
2274
  try {
2105
- const res = await this.fetch(`${this.opts.baseUrl}/v1/me`, { headers: this.headers() });
2275
+ const res = await this.fetch(`${this.opts.baseUrl}/v1/me`, { headers: this.headers(), signal: AbortSignal.timeout(timeoutMs) });
2106
2276
  if (!res.ok) return null;
2107
2277
  const body2 = await res.json();
2108
2278
  return body2.credits ?? null;
@@ -2169,11 +2339,16 @@ var AgentClient = class {
2169
2339
  }
2170
2340
  if (!res.ok || !res.body) {
2171
2341
  let message = `The assistant is unreachable (${res.status}).`;
2342
+ let body2 = {};
2172
2343
  try {
2173
- const body2 = await res.json();
2344
+ body2 = await res.json();
2174
2345
  if (body2.message) message = body2.message;
2175
2346
  } catch {
2176
2347
  }
2348
+ if (res.status === 402 && body2.code === "no_credits") {
2349
+ yield { type: "error", code: "no_credits", message, ...body2.credits ? { credits: body2.credits } : {} };
2350
+ return;
2351
+ }
2177
2352
  if (res.status === 401) message = `${message} Run \`labd login\`.`;
2178
2353
  yield { type: "error", code: res.status === 401 ? "unauthorized" : "upstream", message };
2179
2354
  return;
@@ -2382,1369 +2557,1430 @@ function visibleWidth(line) {
2382
2557
  return line.replace(new RegExp(`${ESC2}\\[[0-9;]*m`, "g"), "").length;
2383
2558
  }
2384
2559
 
2385
- // packages/tui/src/cards.ts
2386
- var OUTPUT_LINES = 8;
2387
- function toolBlock(name, detail2, status, io, width = 100) {
2388
- const label2 = LABELS[name] ?? name.replace(/_/g, " ");
2389
- const dot = status === "error" ? paint.red("\u25CF") : status === "skipped" ? paint.muted("\u25CB") : status === "awaiting" ? paint.yellow("\u25CF") : status === "running" ? paint.dim("\u25CB") : paint.green("\u25CF");
2390
- const out = [` ${dot} ${paint.bold(paint.accent(label2))} ${paint.muted(detail2)}`];
2391
- const room = Math.max(20, width - 9);
2392
- const row = (gutter, text, paintText, soft) => {
2393
- const wrap3 = soft ? wrapWords : wrapCommand;
2394
- const lines = text.split("\n").flatMap((line) => wrap3(line, room));
2395
- lines.forEach((line, i) => {
2396
- out.push(` ${i === 0 ? paint.dim(gutter.padEnd(4)) : " "} ${paintText(line)}`);
2397
- });
2398
- };
2399
- const shell = name === "run_command";
2400
- if (io.input) row(shell ? "$" : "IN", io.input, (t) => paint.cyan(t), false);
2401
- if (io.output) {
2402
- const lines = io.output.replace(/\s+$/, "").split("\n");
2403
- const shown = lines.slice(0, OUTPUT_LINES).join("\n");
2404
- row(shell ? "" : "OUT", shown, (t) => status === "error" ? paint.red(t) : paint.muted(t), true);
2405
- if (lines.length > OUTPUT_LINES) {
2406
- out.push(` ${"".padEnd(4)} ${paint.dim(`\u2026 ${lines.length - OUTPUT_LINES} more lines`)}`);
2560
+ // packages/tui/src/markdown.ts
2561
+ function renderMarkdown(text, opts) {
2562
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
2563
+ const pad4 = opts.indent ?? "";
2564
+ const out = [];
2565
+ let inFence = false;
2566
+ let fenceLang = "";
2567
+ for (const line of lines) {
2568
+ const fence = /^\s*```(\w*)/.exec(line);
2569
+ if (fence) {
2570
+ if (!inFence) {
2571
+ inFence = true;
2572
+ fenceLang = fence[1] ?? "";
2573
+ out.push(paint.dim(`${pad4}${fenceLang || "code"}`));
2574
+ } else {
2575
+ inFence = false;
2576
+ fenceLang = "";
2577
+ out.push("");
2578
+ }
2579
+ continue;
2580
+ }
2581
+ if (inFence) {
2582
+ out.push(`${paint.dim(`${pad4}|`)} ${line}`);
2583
+ continue;
2407
2584
  }
2585
+ out.push(...prose(line, opts.width, pad4));
2408
2586
  }
2409
- return out;
2587
+ return out.join("\n");
2410
2588
  }
2411
- var LABELS = {
2412
- read_file: "Read",
2413
- list_dir: "List",
2414
- search_workspace: "Search",
2415
- edit_file: "Edit",
2416
- create_file: "Create",
2417
- move_file: "Move",
2418
- delete_file: "Delete",
2419
- run_command: "Run",
2420
- spawn_agent: "Helper"
2421
- };
2422
- function approvalCard(preview, call, mode, width) {
2423
- const out = [""];
2424
- if (call.name === "run_command" || preview?.kind === "run") {
2425
- const command = preview?.kind === "run" ? preview.command : String(call.input.command ?? "");
2426
- const why = preview?.kind === "run" ? preview.why : call.input.why;
2427
- out.push(`${paint.yellow("?")} ${paint.bold("Run a command")}`);
2428
- if (why) out.push(` ${paint.muted(why)}`);
2429
- out.push("");
2430
- for (const line of wrapCommand(command, width - 6)) out.push(` ${paint.cyan(line)}`);
2431
- out.push("");
2432
- out.push(
2433
- ` ${paint.dim(
2434
- mode === "auto" ? "On auto, commands still ask - a command can do anything your shell can, and put back isn't one of them." : "Runs in this project. Unlike an edit, this can't be put back."
2435
- )}`
2436
- );
2437
- return out;
2589
+ function prose(line, width, pad4) {
2590
+ const heading = /^(#{1,6})\s+(.*)$/.exec(line);
2591
+ if (heading) return [`${pad4}${paint.bold(paint.accent(heading[2]))}`];
2592
+ if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
2593
+ return [`${pad4}${paint.dim("-".repeat(Math.min(width, 40)))}`];
2438
2594
  }
2439
- const creating = call.name === "create_file";
2440
- const moving = call.name === "move_file";
2441
- const deleting = call.name === "delete_file";
2442
- const path = preview ? preview.path : String(call.input.path ?? call.input.from ?? "a file");
2443
- const destination = preview?.to ?? call.input.to;
2444
- const title = moving ? "Move" : deleting ? "Delete" : creating ? "Create" : "Edit";
2445
- out.push(
2446
- `${paint.yellow("?")} ${paint.bold(title)} ${paint.accent(path)}${moving && destination ? paint.muted(" \u2192 ") + paint.accent(destination) : ""}${preview ? paint.muted(
2447
- ` ${counts(preview.added, preview.removed)}${creating || moving || deleting ? "" : ` at line ${preview.startLine}`}`
2448
- ) : ""}`
2449
- );
2450
- if (preview) {
2451
- out.push("");
2452
- for (const line of diffLines(preview.oldText, preview.newText, width)) out.push(` ${line}`);
2595
+ const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
2596
+ if (bullet) {
2597
+ const indent2 = `${pad4}${bullet[1]}${paint.accent("-")} `;
2598
+ return wrap2(inline(bullet[2]), width, indent2, `${pad4}${bullet[1]} `);
2453
2599
  }
2454
- out.push("");
2455
- out.push(
2456
- ` ${paint.dim(
2457
- moving || deleting ? "Changes the file on disk. You can put the whole answer back afterwards." : "Written to the file. You can put the whole answer back afterwards."
2458
- )}`
2459
- );
2460
- return out;
2461
- }
2462
- function counts(added, removed) {
2463
- const parts = [];
2464
- if (added) parts.push(`+${added}`);
2465
- if (removed) parts.push(`-${removed}`);
2466
- return parts.join(" ");
2467
- }
2468
- function diffLines(oldText, newText, width, max = 12) {
2469
- const out = [];
2470
- const before = oldText ? oldText.split("\n") : [];
2471
- const after = newText ? newText.split("\n") : [];
2472
- for (const line of before.slice(0, max)) out.push(paint.red(`- ${clip(line, width - 4)}`));
2473
- if (before.length > max) out.push(paint.dim(` ... ${before.length - max} more removed`));
2474
- for (const line of after.slice(0, max)) out.push(paint.green(`+ ${clip(line, width - 4)}`));
2475
- if (after.length > max) out.push(paint.dim(` ... ${after.length - max} more added`));
2476
- return out;
2600
+ const numbered = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
2601
+ if (numbered) {
2602
+ const indent2 = `${pad4}${numbered[1]}${paint.accent(`${numbered[2]}.`)} `;
2603
+ return wrap2(inline(numbered[3]), width, indent2, `${pad4}${numbered[1]} `);
2604
+ }
2605
+ const quote = /^\s*>\s?(.*)$/.exec(line);
2606
+ if (quote) {
2607
+ const gutter = `${pad4}${paint.dim("|")} `;
2608
+ return wrap2(paint.muted(inline(quote[1])), width, gutter, gutter);
2609
+ }
2610
+ if (!line.trim()) return [""];
2611
+ return wrap2(inline(line), width, pad4, pad4);
2477
2612
  }
2478
- function clip(line, width) {
2479
- return visibleWidth(line) <= width ? line : `${line.slice(0, Math.max(0, width - 1))}\u2026`;
2613
+ var HOLD = "\0";
2614
+ function inline(text) {
2615
+ const code = [];
2616
+ let held = text.replace(/`([^`]+)`/g, (_, body2) => {
2617
+ code.push(body2);
2618
+ return `${HOLD}${code.length - 1}${HOLD}`;
2619
+ });
2620
+ held = held.replace(/\*\*([^*]+)\*\*/g, (_, body2) => paint.bold(body2)).replace(/(^|[\s(])\*([^*]+)\*/g, (_, pre, body2) => `${pre}${paint.bold(body2)}`).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label2, href) => `${label2} ${paint.dim(`(${href})`)}`);
2621
+ return held.replace(
2622
+ new RegExp(`${HOLD}(\\d+)${HOLD}`, "g"),
2623
+ (_, n) => paint.cyan(code[Number(n)] ?? "")
2624
+ );
2480
2625
  }
2481
- function wrapWords(text, width) {
2482
- if (text.length <= width) return [text];
2626
+ function wrap2(text, width, first, rest) {
2627
+ const limit = Math.max(20, width - visibleWidth(first));
2628
+ if (visibleWidth(text) <= limit) return [`${first}${text}`];
2483
2629
  const out = [];
2484
2630
  let line = "";
2485
2631
  for (const word of text.split(" ")) {
2486
- if (!line) {
2632
+ if (line && visibleWidth(line) + 1 + visibleWidth(word) > limit) {
2633
+ out.push(`${out.length === 0 ? first : rest}${line}`);
2487
2634
  line = word;
2488
- } else if (`${line} ${word}`.length <= width) {
2489
- line = `${line} ${word}`;
2490
2635
  } else {
2491
- out.push(line);
2492
- line = word;
2493
- }
2494
- while (line.length > width) {
2495
- out.push(line.slice(0, width));
2496
- line = line.slice(width);
2636
+ line = line ? `${line} ${word}` : word;
2497
2637
  }
2498
2638
  }
2499
- if (line) out.push(line);
2500
- return out;
2501
- }
2502
- function wrapCommand(command, width) {
2503
- if (command.length <= width) return [command];
2504
- const out = [];
2505
- for (let i = 0; i < command.length; i += width) out.push(command.slice(i, i + width));
2639
+ if (line) out.push(`${out.length === 0 ? first : rest}${line}`);
2506
2640
  return out;
2507
2641
  }
2508
- function statusLine(info2) {
2509
- const bits = [];
2510
- if (info2.contextPercent !== null && info2.contextPercent !== void 0) {
2511
- const crowded = info2.contextPercent >= 75;
2512
- bits.push((crowded ? paint.yellow : paint.muted)(`${info2.contextPercent}% full`));
2642
+
2643
+ // packages/tui/src/transcript.ts
2644
+ var LIVE_LINES = 8;
2645
+ var Transcript = class {
2646
+ entries = [];
2647
+ nextId = 1;
2648
+ cache = /* @__PURE__ */ new Map();
2649
+ /** For a streaming answer: the rendered COMPLETE part, so only the tail is re-rendered per chunk. */
2650
+ prose = /* @__PURE__ */ new Map();
2651
+ get length() {
2652
+ return this.entries.length;
2513
2653
  }
2514
- bits.push(paint.muted(info2.project));
2515
- if (info2.branch) bits.push(paint.muted(info2.branch));
2516
- if (info2.creditsPercent !== null && info2.creditsPercent !== void 0) {
2517
- bits.push(paint.muted(`${info2.creditsPercent}% left`));
2654
+ get blocks() {
2655
+ return this.entries;
2518
2656
  }
2519
- if (info2.effort) bits.push(paint.muted(`think ${info2.effort}`));
2520
- const mode = info2.mode === "agent" ? paint.red(paint.bold("agent")) : info2.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
2521
- bits.push(mode);
2522
- return `${paint.dim(FACE)} ${paint.dim("|")} ${bits.join(paint.dim(" | "))}`;
2523
- }
2524
- function spinnerFrame(tick) {
2525
- const dots = ".".repeat(tick % 3 + 1).padEnd(3);
2526
- return paint.dim(dots);
2527
- }
2528
-
2529
- // packages/tui/src/composer.ts
2530
- var ROUND = { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" };
2531
- var MAX_SUGGESTIONS = 5;
2532
- function composer(opts) {
2533
- const width = Math.max(20, opts.width);
2534
- const colour = borderColour(opts.state);
2535
- const inner = width - 4;
2536
- const out = [];
2537
- for (const line of suggestionLines(opts.suggestions ?? [], inner)) out.push(line);
2538
- out.push(colour(`${ROUND.tl}${ROUND.h.repeat(width - 2)}${ROUND.tr}`));
2539
- out.push(`${colour(ROUND.v)} ${body(opts, inner)} ${colour(ROUND.v)}`);
2540
- out.push(colour(`${ROUND.bl}${ROUND.h.repeat(width - 2)}${ROUND.br}`));
2541
- if (opts.hint) out.push(` ${truncate(opts.hint, width - 2)}`);
2542
- if (opts.status) out.push(opts.status);
2543
- return out;
2544
- }
2545
- function body(opts, inner) {
2546
- const sigil = opts.state === "busy" ? paint.dim(">") : paint.accent(">");
2547
- const room = inner - 2;
2548
- if (!opts.view.text && opts.placeholder && opts.state !== "busy") {
2549
- const text2 = truncate(paint.dim(opts.placeholder), room);
2550
- return `${sigil} ${pad(text2, room)}`;
2551
- }
2552
- const text = withCursor(opts.view, room, opts.state === "busy");
2553
- return `${sigil} ${pad(text, room)}`;
2554
- }
2555
- function withCursor(view, room, hidden) {
2556
- const text = view.text.slice(0, room);
2557
- if (hidden) return text;
2558
- const at2 = Math.min(view.column, room - 1 < 0 ? 0 : room);
2559
- const before = text.slice(0, at2);
2560
- const under = text.slice(at2, at2 + 1) || " ";
2561
- const after = text.slice(at2 + 1);
2562
- return `${before}${paint.invert(under)}${after}`;
2563
- }
2564
- function suggestionLines(all, inner) {
2565
- if (!all.length) return [];
2566
- const shown = all.slice(0, MAX_SUGGESTIONS);
2567
- const column = Math.max(...shown.map((s) => s.name.length + 1));
2568
- const out = shown.map((s, i) => {
2569
- const name = `/${s.name}`.padEnd(column + 1);
2570
- const mark = i === 0 ? paint.accent("-") : " ";
2571
- return ` ${mark} ${paint.accent(name)} ${paint.muted(truncate(s.what, inner - column - 4))}`;
2572
- });
2573
- if (all.length > shown.length) {
2574
- out.push(` ${paint.dim(`... ${all.length - shown.length} more`)}`);
2575
- }
2576
- return out;
2577
- }
2578
- function borderColour(state) {
2579
- switch (state) {
2580
- case "ok":
2581
- return paint.green;
2582
- case "unknown":
2583
- return paint.red;
2584
- case "partial":
2585
- return paint.cyan;
2586
- case "ask":
2587
- return paint.yellow;
2588
- case "busy":
2589
- return paint.accent;
2590
- default:
2591
- return paint.dim;
2592
- }
2593
- }
2594
- function pad(text, width) {
2595
- const short = width - visibleWidth(text);
2596
- return short > 0 ? `${text}${" ".repeat(short)}` : text;
2597
- }
2598
-
2599
- // packages/tui/src/edit.ts
2600
- function sanitize(text) {
2601
- return text.replace(/\r\n?|\n|\t/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
2602
- }
2603
- var PRINTABLE = /^[^\u0000-\u001f\u007f]+$/;
2604
- var segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2605
- function boundaries(text) {
2606
- const out = [0];
2607
- for (const { index, segment } of segmenter.segment(text)) out.push(index + segment.length);
2608
- return out;
2609
- }
2610
- function prevBoundary(text, at2) {
2611
- const marks = boundaries(text);
2612
- let last = 0;
2613
- for (const mark of marks) {
2614
- if (mark >= at2) return last;
2615
- last = mark;
2616
- }
2617
- return last;
2618
- }
2619
- function nextBoundary(text, at2) {
2620
- for (const mark of boundaries(text)) if (mark > at2) return mark;
2621
- return text.length;
2622
- }
2623
- function wordStart(text, at2) {
2624
- let i = at2;
2625
- while (i > 0 && /\s/.test(text[i - 1])) i--;
2626
- while (i > 0 && !/\s/.test(text[i - 1])) i--;
2627
- return i;
2628
- }
2629
- function wordEnd(text, at2) {
2630
- let i = at2;
2631
- while (i < text.length && /\s/.test(text[i])) i++;
2632
- while (i < text.length && !/\s/.test(text[i])) i++;
2633
- return i;
2634
- }
2635
- var LineEditor = class {
2636
- value = "";
2637
- at = 0;
2638
- get text() {
2639
- return this.value;
2640
- }
2641
- /** Cursor position, as an offset into `text`, always on a grapheme boundary. */
2642
- get cursor() {
2643
- return this.at;
2657
+ /** Typed by kind rather than by the literal passed, so `done: false` stays a boolean the caller can flip. */
2658
+ add(block) {
2659
+ const entry = { id: this.nextId++, version: 0, block };
2660
+ this.entries.push(entry);
2661
+ return entry;
2644
2662
  }
2645
- /** Set the whole line - from history, or a completion. Cursor lands at the end. */
2646
- setText(text) {
2647
- this.value = sanitize(text);
2648
- this.at = this.value.length;
2663
+ /**
2664
+ * Move a block to the bottom. For an approval card, which must stay the last
2665
+ * thing on screen while it waits: a helper that keeps working adds rows below
2666
+ * it, and a card pushed up past the fold is one approved without being read.
2667
+ */
2668
+ toEnd(entry) {
2669
+ const at2 = this.entries.indexOf(entry);
2670
+ if (at2 === -1 || at2 === this.entries.length - 1) return;
2671
+ this.entries.splice(at2, 1);
2672
+ this.entries.push(entry);
2649
2673
  }
2650
- reset() {
2651
- this.value = "";
2652
- this.at = 0;
2674
+ /** Say a block changed, so its cached lines are dropped on the next render. */
2675
+ touch(entry) {
2676
+ entry.version++;
2653
2677
  }
2654
- /** Insert at the cursor, newlines removed. Used for typing and for paste. */
2655
- insert(text) {
2656
- const clean = sanitize(text);
2657
- if (!clean) return "same";
2658
- this.value = this.value.slice(0, this.at) + clean + this.value.slice(this.at);
2659
- this.at += clean.length;
2660
- return "text";
2678
+ /** Forget everything shown. The session file is untouched; this is the view. */
2679
+ clear() {
2680
+ this.entries = [];
2681
+ this.cache.clear();
2682
+ this.prose.clear();
2661
2683
  }
2662
- /**
2663
- * Apply one key.
2664
- *
2665
- * Returns `unhandled` for anything this does not claim - Enter, ctrl-c, Tab -
2666
- * so the caller can decide what submitting, cancelling, and completing mean.
2667
- * Those are session concerns, not editing ones.
2668
- */
2669
- apply(key) {
2670
- const { name, ctrl, meta } = key;
2671
- if (name === "left" && !ctrl && !meta) return this.moveTo(prevBoundary(this.value, this.at));
2672
- if (name === "right" && !ctrl && !meta) return this.moveTo(nextBoundary(this.value, this.at));
2673
- if (name === "home" || ctrl && name === "a") return this.moveTo(0);
2674
- if (name === "end" || ctrl && name === "e") return this.moveTo(this.value.length);
2675
- if ((meta || ctrl) && (name === "left" || name === "b")) {
2676
- return this.moveTo(wordStart(this.value, this.at));
2677
- }
2678
- if ((meta || ctrl) && (name === "right" || name === "f")) {
2679
- return this.moveTo(wordEnd(this.value, this.at));
2680
- }
2681
- if (name === "backspace" && (meta || ctrl)) return this.cut(wordStart(this.value, this.at), this.at);
2682
- if (ctrl && name === "w") return this.cut(wordStart(this.value, this.at), this.at);
2683
- if (name === "backspace") return this.cut(prevBoundary(this.value, this.at), this.at);
2684
- if (name === "delete") return this.cut(this.at, nextBoundary(this.value, this.at));
2685
- if (ctrl && name === "u") return this.cut(0, this.at);
2686
- if (ctrl && name === "k") return this.cut(this.at, this.value.length);
2687
- if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) {
2688
- return this.insert(key.sequence);
2684
+ /** Every line, top to bottom, with one blank line between blocks. */
2685
+ render(width) {
2686
+ const out = [];
2687
+ const live = /* @__PURE__ */ new Set();
2688
+ for (const entry of this.entries) {
2689
+ live.add(entry.id);
2690
+ const lines = this.lines(entry, width);
2691
+ if (out.length && lines.length) out.push("");
2692
+ out.push(...lines);
2689
2693
  }
2690
- if (key.paste !== void 0) return this.insert(key.paste);
2691
- return "unhandled";
2692
- }
2693
- moveTo(next) {
2694
- if (next === this.at) return "same";
2695
- this.at = next;
2696
- return "cursor";
2694
+ for (const id of this.cache.keys()) if (!live.has(id)) this.cache.delete(id);
2695
+ for (const id of this.prose.keys()) if (!live.has(id)) this.prose.delete(id);
2696
+ return out;
2697
2697
  }
2698
- cut(from, to) {
2699
- if (from >= to) return "same";
2700
- this.value = this.value.slice(0, from) + this.value.slice(to);
2701
- this.at = from;
2702
- return "text";
2698
+ lines(entry, width) {
2699
+ const hit = this.cache.get(entry.id);
2700
+ if (hit && hit.version === entry.version && hit.width === width) return hit.lines;
2701
+ const lines = entry.block.kind === "assistant" ? this.assistant(entry, width) : renderBlock(entry.block, width);
2702
+ this.cache.set(entry.id, { version: entry.version, width, lines });
2703
+ return lines;
2703
2704
  }
2704
2705
  /**
2705
- * The slice of the line that fits in `width`, and where the cursor sits in it.
2706
+ * An answer, streaming or finished.
2706
2707
  *
2707
- * A line longer than the box scrolls horizontally rather than wrapping, and
2708
- * the window is chosen so the cursor is always inside it - typing past the
2709
- * right edge must not take the cursor somewhere the reader cannot see it.
2708
+ * Complete markdown blocks are rendered; the unfinished tail - a line still
2709
+ * arriving, a fence not yet closed - is shown raw and dimmed, so the reader
2710
+ * sees the words land without a heading flickering between plain and bold.
2710
2711
  */
2711
- viewport(width) {
2712
- if (width <= 0) return { text: "", column: 0 };
2713
- if (this.value.length <= width) return { text: this.value, column: this.at };
2714
- let start = Math.max(0, this.at - width + 1);
2715
- start = Math.min(start, this.value.length - width);
2716
- return { text: this.value.slice(start, start + width), column: this.at - start };
2712
+ assistant(entry, width) {
2713
+ const inner = proseWidth(width);
2714
+ const { text, done } = entry.block;
2715
+ if (done) return text.trim() ? renderMarkdown(text.replace(/\s+$/, ""), { width: inner, indent: " " }).split("\n") : [];
2716
+ const { complete: complete2, tail } = splitStreaming(text);
2717
+ let head = this.prose.get(entry.id);
2718
+ if (!head || head.length !== complete2.length || head.width !== width) {
2719
+ head = {
2720
+ length: complete2.length,
2721
+ width,
2722
+ lines: complete2 ? renderMarkdown(complete2, { width: inner, indent: " " }).split("\n") : []
2723
+ };
2724
+ this.prose.set(entry.id, head);
2725
+ }
2726
+ const raw = tail ? tail.split("\n").flatMap((line) => wrapPlain(line, inner - 2)).map((line) => ` ${paint.dim(line)}`) : [];
2727
+ return [...head.lines, ...raw];
2717
2728
  }
2718
2729
  };
2719
- var History = class {
2720
- constructor(limit = 200) {
2721
- this.limit = limit;
2730
+ function splitStreaming(text) {
2731
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
2732
+ let tail = lines.pop() ?? "";
2733
+ let open = -1;
2734
+ lines.forEach((line, i) => {
2735
+ if (/^\s*```/.test(line)) open = open === -1 ? i : -1;
2736
+ });
2737
+ let complete2 = lines;
2738
+ if (open !== -1) {
2739
+ tail = [...lines.slice(open), tail].join("\n");
2740
+ complete2 = lines.slice(0, open);
2722
2741
  }
2723
- entries = [];
2724
- index = -1;
2725
- draft = "";
2726
- add(line) {
2727
- const clean = sanitize(line).trim();
2728
- if (clean && this.entries.at(-1) !== clean) {
2729
- this.entries.push(clean);
2730
- if (this.entries.length > this.limit) this.entries.shift();
2742
+ return { complete: complete2.join("\n"), tail };
2743
+ }
2744
+ function proseWidth(width) {
2745
+ return Math.max(20, width - 2);
2746
+ }
2747
+ function renderBlock(block, width) {
2748
+ const inner = proseWidth(width);
2749
+ switch (block.kind) {
2750
+ case "user":
2751
+ return block.text.split("\n").flatMap(
2752
+ (line, i) => wrapPlain(line, inner - 2).map((piece, j) => {
2753
+ const text = i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`;
2754
+ return band(text, visibleWidth(text), width);
2755
+ })
2756
+ );
2757
+ case "assistant":
2758
+ return [];
2759
+ case "tool": {
2760
+ const out = toolBlock(block.name, block.detail, block.status, { input: block.input, output: block.output }, inner);
2761
+ if (block.live.length && !block.output) {
2762
+ const shown = block.live.slice(-LIVE_LINES);
2763
+ if (block.live.length > LIVE_LINES) out.push(` ${paint.dim(`\u2026 ${block.live.length - LIVE_LINES} more lines`)}`);
2764
+ for (const line of shown) out.push(` ${paint.dim(line)}`);
2765
+ }
2766
+ return out;
2767
+ }
2768
+ case "approval": {
2769
+ const out = [
2770
+ ...block.helper ? [` ${paint.accent("from a helper:")} ${paint.muted(block.helper)}`] : [],
2771
+ ...approvalCard(block.preview, block.call, block.mode, inner)
2772
+ ];
2773
+ out.push(
2774
+ block.answer === null ? ` ${paint.yellow("y")} ${paint.muted("to allow \xB7")} ${paint.yellow("n")} ${paint.muted("or esc to decline")}` : ` ${block.answer === "allowed" ? paint.green("allowed") : paint.muted("declined")}`
2775
+ );
2776
+ return out;
2777
+ }
2778
+ case "error":
2779
+ return block.text.split("\n").map((line, i) => i === 0 ? `${paint.red(" labd:")} ${line}` : ` ${line}`);
2780
+ case "note":
2781
+ return block.text.replace(/\n$/, "").split("\n");
2782
+ case "compaction": {
2783
+ const label2 = " summarised the conversation so far ";
2784
+ const room = Math.max(0, inner - label2.length);
2785
+ return [paint.dim(` ${"\u2500".repeat(Math.floor(room / 2))}${label2}${"\u2500".repeat(Math.ceil(room / 2))}`)];
2731
2786
  }
2732
- this.index = -1;
2733
- this.draft = "";
2734
2787
  }
2735
- /** The line to show, or null when there is nowhere further to go. */
2736
- up(current) {
2737
- if (!this.entries.length) return null;
2738
- if (this.index === -1) {
2739
- this.draft = current;
2740
- this.index = this.entries.length - 1;
2741
- } else if (this.index > 0) {
2742
- this.index--;
2788
+ }
2789
+ function wrapPlain(text, width) {
2790
+ const limit = Math.max(10, width);
2791
+ if (visibleWidth(text) <= limit) return [text];
2792
+ const out = [];
2793
+ let line = "";
2794
+ for (const word of text.split(" ")) {
2795
+ if (line && line.length + 1 + word.length > limit) {
2796
+ out.push(line);
2797
+ line = word;
2743
2798
  } else {
2744
- return null;
2799
+ line = line ? `${line} ${word}` : word;
2745
2800
  }
2746
- return this.entries[this.index];
2747
- }
2748
- down() {
2749
- if (this.index === -1) return null;
2750
- if (this.index >= this.entries.length - 1) {
2751
- this.index = -1;
2752
- return this.draft;
2801
+ while (line.length > limit) {
2802
+ out.push(line.slice(0, limit));
2803
+ line = line.slice(limit);
2753
2804
  }
2754
- this.index++;
2755
- return this.entries[this.index];
2756
2805
  }
2757
- };
2758
-
2759
- // packages/tui/src/editor.ts
2760
- function sanitizeMultiline(text) {
2761
- return text.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
2806
+ if (line || !out.length) out.push(line);
2807
+ return out;
2762
2808
  }
2763
- var TextEditor = class {
2764
- lines = [""];
2765
- row = 0;
2766
- col = 0;
2767
- /** First line shown in the last `view`, kept so the window scrolls rather than jumps. */
2809
+ var Viewport = class {
2810
+ constructor(height) {
2811
+ this.height = height;
2812
+ }
2768
2813
  top = 0;
2769
- get text() {
2770
- return this.lines.join("\n");
2814
+ follow = true;
2815
+ total = 0;
2816
+ /** The line count when the reader stopped following, so `newLines` can be counted from it. */
2817
+ anchor = 0;
2818
+ /** Lines that arrived since the reader scrolled away. Zero while following. */
2819
+ get newLines() {
2820
+ return this.follow ? 0 : Math.max(0, this.total - this.anchor);
2771
2821
  }
2772
- get cursor() {
2773
- return { row: this.row, col: this.col };
2822
+ /** Tell the viewport how many lines there are now, and how tall it is. */
2823
+ update(total, height = this.height) {
2824
+ this.height = height;
2825
+ this.total = total;
2826
+ if (this.follow) this.top = Math.max(0, total - height);
2827
+ else this.top = Math.max(0, Math.min(this.top, total - height));
2774
2828
  }
2775
- get lineCount() {
2776
- return this.lines.length;
2829
+ scroll(delta) {
2830
+ const max = Math.max(0, this.total - this.height);
2831
+ const next = Math.max(0, Math.min(max, this.top + delta));
2832
+ if (next === this.top && this.follow) return;
2833
+ this.top = next;
2834
+ if (next >= max) this.refollow();
2835
+ else this.unfollow();
2777
2836
  }
2778
- get isEmpty() {
2779
- return this.lines.length === 1 && this.lines[0] === "";
2837
+ pageUp() {
2838
+ this.scroll(-Math.max(1, this.height - 1));
2780
2839
  }
2781
- /** The line the caret is on. Used to work out whether a `/` or `@` is being typed. */
2782
- get currentLine() {
2783
- return this.lines[this.row];
2840
+ pageDown() {
2841
+ this.scroll(Math.max(1, this.height - 1));
2784
2842
  }
2785
- /** Replace everything - from history, or a completion. The caret lands at the end. */
2786
- setText(text) {
2787
- this.lines = sanitizeMultiline(text).split("\n");
2788
- this.row = this.lines.length - 1;
2789
- this.col = this.lines[this.row].length;
2843
+ home() {
2844
+ this.top = 0;
2845
+ if (this.total > this.height) this.unfollow();
2790
2846
  }
2791
- /** Replace just the caret's line, keeping the rest. For a completion of `/cmd` or `@path`. */
2792
- setCurrentLine(text) {
2793
- this.lines[this.row] = sanitizeMultiline(text).replace(/\n/g, " ");
2794
- this.col = this.lines[this.row].length;
2847
+ end() {
2848
+ this.refollow();
2849
+ this.top = Math.max(0, this.total - this.height);
2795
2850
  }
2796
- reset() {
2797
- this.lines = [""];
2798
- this.row = 0;
2799
- this.col = 0;
2800
- this.top = 0;
2851
+ /** The slice of `lines` on screen. */
2852
+ slice(lines) {
2853
+ return lines.slice(this.top, this.top + this.height);
2801
2854
  }
2802
- /** Insert at the caret. Newlines in `text` become new lines. */
2803
- insert(text) {
2804
- const clean = sanitizeMultiline(text);
2805
- if (!clean) return "same";
2806
- const parts = clean.split("\n");
2807
- const line = this.lines[this.row];
2808
- const before = line.slice(0, this.col);
2809
- const after = line.slice(this.col);
2810
- if (parts.length === 1) {
2811
- this.lines[this.row] = before + parts[0] + after;
2812
- this.col += parts[0].length;
2813
- return "text";
2814
- }
2815
- const inserted = [before + parts[0], ...parts.slice(1, -1), parts[parts.length - 1] + after];
2816
- this.lines.splice(this.row, 1, ...inserted);
2817
- this.row += parts.length - 1;
2818
- this.col = parts[parts.length - 1].length;
2819
- return "text";
2855
+ unfollow() {
2856
+ if (this.follow) this.anchor = this.total;
2857
+ this.follow = false;
2820
2858
  }
2821
- newline() {
2822
- return this.insert("\n");
2859
+ refollow() {
2860
+ this.follow = true;
2823
2861
  }
2824
- /**
2825
- * Apply one key. `unhandled` for a plain Enter (the caller sends), for Up on
2826
- * the first line and Down on the last (the caller walks history), and for
2827
- * anything else the editor does not claim.
2828
- */
2829
- apply(key) {
2830
- const { name, ctrl, meta, shift } = key;
2831
- const line = this.lines[this.row];
2832
- if (key.paste !== void 0) return this.insert(key.paste);
2833
- if (name === "return") {
2834
- if (shift || ctrl || meta) return this.newline();
2835
- if (/(^|[^\\])\\$/.test(line)) {
2836
- this.lines[this.row] = line.slice(0, -1);
2837
- this.col = Math.min(this.col, this.lines[this.row].length);
2838
- return this.newline();
2839
- }
2840
- return "unhandled";
2841
- }
2842
- if (ctrl && name === "j") return this.newline();
2843
- if (name === "up" && !ctrl && !meta) {
2844
- if (this.row === 0) return "unhandled";
2845
- this.row--;
2846
- this.col = Math.min(this.col, this.lines[this.row].length);
2847
- return "cursor";
2848
- }
2849
- if (name === "down" && !ctrl && !meta) {
2850
- if (this.row === this.lines.length - 1) return "unhandled";
2851
- this.row++;
2852
- this.col = Math.min(this.col, this.lines[this.row].length);
2853
- return "cursor";
2854
- }
2855
- if (name === "left" && !ctrl && !meta) {
2856
- if (this.col === 0) {
2857
- if (this.row === 0) return "same";
2858
- this.row--;
2859
- this.col = this.lines[this.row].length;
2860
- return "cursor";
2861
- }
2862
- return this.moveTo(prevBoundary(line, this.col));
2862
+ };
2863
+
2864
+ // packages/tui/src/credits.ts
2865
+ var OUT_OF_CREDITS_PLACEHOLDER = "Out of labd for this month. Press o to open the top-up page, or /topup";
2866
+ var CREDITS_REFRESH_MS = 1e4;
2867
+ function outOfCreditsAt(credits, now) {
2868
+ if (!isOutOfCredits(credits)) return false;
2869
+ return !(now >= Date.parse(credits?.resetsAt ?? ""));
2870
+ }
2871
+ function boxLocked(credits, now, refused) {
2872
+ return refused || outOfCreditsAt(credits, now);
2873
+ }
2874
+ function spentBeforeTurn(credits, now) {
2875
+ return isOutOfCredits(credits) && !outOfCreditsAt(credits, now);
2876
+ }
2877
+ var MAX_TIMER_MS = 2 ** 31 - 1;
2878
+ var AFTER_TURN_MS = 1e3;
2879
+ function untilReset(credits, now) {
2880
+ if (!outOfCreditsAt(credits, now)) return null;
2881
+ const at2 = Date.parse(credits?.resetsAt ?? "");
2882
+ if (Number.isNaN(at2)) return null;
2883
+ return Math.min(at2 - now + AFTER_TURN_MS, MAX_TIMER_MS);
2884
+ }
2885
+ function shouldRefreshCredits(lastAskedAt, now) {
2886
+ return lastAskedAt === null || now - lastAskedAt >= CREDITS_REFRESH_MS;
2887
+ }
2888
+ function keyWhileOut(key, at2) {
2889
+ if (!at2.out || !at2.boxEmpty || at2.popup || at2.approval || !typedText(key)) return "pass";
2890
+ if (key.sequence === "o" || key.sequence === "O") return "open";
2891
+ return key.sequence?.startsWith("/") ? "pass" : "drop";
2892
+ }
2893
+ function typedText(key) {
2894
+ return !key.name && !key.ctrl && !key.meta && Boolean(key.sequence);
2895
+ }
2896
+ function creditsReading(credits, usual, now = Date.now()) {
2897
+ if (outOfCreditsAt(credits, now)) return paint.red("out of labd \xB7 /topup");
2898
+ if (isLowOnCredits(credits)) return paint.yellow(`${credits.percentLeft}% left \xB7 /topup`);
2899
+ return paint.muted(usual);
2900
+ }
2901
+ var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
2902
+ function resetDay(iso) {
2903
+ const at2 = new Date(iso ?? "");
2904
+ if (Number.isNaN(at2.getTime())) return null;
2905
+ return `${at2.getUTCDate()} ${MONTHS[at2.getUTCMonth()]}`;
2906
+ }
2907
+ function allowanceFacts(credits) {
2908
+ const saved = credits.resetsSaved ?? 0;
2909
+ const day = resetDay(credits.resetsAt);
2910
+ return [
2911
+ ...saved > 0 ? [`${saved} saved ${saved === 1 ? "reset" : "resets"}`] : [],
2912
+ ...day ? [`resets on ${day}`] : []
2913
+ ].join(" \xB7 ");
2914
+ }
2915
+ function noCreditsLines(opts) {
2916
+ const inner = Math.max(20, opts.width - 10);
2917
+ const [first = "", ...rest] = wrapPlain(opts.message, inner);
2918
+ const facts = opts.credits ? allowanceFacts(opts.credits) : "";
2919
+ return [
2920
+ `${paint.red(" labd:")} ${first}`,
2921
+ ...rest.map((line) => ` ${line}`),
2922
+ ...facts ? [` ${paint.muted(facts)}`] : [],
2923
+ ...opts.url ? [` ${paint.accent(opts.url)}`, ` ${paint.muted(opts.howToOpen)}`] : []
2924
+ ];
2925
+ }
2926
+
2927
+ // packages/tui/src/cards.ts
2928
+ var OUTPUT_LINES = 8;
2929
+ function toolBlock(name, detail2, status, io, width = 100) {
2930
+ const label2 = LABELS[name] ?? name.replace(/_/g, " ");
2931
+ const dot = status === "error" ? paint.red("\u25CF") : status === "skipped" ? paint.muted("\u25CB") : status === "awaiting" ? paint.yellow("\u25CF") : status === "running" ? paint.dim("\u25CB") : paint.green("\u25CF");
2932
+ const out = [` ${dot} ${paint.bold(paint.accent(label2))} ${paint.muted(detail2)}`];
2933
+ const room = Math.max(20, width - 9);
2934
+ const row = (gutter, text, paintText, soft) => {
2935
+ const wrap3 = soft ? wrapWords : wrapCommand;
2936
+ const lines = text.split("\n").flatMap((line) => wrap3(line, room));
2937
+ lines.forEach((line, i) => {
2938
+ out.push(` ${i === 0 ? paint.dim(gutter.padEnd(4)) : " "} ${paintText(line)}`);
2939
+ });
2940
+ };
2941
+ const shell = name === "run_command";
2942
+ if (io.input) row(shell ? "$" : "IN", io.input, (t) => paint.cyan(t), false);
2943
+ if (io.output) {
2944
+ const lines = io.output.replace(/\s+$/, "").split("\n");
2945
+ const shown = lines.slice(0, OUTPUT_LINES).join("\n");
2946
+ row(shell ? "" : "OUT", shown, (t) => status === "error" ? paint.red(t) : paint.muted(t), true);
2947
+ if (lines.length > OUTPUT_LINES) {
2948
+ out.push(` ${"".padEnd(4)} ${paint.dim(`\u2026 ${lines.length - OUTPUT_LINES} more lines`)}`);
2863
2949
  }
2864
- if (name === "right" && !ctrl && !meta) {
2865
- if (this.col >= line.length) {
2866
- if (this.row === this.lines.length - 1) return "same";
2867
- this.row++;
2868
- this.col = 0;
2869
- return "cursor";
2870
- }
2871
- return this.moveTo(nextBoundary(line, this.col));
2872
- }
2873
- if (name === "home" || ctrl && name === "a") return this.moveTo(0);
2874
- if (name === "end" || ctrl && name === "e") return this.moveTo(line.length);
2875
- if ((meta || ctrl) && (name === "left" || name === "b")) return this.moveTo(wordStart(line, this.col));
2876
- if ((meta || ctrl) && (name === "right" || name === "f")) return this.moveTo(wordEnd(line, this.col));
2877
- if (name === "backspace" && (meta || ctrl)) {
2878
- if (this.col === 0) return this.joinUp();
2879
- return this.cut(wordStart(line, this.col), this.col);
2880
- }
2881
- if (ctrl && name === "w") {
2882
- if (this.col === 0) return this.joinUp();
2883
- return this.cut(wordStart(line, this.col), this.col);
2884
- }
2885
- if (name === "backspace") {
2886
- if (this.col === 0) return this.joinUp();
2887
- return this.cut(prevBoundary(line, this.col), this.col);
2888
- }
2889
- if (name === "delete") {
2890
- if (this.col >= line.length) return this.joinDown();
2891
- return this.cut(this.col, nextBoundary(line, this.col));
2892
- }
2893
- if (ctrl && name === "u") return this.cut(0, this.col);
2894
- if (ctrl && name === "k") {
2895
- if (this.col >= line.length) return this.joinDown();
2896
- return this.cut(this.col, line.length);
2897
- }
2898
- if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) return this.insert(key.sequence);
2899
- return "unhandled";
2900
- }
2901
- /**
2902
- * The lines to draw and where the caret is among them.
2903
- *
2904
- * At most `maxLines` rows, chosen so the caret's row is always one of them;
2905
- * each row scrolls horizontally on its own so a long line never wraps into the
2906
- * row below and the caret never leaves the box.
2907
- */
2908
- view(width, maxLines) {
2909
- const rows = Math.max(1, maxLines);
2910
- if (this.row < this.top) this.top = this.row;
2911
- if (this.row >= this.top + rows) this.top = this.row - rows + 1;
2912
- this.top = Math.max(0, Math.min(this.top, this.lines.length - rows));
2913
- const shown = this.lines.slice(this.top, this.top + rows);
2914
- let cursorCol = this.col;
2915
- const lines = shown.map((line, i) => {
2916
- if (width <= 0) return "";
2917
- if (line.length <= width) return line;
2918
- if (this.top + i !== this.row) return line.slice(0, width);
2919
- const start = Math.max(0, this.col - width + 1);
2920
- cursorCol = this.col - start;
2921
- return line.slice(start, start + width);
2922
- });
2923
- return { lines, cursor: { row: this.row - this.top, col: cursorCol } };
2924
- }
2925
- moveTo(next) {
2926
- if (next === this.col) return "same";
2927
- this.col = next;
2928
- return "cursor";
2929
- }
2930
- cut(from, to) {
2931
- if (from >= to) return "same";
2932
- const line = this.lines[this.row];
2933
- this.lines[this.row] = line.slice(0, from) + line.slice(to);
2934
- this.col = from;
2935
- return "text";
2936
2950
  }
2937
- joinUp() {
2938
- if (this.row === 0) return "same";
2939
- const above = this.lines[this.row - 1];
2940
- this.lines.splice(this.row - 1, 2, above + this.lines[this.row]);
2941
- this.row--;
2942
- this.col = above.length;
2943
- return "text";
2951
+ return out;
2952
+ }
2953
+ var LABELS = {
2954
+ read_file: "Read",
2955
+ list_dir: "List",
2956
+ search_workspace: "Search",
2957
+ edit_file: "Edit",
2958
+ create_file: "Create",
2959
+ move_file: "Move",
2960
+ delete_file: "Delete",
2961
+ run_command: "Run",
2962
+ spawn_agent: "Helper"
2963
+ };
2964
+ function approvalCard(preview, call, mode, width) {
2965
+ const out = [""];
2966
+ if (call.name === "run_command" || preview?.kind === "run") {
2967
+ const command = preview?.kind === "run" ? preview.command : String(call.input.command ?? "");
2968
+ const why = preview?.kind === "run" ? preview.why : call.input.why;
2969
+ out.push(`${paint.yellow("?")} ${paint.bold("Run a command")}`);
2970
+ if (why) out.push(` ${paint.muted(why)}`);
2971
+ out.push("");
2972
+ for (const line of wrapCommand(command, width - 6)) out.push(` ${paint.cyan(line)}`);
2973
+ out.push("");
2974
+ out.push(
2975
+ ` ${paint.dim(
2976
+ mode === "auto" ? "On auto, commands still ask - a command can do anything your shell can, and put back isn't one of them." : "Runs in this project. Unlike an edit, this can't be put back."
2977
+ )}`
2978
+ );
2979
+ return out;
2944
2980
  }
2945
- joinDown() {
2946
- if (this.row >= this.lines.length - 1) return "same";
2947
- this.lines.splice(this.row, 2, this.lines[this.row] + this.lines[this.row + 1]);
2948
- return "text";
2981
+ const creating = call.name === "create_file";
2982
+ const moving = call.name === "move_file";
2983
+ const deleting = call.name === "delete_file";
2984
+ const path = preview ? preview.path : String(call.input.path ?? call.input.from ?? "a file");
2985
+ const destination = preview?.to ?? call.input.to;
2986
+ const title = moving ? "Move" : deleting ? "Delete" : creating ? "Create" : "Edit";
2987
+ out.push(
2988
+ `${paint.yellow("?")} ${paint.bold(title)} ${paint.accent(path)}${moving && destination ? paint.muted(" \u2192 ") + paint.accent(destination) : ""}${preview ? paint.muted(
2989
+ ` ${counts(preview.added, preview.removed)}${creating || moving || deleting ? "" : ` at line ${preview.startLine}`}`
2990
+ ) : ""}`
2991
+ );
2992
+ if (preview) {
2993
+ out.push("");
2994
+ for (const line of diffLines(preview.oldText, preview.newText, width)) out.push(` ${line}`);
2949
2995
  }
2950
- };
2951
-
2952
- // packages/tui/src/keys.ts
2953
- var ESC3 = "\x1B";
2954
- var PASTE_START = `${ESC3}[200~`;
2955
- var PASTE_END = `${ESC3}[201~`;
2956
- var CSI_FINAL = {
2957
- A: "up",
2958
- B: "down",
2959
- C: "right",
2960
- D: "left",
2961
- H: "home",
2962
- F: "end",
2963
- Z: "tab"
2964
- };
2965
- var TILDE = {
2966
- "1": "home",
2967
- "2": "insert",
2968
- "3": "delete",
2969
- "4": "end",
2970
- "5": "pageup",
2971
- "6": "pagedown",
2972
- "7": "home",
2973
- "8": "end"
2974
- };
2975
- function modifiers(param) {
2976
- const n = (Number(param) || 1) - 1;
2977
- return {
2978
- ...n & 1 ? { shift: true } : {},
2979
- ...n & 2 ? { meta: true } : {},
2980
- ...n & 4 ? { ctrl: true } : {}
2981
- };
2996
+ out.push("");
2997
+ out.push(
2998
+ ` ${paint.dim(
2999
+ moving || deleting ? "Changes the file on disk. You can put the whole answer back afterwards." : "Written to the file. You can put the whole answer back afterwards."
3000
+ )}`
3001
+ );
3002
+ return out;
2982
3003
  }
2983
- function coded(code, mods, sequence) {
2984
- switch (code) {
2985
- case 13:
2986
- return { name: "return", ...mods, sequence };
2987
- case 9:
2988
- return { name: "tab", ...mods, sequence };
2989
- case 27:
2990
- return { name: "escape", ...mods, sequence };
2991
- case 127:
2992
- case 8:
2993
- return { name: "backspace", ...mods, sequence };
2994
- default:
2995
- if (code >= 32) {
2996
- const ch = String.fromCodePoint(code);
2997
- return { name: ch.toLowerCase(), ...mods, sequence: mods.ctrl || mods.meta ? sequence : ch };
2998
- }
2999
- return null;
3004
+ function counts(added, removed) {
3005
+ const parts = [];
3006
+ if (added) parts.push(`+${added}`);
3007
+ if (removed) parts.push(`-${removed}`);
3008
+ return parts.join(" ");
3009
+ }
3010
+ function diffLines(oldText, newText, width, max = 12) {
3011
+ const out = [];
3012
+ const before = oldText ? oldText.split("\n") : [];
3013
+ const after = newText ? newText.split("\n") : [];
3014
+ for (const line of before.slice(0, max)) out.push(paint.red(`- ${clip(line, width - 4)}`));
3015
+ if (before.length > max) out.push(paint.dim(` ... ${before.length - max} more removed`));
3016
+ for (const line of after.slice(0, max)) out.push(paint.green(`+ ${clip(line, width - 4)}`));
3017
+ if (after.length > max) out.push(paint.dim(` ... ${after.length - max} more added`));
3018
+ return out;
3019
+ }
3020
+ function clip(line, width) {
3021
+ return visibleWidth(line) <= width ? line : `${line.slice(0, Math.max(0, width - 1))}\u2026`;
3022
+ }
3023
+ function wrapWords(text, width) {
3024
+ if (text.length <= width) return [text];
3025
+ const out = [];
3026
+ let line = "";
3027
+ for (const word of text.split(" ")) {
3028
+ if (!line) {
3029
+ line = word;
3030
+ } else if (`${line} ${word}`.length <= width) {
3031
+ line = `${line} ${word}`;
3032
+ } else {
3033
+ out.push(line);
3034
+ line = word;
3035
+ }
3036
+ while (line.length > width) {
3037
+ out.push(line.slice(0, width));
3038
+ line = line.slice(width);
3039
+ }
3000
3040
  }
3041
+ if (line) out.push(line);
3042
+ return out;
3001
3043
  }
3002
- function isPrefix(text, whole) {
3003
- return text.length <= whole.length && whole.startsWith(text);
3044
+ function wrapCommand(command, width) {
3045
+ if (command.length <= width) return [command];
3046
+ const out = [];
3047
+ for (let i = 0; i < command.length; i += width) out.push(command.slice(i, i + width));
3048
+ return out;
3004
3049
  }
3005
- function partialMarker(buf, marker) {
3006
- for (let n = Math.min(marker.length - 1, buf.length); n > 0; n--) {
3007
- if (isPrefix(buf.slice(buf.length - n), marker)) return n;
3050
+ function statusLine(info2) {
3051
+ const bits = [];
3052
+ if (info2.contextPercent !== null && info2.contextPercent !== void 0) {
3053
+ const crowded = info2.contextPercent >= 75;
3054
+ bits.push((crowded ? paint.yellow : paint.muted)(`${info2.contextPercent}% full`));
3008
3055
  }
3009
- return 0;
3056
+ bits.push(paint.muted(info2.project));
3057
+ if (info2.branch) bits.push(paint.muted(info2.branch));
3058
+ if (info2.credits) bits.push(creditsReading(info2.credits, `${info2.credits.percentLeft}% left`));
3059
+ if (info2.effort) bits.push(paint.muted(`think ${info2.effort}`));
3060
+ const mode = info2.mode === "agent" ? paint.red(paint.bold("agent")) : info2.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
3061
+ bits.push(mode);
3062
+ return `${paint.dim(FACE)} ${paint.dim("|")} ${bits.join(paint.dim(" | "))}`;
3010
3063
  }
3011
- var KeyDecoder = class {
3012
- /** Bytes that could still turn out to be the start of something. */
3013
- pending = "";
3014
- /** Inside a bracketed paste: the text so far. Null outside one. */
3015
- pasting = null;
3016
- /**
3017
- * Decode a chunk. Sequences split across two reads are held until the next
3018
- * one; a lone ESC at the END of a chunk is reported as Escape at once, because
3019
- * a terminal sends a whole sequence in one write and a person pressing the key
3020
- * should not have to press another to be heard.
3021
- */
3022
- feed(chunk) {
3023
- const out = [];
3024
- let buf = this.pending + chunk;
3025
- this.pending = "";
3026
- let i = 0;
3027
- while (i < buf.length) {
3028
- if (this.pasting !== null) {
3029
- const end = buf.indexOf(PASTE_END, i);
3030
- if (end === -1) {
3031
- const hold = partialMarker(buf, PASTE_END);
3032
- this.pasting += buf.slice(i, buf.length - hold);
3033
- this.pending = buf.slice(buf.length - hold);
3034
- return out;
3035
- }
3036
- this.pasting += buf.slice(i, end);
3037
- out.push({ name: "paste", paste: this.pasting.replace(/\r\n?/g, "\n") });
3038
- this.pasting = null;
3039
- i = end + PASTE_END.length;
3040
- continue;
3041
- }
3042
- const ch = buf[i];
3043
- if (ch !== ESC3) {
3044
- const code2 = ch.charCodeAt(0);
3045
- if (code2 === 13) out.push({ name: "return", sequence: ch });
3046
- else if (code2 === 10) out.push({ name: "j", ctrl: true, sequence: ch });
3047
- else if (code2 === 9) out.push({ name: "tab", sequence: ch });
3048
- else if (code2 === 127 || code2 === 8) out.push({ name: "backspace", sequence: ch });
3049
- else if (code2 < 32) {
3050
- if (code2 > 0) out.push({ name: String.fromCharCode(96 + code2), ctrl: true, sequence: ch });
3051
- } else {
3052
- let j = i + 1;
3053
- while (j < buf.length) {
3054
- const c = buf.charCodeAt(j);
3055
- if (c < 32 || c === 127 || buf[j] === ESC3) break;
3056
- j++;
3057
- }
3058
- out.push({ sequence: buf.slice(i, j) });
3059
- i = j;
3060
- continue;
3061
- }
3062
- i++;
3063
- continue;
3064
- }
3065
- if (i === buf.length - 1) {
3066
- out.push({ name: "escape", sequence: ESC3 });
3067
- i++;
3068
- continue;
3069
- }
3070
- const next = buf[i + 1];
3071
- if (next === "[") {
3072
- let j = i + 2;
3073
- while (j < buf.length && !(buf.charCodeAt(j) >= 64 && buf.charCodeAt(j) <= 126)) j++;
3074
- if (j >= buf.length) {
3075
- this.pending = buf.slice(i);
3076
- return out;
3077
- }
3078
- const seq = buf.slice(i, j + 1);
3079
- const body2 = buf.slice(i + 2, j);
3080
- const final = buf[j];
3081
- i = j + 1;
3082
- if (seq === PASTE_START) {
3083
- this.pasting = "";
3084
- continue;
3085
- }
3086
- const key = decodeCsi(body2, final, seq);
3087
- if (key) out.push(key);
3088
- continue;
3089
- }
3090
- if (next === "O") {
3091
- const final = buf[i + 2];
3092
- if (final === void 0) {
3093
- this.pending = buf.slice(i);
3094
- return out;
3095
- }
3096
- const name = CSI_FINAL[final];
3097
- if (name) out.push({ name, sequence: buf.slice(i, i + 3) });
3098
- i += 3;
3099
- continue;
3100
- }
3101
- if (next === "\x7F" || next === "\b") {
3102
- out.push({ name: "backspace", meta: true, sequence: buf.slice(i, i + 2) });
3103
- i += 2;
3104
- continue;
3105
- }
3106
- if (next === ESC3) {
3107
- out.push({ name: "escape", sequence: ESC3 });
3108
- i++;
3109
- continue;
3110
- }
3111
- const code = next.charCodeAt(0);
3112
- if (code >= 32 && code !== 127) {
3113
- out.push({
3114
- name: next.toLowerCase(),
3115
- meta: true,
3116
- ...next !== next.toLowerCase() ? { shift: true } : {},
3117
- sequence: buf.slice(i, i + 2)
3118
- });
3119
- i += 2;
3120
- continue;
3121
- }
3122
- out.push({ name: "escape", sequence: ESC3 });
3123
- i++;
3124
- }
3125
- return out;
3126
- }
3127
- /** Anything still held back - the tail of a paste, an escape that never finished. */
3128
- flush() {
3129
- const out = [];
3130
- if (this.pasting !== null) {
3131
- out.push({ name: "paste", paste: this.pasting + this.pending });
3132
- this.pasting = null;
3133
- } else if (this.pending === ESC3) {
3134
- out.push({ name: "escape", sequence: ESC3 });
3135
- }
3136
- this.pending = "";
3137
- return out;
3138
- }
3139
- };
3140
- function decodeCsi(body2, final, sequence) {
3141
- if (body2.startsWith("<") && (final === "M" || final === "m")) {
3142
- const [b, x, y] = body2.slice(1).split(";").map(Number);
3143
- if (final === "m") return null;
3144
- if (b & 32) return null;
3145
- const button = b & 3;
3146
- if (b & 64) {
3147
- return { name: "mouse", sequence, mouse: { kind: button === 0 ? "wheel-up" : "wheel-down", button: b, x, y } };
3148
- }
3149
- return { name: "mouse", sequence, mouse: { kind: "click", button, x, y } };
3150
- }
3151
- const params = body2.split(";");
3152
- if (final === "~") {
3153
- if (params[0] === "27") return coded(Number(params[2]), modifiers(params[1]), sequence);
3154
- if (params[0] === "200" || params[0] === "201") return null;
3155
- const name2 = TILDE[params[0]];
3156
- return name2 ? { name: name2, ...modifiers(params[1]), sequence } : null;
3157
- }
3158
- if (final === "u") return coded(Number(params[0]), modifiers(params[1]), sequence);
3159
- const name = CSI_FINAL[final];
3160
- if (!name) return null;
3161
- const mods = final === "Z" ? { shift: true } : modifiers(params[1]);
3162
- return { name, ...mods, sequence };
3064
+ function spinnerFrame(tick) {
3065
+ const dots = ".".repeat(tick % 3 + 1).padEnd(3);
3066
+ return paint.dim(dots);
3163
3067
  }
3164
3068
 
3165
- // packages/tui/src/layout.ts
3166
- var MAX_COMPOSER_ROWS = 8;
3167
- var SIDEBAR_MIN_COLUMNS = 120;
3168
- var SIDEBAR_WIDTH = 32;
3169
- function layout(opts) {
3170
- const rows = Math.max(4, opts.rows);
3171
- const columns = Math.max(20, opts.columns);
3172
- const wanted = Math.min(MAX_COMPOSER_ROWS, Math.max(1, opts.editorLines) + 2);
3173
- const composerHeight = Math.max(3, Math.min(wanted, rows - 3));
3174
- const header = { top: 0, left: 0, width: columns, height: 1 };
3175
- const footer = { top: rows - 1, left: 0, width: columns, height: 1 };
3176
- const composer2 = { top: rows - 1 - composerHeight, left: 0, width: columns, height: composerHeight };
3177
- const transcriptHeight = Math.max(0, composer2.top - 1);
3178
- const withSidebar = Boolean(opts.sidebar) && columns >= SIDEBAR_MIN_COLUMNS;
3179
- const mainWidth = withSidebar ? columns - SIDEBAR_WIDTH - 1 : columns;
3180
- const out = {
3181
- header,
3182
- transcript: { top: 1, left: 0, width: mainWidth, height: transcriptHeight },
3183
- composer: { ...composer2, width: mainWidth },
3184
- footer
3185
- };
3186
- if (withSidebar) {
3187
- out.sidebar = { top: 1, left: mainWidth + 1, width: SIDEBAR_WIDTH, height: composer2.top + composerHeight - 1 };
3188
- }
3069
+ // packages/tui/src/composer.ts
3070
+ var ROUND = { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" };
3071
+ var MAX_SUGGESTIONS = 5;
3072
+ function composer(opts) {
3073
+ const width = Math.max(20, opts.width);
3074
+ const colour = borderColour(opts.state);
3075
+ const inner = width - 4;
3076
+ const out = [];
3077
+ for (const line of suggestionLines(opts.suggestions ?? [], inner)) out.push(line);
3078
+ out.push(colour(`${ROUND.tl}${ROUND.h.repeat(width - 2)}${ROUND.tr}`));
3079
+ out.push(`${colour(ROUND.v)} ${body(opts, inner)} ${colour(ROUND.v)}`);
3080
+ out.push(colour(`${ROUND.bl}${ROUND.h.repeat(width - 2)}${ROUND.br}`));
3081
+ if (opts.hint) out.push(` ${truncate(opts.hint, width - 2)}`);
3082
+ if (opts.status) out.push(opts.status);
3189
3083
  return out;
3190
3084
  }
3191
-
3192
- // packages/tui/src/popup.ts
3193
- var MAX_POPUP_ROWS = 8;
3194
- var Popup = class {
3195
- constructor(kind, items) {
3196
- this.kind = kind;
3197
- this.items = items;
3198
- }
3199
- selected = 0;
3200
- get current() {
3201
- return this.items[this.selected] ?? null;
3085
+ function body(opts, inner) {
3086
+ const sigil = opts.state === "busy" ? paint.dim(">") : paint.accent(">");
3087
+ const room = inner - 2;
3088
+ if (!opts.view.text && opts.placeholder && opts.state !== "busy") {
3089
+ const text2 = truncate(paint.dim(opts.placeholder), room);
3090
+ return `${sigil} ${pad(text2, room)}`;
3202
3091
  }
3203
- move(delta) {
3204
- if (!this.items.length) return;
3205
- this.selected = (this.selected + delta + this.items.length) % this.items.length;
3092
+ const text = withCursor(opts.view, room, opts.state === "busy");
3093
+ return `${sigil} ${pad(text, room)}`;
3094
+ }
3095
+ function withCursor(view, room, hidden) {
3096
+ const text = view.text.slice(0, room);
3097
+ if (hidden) return text;
3098
+ const at2 = Math.min(view.column, room - 1 < 0 ? 0 : room);
3099
+ const before = text.slice(0, at2);
3100
+ const under = text.slice(at2, at2 + 1) || " ";
3101
+ const after = text.slice(at2 + 1);
3102
+ return `${before}${paint.invert(under)}${after}`;
3103
+ }
3104
+ function suggestionLines(all, inner) {
3105
+ if (!all.length) return [];
3106
+ const shown = all.slice(0, MAX_SUGGESTIONS);
3107
+ const column = Math.max(...shown.map((s) => s.name.length + 1));
3108
+ const out = shown.map((s, i) => {
3109
+ const name = `/${s.name}`.padEnd(column + 1);
3110
+ const mark = i === 0 ? paint.accent("-") : " ";
3111
+ return ` ${mark} ${paint.accent(name)} ${paint.muted(truncate(s.what, inner - column - 4))}`;
3112
+ });
3113
+ if (all.length > shown.length) {
3114
+ out.push(` ${paint.dim(`... ${all.length - shown.length} more`)}`);
3206
3115
  }
3207
- /** Rows to draw, at most `MAX_POPUP_ROWS`, keeping the selection in view. */
3208
- render(width, max = MAX_POPUP_ROWS) {
3209
- if (!this.items.length) return [];
3210
- const rows = Math.max(1, max);
3211
- const start = Math.max(0, Math.min(this.selected - rows + 1, this.items.length - rows));
3212
- const shown = this.items.slice(start, start + rows);
3213
- const column = Math.min(Math.max(...shown.map((s) => s.label.length)), Math.max(10, Math.floor(width / 2)));
3214
- return shown.map((item, i) => {
3215
- const isSelected = start + i === this.selected;
3216
- const mark = isSelected ? paint.accent("\u25B8") : " ";
3217
- const label2 = item.label.padEnd(column);
3218
- const room = width - column - 6;
3219
- const what = item.what ? paint.muted(truncate(item.what, Math.max(0, room))) : "";
3220
- const line = ` ${mark} ${isSelected ? paint.bold(paint.accent(label2)) : paint.accent(label2)} ${what}`;
3221
- return pad2(truncate(line, width), width);
3222
- });
3116
+ return out;
3117
+ }
3118
+ function borderColour(state) {
3119
+ switch (state) {
3120
+ case "ok":
3121
+ return paint.green;
3122
+ case "unknown":
3123
+ return paint.red;
3124
+ case "partial":
3125
+ return paint.cyan;
3126
+ case "ask":
3127
+ return paint.yellow;
3128
+ case "busy":
3129
+ return paint.accent;
3130
+ default:
3131
+ return paint.dim;
3223
3132
  }
3224
- };
3225
- function pad2(text, width) {
3133
+ }
3134
+ function pad(text, width) {
3226
3135
  const short = width - visibleWidth(text);
3227
3136
  return short > 0 ? `${text}${" ".repeat(short)}` : text;
3228
3137
  }
3229
3138
 
3230
- // packages/tui/src/dialog.ts
3231
- function pick(title, items, onSelect) {
3232
- return { kind: "pick", title, items, query: "", selected: 0, onSelect };
3139
+ // packages/tui/src/edit.ts
3140
+ function sanitize(text) {
3141
+ return text.replace(/\r\n?|\n|\t/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
3233
3142
  }
3234
- function info(title, lines) {
3235
- return { kind: "info", title, lines, scroll: 0 };
3236
- }
3237
- function filterItems(items, query) {
3238
- const words = query.toLowerCase().split(/\s+/).filter(Boolean);
3239
- if (!words.length) return items;
3240
- return items.filter((item) => {
3241
- const hay = `${item.label} ${item.hint ?? ""}`.toLowerCase();
3242
- return words.every((w) => hay.includes(w));
3243
- });
3143
+ var PRINTABLE = /^[^\u0000-\u001f\u007f]+$/;
3144
+ var segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3145
+ function boundaries(text) {
3146
+ const out = [0];
3147
+ for (const { index, segment } of segmenter.segment(text)) out.push(index + segment.length);
3148
+ return out;
3244
3149
  }
3245
- function dialogKey(dialog, key) {
3246
- if (key.name === "escape" || key.ctrl && (key.name === "c" || key.name === "g")) return "close";
3247
- if (dialog.kind === "info") {
3248
- if (key.name === "up" || key.name === "k") dialog.scroll = Math.max(0, dialog.scroll - 1);
3249
- else if (key.name === "down" || key.name === "j") dialog.scroll++;
3250
- else if (key.name === "pageup") dialog.scroll = Math.max(0, dialog.scroll - 10);
3251
- else if (key.name === "pagedown") dialog.scroll += 10;
3252
- else if (key.name === "home") dialog.scroll = 0;
3253
- else if (key.name === "return" || key.name === "q") return "close";
3254
- else if (key.mouse?.kind === "wheel-up") dialog.scroll = Math.max(0, dialog.scroll - 3);
3255
- else if (key.mouse?.kind === "wheel-down") dialog.scroll += 3;
3256
- else return "same";
3257
- return "changed";
3258
- }
3259
- const shown = filterItems(dialog.items, dialog.query);
3260
- if (key.name === "up" || key.mouse?.kind === "wheel-up") {
3261
- dialog.selected = shown.length ? (dialog.selected - 1 + shown.length) % shown.length : 0;
3262
- return "changed";
3263
- }
3264
- if (key.name === "down" || key.mouse?.kind === "wheel-down" || key.name === "tab") {
3265
- dialog.selected = shown.length ? (dialog.selected + 1) % shown.length : 0;
3266
- return "changed";
3267
- }
3268
- if (key.name === "return") {
3269
- const item = shown[dialog.selected];
3270
- if (!item) return "same";
3271
- dialog.onSelect(item);
3272
- return "select";
3273
- }
3274
- if (key.name === "backspace") {
3275
- if (!dialog.query) return "same";
3276
- dialog.query = dialog.query.slice(0, -1);
3277
- dialog.selected = 0;
3278
- return "changed";
3279
- }
3280
- if (!key.ctrl && !key.meta && key.sequence && /^[^\u0000-\u001f\u007f]+$/.test(key.sequence)) {
3281
- dialog.query += key.sequence;
3282
- dialog.selected = 0;
3283
- return "changed";
3150
+ function prevBoundary(text, at2) {
3151
+ const marks = boundaries(text);
3152
+ let last = 0;
3153
+ for (const mark of marks) {
3154
+ if (mark >= at2) return last;
3155
+ last = mark;
3284
3156
  }
3285
- return "same";
3157
+ return last;
3286
3158
  }
3287
- var BOX = { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" };
3288
- function renderDialog(dialog, rows, columns) {
3289
- const width = Math.max(24, Math.min(columns - 4, 84));
3290
- const inner = width - 4;
3291
- const maxBody = Math.max(3, rows - 6);
3292
- const body2 = [];
3293
- if (dialog.kind === "pick") {
3294
- const shown = filterItems(dialog.items, dialog.query);
3295
- const listRows = Math.max(1, Math.min(shown.length, maxBody - 2));
3296
- const start = Math.max(0, Math.min(dialog.selected - listRows + 1, shown.length - listRows));
3297
- body2.push(`${paint.accent("search")} ${dialog.query}${paint.invert(" ")}`);
3298
- body2.push("");
3299
- if (!shown.length) body2.push(paint.muted("nothing matches"));
3300
- for (const [i, item] of shown.slice(start, start + listRows).entries()) {
3301
- const isSelected = start + i === dialog.selected;
3302
- const mark = isSelected ? paint.accent("\u25B8") : " ";
3303
- const label2 = isSelected ? paint.bold(item.label) : item.label;
3304
- body2.push(truncate(`${mark} ${label2}${item.hint ? ` ${paint.muted(item.hint)}` : ""}`, inner));
3305
- }
3306
- if (shown.length > listRows) body2.push(paint.dim(`\u2026 ${shown.length - listRows} more; type to narrow`));
3307
- } else {
3308
- const visible = Math.min(dialog.lines.length, maxBody);
3309
- dialog.scroll = Math.max(0, Math.min(dialog.scroll, dialog.lines.length - visible));
3310
- for (const line of dialog.lines.slice(dialog.scroll, dialog.scroll + visible)) body2.push(truncate(line, inner));
3311
- if (dialog.lines.length > visible) {
3312
- body2.push(paint.dim(`\u2026 ${dialog.scroll + visible} of ${dialog.lines.length} lines \xB7 \u2191\u2193 to scroll`));
3313
- }
3314
- }
3315
- const title = ` ${dialog.title} `;
3316
- const rule = BOX.h.repeat(Math.max(0, width - 2 - title.length - 1));
3317
- const lines = [
3318
- paint.dim(`${BOX.tl}${BOX.h}`) + paint.bold(title) + paint.dim(`${rule}${BOX.tr}`),
3319
- ...body2.map((line) => `${paint.dim(BOX.v)} ${pad3(truncate(line, inner), inner)} ${paint.dim(BOX.v)}`),
3320
- paint.dim(`${BOX.bl}${BOX.h.repeat(width - 2)}${BOX.br}`)
3321
- ];
3322
- const top = Math.max(0, Math.floor((rows - lines.length) / 2));
3323
- const left = Math.max(0, Math.floor((columns - width) / 2));
3324
- return { top, left, lines };
3159
+ function nextBoundary(text, at2) {
3160
+ for (const mark of boundaries(text)) if (mark > at2) return mark;
3161
+ return text.length;
3325
3162
  }
3326
- function pad3(text, width) {
3327
- const short = width - visibleWidth(text);
3328
- return short > 0 ? `${text}${" ".repeat(short)}` : text;
3163
+ function wordStart(text, at2) {
3164
+ let i = at2;
3165
+ while (i > 0 && /\s/.test(text[i - 1])) i--;
3166
+ while (i > 0 && !/\s/.test(text[i - 1])) i--;
3167
+ return i;
3329
3168
  }
3330
-
3331
- // packages/tui/src/screen.ts
3332
- import { EventEmitter } from "node:events";
3333
- var ESC4 = "\x1B";
3334
- var ALT_SCREEN_ON = `${ESC4}[?1049h`;
3335
- var ALT_SCREEN_OFF = `${ESC4}[?1049l`;
3336
- var CURSOR_HIDE = `${ESC4}[?25l`;
3337
- var CURSOR_SHOW = `${ESC4}[?25h`;
3338
- var PASTE_ON = `${ESC4}[?2004h`;
3339
- var PASTE_OFF = `${ESC4}[?2004l`;
3340
- var MOUSE_ON = `${ESC4}[?1000h${ESC4}[?1006h`;
3341
- var MOUSE_OFF = `${ESC4}[?1006l${ESC4}[?1000l`;
3342
- var CLEAR = `${ESC4}[2J`;
3343
- var RESET = `${ESC4}[0m`;
3344
- var at = (row, col) => `${ESC4}[${row + 1};${col + 1}H`;
3345
- var Screen = class extends EventEmitter {
3346
- constructor(out = process.stdout, input = process.stdin, opts = {}) {
3347
- super();
3348
- this.out = out;
3349
- this.input = input;
3350
- this.opts = opts;
3169
+ function wordEnd(text, at2) {
3170
+ let i = at2;
3171
+ while (i < text.length && /\s/.test(text[i])) i++;
3172
+ while (i < text.length && !/\s/.test(text[i])) i++;
3173
+ return i;
3174
+ }
3175
+ var LineEditor = class {
3176
+ value = "";
3177
+ at = 0;
3178
+ get text() {
3179
+ return this.value;
3351
3180
  }
3352
- last = null;
3353
- lastSize = "";
3354
- entered = false;
3355
- decoder = new KeyDecoder();
3356
- get rows() {
3357
- const n = this.opts.rows ?? this.out.rows;
3358
- return typeof n === "number" && n > 0 ? n : 24;
3181
+ /** Cursor position, as an offset into `text`, always on a grapheme boundary. */
3182
+ get cursor() {
3183
+ return this.at;
3359
3184
  }
3360
- get columns() {
3361
- const n = this.opts.columns ?? this.out.columns;
3362
- return typeof n === "number" && n > 0 ? n : 80;
3185
+ /** Set the whole line - from history, or a completion. Cursor lands at the end. */
3186
+ setText(text) {
3187
+ this.value = sanitize(text);
3188
+ this.at = this.value.length;
3363
3189
  }
3364
- /** Take the terminal: alternate screen, raw keys, bracketed paste, the mouse if asked. */
3365
- enter() {
3366
- if (this.entered) return;
3367
- this.entered = true;
3368
- this.out.write(`${ALT_SCREEN_ON}${CURSOR_HIDE}${CLEAR}${PASTE_ON}${this.opts.mouse ? MOUSE_ON : ""}`);
3369
- try {
3370
- this.input.setRawMode?.(true);
3371
- } catch {
3372
- }
3373
- this.input.setEncoding?.("utf8");
3374
- this.input.on("data", this.onData);
3375
- this.input.on("end", this.onEnd);
3376
- this.input.resume();
3377
- this.out.on?.("resize", this.onResize);
3378
- process.once("exit", this.leave);
3190
+ reset() {
3191
+ this.value = "";
3192
+ this.at = 0;
3193
+ }
3194
+ /** Insert at the cursor, newlines removed. Used for typing and for paste. */
3195
+ insert(text) {
3196
+ const clean = sanitize(text);
3197
+ if (!clean) return "same";
3198
+ this.value = this.value.slice(0, this.at) + clean + this.value.slice(this.at);
3199
+ this.at += clean.length;
3200
+ return "text";
3379
3201
  }
3380
- /** Give the terminal back, in the reverse order. Safe to call more than once. */
3381
- leave = () => {
3382
- if (!this.entered) return;
3383
- this.entered = false;
3384
- process.off("exit", this.leave);
3385
- this.out.off?.("resize", this.onResize);
3386
- this.input.off("data", this.onData);
3387
- this.input.off("end", this.onEnd);
3388
- try {
3389
- this.input.pause();
3390
- this.input.setRawMode?.(false);
3391
- } catch {
3392
- }
3393
- this.out.write(`${this.opts.mouse ? MOUSE_OFF : ""}${PASTE_OFF}${RESET}${CURSOR_SHOW}${ALT_SCREEN_OFF}`);
3394
- this.last = null;
3395
- };
3396
3202
  /**
3397
- * Draw a whole frame, writing only the rows that changed.
3203
+ * Apply one key.
3398
3204
  *
3399
- * `frame` is read as `rows` lines; missing ones are blank, extra ones are
3400
- * dropped. A size change since the last paint clears and redraws everything,
3401
- * because a shorter line leaves the old one's tail where a diff cannot see it.
3205
+ * Returns `unhandled` for anything this does not claim - Enter, ctrl-c, Tab -
3206
+ * so the caller can decide what submitting, cancelling, and completing mean.
3207
+ * Those are session concerns, not editing ones.
3402
3208
  */
3403
- paint(frame, cursor = null) {
3404
- if (!this.entered) return;
3405
- const rows = this.rows;
3406
- const columns = this.columns;
3407
- const size = `${rows}x${columns}`;
3408
- let out = CURSOR_HIDE;
3409
- if (!this.last || this.lastSize !== size) {
3410
- out += CLEAR;
3411
- this.last = new Array(rows).fill(null);
3412
- this.lastSize = size;
3209
+ apply(key) {
3210
+ const { name, ctrl, meta } = key;
3211
+ if (name === "left" && !ctrl && !meta) return this.moveTo(prevBoundary(this.value, this.at));
3212
+ if (name === "right" && !ctrl && !meta) return this.moveTo(nextBoundary(this.value, this.at));
3213
+ if (name === "home" || ctrl && name === "a") return this.moveTo(0);
3214
+ if (name === "end" || ctrl && name === "e") return this.moveTo(this.value.length);
3215
+ if ((meta || ctrl) && (name === "left" || name === "b")) {
3216
+ return this.moveTo(wordStart(this.value, this.at));
3413
3217
  }
3414
- for (let r = 0; r < rows; r++) {
3415
- const line = fit(frame[r] ?? "", columns);
3416
- if (this.last[r] === line) continue;
3417
- this.last[r] = line;
3418
- out += `${at(r, 0)}${line}${RESET}`;
3218
+ if ((meta || ctrl) && (name === "right" || name === "f")) {
3219
+ return this.moveTo(wordEnd(this.value, this.at));
3419
3220
  }
3420
- if (cursor) out += `${at(Math.min(cursor.row, rows - 1), Math.min(cursor.col, columns - 1))}${CURSOR_SHOW}`;
3421
- this.out.write(out);
3221
+ if (name === "backspace" && (meta || ctrl)) return this.cut(wordStart(this.value, this.at), this.at);
3222
+ if (ctrl && name === "w") return this.cut(wordStart(this.value, this.at), this.at);
3223
+ if (name === "backspace") return this.cut(prevBoundary(this.value, this.at), this.at);
3224
+ if (name === "delete") return this.cut(this.at, nextBoundary(this.value, this.at));
3225
+ if (ctrl && name === "u") return this.cut(0, this.at);
3226
+ if (ctrl && name === "k") return this.cut(this.at, this.value.length);
3227
+ if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) {
3228
+ return this.insert(key.sequence);
3229
+ }
3230
+ if (key.paste !== void 0) return this.insert(key.paste);
3231
+ return "unhandled";
3422
3232
  }
3423
- /** Forget the last frame, so the next paint redraws every row. For ctrl-l. */
3424
- invalidate() {
3425
- this.last = null;
3233
+ moveTo(next) {
3234
+ if (next === this.at) return "same";
3235
+ this.at = next;
3236
+ return "cursor";
3237
+ }
3238
+ cut(from, to) {
3239
+ if (from >= to) return "same";
3240
+ this.value = this.value.slice(0, from) + this.value.slice(to);
3241
+ this.at = from;
3242
+ return "text";
3243
+ }
3244
+ /**
3245
+ * The slice of the line that fits in `width`, and where the cursor sits in it.
3246
+ *
3247
+ * A line longer than the box scrolls horizontally rather than wrapping, and
3248
+ * the window is chosen so the cursor is always inside it - typing past the
3249
+ * right edge must not take the cursor somewhere the reader cannot see it.
3250
+ */
3251
+ viewport(width) {
3252
+ if (width <= 0) return { text: "", column: 0 };
3253
+ if (this.value.length <= width) return { text: this.value, column: this.at };
3254
+ let start = Math.max(0, this.at - width + 1);
3255
+ start = Math.min(start, this.value.length - width);
3256
+ return { text: this.value.slice(start, start + width), column: this.at - start };
3426
3257
  }
3427
- onData = (chunk) => {
3428
- const keys = this.decoder.feed(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
3429
- if (keys.length) this.emit("keys", keys);
3430
- };
3431
- onEnd = () => {
3432
- this.emit("end");
3433
- };
3434
- onResize = () => {
3435
- this.last = null;
3436
- this.emit("resize");
3437
- };
3438
3258
  };
3439
- function fit(line, width) {
3440
- const cut = truncate(line, width);
3441
- const short = width - visibleWidth(cut);
3442
- return short > 0 ? `${cut}${" ".repeat(short)}` : cut;
3443
- }
3444
-
3445
- // packages/tui/src/markdown.ts
3446
- function renderMarkdown(text, opts) {
3447
- const lines = text.replace(/\r\n/g, "\n").split("\n");
3448
- const pad4 = opts.indent ?? "";
3449
- const out = [];
3450
- let inFence = false;
3451
- let fenceLang = "";
3452
- for (const line of lines) {
3453
- const fence = /^\s*```(\w*)/.exec(line);
3454
- if (fence) {
3455
- if (!inFence) {
3456
- inFence = true;
3457
- fenceLang = fence[1] ?? "";
3458
- out.push(paint.dim(`${pad4}${fenceLang || "code"}`));
3459
- } else {
3460
- inFence = false;
3461
- fenceLang = "";
3462
- out.push("");
3463
- }
3464
- continue;
3259
+ var History = class {
3260
+ constructor(limit = 200) {
3261
+ this.limit = limit;
3262
+ }
3263
+ entries = [];
3264
+ index = -1;
3265
+ draft = "";
3266
+ add(line) {
3267
+ const clean = sanitize(line).trim();
3268
+ if (clean && this.entries.at(-1) !== clean) {
3269
+ this.entries.push(clean);
3270
+ if (this.entries.length > this.limit) this.entries.shift();
3465
3271
  }
3466
- if (inFence) {
3467
- out.push(`${paint.dim(`${pad4}|`)} ${line}`);
3468
- continue;
3272
+ this.index = -1;
3273
+ this.draft = "";
3274
+ }
3275
+ /** The line to show, or null when there is nowhere further to go. */
3276
+ up(current) {
3277
+ if (!this.entries.length) return null;
3278
+ if (this.index === -1) {
3279
+ this.draft = current;
3280
+ this.index = this.entries.length - 1;
3281
+ } else if (this.index > 0) {
3282
+ this.index--;
3283
+ } else {
3284
+ return null;
3469
3285
  }
3470
- out.push(...prose(line, opts.width, pad4));
3286
+ return this.entries[this.index];
3471
3287
  }
3472
- return out.join("\n");
3288
+ down() {
3289
+ if (this.index === -1) return null;
3290
+ if (this.index >= this.entries.length - 1) {
3291
+ this.index = -1;
3292
+ return this.draft;
3293
+ }
3294
+ this.index++;
3295
+ return this.entries[this.index];
3296
+ }
3297
+ };
3298
+
3299
+ // packages/tui/src/editor.ts
3300
+ function sanitizeMultiline(text) {
3301
+ return text.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
3473
3302
  }
3474
- function prose(line, width, pad4) {
3475
- const heading = /^(#{1,6})\s+(.*)$/.exec(line);
3476
- if (heading) return [`${pad4}${paint.bold(paint.accent(heading[2]))}`];
3477
- if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
3478
- return [`${pad4}${paint.dim("-".repeat(Math.min(width, 40)))}`];
3303
+ var TextEditor = class {
3304
+ lines = [""];
3305
+ row = 0;
3306
+ col = 0;
3307
+ /** First line shown in the last `view`, kept so the window scrolls rather than jumps. */
3308
+ top = 0;
3309
+ get text() {
3310
+ return this.lines.join("\n");
3479
3311
  }
3480
- const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
3481
- if (bullet) {
3482
- const indent2 = `${pad4}${bullet[1]}${paint.accent("-")} `;
3483
- return wrap2(inline(bullet[2]), width, indent2, `${pad4}${bullet[1]} `);
3312
+ get cursor() {
3313
+ return { row: this.row, col: this.col };
3484
3314
  }
3485
- const numbered = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
3486
- if (numbered) {
3487
- const indent2 = `${pad4}${numbered[1]}${paint.accent(`${numbered[2]}.`)} `;
3488
- return wrap2(inline(numbered[3]), width, indent2, `${pad4}${numbered[1]} `);
3315
+ get lineCount() {
3316
+ return this.lines.length;
3489
3317
  }
3490
- const quote = /^\s*>\s?(.*)$/.exec(line);
3491
- if (quote) {
3492
- const gutter = `${pad4}${paint.dim("|")} `;
3493
- return wrap2(paint.muted(inline(quote[1])), width, gutter, gutter);
3318
+ get isEmpty() {
3319
+ return this.lines.length === 1 && this.lines[0] === "";
3494
3320
  }
3495
- if (!line.trim()) return [""];
3496
- return wrap2(inline(line), width, pad4, pad4);
3497
- }
3498
- var HOLD = "\0";
3499
- function inline(text) {
3500
- const code = [];
3501
- let held = text.replace(/`([^`]+)`/g, (_, body2) => {
3502
- code.push(body2);
3503
- return `${HOLD}${code.length - 1}${HOLD}`;
3504
- });
3505
- held = held.replace(/\*\*([^*]+)\*\*/g, (_, body2) => paint.bold(body2)).replace(/(^|[\s(])\*([^*]+)\*/g, (_, pre, body2) => `${pre}${paint.bold(body2)}`).replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label2, href) => `${label2} ${paint.dim(`(${href})`)}`);
3506
- return held.replace(
3507
- new RegExp(`${HOLD}(\\d+)${HOLD}`, "g"),
3508
- (_, n) => paint.cyan(code[Number(n)] ?? "")
3509
- );
3510
- }
3511
- function wrap2(text, width, first, rest) {
3512
- const limit = Math.max(20, width - visibleWidth(first));
3513
- if (visibleWidth(text) <= limit) return [`${first}${text}`];
3514
- const out = [];
3515
- let line = "";
3516
- for (const word of text.split(" ")) {
3517
- if (line && visibleWidth(line) + 1 + visibleWidth(word) > limit) {
3518
- out.push(`${out.length === 0 ? first : rest}${line}`);
3519
- line = word;
3520
- } else {
3521
- line = line ? `${line} ${word}` : word;
3321
+ /** The line the caret is on. Used to work out whether a `/` or `@` is being typed. */
3322
+ get currentLine() {
3323
+ return this.lines[this.row];
3324
+ }
3325
+ /** Replace everything - from history, or a completion. The caret lands at the end. */
3326
+ setText(text) {
3327
+ this.lines = sanitizeMultiline(text).split("\n");
3328
+ this.row = this.lines.length - 1;
3329
+ this.col = this.lines[this.row].length;
3330
+ }
3331
+ /** Replace just the caret's line, keeping the rest. For a completion of `/cmd` or `@path`. */
3332
+ setCurrentLine(text) {
3333
+ this.lines[this.row] = sanitizeMultiline(text).replace(/\n/g, " ");
3334
+ this.col = this.lines[this.row].length;
3335
+ }
3336
+ reset() {
3337
+ this.lines = [""];
3338
+ this.row = 0;
3339
+ this.col = 0;
3340
+ this.top = 0;
3341
+ }
3342
+ /** Insert at the caret. Newlines in `text` become new lines. */
3343
+ insert(text) {
3344
+ const clean = sanitizeMultiline(text);
3345
+ if (!clean) return "same";
3346
+ const parts = clean.split("\n");
3347
+ const line = this.lines[this.row];
3348
+ const before = line.slice(0, this.col);
3349
+ const after = line.slice(this.col);
3350
+ if (parts.length === 1) {
3351
+ this.lines[this.row] = before + parts[0] + after;
3352
+ this.col += parts[0].length;
3353
+ return "text";
3354
+ }
3355
+ const inserted = [before + parts[0], ...parts.slice(1, -1), parts[parts.length - 1] + after];
3356
+ this.lines.splice(this.row, 1, ...inserted);
3357
+ this.row += parts.length - 1;
3358
+ this.col = parts[parts.length - 1].length;
3359
+ return "text";
3360
+ }
3361
+ newline() {
3362
+ return this.insert("\n");
3363
+ }
3364
+ /**
3365
+ * Apply one key. `unhandled` for a plain Enter (the caller sends), for Up on
3366
+ * the first line and Down on the last (the caller walks history), and for
3367
+ * anything else the editor does not claim.
3368
+ */
3369
+ apply(key) {
3370
+ const { name, ctrl, meta, shift } = key;
3371
+ const line = this.lines[this.row];
3372
+ if (key.paste !== void 0) return this.insert(key.paste);
3373
+ if (name === "return") {
3374
+ if (shift || ctrl || meta) return this.newline();
3375
+ if (/(^|[^\\])\\$/.test(line)) {
3376
+ this.lines[this.row] = line.slice(0, -1);
3377
+ this.col = Math.min(this.col, this.lines[this.row].length);
3378
+ return this.newline();
3379
+ }
3380
+ return "unhandled";
3381
+ }
3382
+ if (ctrl && name === "j") return this.newline();
3383
+ if (name === "up" && !ctrl && !meta) {
3384
+ if (this.row === 0) return "unhandled";
3385
+ this.row--;
3386
+ this.col = Math.min(this.col, this.lines[this.row].length);
3387
+ return "cursor";
3388
+ }
3389
+ if (name === "down" && !ctrl && !meta) {
3390
+ if (this.row === this.lines.length - 1) return "unhandled";
3391
+ this.row++;
3392
+ this.col = Math.min(this.col, this.lines[this.row].length);
3393
+ return "cursor";
3394
+ }
3395
+ if (name === "left" && !ctrl && !meta) {
3396
+ if (this.col === 0) {
3397
+ if (this.row === 0) return "same";
3398
+ this.row--;
3399
+ this.col = this.lines[this.row].length;
3400
+ return "cursor";
3401
+ }
3402
+ return this.moveTo(prevBoundary(line, this.col));
3403
+ }
3404
+ if (name === "right" && !ctrl && !meta) {
3405
+ if (this.col >= line.length) {
3406
+ if (this.row === this.lines.length - 1) return "same";
3407
+ this.row++;
3408
+ this.col = 0;
3409
+ return "cursor";
3410
+ }
3411
+ return this.moveTo(nextBoundary(line, this.col));
3412
+ }
3413
+ if (name === "home" || ctrl && name === "a") return this.moveTo(0);
3414
+ if (name === "end" || ctrl && name === "e") return this.moveTo(line.length);
3415
+ if ((meta || ctrl) && (name === "left" || name === "b")) return this.moveTo(wordStart(line, this.col));
3416
+ if ((meta || ctrl) && (name === "right" || name === "f")) return this.moveTo(wordEnd(line, this.col));
3417
+ if (name === "backspace" && (meta || ctrl)) {
3418
+ if (this.col === 0) return this.joinUp();
3419
+ return this.cut(wordStart(line, this.col), this.col);
3420
+ }
3421
+ if (ctrl && name === "w") {
3422
+ if (this.col === 0) return this.joinUp();
3423
+ return this.cut(wordStart(line, this.col), this.col);
3424
+ }
3425
+ if (name === "backspace") {
3426
+ if (this.col === 0) return this.joinUp();
3427
+ return this.cut(prevBoundary(line, this.col), this.col);
3428
+ }
3429
+ if (name === "delete") {
3430
+ if (this.col >= line.length) return this.joinDown();
3431
+ return this.cut(this.col, nextBoundary(line, this.col));
3432
+ }
3433
+ if (ctrl && name === "u") return this.cut(0, this.col);
3434
+ if (ctrl && name === "k") {
3435
+ if (this.col >= line.length) return this.joinDown();
3436
+ return this.cut(this.col, line.length);
3437
+ }
3438
+ if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) return this.insert(key.sequence);
3439
+ return "unhandled";
3440
+ }
3441
+ /**
3442
+ * The lines to draw and where the caret is among them.
3443
+ *
3444
+ * At most `maxLines` rows, chosen so the caret's row is always one of them;
3445
+ * each row scrolls horizontally on its own so a long line never wraps into the
3446
+ * row below and the caret never leaves the box.
3447
+ */
3448
+ view(width, maxLines) {
3449
+ const rows = Math.max(1, maxLines);
3450
+ if (this.row < this.top) this.top = this.row;
3451
+ if (this.row >= this.top + rows) this.top = this.row - rows + 1;
3452
+ this.top = Math.max(0, Math.min(this.top, this.lines.length - rows));
3453
+ const shown = this.lines.slice(this.top, this.top + rows);
3454
+ let cursorCol = this.col;
3455
+ const lines = shown.map((line, i) => {
3456
+ if (width <= 0) return "";
3457
+ if (line.length <= width) return line;
3458
+ if (this.top + i !== this.row) return line.slice(0, width);
3459
+ const start = Math.max(0, this.col - width + 1);
3460
+ cursorCol = this.col - start;
3461
+ return line.slice(start, start + width);
3462
+ });
3463
+ return { lines, cursor: { row: this.row - this.top, col: cursorCol } };
3464
+ }
3465
+ moveTo(next) {
3466
+ if (next === this.col) return "same";
3467
+ this.col = next;
3468
+ return "cursor";
3469
+ }
3470
+ cut(from, to) {
3471
+ if (from >= to) return "same";
3472
+ const line = this.lines[this.row];
3473
+ this.lines[this.row] = line.slice(0, from) + line.slice(to);
3474
+ this.col = from;
3475
+ return "text";
3476
+ }
3477
+ joinUp() {
3478
+ if (this.row === 0) return "same";
3479
+ const above = this.lines[this.row - 1];
3480
+ this.lines.splice(this.row - 1, 2, above + this.lines[this.row]);
3481
+ this.row--;
3482
+ this.col = above.length;
3483
+ return "text";
3484
+ }
3485
+ joinDown() {
3486
+ if (this.row >= this.lines.length - 1) return "same";
3487
+ this.lines.splice(this.row, 2, this.lines[this.row] + this.lines[this.row + 1]);
3488
+ return "text";
3489
+ }
3490
+ };
3491
+
3492
+ // packages/tui/src/keys.ts
3493
+ var ESC3 = "\x1B";
3494
+ var PASTE_START = `${ESC3}[200~`;
3495
+ var PASTE_END = `${ESC3}[201~`;
3496
+ var CSI_FINAL = {
3497
+ A: "up",
3498
+ B: "down",
3499
+ C: "right",
3500
+ D: "left",
3501
+ H: "home",
3502
+ F: "end",
3503
+ Z: "tab"
3504
+ };
3505
+ var TILDE = {
3506
+ "1": "home",
3507
+ "2": "insert",
3508
+ "3": "delete",
3509
+ "4": "end",
3510
+ "5": "pageup",
3511
+ "6": "pagedown",
3512
+ "7": "home",
3513
+ "8": "end"
3514
+ };
3515
+ function modifiers(param) {
3516
+ const n = (Number(param) || 1) - 1;
3517
+ return {
3518
+ ...n & 1 ? { shift: true } : {},
3519
+ ...n & 2 ? { meta: true } : {},
3520
+ ...n & 4 ? { ctrl: true } : {}
3521
+ };
3522
+ }
3523
+ function coded(code, mods, sequence) {
3524
+ switch (code) {
3525
+ case 13:
3526
+ return { name: "return", ...mods, sequence };
3527
+ case 9:
3528
+ return { name: "tab", ...mods, sequence };
3529
+ case 27:
3530
+ return { name: "escape", ...mods, sequence };
3531
+ case 127:
3532
+ case 8:
3533
+ return { name: "backspace", ...mods, sequence };
3534
+ default:
3535
+ if (code >= 32) {
3536
+ const ch = String.fromCodePoint(code);
3537
+ return { name: ch.toLowerCase(), ...mods, sequence: mods.ctrl || mods.meta ? sequence : ch };
3538
+ }
3539
+ return null;
3540
+ }
3541
+ }
3542
+ function isPrefix(text, whole) {
3543
+ return text.length <= whole.length && whole.startsWith(text);
3544
+ }
3545
+ function partialMarker(buf, marker) {
3546
+ for (let n = Math.min(marker.length - 1, buf.length); n > 0; n--) {
3547
+ if (isPrefix(buf.slice(buf.length - n), marker)) return n;
3548
+ }
3549
+ return 0;
3550
+ }
3551
+ var KeyDecoder = class {
3552
+ /** Bytes that could still turn out to be the start of something. */
3553
+ pending = "";
3554
+ /** Inside a bracketed paste: the text so far. Null outside one. */
3555
+ pasting = null;
3556
+ /**
3557
+ * Decode a chunk. Sequences split across two reads are held until the next
3558
+ * one; a lone ESC at the END of a chunk is reported as Escape at once, because
3559
+ * a terminal sends a whole sequence in one write and a person pressing the key
3560
+ * should not have to press another to be heard.
3561
+ */
3562
+ feed(chunk) {
3563
+ const out = [];
3564
+ let buf = this.pending + chunk;
3565
+ this.pending = "";
3566
+ let i = 0;
3567
+ while (i < buf.length) {
3568
+ if (this.pasting !== null) {
3569
+ const end = buf.indexOf(PASTE_END, i);
3570
+ if (end === -1) {
3571
+ const hold = partialMarker(buf, PASTE_END);
3572
+ this.pasting += buf.slice(i, buf.length - hold);
3573
+ this.pending = buf.slice(buf.length - hold);
3574
+ return out;
3575
+ }
3576
+ this.pasting += buf.slice(i, end);
3577
+ out.push({ name: "paste", paste: this.pasting.replace(/\r\n?/g, "\n") });
3578
+ this.pasting = null;
3579
+ i = end + PASTE_END.length;
3580
+ continue;
3581
+ }
3582
+ const ch = buf[i];
3583
+ if (ch !== ESC3) {
3584
+ const code2 = ch.charCodeAt(0);
3585
+ if (code2 === 13) out.push({ name: "return", sequence: ch });
3586
+ else if (code2 === 10) out.push({ name: "j", ctrl: true, sequence: ch });
3587
+ else if (code2 === 9) out.push({ name: "tab", sequence: ch });
3588
+ else if (code2 === 127 || code2 === 8) out.push({ name: "backspace", sequence: ch });
3589
+ else if (code2 < 32) {
3590
+ if (code2 > 0) out.push({ name: String.fromCharCode(96 + code2), ctrl: true, sequence: ch });
3591
+ } else {
3592
+ let j = i + 1;
3593
+ while (j < buf.length) {
3594
+ const c = buf.charCodeAt(j);
3595
+ if (c < 32 || c === 127 || buf[j] === ESC3) break;
3596
+ j++;
3597
+ }
3598
+ out.push({ sequence: buf.slice(i, j) });
3599
+ i = j;
3600
+ continue;
3601
+ }
3602
+ i++;
3603
+ continue;
3604
+ }
3605
+ if (i === buf.length - 1) {
3606
+ out.push({ name: "escape", sequence: ESC3 });
3607
+ i++;
3608
+ continue;
3609
+ }
3610
+ const next = buf[i + 1];
3611
+ if (next === "[") {
3612
+ let j = i + 2;
3613
+ while (j < buf.length && !(buf.charCodeAt(j) >= 64 && buf.charCodeAt(j) <= 126)) j++;
3614
+ if (j >= buf.length) {
3615
+ this.pending = buf.slice(i);
3616
+ return out;
3617
+ }
3618
+ const seq = buf.slice(i, j + 1);
3619
+ const body2 = buf.slice(i + 2, j);
3620
+ const final = buf[j];
3621
+ i = j + 1;
3622
+ if (seq === PASTE_START) {
3623
+ this.pasting = "";
3624
+ continue;
3625
+ }
3626
+ const key = decodeCsi(body2, final, seq);
3627
+ if (key) out.push(key);
3628
+ continue;
3629
+ }
3630
+ if (next === "O") {
3631
+ const final = buf[i + 2];
3632
+ if (final === void 0) {
3633
+ this.pending = buf.slice(i);
3634
+ return out;
3635
+ }
3636
+ const name = CSI_FINAL[final];
3637
+ if (name) out.push({ name, sequence: buf.slice(i, i + 3) });
3638
+ i += 3;
3639
+ continue;
3640
+ }
3641
+ if (next === "\x7F" || next === "\b") {
3642
+ out.push({ name: "backspace", meta: true, sequence: buf.slice(i, i + 2) });
3643
+ i += 2;
3644
+ continue;
3645
+ }
3646
+ if (next === ESC3) {
3647
+ out.push({ name: "escape", sequence: ESC3 });
3648
+ i++;
3649
+ continue;
3650
+ }
3651
+ const code = next.charCodeAt(0);
3652
+ if (code >= 32 && code !== 127) {
3653
+ out.push({
3654
+ name: next.toLowerCase(),
3655
+ meta: true,
3656
+ ...next !== next.toLowerCase() ? { shift: true } : {},
3657
+ sequence: buf.slice(i, i + 2)
3658
+ });
3659
+ i += 2;
3660
+ continue;
3661
+ }
3662
+ out.push({ name: "escape", sequence: ESC3 });
3663
+ i++;
3664
+ }
3665
+ return out;
3666
+ }
3667
+ /** Anything still held back - the tail of a paste, an escape that never finished. */
3668
+ flush() {
3669
+ const out = [];
3670
+ if (this.pasting !== null) {
3671
+ out.push({ name: "paste", paste: this.pasting + this.pending });
3672
+ this.pasting = null;
3673
+ } else if (this.pending === ESC3) {
3674
+ out.push({ name: "escape", sequence: ESC3 });
3675
+ }
3676
+ this.pending = "";
3677
+ return out;
3678
+ }
3679
+ };
3680
+ function decodeCsi(body2, final, sequence) {
3681
+ if (body2.startsWith("<") && (final === "M" || final === "m")) {
3682
+ const [b, x, y] = body2.slice(1).split(";").map(Number);
3683
+ if (final === "m") return null;
3684
+ if (b & 32) return null;
3685
+ const button = b & 3;
3686
+ if (b & 64) {
3687
+ return { name: "mouse", sequence, mouse: { kind: button === 0 ? "wheel-up" : "wheel-down", button: b, x, y } };
3522
3688
  }
3689
+ return { name: "mouse", sequence, mouse: { kind: "click", button, x, y } };
3523
3690
  }
3524
- if (line) out.push(`${out.length === 0 ? first : rest}${line}`);
3525
- return out;
3691
+ const params = body2.split(";");
3692
+ if (final === "~") {
3693
+ if (params[0] === "27") return coded(Number(params[2]), modifiers(params[1]), sequence);
3694
+ if (params[0] === "200" || params[0] === "201") return null;
3695
+ const name2 = TILDE[params[0]];
3696
+ return name2 ? { name: name2, ...modifiers(params[1]), sequence } : null;
3697
+ }
3698
+ if (final === "u") return coded(Number(params[0]), modifiers(params[1]), sequence);
3699
+ const name = CSI_FINAL[final];
3700
+ if (!name) return null;
3701
+ const mods = final === "Z" ? { shift: true } : modifiers(params[1]);
3702
+ return { name, ...mods, sequence };
3526
3703
  }
3527
3704
 
3528
- // packages/tui/src/transcript.ts
3529
- var LIVE_LINES = 8;
3530
- var Transcript = class {
3531
- entries = [];
3532
- nextId = 1;
3533
- cache = /* @__PURE__ */ new Map();
3534
- /** For a streaming answer: the rendered COMPLETE part, so only the tail is re-rendered per chunk. */
3535
- prose = /* @__PURE__ */ new Map();
3536
- get length() {
3537
- return this.entries.length;
3538
- }
3539
- get blocks() {
3540
- return this.entries;
3541
- }
3542
- /** Typed by kind rather than by the literal passed, so `done: false` stays a boolean the caller can flip. */
3543
- add(block) {
3544
- const entry = { id: this.nextId++, version: 0, block };
3545
- this.entries.push(entry);
3546
- return entry;
3547
- }
3548
- /**
3549
- * Move a block to the bottom. For an approval card, which must stay the last
3550
- * thing on screen while it waits: a helper that keeps working adds rows below
3551
- * it, and a card pushed up past the fold is one approved without being read.
3552
- */
3553
- toEnd(entry) {
3554
- const at2 = this.entries.indexOf(entry);
3555
- if (at2 === -1 || at2 === this.entries.length - 1) return;
3556
- this.entries.splice(at2, 1);
3557
- this.entries.push(entry);
3558
- }
3559
- /** Say a block changed, so its cached lines are dropped on the next render. */
3560
- touch(entry) {
3561
- entry.version++;
3705
+ // packages/tui/src/layout.ts
3706
+ var MAX_COMPOSER_ROWS = 8;
3707
+ var SIDEBAR_MIN_COLUMNS = 120;
3708
+ var SIDEBAR_WIDTH = 32;
3709
+ function layout(opts) {
3710
+ const rows = Math.max(4, opts.rows);
3711
+ const columns = Math.max(20, opts.columns);
3712
+ const wanted = Math.min(MAX_COMPOSER_ROWS, Math.max(1, opts.editorLines) + 2);
3713
+ const composerHeight = Math.max(3, Math.min(wanted, rows - 3));
3714
+ const header = { top: 0, left: 0, width: columns, height: 1 };
3715
+ const footer = { top: rows - 1, left: 0, width: columns, height: 1 };
3716
+ const composer2 = { top: rows - 1 - composerHeight, left: 0, width: columns, height: composerHeight };
3717
+ const transcriptHeight = Math.max(0, composer2.top - 1);
3718
+ const withSidebar = Boolean(opts.sidebar) && columns >= SIDEBAR_MIN_COLUMNS;
3719
+ const mainWidth = withSidebar ? columns - SIDEBAR_WIDTH - 1 : columns;
3720
+ const out = {
3721
+ header,
3722
+ transcript: { top: 1, left: 0, width: mainWidth, height: transcriptHeight },
3723
+ composer: { ...composer2, width: mainWidth },
3724
+ footer
3725
+ };
3726
+ if (withSidebar) {
3727
+ out.sidebar = { top: 1, left: mainWidth + 1, width: SIDEBAR_WIDTH, height: composer2.top + composerHeight - 1 };
3562
3728
  }
3563
- /** Forget everything shown. The session file is untouched; this is the view. */
3564
- clear() {
3565
- this.entries = [];
3566
- this.cache.clear();
3567
- this.prose.clear();
3729
+ return out;
3730
+ }
3731
+
3732
+ // packages/tui/src/popup.ts
3733
+ var MAX_POPUP_ROWS = 8;
3734
+ var Popup = class {
3735
+ constructor(kind, items) {
3736
+ this.kind = kind;
3737
+ this.items = items;
3568
3738
  }
3569
- /** Every line, top to bottom, with one blank line between blocks. */
3570
- render(width) {
3571
- const out = [];
3572
- const live = /* @__PURE__ */ new Set();
3573
- for (const entry of this.entries) {
3574
- live.add(entry.id);
3575
- const lines = this.lines(entry, width);
3576
- if (out.length && lines.length) out.push("");
3577
- out.push(...lines);
3578
- }
3579
- for (const id of this.cache.keys()) if (!live.has(id)) this.cache.delete(id);
3580
- for (const id of this.prose.keys()) if (!live.has(id)) this.prose.delete(id);
3581
- return out;
3739
+ selected = 0;
3740
+ get current() {
3741
+ return this.items[this.selected] ?? null;
3582
3742
  }
3583
- lines(entry, width) {
3584
- const hit = this.cache.get(entry.id);
3585
- if (hit && hit.version === entry.version && hit.width === width) return hit.lines;
3586
- const lines = entry.block.kind === "assistant" ? this.assistant(entry, width) : renderBlock(entry.block, width);
3587
- this.cache.set(entry.id, { version: entry.version, width, lines });
3588
- return lines;
3743
+ move(delta) {
3744
+ if (!this.items.length) return;
3745
+ this.selected = (this.selected + delta + this.items.length) % this.items.length;
3589
3746
  }
3590
- /**
3591
- * An answer, streaming or finished.
3592
- *
3593
- * Complete markdown blocks are rendered; the unfinished tail - a line still
3594
- * arriving, a fence not yet closed - is shown raw and dimmed, so the reader
3595
- * sees the words land without a heading flickering between plain and bold.
3596
- */
3597
- assistant(entry, width) {
3598
- const inner = proseWidth(width);
3599
- const { text, done } = entry.block;
3600
- if (done) return text.trim() ? renderMarkdown(text.replace(/\s+$/, ""), { width: inner, indent: " " }).split("\n") : [];
3601
- const { complete: complete2, tail } = splitStreaming(text);
3602
- let head = this.prose.get(entry.id);
3603
- if (!head || head.length !== complete2.length || head.width !== width) {
3604
- head = {
3605
- length: complete2.length,
3606
- width,
3607
- lines: complete2 ? renderMarkdown(complete2, { width: inner, indent: " " }).split("\n") : []
3608
- };
3609
- this.prose.set(entry.id, head);
3610
- }
3611
- const raw = tail ? tail.split("\n").flatMap((line) => wrapPlain(line, inner - 2)).map((line) => ` ${paint.dim(line)}`) : [];
3612
- return [...head.lines, ...raw];
3747
+ /** Rows to draw, at most `MAX_POPUP_ROWS`, keeping the selection in view. */
3748
+ render(width, max = MAX_POPUP_ROWS) {
3749
+ if (!this.items.length) return [];
3750
+ const rows = Math.max(1, max);
3751
+ const start = Math.max(0, Math.min(this.selected - rows + 1, this.items.length - rows));
3752
+ const shown = this.items.slice(start, start + rows);
3753
+ const column = Math.min(Math.max(...shown.map((s) => s.label.length)), Math.max(10, Math.floor(width / 2)));
3754
+ return shown.map((item, i) => {
3755
+ const isSelected = start + i === this.selected;
3756
+ const mark = isSelected ? paint.accent("\u25B8") : " ";
3757
+ const label2 = item.label.padEnd(column);
3758
+ const room = width - column - 6;
3759
+ const what = item.what ? paint.muted(truncate(item.what, Math.max(0, room))) : "";
3760
+ const line = ` ${mark} ${isSelected ? paint.bold(paint.accent(label2)) : paint.accent(label2)} ${what}`;
3761
+ return pad2(truncate(line, width), width);
3762
+ });
3613
3763
  }
3614
3764
  };
3615
- function splitStreaming(text) {
3616
- const lines = text.replace(/\r\n?/g, "\n").split("\n");
3617
- let tail = lines.pop() ?? "";
3618
- let open = -1;
3619
- lines.forEach((line, i) => {
3620
- if (/^\s*```/.test(line)) open = open === -1 ? i : -1;
3621
- });
3622
- let complete2 = lines;
3623
- if (open !== -1) {
3624
- tail = [...lines.slice(open), tail].join("\n");
3625
- complete2 = lines.slice(0, open);
3626
- }
3627
- return { complete: complete2.join("\n"), tail };
3765
+ function pad2(text, width) {
3766
+ const short = width - visibleWidth(text);
3767
+ return short > 0 ? `${text}${" ".repeat(short)}` : text;
3628
3768
  }
3629
- function proseWidth(width) {
3630
- return Math.max(20, width - 2);
3769
+
3770
+ // packages/tui/src/dialog.ts
3771
+ function pick(title, items, onSelect) {
3772
+ return { kind: "pick", title, items, query: "", selected: 0, onSelect };
3631
3773
  }
3632
- function renderBlock(block, width) {
3633
- const inner = proseWidth(width);
3634
- switch (block.kind) {
3635
- case "user":
3636
- return block.text.split("\n").flatMap(
3637
- (line, i) => wrapPlain(line, inner - 2).map((piece, j) => {
3638
- const text = i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`;
3639
- return band(text, visibleWidth(text), width);
3640
- })
3641
- );
3642
- case "assistant":
3643
- return [];
3644
- case "tool": {
3645
- const out = toolBlock(block.name, block.detail, block.status, { input: block.input, output: block.output }, inner);
3646
- if (block.live.length && !block.output) {
3647
- const shown = block.live.slice(-LIVE_LINES);
3648
- if (block.live.length > LIVE_LINES) out.push(` ${paint.dim(`\u2026 ${block.live.length - LIVE_LINES} more lines`)}`);
3649
- for (const line of shown) out.push(` ${paint.dim(line)}`);
3650
- }
3651
- return out;
3652
- }
3653
- case "approval": {
3654
- const out = [
3655
- ...block.helper ? [` ${paint.accent("from a helper:")} ${paint.muted(block.helper)}`] : [],
3656
- ...approvalCard(block.preview, block.call, block.mode, inner)
3657
- ];
3658
- out.push(
3659
- block.answer === null ? ` ${paint.yellow("y")} ${paint.muted("to allow \xB7")} ${paint.yellow("n")} ${paint.muted("or esc to decline")}` : ` ${block.answer === "allowed" ? paint.green("allowed") : paint.muted("declined")}`
3660
- );
3661
- return out;
3662
- }
3663
- case "error":
3664
- return block.text.split("\n").map((line, i) => i === 0 ? `${paint.red(" labd:")} ${line}` : ` ${line}`);
3665
- case "note":
3666
- return block.text.replace(/\n$/, "").split("\n");
3667
- case "compaction": {
3668
- const label2 = " summarised the conversation so far ";
3669
- const room = Math.max(0, inner - label2.length);
3670
- return [paint.dim(` ${"\u2500".repeat(Math.floor(room / 2))}${label2}${"\u2500".repeat(Math.ceil(room / 2))}`)];
3671
- }
3672
- }
3774
+ function info(title, lines) {
3775
+ return { kind: "info", title, lines, scroll: 0 };
3673
3776
  }
3674
- function wrapPlain(text, width) {
3675
- const limit = Math.max(10, width);
3676
- if (visibleWidth(text) <= limit) return [text];
3677
- const out = [];
3678
- let line = "";
3679
- for (const word of text.split(" ")) {
3680
- if (line && line.length + 1 + word.length > limit) {
3681
- out.push(line);
3682
- line = word;
3683
- } else {
3684
- line = line ? `${line} ${word}` : word;
3685
- }
3686
- while (line.length > limit) {
3687
- out.push(line.slice(0, limit));
3688
- line = line.slice(limit);
3689
- }
3777
+ function filterItems(items, query) {
3778
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
3779
+ if (!words.length) return items;
3780
+ return items.filter((item) => {
3781
+ const hay = `${item.label} ${item.hint ?? ""}`.toLowerCase();
3782
+ return words.every((w) => hay.includes(w));
3783
+ });
3784
+ }
3785
+ function dialogKey(dialog, key) {
3786
+ if (key.name === "escape" || key.ctrl && (key.name === "c" || key.name === "g")) return "close";
3787
+ if (dialog.kind === "info") {
3788
+ if (key.name === "up" || key.name === "k") dialog.scroll = Math.max(0, dialog.scroll - 1);
3789
+ else if (key.name === "down" || key.name === "j") dialog.scroll++;
3790
+ else if (key.name === "pageup") dialog.scroll = Math.max(0, dialog.scroll - 10);
3791
+ else if (key.name === "pagedown") dialog.scroll += 10;
3792
+ else if (key.name === "home") dialog.scroll = 0;
3793
+ else if (key.name === "return" || key.name === "q") return "close";
3794
+ else if (key.mouse?.kind === "wheel-up") dialog.scroll = Math.max(0, dialog.scroll - 3);
3795
+ else if (key.mouse?.kind === "wheel-down") dialog.scroll += 3;
3796
+ else return "same";
3797
+ return "changed";
3798
+ }
3799
+ const shown = filterItems(dialog.items, dialog.query);
3800
+ if (key.name === "up" || key.mouse?.kind === "wheel-up") {
3801
+ dialog.selected = shown.length ? (dialog.selected - 1 + shown.length) % shown.length : 0;
3802
+ return "changed";
3690
3803
  }
3691
- if (line || !out.length) out.push(line);
3692
- return out;
3693
- }
3694
- var Viewport = class {
3695
- constructor(height) {
3696
- this.height = height;
3804
+ if (key.name === "down" || key.mouse?.kind === "wheel-down" || key.name === "tab") {
3805
+ dialog.selected = shown.length ? (dialog.selected + 1) % shown.length : 0;
3806
+ return "changed";
3697
3807
  }
3698
- top = 0;
3699
- follow = true;
3700
- total = 0;
3701
- /** The line count when the reader stopped following, so `newLines` can be counted from it. */
3702
- anchor = 0;
3703
- /** Lines that arrived since the reader scrolled away. Zero while following. */
3704
- get newLines() {
3705
- return this.follow ? 0 : Math.max(0, this.total - this.anchor);
3808
+ if (key.name === "return") {
3809
+ const item = shown[dialog.selected];
3810
+ if (!item) return "same";
3811
+ dialog.onSelect(item);
3812
+ return "select";
3706
3813
  }
3707
- /** Tell the viewport how many lines there are now, and how tall it is. */
3708
- update(total, height = this.height) {
3709
- this.height = height;
3710
- this.total = total;
3711
- if (this.follow) this.top = Math.max(0, total - height);
3712
- else this.top = Math.max(0, Math.min(this.top, total - height));
3814
+ if (key.name === "backspace") {
3815
+ if (!dialog.query) return "same";
3816
+ dialog.query = dialog.query.slice(0, -1);
3817
+ dialog.selected = 0;
3818
+ return "changed";
3713
3819
  }
3714
- scroll(delta) {
3715
- const max = Math.max(0, this.total - this.height);
3716
- const next = Math.max(0, Math.min(max, this.top + delta));
3717
- if (next === this.top && this.follow) return;
3718
- this.top = next;
3719
- if (next >= max) this.refollow();
3720
- else this.unfollow();
3820
+ if (!key.ctrl && !key.meta && key.sequence && /^[^\u0000-\u001f\u007f]+$/.test(key.sequence)) {
3821
+ dialog.query += key.sequence;
3822
+ dialog.selected = 0;
3823
+ return "changed";
3721
3824
  }
3722
- pageUp() {
3723
- this.scroll(-Math.max(1, this.height - 1));
3825
+ return "same";
3826
+ }
3827
+ var BOX = { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" };
3828
+ function renderDialog(dialog, rows, columns) {
3829
+ const width = Math.max(24, Math.min(columns - 4, 84));
3830
+ const inner = width - 4;
3831
+ const maxBody = Math.max(3, rows - 6);
3832
+ const body2 = [];
3833
+ if (dialog.kind === "pick") {
3834
+ const shown = filterItems(dialog.items, dialog.query);
3835
+ const listRows = Math.max(1, Math.min(shown.length, maxBody - 2));
3836
+ const start = Math.max(0, Math.min(dialog.selected - listRows + 1, shown.length - listRows));
3837
+ body2.push(`${paint.accent("search")} ${dialog.query}${paint.invert(" ")}`);
3838
+ body2.push("");
3839
+ if (!shown.length) body2.push(paint.muted("nothing matches"));
3840
+ for (const [i, item] of shown.slice(start, start + listRows).entries()) {
3841
+ const isSelected = start + i === dialog.selected;
3842
+ const mark = isSelected ? paint.accent("\u25B8") : " ";
3843
+ const label2 = isSelected ? paint.bold(item.label) : item.label;
3844
+ body2.push(truncate(`${mark} ${label2}${item.hint ? ` ${paint.muted(item.hint)}` : ""}`, inner));
3845
+ }
3846
+ if (shown.length > listRows) body2.push(paint.dim(`\u2026 ${shown.length - listRows} more; type to narrow`));
3847
+ } else {
3848
+ const visible = Math.min(dialog.lines.length, maxBody);
3849
+ dialog.scroll = Math.max(0, Math.min(dialog.scroll, dialog.lines.length - visible));
3850
+ for (const line of dialog.lines.slice(dialog.scroll, dialog.scroll + visible)) body2.push(truncate(line, inner));
3851
+ if (dialog.lines.length > visible) {
3852
+ body2.push(paint.dim(`\u2026 ${dialog.scroll + visible} of ${dialog.lines.length} lines \xB7 \u2191\u2193 to scroll`));
3853
+ }
3724
3854
  }
3725
- pageDown() {
3726
- this.scroll(Math.max(1, this.height - 1));
3855
+ const title = ` ${dialog.title} `;
3856
+ const rule = BOX.h.repeat(Math.max(0, width - 2 - title.length - 1));
3857
+ const lines = [
3858
+ paint.dim(`${BOX.tl}${BOX.h}`) + paint.bold(title) + paint.dim(`${rule}${BOX.tr}`),
3859
+ ...body2.map((line) => `${paint.dim(BOX.v)} ${pad3(truncate(line, inner), inner)} ${paint.dim(BOX.v)}`),
3860
+ paint.dim(`${BOX.bl}${BOX.h.repeat(width - 2)}${BOX.br}`)
3861
+ ];
3862
+ const top = Math.max(0, Math.floor((rows - lines.length) / 2));
3863
+ const left = Math.max(0, Math.floor((columns - width) / 2));
3864
+ return { top, left, lines };
3865
+ }
3866
+ function pad3(text, width) {
3867
+ const short = width - visibleWidth(text);
3868
+ return short > 0 ? `${text}${" ".repeat(short)}` : text;
3869
+ }
3870
+
3871
+ // packages/tui/src/screen.ts
3872
+ import { EventEmitter } from "node:events";
3873
+ var ESC4 = "\x1B";
3874
+ var ALT_SCREEN_ON = `${ESC4}[?1049h`;
3875
+ var ALT_SCREEN_OFF = `${ESC4}[?1049l`;
3876
+ var CURSOR_HIDE = `${ESC4}[?25l`;
3877
+ var CURSOR_SHOW = `${ESC4}[?25h`;
3878
+ var PASTE_ON = `${ESC4}[?2004h`;
3879
+ var PASTE_OFF = `${ESC4}[?2004l`;
3880
+ var MOUSE_ON = `${ESC4}[?1000h${ESC4}[?1006h`;
3881
+ var MOUSE_OFF = `${ESC4}[?1006l${ESC4}[?1000l`;
3882
+ var CLEAR = `${ESC4}[2J`;
3883
+ var RESET = `${ESC4}[0m`;
3884
+ var at = (row, col) => `${ESC4}[${row + 1};${col + 1}H`;
3885
+ var Screen = class extends EventEmitter {
3886
+ constructor(out = process.stdout, input = process.stdin, opts = {}) {
3887
+ super();
3888
+ this.out = out;
3889
+ this.input = input;
3890
+ this.opts = opts;
3727
3891
  }
3728
- home() {
3729
- this.top = 0;
3730
- if (this.total > this.height) this.unfollow();
3892
+ last = null;
3893
+ lastSize = "";
3894
+ entered = false;
3895
+ decoder = new KeyDecoder();
3896
+ get rows() {
3897
+ const n = this.opts.rows ?? this.out.rows;
3898
+ return typeof n === "number" && n > 0 ? n : 24;
3731
3899
  }
3732
- end() {
3733
- this.refollow();
3734
- this.top = Math.max(0, this.total - this.height);
3900
+ get columns() {
3901
+ const n = this.opts.columns ?? this.out.columns;
3902
+ return typeof n === "number" && n > 0 ? n : 80;
3735
3903
  }
3736
- /** The slice of `lines` on screen. */
3737
- slice(lines) {
3738
- return lines.slice(this.top, this.top + this.height);
3904
+ /** Take the terminal: alternate screen, raw keys, bracketed paste, the mouse if asked. */
3905
+ enter() {
3906
+ if (this.entered) return;
3907
+ this.entered = true;
3908
+ this.out.write(`${ALT_SCREEN_ON}${CURSOR_HIDE}${CLEAR}${PASTE_ON}${this.opts.mouse ? MOUSE_ON : ""}`);
3909
+ try {
3910
+ this.input.setRawMode?.(true);
3911
+ } catch {
3912
+ }
3913
+ this.input.setEncoding?.("utf8");
3914
+ this.input.on("data", this.onData);
3915
+ this.input.on("end", this.onEnd);
3916
+ this.input.resume();
3917
+ this.out.on?.("resize", this.onResize);
3918
+ process.once("exit", this.leave);
3739
3919
  }
3740
- unfollow() {
3741
- if (this.follow) this.anchor = this.total;
3742
- this.follow = false;
3920
+ /** Give the terminal back, in the reverse order. Safe to call more than once. */
3921
+ leave = () => {
3922
+ if (!this.entered) return;
3923
+ this.entered = false;
3924
+ process.off("exit", this.leave);
3925
+ this.out.off?.("resize", this.onResize);
3926
+ this.input.off("data", this.onData);
3927
+ this.input.off("end", this.onEnd);
3928
+ try {
3929
+ this.input.pause();
3930
+ this.input.setRawMode?.(false);
3931
+ } catch {
3932
+ }
3933
+ this.out.write(`${this.opts.mouse ? MOUSE_OFF : ""}${PASTE_OFF}${RESET}${CURSOR_SHOW}${ALT_SCREEN_OFF}`);
3934
+ this.last = null;
3935
+ };
3936
+ /**
3937
+ * Draw a whole frame, writing only the rows that changed.
3938
+ *
3939
+ * `frame` is read as `rows` lines; missing ones are blank, extra ones are
3940
+ * dropped. A size change since the last paint clears and redraws everything,
3941
+ * because a shorter line leaves the old one's tail where a diff cannot see it.
3942
+ */
3943
+ paint(frame, cursor = null) {
3944
+ if (!this.entered) return;
3945
+ const rows = this.rows;
3946
+ const columns = this.columns;
3947
+ const size = `${rows}x${columns}`;
3948
+ let out = CURSOR_HIDE;
3949
+ if (!this.last || this.lastSize !== size) {
3950
+ out += CLEAR;
3951
+ this.last = new Array(rows).fill(null);
3952
+ this.lastSize = size;
3953
+ }
3954
+ for (let r = 0; r < rows; r++) {
3955
+ const line = fit(frame[r] ?? "", columns);
3956
+ if (this.last[r] === line) continue;
3957
+ this.last[r] = line;
3958
+ out += `${at(r, 0)}${line}${RESET}`;
3959
+ }
3960
+ if (cursor) out += `${at(Math.min(cursor.row, rows - 1), Math.min(cursor.col, columns - 1))}${CURSOR_SHOW}`;
3961
+ this.out.write(out);
3743
3962
  }
3744
- refollow() {
3745
- this.follow = true;
3963
+ /** Forget the last frame, so the next paint redraws every row. For ctrl-l. */
3964
+ invalidate() {
3965
+ this.last = null;
3746
3966
  }
3967
+ onData = (chunk) => {
3968
+ const keys = this.decoder.feed(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
3969
+ if (keys.length) this.emit("keys", keys);
3970
+ };
3971
+ onEnd = () => {
3972
+ this.emit("end");
3973
+ };
3974
+ onResize = () => {
3975
+ this.last = null;
3976
+ this.emit("resize");
3977
+ };
3747
3978
  };
3979
+ function fit(line, width) {
3980
+ const cut = truncate(line, width);
3981
+ const short = width - visibleWidth(cut);
3982
+ return short > 0 ? `${cut}${" ".repeat(short)}` : cut;
3983
+ }
3748
3984
 
3749
3985
  // packages/tui/src/heatmap.ts
3750
3986
  var INDENT = " ";
@@ -3903,8 +4139,8 @@ var Prompt = class {
3903
4139
  this.busyLabel = null;
3904
4140
  this.editor.reset();
3905
4141
  this.draw();
3906
- return new Promise((resolve2) => {
3907
- this.resolve = resolve2;
4142
+ return new Promise((resolve3) => {
4143
+ this.resolve = resolve3;
3908
4144
  });
3909
4145
  }
3910
4146
  /** Put the box in its working state, or take it out of one. */
@@ -4022,11 +4258,11 @@ var Prompt = class {
4022
4258
  this.finish({ kind: "exit" });
4023
4259
  }
4024
4260
  finish(result) {
4025
- const resolve2 = this.resolve;
4261
+ const resolve3 = this.resolve;
4026
4262
  this.resolve = null;
4027
4263
  this.label = null;
4028
4264
  this.renderer.clear();
4029
- resolve2?.(result);
4265
+ resolve3?.(result);
4030
4266
  }
4031
4267
  };
4032
4268
 
@@ -4227,11 +4463,11 @@ var Auth = class {
4227
4463
  }
4228
4464
  };
4229
4465
  function sleep(ms, signal) {
4230
- return new Promise((resolve2) => {
4231
- const timer = setTimeout(resolve2, ms);
4466
+ return new Promise((resolve3) => {
4467
+ const timer = setTimeout(resolve3, ms);
4232
4468
  signal?.addEventListener("abort", () => {
4233
4469
  clearTimeout(timer);
4234
- resolve2();
4470
+ resolve3();
4235
4471
  }, { once: true });
4236
4472
  });
4237
4473
  }
@@ -4271,11 +4507,18 @@ function stateDir() {
4271
4507
  function projectDir(root) {
4272
4508
  return join6(root, ".labd");
4273
4509
  }
4274
- function loadSettings(root, overrides = {}, opts = {}) {
4510
+ var GLOBAL_ONLY = ["serverUrl", "checkpoints", "effort"];
4511
+ function ignoredSettingsNote(keys) {
4512
+ return `ignoring ${keys.join(", ")} in .labd/settings.json - set ${keys.length > 1 ? "them" : "it"} in ~/.labd/settings.json instead`;
4513
+ }
4514
+ function loadSettings(root, overrides = {}) {
4275
4515
  const global = read(join6(stateDir(), "settings.json"));
4276
4516
  const project = read(join6(projectDir(root), "settings.json"));
4277
- const projectServerUrl = asString(project.serverUrl);
4278
- const honourProjectUrl = opts.trusted === true;
4517
+ const settings = resolve(global, project, overrides);
4518
+ const ignored = GLOBAL_ONLY.filter((key) => project[key] !== void 0 && project[key] !== settings[key]);
4519
+ return ignored.length ? { ...settings, ignoredProjectKeys: [...ignored] } : settings;
4520
+ }
4521
+ function resolve(global, project, overrides) {
4279
4522
  return {
4280
4523
  // Global, then the command line. The project is deliberately not consulted:
4281
4524
  // a checked-in file must not be able to turn approvals off for whoever runs
@@ -4286,15 +4529,14 @@ function loadSettings(root, overrides = {}, opts = {}) {
4286
4529
  // settings file is not a choice, which is why this tests the parse rather
4287
4530
  // than the presence of the key.
4288
4531
  modeChosen: overrides.mode !== void 0 || AGENT_MODES.includes(global.mode) || global.mode === "approve",
4289
- effort: overrides.effort ?? pickEffort(project.effort ?? global.effort),
4532
+ effort: overrides.effort ?? pickEffort(global.effort),
4290
4533
  serverUrl: stripSlash(
4291
- overrides.serverUrl ?? (honourProjectUrl ? projectServerUrl : void 0) ?? asString(global.serverUrl) ?? process.env.LABD_SERVER_URL ?? DEFAULTS.serverUrl
4534
+ overrides.serverUrl ?? asString(global.serverUrl) ?? process.env.LABD_SERVER_URL ?? DEFAULTS.serverUrl
4292
4535
  ),
4293
- ...projectServerUrl && !honourProjectUrl ? { ignoredProjectServerUrl: projectServerUrl } : {},
4294
4536
  commandTimeoutSeconds: clampTimeout(
4295
4537
  overrides.commandTimeoutSeconds ?? asNumber(project.commandTimeoutSeconds) ?? asNumber(global.commandTimeoutSeconds) ?? DEFAULTS.commandTimeoutSeconds
4296
4538
  ),
4297
- checkpoints: overrides.checkpoints ?? asBoolean(project.checkpoints) ?? asBoolean(global.checkpoints) ?? DEFAULTS.checkpoints,
4539
+ checkpoints: overrides.checkpoints ?? asBoolean(global.checkpoints) ?? DEFAULTS.checkpoints,
4298
4540
  // Screen preferences. Harmless from either file: neither changes what labd
4299
4541
  // may do, only how the session is drawn.
4300
4542
  mouse: overrides.mouse ?? asBoolean(project.mouse) ?? asBoolean(global.mouse) ?? DEFAULTS.mouse,
@@ -4338,6 +4580,7 @@ var BUILTIN_COMMANDS = [
4338
4580
  { name: "rename", args: "<name>", what: "name this session, so /sessions and --resume show it" },
4339
4581
  { name: "new", what: "start a fresh session; this one stays saved" },
4340
4582
  { name: "usage", what: "what's left of this month's allowance, and your activity" },
4583
+ { name: "topup", what: "open the page to top up or use a saved reset" },
4341
4584
  { name: "trust", what: "let this project load its own extensions" },
4342
4585
  { name: "logout", what: "sign this machine out and leave" },
4343
4586
  { name: "reload", what: "reload extensions and the file list" },
@@ -4479,7 +4722,7 @@ async function createRuntime(opts) {
4479
4722
  const root = opts.root;
4480
4723
  const state = stateDir();
4481
4724
  const trusted = isTrusted(state, root);
4482
- const settings = loadSettings(root, opts.overrides, { trusted });
4725
+ const settings = loadSettings(root, opts.overrides);
4483
4726
  const auth = new Auth(state, settings.serverUrl);
4484
4727
  const client = new AgentClient({
4485
4728
  baseUrl: settings.serverUrl,
@@ -4556,6 +4799,44 @@ import { basename as basename4 } from "node:path";
4556
4799
  // packages/cli/src/commands.ts
4557
4800
  import { dirname as dirname3 } from "node:path";
4558
4801
 
4802
+ // packages/cli/src/browser.ts
4803
+ import { spawn as spawn2 } from "node:child_process";
4804
+ import { win32 } from "node:path";
4805
+ var PLAIN_URL = /^[a-z0-9.:/-]+$/;
4806
+ function launcherFor(platform, url, env = process.env) {
4807
+ const safe2 = safeTopUpUrl(url);
4808
+ if (!safe2 || !PLAIN_URL.test(safe2)) return null;
4809
+ if (platform === "darwin") return { command: "open", args: [safe2] };
4810
+ if (platform === "win32") {
4811
+ const cmd = env.ComSpec ?? win32.join(env.SystemRoot ?? "C:\\Windows", "System32", "cmd.exe");
4812
+ return { command: cmd, args: ["/c", "start", '""', safe2] };
4813
+ }
4814
+ return { command: "xdg-open", args: [safe2] };
4815
+ }
4816
+ function openInBrowser(url) {
4817
+ const launcher = launcherFor(process.platform, url);
4818
+ return launcher ? launch(launcher) : Promise.resolve(false);
4819
+ }
4820
+ function launch(launcher) {
4821
+ return new Promise((resolve3) => {
4822
+ try {
4823
+ const child = spawn2(launcher.command, launcher.args, {
4824
+ detached: true,
4825
+ stdio: "ignore",
4826
+ // Node would quote the "" title into \"\"; cmd needs it as typed.
4827
+ windowsVerbatimArguments: process.platform === "win32"
4828
+ });
4829
+ child.once("error", () => resolve3(false));
4830
+ child.once("spawn", () => {
4831
+ child.unref();
4832
+ resolve3(true);
4833
+ });
4834
+ } catch {
4835
+ resolve3(false);
4836
+ }
4837
+ });
4838
+ }
4839
+
4559
4840
  // packages/cli/src/files.ts
4560
4841
  import { readdirSync as readdirSync6 } from "node:fs";
4561
4842
  import { join as join8, relative as relative4 } from "node:path";
@@ -4655,6 +4936,7 @@ async function handleCommand(input, ctx) {
4655
4936
  print(` ${paint.muted("Couldn't check that just now.")}`);
4656
4937
  } else {
4657
4938
  print(` ${paint.bold(`${credits.percentLeft}%`)} ${paint.muted("of this month left")}`);
4939
+ printAllowance(credits, print);
4658
4940
  }
4659
4941
  if (activity && !activity.days.length) {
4660
4942
  print(` ${paint.muted("No activity yet. Ask labd something and it shows up here.")}`);
@@ -4664,7 +4946,23 @@ async function handleCommand(input, ctx) {
4664
4946
  } else if (credits) {
4665
4947
  print(` ${paint.muted("Couldn't read your activity just now.")}`);
4666
4948
  }
4667
- return {};
4949
+ return credits ? { credits } : {};
4950
+ }
4951
+ case "topup": {
4952
+ const credits = await runtime.client.credits();
4953
+ if (!credits) {
4954
+ print(` ${paint.muted("Couldn't check that just now.")}`);
4955
+ return {};
4956
+ }
4957
+ const url = safeTopUpUrl(credits.topUpUrl);
4958
+ if (!url) {
4959
+ print(` ${paint.muted("This server didn't send a top-up link. Top up from labd in your portal settings.")}`);
4960
+ return { credits };
4961
+ }
4962
+ printAllowance(credits, print);
4963
+ const opened = await (ctx.openUrl ?? openInBrowser)(url);
4964
+ print(` ${paint.muted(opened ? "Opening it in your browser." : "Open this link in your browser.")}`);
4965
+ return { credits };
4668
4966
  }
4669
4967
  case "tree": {
4670
4968
  const nodes = runtime.session.all();
@@ -4843,6 +5141,12 @@ async function handleCommand(input, ctx) {
4843
5141
  }
4844
5142
  }
4845
5143
  }
5144
+ function printAllowance(credits, print) {
5145
+ const facts = allowanceFacts(credits);
5146
+ if (facts) print(` ${paint.muted(facts)}`);
5147
+ const url = safeTopUpUrl(credits.topUpUrl);
5148
+ if (url) print(` ${paint.muted("top up or use a saved reset at")} ${paint.accent(url)}`);
5149
+ }
4846
5150
  function summarise(body2) {
4847
5151
  const text = typeof body2.text === "string" ? body2.text : "";
4848
5152
  if (body2.kind === "tool") {
@@ -4960,7 +5264,15 @@ async function runInteractive(runtime) {
4960
5264
  credits: null,
4961
5265
  tick: 0
4962
5266
  };
5267
+ const opening = runtime.client.credits();
4963
5268
  await banner(renderer, runtime, state.mode);
5269
+ state.credits = await opening;
5270
+ if (isOutOfCredits(state.credits)) {
5271
+ const url = safeTopUpUrl(state.credits?.topUpUrl);
5272
+ renderer.print(` ${paint.red("Out of labd for this month.")}${url ? ` ${paint.muted("Top up or use a saved reset at")} ${paint.accent(url)}` : ""}`);
5273
+ renderer.print(` ${paint.dim("/topup opens it in your browser.")}`);
5274
+ renderer.print("");
5275
+ }
4964
5276
  let abort = null;
4965
5277
  const prompt = new Prompt(renderer, {
4966
5278
  describe: (line) => describe(line, runtime),
@@ -4969,7 +5281,7 @@ async function runInteractive(runtime) {
4969
5281
  project: basename4(runtime.root),
4970
5282
  branch: runtime.branch,
4971
5283
  contextPercent: state.context?.percentUsed ?? null,
4972
- creditsPercent: state.credits?.percentLeft ?? null,
5284
+ credits: state.credits,
4973
5285
  mode: state.mode,
4974
5286
  effort: state.effort ?? null
4975
5287
  }),
@@ -5022,6 +5334,7 @@ async function runInteractive(runtime) {
5022
5334
  if (outcome.mode) state.mode = outcome.mode;
5023
5335
  if (outcome.effort !== void 0) state.effort = outcome.effort;
5024
5336
  if (outcome.moved) state.context = null;
5337
+ if (outcome.credits) state.credits = outcome.credits;
5025
5338
  continue;
5026
5339
  }
5027
5340
  const tags = taggedPaths(input);
@@ -5093,9 +5406,19 @@ async function runInteractive(runtime) {
5093
5406
  for (const line of waiting) renderer.print(line);
5094
5407
  }
5095
5408
  },
5096
- error(message) {
5409
+ error(message, info2) {
5097
5410
  stopSpinner();
5098
- renderer.print(`${paint.red(" labd:")} ${message}`);
5411
+ if (info2?.code !== "no_credits") {
5412
+ renderer.print(`${paint.red(" labd:")} ${message}`);
5413
+ return;
5414
+ }
5415
+ if (info2.credits) state.credits = info2.credits;
5416
+ const url = safeTopUpUrl(info2.credits?.topUpUrl);
5417
+ const lines = noCreditsLines({ message, credits: info2.credits, url, howToOpen: "/topup opens it in your browser.", width: renderer.contentWidth });
5418
+ for (const line of lines) renderer.print(line);
5419
+ },
5420
+ ready(info2) {
5421
+ if (info2.credits) state.credits = info2.credits;
5099
5422
  },
5100
5423
  done(info2) {
5101
5424
  if (info2.context) state.context = info2.context;
@@ -5171,10 +5494,10 @@ async function banner(renderer, runtime, mode) {
5171
5494
  renderer.print("");
5172
5495
  renderer.print(`${paint.accent(FACE)} ${paint.bold("labd")} ${paint.dim(`in ${where}${runtime.branch ? ` on ${runtime.branch}` : ""}`)}`);
5173
5496
  renderer.print(` ${paint.dim(MODE_CAPTION[mode])}`);
5174
- if (runtime.settings.ignoredProjectServerUrl) {
5497
+ if (runtime.settings.ignoredProjectKeys) {
5175
5498
  renderer.print(
5176
5499
  ` ${paint.yellow("!")} ${paint.muted(
5177
- `ignoring serverUrl in .labd/settings.json (${runtime.settings.ignoredProjectServerUrl}) - this project isn't trusted; \`labd trust\` if you meant it`
5500
+ ignoredSettingsNote(runtime.settings.ignoredProjectKeys)
5178
5501
  )}`
5179
5502
  );
5180
5503
  }
@@ -5184,7 +5507,7 @@ async function banner(renderer, runtime, mode) {
5184
5507
  renderer.print("");
5185
5508
  }
5186
5509
  function sleep2(ms) {
5187
- return new Promise((resolve2) => setTimeout(resolve2, ms));
5510
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
5188
5511
  }
5189
5512
 
5190
5513
  // packages/cli/src/modes/screen.ts
@@ -5297,6 +5620,14 @@ async function runScreen(runtime, opts) {
5297
5620
  effort: runtime.settings.effort,
5298
5621
  context: null,
5299
5622
  credits: null,
5623
+ /** Bumped on every new reading, so a fetch that started before one does not overwrite it. */
5624
+ creditsVersion: 0,
5625
+ /** When a locked-out box last asked the server again. See shouldRefreshCredits. */
5626
+ creditsAskedAt: null,
5627
+ /** A turn was refused and no reading has arrived since: locked whatever the clock says. See boxLocked. */
5628
+ refusedLock: false,
5629
+ /** Fires when a spent allowance resets, so the box opens without a key. */
5630
+ resetTimer: null,
5300
5631
  sidebar: opts.sidebar,
5301
5632
  tick: 0,
5302
5633
  /** What labd is doing right now, for the footer. Null when idle. */
@@ -5355,8 +5686,8 @@ async function runScreen(runtime, opts) {
5355
5686
  schedule();
5356
5687
  };
5357
5688
  let resolveExit = null;
5358
- const exited = new Promise((resolve2) => {
5359
- resolveExit = resolve2;
5689
+ const exited = new Promise((resolve3) => {
5690
+ resolveExit = resolve3;
5360
5691
  });
5361
5692
  const quit = () => {
5362
5693
  if (state.quitting) return;
@@ -5442,6 +5773,7 @@ async function runScreen(runtime, opts) {
5442
5773
  else if (state.approval) left = `${paint.yellow("y")} ${d("allow \xB7")} ${paint.yellow("n")} ${d("decline \xB7 ctrl+c stop")}`;
5443
5774
  else if (state.busy) left = `${paint.accent(state.busy)}${spinnerFrame(state.tick)} ${d("enter queues \xB7 ctrl+c stop \xB7 pgup/pgdn scroll")}`;
5444
5775
  else if (state.popup) left = d("\u2191\u2193 move \xB7 tab/enter accept \xB7 esc close");
5776
+ else if (outOfCredits() && editor.isEmpty) left = `${paint.yellow("o")} ${d("open the top-up page \xB7 /topup \xB7 /usage \xB7 ctrl+d quit")}`;
5445
5777
  else {
5446
5778
  const described = describe(editor.currentLine, runtime);
5447
5779
  left = described.hint ? described.hint : d("enter send \xB7 shift+tab mode \xB7 / commands \xB7 @ files \xB7 pgup/pgdn scroll \xB7 ctrl+c stop \xB7 ctrl+d quit");
@@ -5451,13 +5783,13 @@ async function runScreen(runtime, opts) {
5451
5783
  const crowded = state.context.percentUsed >= 75;
5452
5784
  bits.push((crowded ? paint.yellow : paint.muted)(`context ${state.context.percentUsed}%`));
5453
5785
  }
5454
- if (state.credits) bits.push(paint.muted(`credits ${state.credits.percentLeft}%`));
5786
+ if (state.credits) bits.push(creditsReading(state.credits, `credits ${state.credits.percentLeft}%`));
5455
5787
  return spread(` ${left}`, `${bits.join(paint.dim(" \xB7 "))} `, width);
5456
5788
  }
5457
5789
  function composer2(width, height) {
5458
5790
  const inner = width - 4;
5459
5791
  const textRows = Math.max(1, height - 2);
5460
- const colour = state.approval ? paint.yellow : state.busy ? paint.accent : borderColour2(describe(editor.currentLine, runtime).state);
5792
+ const colour = state.approval ? paint.yellow : state.busy ? paint.accent : outOfCredits() && editor.isEmpty ? paint.red : borderColour2(describe(editor.currentLine, runtime).state);
5461
5793
  const out = [];
5462
5794
  out.push(colour(`\u256D${"\u2500".repeat(Math.max(0, width - 2))}\u256E`));
5463
5795
  const view = editor.view(inner - 2, textRows);
@@ -5468,7 +5800,7 @@ async function runScreen(runtime, opts) {
5468
5800
  const sigil = first ? state.busy ? paint.dim(">") : paint.accent(">") : " ";
5469
5801
  let text = line;
5470
5802
  if (editor.isEmpty && i === 0) {
5471
- text = state.approval ? paint.yellow("y to allow, n or esc to decline") : state.busy ? paint.dim("labd is working - type to queue a message, ctrl+c stops it") : paint.dim("ask labd something - / for commands - @ to tag a file");
5803
+ text = state.approval ? paint.yellow("y to allow, n or esc to decline") : state.busy ? paint.dim("labd is working - type to queue a message, ctrl+c stops it") : outOfCredits() ? paint.red(OUT_OF_CREDITS_PLACEHOLDER) : paint.dim("ask labd something - / for commands - @ to tag a file");
5472
5804
  }
5473
5805
  out.push(`${colour("\u2502")} ${sigil} ${fit(text, inner - 2)} ${colour("\u2502")}`);
5474
5806
  });
@@ -5554,6 +5886,10 @@ async function runScreen(runtime, opts) {
5554
5886
  refreshPopup();
5555
5887
  return schedule();
5556
5888
  }
5889
+ if (spentBeforeTurn(state.credits, Date.now())) refreshWhileOut();
5890
+ const out = keyWhileOut(key, { out: outOfCredits(), boxEmpty: editor.isEmpty, popup: state.popup !== null, approval: state.approval !== null });
5891
+ if (out === "open") return void openTopUp();
5892
+ if (out === "drop") return refreshWhileOut();
5557
5893
  const outcome = editor.apply(key);
5558
5894
  if (outcome === "unhandled") {
5559
5895
  if (key.name === "return") return submit();
@@ -5735,9 +6071,10 @@ async function runScreen(runtime, opts) {
5735
6071
  mode: state.mode,
5736
6072
  effort: state.effort,
5737
6073
  asked: state.asked,
5738
- width: layout({ rows: screen.rows, columns: screen.columns, editorLines: editor.lineCount, sidebar: state.sidebar }).transcript.width,
6074
+ width: transcriptWidth(),
5739
6075
  print: (text) => note(text)
5740
6076
  });
6077
+ if (outcome.credits) setCredits(outcome.credits);
5741
6078
  if (outcome.farewell) state.farewell = outcome.farewell;
5742
6079
  if (outcome.exit) return quit();
5743
6080
  if (outcome.mode) {
@@ -5848,8 +6185,8 @@ async function runScreen(runtime, opts) {
5848
6185
  state.answer = null;
5849
6186
  const tags = taggedPaths(input);
5850
6187
  const attachments = tags.length ? attachTagged(runtime.root, tags) : void 0;
5851
- for (const refused of attachments?.filter((a) => a.omitted) ?? []) {
5852
- note(` ${paint.yellow("!")} ${paint.muted(`${refused.path}: ${refused.omitted}`)}`);
6188
+ for (const refused2 of attachments?.filter((a) => a.omitted) ?? []) {
6189
+ note(` ${paint.yellow("!")} ${paint.muted(`${refused2.path}: ${refused2.omitted}`)}`);
5853
6190
  }
5854
6191
  for (const handler of runtime.extensions.onTurn) {
5855
6192
  try {
@@ -5910,19 +6247,23 @@ async function runScreen(runtime, opts) {
5910
6247
  closeAnswer();
5911
6248
  const entry = transcript.add({ kind: "approval", call, preview, helper, mode: effectiveMode(), answer: null });
5912
6249
  viewport.end();
5913
- return new Promise((resolve2) => {
5914
- state.approval = { entry, resolve: resolve2 };
6250
+ return new Promise((resolve3) => {
6251
+ state.approval = { entry, resolve: resolve3 };
5915
6252
  schedule();
5916
6253
  });
5917
6254
  },
5918
- error(message) {
6255
+ error(message, info2) {
5919
6256
  setBusy(null);
5920
- transcript.add({ kind: "error", text: message });
6257
+ if (info2?.code === "no_credits") refused(message, info2);
6258
+ else transcript.add({ kind: "error", text: message });
5921
6259
  schedule();
5922
6260
  },
6261
+ ready(info2) {
6262
+ if (info2.credits) setCredits(info2.credits);
6263
+ },
5923
6264
  done(info2) {
5924
6265
  if (info2.context) state.context = info2.context;
5925
- if (info2.credits) state.credits = info2.credits;
6266
+ if (info2.credits) setCredits(info2.credits);
5926
6267
  schedule();
5927
6268
  },
5928
6269
  commandOutput(line, key) {
@@ -5970,6 +6311,64 @@ async function runScreen(runtime, opts) {
5970
6311
  setBusy(null);
5971
6312
  }
5972
6313
  }
6314
+ function outOfCredits() {
6315
+ return boxLocked(state.credits, Date.now(), state.refusedLock);
6316
+ }
6317
+ function setCredits(credits) {
6318
+ const wasSpent = state.refusedLock || isOutOfCredits(state.credits);
6319
+ state.credits = credits;
6320
+ state.creditsVersion++;
6321
+ state.refusedLock = false;
6322
+ if (wasSpent && !isOutOfCredits(credits)) note(` ${paint.green("labd is back:")} ${paint.muted(`${credits.percentLeft}% left`)}`);
6323
+ armResetTimer();
6324
+ schedule();
6325
+ }
6326
+ function armResetTimer() {
6327
+ if (state.resetTimer) clearTimeout(state.resetTimer);
6328
+ state.resetTimer = null;
6329
+ if (state.quitting) return;
6330
+ const wait = untilReset(state.credits, Date.now());
6331
+ if (wait === null) return;
6332
+ state.resetTimer = setTimeout(() => {
6333
+ state.resetTimer = null;
6334
+ fetchCredits();
6335
+ schedule();
6336
+ }, wait);
6337
+ state.resetTimer.unref();
6338
+ }
6339
+ function fetchCredits() {
6340
+ const version = state.creditsVersion;
6341
+ void runtime.client.credits().then((credits) => {
6342
+ if (credits && state.creditsVersion === version) setCredits(credits);
6343
+ });
6344
+ }
6345
+ function refreshWhileOut() {
6346
+ const now = Date.now();
6347
+ if (!shouldRefreshCredits(state.creditsAskedAt, now)) return;
6348
+ state.creditsAskedAt = now;
6349
+ fetchCredits();
6350
+ }
6351
+ function transcriptWidth() {
6352
+ return layout({ rows: screen.rows, columns: screen.columns, editorLines: editor.lineCount, sidebar: state.sidebar }).transcript.width;
6353
+ }
6354
+ function refused(message, info2) {
6355
+ if (info2.credits) setCredits(info2.credits);
6356
+ else {
6357
+ state.creditsVersion++;
6358
+ fetchCredits();
6359
+ }
6360
+ state.refusedLock = true;
6361
+ schedule();
6362
+ const url = safeTopUpUrl(state.credits?.topUpUrl);
6363
+ const lines = noCreditsLines({ message, credits: info2.credits, url, howToOpen: "Press o to open it in your browser, or /topup.", width: transcriptWidth() });
6364
+ note(lines.join("\n"));
6365
+ }
6366
+ async function openTopUp() {
6367
+ const url = safeTopUpUrl(state.credits?.topUpUrl);
6368
+ if (!url) return void serial.submit("/topup");
6369
+ const opened = await openInBrowser(url);
6370
+ note(` ${paint.muted(opened ? "Opening in your browser:" : "Open this link in your browser:")} ${paint.accent(url)}`);
6371
+ }
5973
6372
  function closeAnswer() {
5974
6373
  const answer = state.answer;
5975
6374
  if (!answer || answer.block.done) return;
@@ -6014,10 +6413,10 @@ async function runScreen(runtime, opts) {
6014
6413
  process.on("SIGHUP", onHup);
6015
6414
  screen.enter();
6016
6415
  if (state.sidebar) refreshProjectFacts();
6017
- if (runtime.settings.ignoredProjectServerUrl) {
6416
+ if (runtime.settings.ignoredProjectKeys) {
6018
6417
  note(
6019
6418
  ` ${paint.yellow("!")} ${paint.muted(
6020
- `ignoring serverUrl in .labd/settings.json (${runtime.settings.ignoredProjectServerUrl}) - this project isn't trusted; \`labd trust\` if you meant it`
6419
+ ignoredSettingsNote(runtime.settings.ignoredProjectKeys)
6021
6420
  )}`
6022
6421
  );
6023
6422
  }
@@ -6036,11 +6435,13 @@ async function runScreen(runtime, opts) {
6036
6435
  }
6037
6436
  if (opts.pickSession) openSessions();
6038
6437
  draw();
6438
+ fetchCredits();
6039
6439
  try {
6040
6440
  await exited;
6041
6441
  if (serial.running) await Promise.race([serial.running, sleep3(1500)]);
6042
6442
  } finally {
6043
6443
  if (faceTimer) clearInterval(faceTimer);
6444
+ if (state.resetTimer) clearTimeout(state.resetTimer);
6044
6445
  if (frameTimer) clearTimeout(frameTimer);
6045
6446
  setBusy(null);
6046
6447
  process.off("SIGTERM", onTerm);
@@ -6113,13 +6514,14 @@ function summarise2(body2) {
6113
6514
  return text.replace(/\s+/g, " ").slice(0, 60);
6114
6515
  }
6115
6516
  function sleep3(ms) {
6116
- return new Promise((resolve2) => setTimeout(resolve2, ms));
6517
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
6117
6518
  }
6118
6519
 
6119
6520
  // packages/cli/src/modes/print.ts
6120
6521
  function toStderr(text) {
6121
6522
  process.stderr.write(process.stderr.isTTY ? text : stripColor(text));
6122
6523
  }
6524
+ var EXIT_NO_CREDITS = 3;
6123
6525
  async function runPrint(runtime, opts) {
6124
6526
  const question = await withStdin(opts.question);
6125
6527
  if (!question.trim()) {
@@ -6129,6 +6531,7 @@ async function runPrint(runtime, opts) {
6129
6531
  }
6130
6532
  let failed = false;
6131
6533
  let declined = false;
6534
+ let outOfCredits = false;
6132
6535
  let answer = "";
6133
6536
  const emit = (event) => {
6134
6537
  if (opts.json) process.stdout.write(`${JSON.stringify(event)}
@@ -6164,9 +6567,10 @@ async function runPrint(runtime, opts) {
6164
6567
  }
6165
6568
  if (step.status === "skipped") declined = true;
6166
6569
  },
6167
- error(message) {
6570
+ error(message, info2) {
6168
6571
  failed = true;
6169
- if (opts.json) emit({ type: "error", message });
6572
+ if (info2?.code === "no_credits") outOfCredits = true;
6573
+ if (opts.json) emit({ type: "error", message, ...info2 });
6170
6574
  else toStderr(`${paint.red("labd:")} ${message}
6171
6575
  `);
6172
6576
  },
@@ -6195,6 +6599,7 @@ async function runPrint(runtime, opts) {
6195
6599
  });
6196
6600
  if (!opts.json && answer && !answer.endsWith("\n")) process.stdout.write("\n");
6197
6601
  await closeConversation(runtime);
6602
+ if (outOfCredits) return EXIT_NO_CREDITS;
6198
6603
  if (failed) return 1;
6199
6604
  if (declined) return 2;
6200
6605
  return result.text.trim() ? 0 : 1;
@@ -6247,11 +6652,11 @@ async function runRpc(runtime) {
6247
6652
  return;
6248
6653
  }
6249
6654
  if (req.method === void 0 && req.id !== void 0 && req.id !== null) {
6250
- const resolve2 = pending.get(Number(req.id));
6251
- if (resolve2) {
6655
+ const resolve3 = pending.get(Number(req.id));
6656
+ if (resolve3) {
6252
6657
  pending.delete(Number(req.id));
6253
6658
  const result = req.result;
6254
- resolve2(result?.approved === true);
6659
+ resolve3(result?.approved === true);
6255
6660
  }
6256
6661
  return;
6257
6662
  }
@@ -6327,8 +6732,8 @@ async function runRpc(runtime) {
6327
6732
  output: step.output
6328
6733
  });
6329
6734
  },
6330
- error(message) {
6331
- notify("labd/error", { message });
6735
+ error(message, info2) {
6736
+ notify("labd/error", { message, ...info2 });
6332
6737
  },
6333
6738
  done(info2) {
6334
6739
  notify("labd/done", info2);
@@ -6343,15 +6748,15 @@ async function runRpc(runtime) {
6343
6748
  * asleep.
6344
6749
  */
6345
6750
  approve(call, preview, helper) {
6346
- return new Promise((resolve2) => {
6347
- if (closed) return resolve2(false);
6751
+ return new Promise((resolve3) => {
6752
+ if (closed) return resolve3(false);
6348
6753
  const id = nextId++;
6349
6754
  const timer = setTimeout(() => {
6350
- if (pending.delete(id)) resolve2(false);
6755
+ if (pending.delete(id)) resolve3(false);
6351
6756
  }, APPROVAL_TIMEOUT_MS);
6352
6757
  pending.set(id, (approved) => {
6353
6758
  clearTimeout(timer);
6354
- resolve2(approved);
6759
+ resolve3(approved);
6355
6760
  });
6356
6761
  out({ jsonrpc: "2.0", id, method: "labd/approve", params: { call, preview, ...helper ? { helper } : {} } });
6357
6762
  });
@@ -6376,14 +6781,14 @@ async function runRpc(runtime) {
6376
6781
  mode: unattendedMode(runtime.settings),
6377
6782
  root: runtime.root
6378
6783
  });
6379
- await new Promise((resolve2) => process.stdin.on("end", resolve2));
6784
+ await new Promise((resolve3) => process.stdin.on("end", resolve3));
6380
6785
  await busy;
6381
6786
  await closeConversation(runtime);
6382
6787
  return 0;
6383
6788
  }
6384
6789
 
6385
6790
  // packages/cli/src/index.ts
6386
- var VERSION = true ? "0.7.0" : "0.0.0-dev";
6791
+ var VERSION = true ? "0.8.0" : "0.0.0-dev";
6387
6792
  async function main(argv) {
6388
6793
  const args = parse(argv);
6389
6794
  if (args.color !== void 0) setColor(args.color);
@@ -6396,7 +6801,7 @@ async function main(argv) {
6396
6801
  `);
6397
6802
  return 0;
6398
6803
  }
6399
- const root = resolve(args.cwd ?? process.cwd());
6804
+ const root = resolve2(args.cwd ?? process.cwd());
6400
6805
  switch (args.command) {
6401
6806
  case "login":
6402
6807
  return login(root);
@@ -6443,7 +6848,7 @@ async function main(argv) {
6443
6848
  return runScreen(runtime, { mouse: runtime.settings.mouse, sidebar: runtime.settings.sidebar, pickSession: args.pickSession });
6444
6849
  }
6445
6850
  async function login(root) {
6446
- const settings = loadSettings(root, {}, { trusted: isTrusted(stateDir(), root) });
6851
+ const settings = loadSettings(root);
6447
6852
  const auth = new Auth(stateDir(), settings.serverUrl);
6448
6853
  const say = (text) => process.stderr.write(text);
6449
6854
  say(`
@@ -6474,7 +6879,7 @@ ${paint.red("labd:")} ${result.reason ?? "that did not complete"}.
6474
6879
  return 0;
6475
6880
  }
6476
6881
  async function logout(root) {
6477
- const settings = loadSettings(root, {}, { trusted: isTrusted(stateDir(), root) });
6882
+ const settings = loadSettings(root);
6478
6883
  const hadStored = await new Auth(stateDir(), settings.serverUrl).signOut();
6479
6884
  for (const line of signedOutMessage(hadStored, Boolean(process.env.LABD_TOKEN)).split("\n")) {
6480
6885
  process.stderr.write(` ${paint.muted(line)}
@@ -6504,7 +6909,7 @@ function trustHere(root) {
6504
6909
  trust(stateDir(), root);
6505
6910
  process.stdout.write(` ${paint.bold("trusted")} ${paint.muted(root)}
6506
6911
  `);
6507
- process.stdout.write(` ${paint.dim("Extensions in .labd/extensions, and a serverUrl in .labd/settings.json, will now be honoured here.")}
6912
+ process.stdout.write(` ${paint.dim("Extensions in .labd/extensions will now load here.")}
6508
6913
  `);
6509
6914
  return 0;
6510
6915
  }