@thedevlabs-io/labd 0.6.1 → 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,120 @@
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
+
6
+ // vendor/labd/packages/protocol/src/activity.ts
7
+ var ACTIVITY_WEEKS = 53;
8
+ function utcOffsetMinutes(at2 = /* @__PURE__ */ new Date()) {
9
+ return -at2.getTimezoneOffset();
10
+ }
11
+ var DAY_MS = 864e5;
12
+ function localDate(at2 = /* @__PURE__ */ new Date()) {
13
+ const pad4 = (n) => String(n).padStart(2, "0");
14
+ return `${at2.getFullYear()}-${pad4(at2.getMonth() + 1)}-${pad4(at2.getDate())}`;
15
+ }
16
+ function dayNumber(date) {
17
+ return Math.floor(Date.parse(`${date}T00:00:00Z`) / DAY_MS);
18
+ }
19
+ function dateOf(day) {
20
+ return new Date(day * DAY_MS).toISOString().slice(0, 10);
21
+ }
22
+ function summariseActivity(activity, weeks, today = localDate()) {
23
+ const span = Math.max(1, Math.min(ACTIVITY_WEEKS, Math.floor(weeks)));
24
+ const last = dayNumber(today);
25
+ const monday = last - (last + 3) % 7;
26
+ const first = monday - (span - 1) * 7;
27
+ const earliest = monday - (ACTIVITY_WEEKS - 1) * 7;
28
+ const perDay = /* @__PURE__ */ new Map();
29
+ const perSurface = /* @__PURE__ */ new Map();
30
+ const totals = { turns: 0, input: 0, output: 0, activeDays: 0 };
31
+ for (const bucket of activity.days) {
32
+ const day = dayNumber(bucket.day);
33
+ if (!(day >= earliest && day <= last)) continue;
34
+ const tokens = bucket.input + bucket.output;
35
+ const d = perDay.get(day) ?? { turns: 0, tokens: 0 };
36
+ d.turns += bucket.turns;
37
+ d.tokens += tokens;
38
+ perDay.set(day, d);
39
+ if (day < first) continue;
40
+ const s = perSurface.get(bucket.surface) ?? { turns: 0, tokens: 0 };
41
+ s.turns += bucket.turns;
42
+ s.tokens += tokens;
43
+ perSurface.set(bucket.surface, s);
44
+ totals.turns += bucket.turns;
45
+ totals.input += bucket.input;
46
+ totals.output += bucket.output;
47
+ }
48
+ let run = 0;
49
+ let longest = 0;
50
+ for (let day = earliest; day <= last; day++) {
51
+ if ((perDay.get(day)?.turns ?? 0) > 0) {
52
+ run += 1;
53
+ longest = Math.max(longest, run);
54
+ } else if (day !== last) {
55
+ run = 0;
56
+ }
57
+ }
58
+ const shown = [...perDay.entries()].filter(([day]) => day >= first).map(([, d]) => d.tokens);
59
+ const ranked = [...new Set(shown.filter((t) => t > 0))].sort((a, b) => a - b);
60
+ const days = [];
61
+ let busiest = null;
62
+ for (let day = first; day <= last; day++) {
63
+ const d = perDay.get(day) ?? { turns: 0, tokens: 0 };
64
+ const entry = { date: dateOf(day), turns: d.turns, tokens: d.tokens, level: levelFor(d.tokens, ranked) };
65
+ days.push(entry);
66
+ if (d.turns > 0) totals.activeDays += 1;
67
+ if (d.tokens > 0 && (!busiest || d.tokens > busiest.tokens)) busiest = entry;
68
+ }
69
+ const hours = activity.hours.length === 24 ? activity.hours : [];
70
+ const peak = Math.max(0, ...hours);
71
+ return {
72
+ days,
73
+ totals,
74
+ streak: { current: run, longest },
75
+ peakHour: peak > 0 ? hours.indexOf(peak) : null,
76
+ busiest,
77
+ surfaces: [...perSurface.entries()].map(([surface, s]) => ({ surface, ...s })).sort((a, b) => b.tokens - a.tokens || a.surface.localeCompare(b.surface))
78
+ };
79
+ }
80
+ function levelFor(tokens, ranked) {
81
+ if (!(tokens > 0)) return 0;
82
+ if (ranked.length === 1) return 4;
83
+ return 1 + Math.round(3 * ranked.indexOf(tokens) / (ranked.length - 1));
84
+ }
85
+ function formatTokens(n) {
86
+ const units = ["", "k", "M", "B"];
87
+ let value = n;
88
+ let unit = 0;
89
+ while (value >= 999.5 && unit < units.length - 1) {
90
+ value /= 1e3;
91
+ unit += 1;
92
+ }
93
+ const shown = unit === 0 || value >= 10 ? String(Math.round(value)) : value.toFixed(1).replace(/\.0$/, "");
94
+ return `${shown}${units[unit]}`;
95
+ }
96
+ function surfaceLabel(surface) {
97
+ switch (surface) {
98
+ case "web":
99
+ return "portal";
100
+ case "vscode":
101
+ return "editor";
102
+ case "cli":
103
+ return "terminal";
104
+ case "memory":
105
+ return "memory (background)";
106
+ case "api":
107
+ return "API";
108
+ case "help":
109
+ return "plans & billing help";
110
+ default:
111
+ return surface;
112
+ }
113
+ }
114
+ function formatHour(hour) {
115
+ const h = hour % 12 === 0 ? 12 : hour % 12;
116
+ return `${h}${hour < 12 ? "am" : "pm"}`;
117
+ }
5
118
 
6
119
  // vendor/labd/packages/protocol/src/tags.ts
7
120
  var TAG = /(^|[\s([])@(?:"([^"\n]+)"|([^\s"]+))/g;
@@ -75,7 +188,7 @@ function rankPaths(query, paths, limit = 12) {
75
188
  }
76
189
 
77
190
  // vendor/labd/packages/protocol/src/workspace.ts
78
- 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;
79
192
  var EXCLUDED_DIRS = [
80
193
  ".git",
81
194
  "node_modules",
@@ -90,6 +203,7 @@ var OFF_LIMITS = new RegExp(
90
203
  `(^|/)(${EXCLUDED_DIRS.map((d) => d.replace(/\./g, "\\.")).join("|")})(/|$)`,
91
204
  "i"
92
205
  );
206
+ var EDITOR_SETTINGS = /(^|\/)\.vscode\/(settings|tasks|launch|mcp)\.json$|(^|\/)\.devcontainer(\/|$)|(^|\/)\.devcontainer\.json$|\.code-workspace$/i;
93
207
  var EXCLUDED_GLOB = `**/{${EXCLUDED_DIRS.join(",")}}/**`;
94
208
  var ASSIGNMENT = /^(\s*(?:export\s+)?["']?[\w.-]+["']?\s*[:=]\s*)(.+)$/;
95
209
  function redactValues(text) {
@@ -182,8 +296,12 @@ function hasControlChars(command) {
182
296
  }
183
297
  var INTERPRETERS = /^(sh|bash|zsh|fish|dash|ksh|python[\d.]*|node|deno|bun|ruby|perl|php|osascript)$/;
184
298
  var DOWNLOADERS = /^(curl|wget|fetch|httpie|http)$/;
185
- 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_]*=/;
186
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.";
187
305
  var REFUSALS = [
188
306
  {
189
307
  // Everything after this runs as a different user, which puts it outside
@@ -251,9 +369,17 @@ var REFUSALS = [
251
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"),
252
370
  reason: "it waits for input or never exits, so there would be no output to read. Ask for a command that finishes."
253
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
+ },
254
380
  {
255
381
  test: (argv) => namesASecret(argv),
256
- 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
257
383
  },
258
384
  {
259
385
  test: (argv) => argv.length === 1 && /^(env|printenv|set)$/.test(argv[0] ?? ""),
@@ -274,9 +400,13 @@ function isBigTarget(t) {
274
400
  }
275
401
  function unwrap(argv) {
276
402
  let out = argv;
277
- 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;
278
406
  out = out.slice(1);
279
- 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
+ }
280
410
  }
281
411
  return out.length ? [bare(out[0]), ...out.slice(1)] : out;
282
412
  }
@@ -309,14 +439,115 @@ function pipesNetworkIntoShell(argv) {
309
439
  return downloader && interpreter;
310
440
  }
311
441
  function namesASecret(argv) {
312
- 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);
313
544
  }
314
545
  function bare(arg) {
315
546
  const unquoted = arg.replace(/^["']|["']$/g, "");
316
547
  const slash = Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\"));
317
548
  return slash === -1 ? unquoted : unquoted.slice(slash + 1);
318
549
  }
319
- function segments(command) {
550
+ function segments(command, literalBackslash = false) {
320
551
  const out = [];
321
552
  let argv = [];
322
553
  let token = "";
@@ -341,7 +572,7 @@ function segments(command) {
341
572
  quote = c;
342
573
  continue;
343
574
  }
344
- if (c === "\\" && i + 1 < command.length) {
575
+ if (c === "\\" && i + 1 < command.length && !literalBackslash) {
345
576
  token += command[++i];
346
577
  continue;
347
578
  }
@@ -359,6 +590,20 @@ function segments(command) {
359
590
  argv.push(c);
360
591
  continue;
361
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
+ }
362
607
  if (c === "&") {
363
608
  const standalone = token === "" && (i === 0 || command[i - 1] === " " || command[i - 1] === " ");
364
609
  if (standalone) argv.push("&");
@@ -391,6 +636,12 @@ function classifyCommand(command) {
391
636
  }
392
637
  for (const segment of segments(raw)) {
393
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
+ }
394
645
  if (argv.includes("&")) {
395
646
  return {
396
647
  ok: false,
@@ -408,6 +659,13 @@ function classifyCommand(command) {
408
659
  }
409
660
  }
410
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
+ }
411
669
  return { ok: true };
412
670
  }
413
671
 
@@ -502,6 +760,21 @@ function forExtraction(messages) {
502
760
  };
503
761
  });
504
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;
505
778
 
506
779
  // packages/core/src/agent.ts
507
780
  import { randomUUID as randomUUID2 } from "node:crypto";
@@ -1270,7 +1543,7 @@ async function runCommand(input, ctx) {
1270
1543
  ].join("\n");
1271
1544
  }
1272
1545
  function execute(command, ctx) {
1273
- return new Promise((resolve2) => {
1546
+ return new Promise((resolve3) => {
1274
1547
  const windows = process.platform === "win32";
1275
1548
  const shell = windows ? process.env.ComSpec || "cmd.exe" : process.env.SHELL || "/bin/bash";
1276
1549
  const args = windows ? ["/d", "/s", "/c", command] : ["-lc", command];
@@ -1325,7 +1598,7 @@ ${tail}` : head + tail;
1325
1598
  if (settled) return;
1326
1599
  settled = true;
1327
1600
  clearTimeout(timer);
1328
- resolve2({ code, output: output(), timedOut });
1601
+ resolve3({ code, output: output(), timedOut });
1329
1602
  };
1330
1603
  child.on("close", done);
1331
1604
  child.on("error", (err) => {
@@ -1555,13 +1828,14 @@ async function runTurn(question, opts) {
1555
1828
  case "need_tools":
1556
1829
  needTools = true;
1557
1830
  return "go";
1831
+ case "ready":
1832
+ ui.ready?.({ credits: event.credits });
1833
+ return "go";
1558
1834
  case "done":
1559
- result.context = event.context;
1560
- result.credits = event.credits;
1561
1835
  ui.done?.({ credits: event.credits, context: event.context });
1562
1836
  return "go";
1563
1837
  case "error":
1564
- ui.error(event.message);
1838
+ ui.error(event.message, { code: event.code, ...event.credits ? { credits: event.credits } : {} });
1565
1839
  return "stop";
1566
1840
  default:
1567
1841
  return "go";
@@ -1991,9 +2265,14 @@ var AgentClient = class {
1991
2265
  if (this.opts.token) headers.Authorization = `Bearer ${this.opts.token}`;
1992
2266
  return headers;
1993
2267
  }
1994
- 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) {
1995
2274
  try {
1996
- 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) });
1997
2276
  if (!res.ok) return null;
1998
2277
  const body2 = await res.json();
1999
2278
  return body2.credits ?? null;
@@ -2001,6 +2280,18 @@ var AgentClient = class {
2001
2280
  return null;
2002
2281
  }
2003
2282
  }
2283
+ /** The learner's usage by local hour, for the heatmap under `/usage`. */
2284
+ async activity(offsetMinutes) {
2285
+ try {
2286
+ const res = await this.fetch(`${this.opts.baseUrl}/v1/usage/activity?offset=${offsetMinutes}`, { headers: this.headers() });
2287
+ if (!res.ok) return null;
2288
+ const body2 = await res.json();
2289
+ if (!Array.isArray(body2.days) || !Array.isArray(body2.hours)) return null;
2290
+ return { days: body2.days.filter(isBucket), hours: body2.hours.map((n) => isCount(n) ? n : 0) };
2291
+ } catch {
2292
+ return null;
2293
+ }
2294
+ }
2004
2295
  /**
2005
2296
  * Tell the server this conversation is over and can be distilled into memory.
2006
2297
  *
@@ -2048,11 +2339,16 @@ var AgentClient = class {
2048
2339
  }
2049
2340
  if (!res.ok || !res.body) {
2050
2341
  let message = `The assistant is unreachable (${res.status}).`;
2342
+ let body2 = {};
2051
2343
  try {
2052
- const body2 = await res.json();
2344
+ body2 = await res.json();
2053
2345
  if (body2.message) message = body2.message;
2054
2346
  } catch {
2055
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
+ }
2056
2352
  if (res.status === 401) message = `${message} Run \`labd login\`.`;
2057
2353
  yield { type: "error", code: res.status === 401 ? "unauthorized" : "upstream", message };
2058
2354
  return;
@@ -2078,6 +2374,13 @@ var AgentClient = class {
2078
2374
  }
2079
2375
  }
2080
2376
  };
2377
+ function isBucket(value) {
2378
+ const b = value;
2379
+ return typeof b?.day === "string" && typeof b.surface === "string" && [b.turns, b.input, b.output].every(isCount);
2380
+ }
2381
+ function isCount(n) {
2382
+ return typeof n === "number" && Number.isFinite(n);
2383
+ }
2081
2384
 
2082
2385
  // packages/tui/src/theme.ts
2083
2386
  var ESC = "\x1B";
@@ -2103,6 +2406,14 @@ var paint = {
2103
2406
  * Closes the foreground only, so it can sit inside a `band` without ending it.
2104
2407
  */
2105
2408
  accent: wrap("38;5;214", "39"),
2409
+ /**
2410
+ * The heatmap's lower steps, walking the same orange down the cube towards
2411
+ * brown, with `accent` as the top one. The heatmap changes glyph with each
2412
+ * step too, so a light terminal where brown reads heavier is still legible.
2413
+ */
2414
+ heat1: wrap("38;5;94", "39"),
2415
+ heat2: wrap("38;5;130", "39"),
2416
+ heat3: wrap("38;5;172", "39"),
2106
2417
  bold: wrap("1", "22"),
2107
2418
  dim: wrap("2", "22"),
2108
2419
  muted: wrap("90"),
@@ -2246,1370 +2557,1502 @@ function visibleWidth(line) {
2246
2557
  return line.replace(new RegExp(`${ESC2}\\[[0-9;]*m`, "g"), "").length;
2247
2558
  }
2248
2559
 
2249
- // packages/tui/src/cards.ts
2250
- var OUTPUT_LINES = 8;
2251
- function toolBlock(name, detail2, status, io, width = 100) {
2252
- const label2 = LABELS[name] ?? name.replace(/_/g, " ");
2253
- 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");
2254
- const out = [` ${dot} ${paint.bold(paint.accent(label2))} ${paint.muted(detail2)}`];
2255
- const room = Math.max(20, width - 9);
2256
- const row = (gutter, text, paintText, soft) => {
2257
- const wrap3 = soft ? wrapWords : wrapCommand;
2258
- const lines = text.split("\n").flatMap((line) => wrap3(line, room));
2259
- lines.forEach((line, i) => {
2260
- out.push(` ${i === 0 ? paint.dim(gutter.padEnd(4)) : " "} ${paintText(line)}`);
2261
- });
2262
- };
2263
- const shell = name === "run_command";
2264
- if (io.input) row(shell ? "$" : "IN", io.input, (t) => paint.cyan(t), false);
2265
- if (io.output) {
2266
- const lines = io.output.replace(/\s+$/, "").split("\n");
2267
- const shown = lines.slice(0, OUTPUT_LINES).join("\n");
2268
- row(shell ? "" : "OUT", shown, (t) => status === "error" ? paint.red(t) : paint.muted(t), true);
2269
- if (lines.length > OUTPUT_LINES) {
2270
- 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;
2271
2584
  }
2585
+ out.push(...prose(line, opts.width, pad4));
2272
2586
  }
2273
- return out;
2587
+ return out.join("\n");
2274
2588
  }
2275
- var LABELS = {
2276
- read_file: "Read",
2277
- list_dir: "List",
2278
- search_workspace: "Search",
2279
- edit_file: "Edit",
2280
- create_file: "Create",
2281
- move_file: "Move",
2282
- delete_file: "Delete",
2283
- run_command: "Run",
2284
- spawn_agent: "Helper"
2285
- };
2286
- function approvalCard(preview, call, mode, width) {
2287
- const out = [""];
2288
- if (call.name === "run_command" || preview?.kind === "run") {
2289
- const command = preview?.kind === "run" ? preview.command : String(call.input.command ?? "");
2290
- const why = preview?.kind === "run" ? preview.why : call.input.why;
2291
- out.push(`${paint.yellow("?")} ${paint.bold("Run a command")}`);
2292
- if (why) out.push(` ${paint.muted(why)}`);
2293
- out.push("");
2294
- for (const line of wrapCommand(command, width - 6)) out.push(` ${paint.cyan(line)}`);
2295
- out.push("");
2296
- out.push(
2297
- ` ${paint.dim(
2298
- 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."
2299
- )}`
2300
- );
2301
- 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)))}`];
2302
2594
  }
2303
- const creating = call.name === "create_file";
2304
- const moving = call.name === "move_file";
2305
- const deleting = call.name === "delete_file";
2306
- const path = preview ? preview.path : String(call.input.path ?? call.input.from ?? "a file");
2307
- const destination = preview?.to ?? call.input.to;
2308
- const title = moving ? "Move" : deleting ? "Delete" : creating ? "Create" : "Edit";
2309
- out.push(
2310
- `${paint.yellow("?")} ${paint.bold(title)} ${paint.accent(path)}${moving && destination ? paint.muted(" \u2192 ") + paint.accent(destination) : ""}${preview ? paint.muted(
2311
- ` ${counts(preview.added, preview.removed)}${creating || moving || deleting ? "" : ` at line ${preview.startLine}`}`
2312
- ) : ""}`
2313
- );
2314
- if (preview) {
2315
- out.push("");
2316
- 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]} `);
2317
2599
  }
2318
- out.push("");
2319
- out.push(
2320
- ` ${paint.dim(
2321
- 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."
2322
- )}`
2323
- );
2324
- return out;
2325
- }
2326
- function counts(added, removed) {
2327
- const parts = [];
2328
- if (added) parts.push(`+${added}`);
2329
- if (removed) parts.push(`-${removed}`);
2330
- return parts.join(" ");
2331
- }
2332
- function diffLines(oldText, newText, width, max = 12) {
2333
- const out = [];
2334
- const before = oldText ? oldText.split("\n") : [];
2335
- const after = newText ? newText.split("\n") : [];
2336
- for (const line of before.slice(0, max)) out.push(paint.red(`- ${clip(line, width - 4)}`));
2337
- if (before.length > max) out.push(paint.dim(` ... ${before.length - max} more removed`));
2338
- for (const line of after.slice(0, max)) out.push(paint.green(`+ ${clip(line, width - 4)}`));
2339
- if (after.length > max) out.push(paint.dim(` ... ${after.length - max} more added`));
2340
- 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);
2341
2612
  }
2342
- function clip(line, width) {
2343
- 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
+ );
2344
2625
  }
2345
- function wrapWords(text, width) {
2346
- 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}`];
2347
2629
  const out = [];
2348
2630
  let line = "";
2349
2631
  for (const word of text.split(" ")) {
2350
- if (!line) {
2632
+ if (line && visibleWidth(line) + 1 + visibleWidth(word) > limit) {
2633
+ out.push(`${out.length === 0 ? first : rest}${line}`);
2351
2634
  line = word;
2352
- } else if (`${line} ${word}`.length <= width) {
2353
- line = `${line} ${word}`;
2354
2635
  } else {
2355
- out.push(line);
2356
- line = word;
2357
- }
2358
- while (line.length > width) {
2359
- out.push(line.slice(0, width));
2360
- line = line.slice(width);
2636
+ line = line ? `${line} ${word}` : word;
2361
2637
  }
2362
2638
  }
2363
- if (line) out.push(line);
2364
- return out;
2365
- }
2366
- function wrapCommand(command, width) {
2367
- if (command.length <= width) return [command];
2368
- const out = [];
2369
- 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}`);
2370
2640
  return out;
2371
2641
  }
2372
- function statusLine(info2) {
2373
- const bits = [];
2374
- if (info2.contextPercent !== null && info2.contextPercent !== void 0) {
2375
- const crowded = info2.contextPercent >= 75;
2376
- bits.push((crowded ? paint.yellow : paint.muted)(`${info2.contextPercent}% full`));
2377
- }
2378
- bits.push(paint.muted(info2.project));
2379
- if (info2.branch) bits.push(paint.muted(info2.branch));
2380
- if (info2.creditsPercent !== null && info2.creditsPercent !== void 0) {
2381
- bits.push(paint.muted(`${info2.creditsPercent}% left`));
2382
- }
2383
- if (info2.effort) bits.push(paint.muted(`think ${info2.effort}`));
2384
- const mode = info2.mode === "agent" ? paint.red(paint.bold("agent")) : info2.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
2385
- bits.push(mode);
2386
- const left = info2.busy ? `${paint.accent(FACE)} ${paint.dim(info2.busy)}` : paint.dim(FACE);
2387
- return `${left} ${paint.dim("|")} ${bits.join(paint.dim(" | "))}`;
2388
- }
2389
- function spinnerFrame(tick) {
2390
- const dots = ".".repeat(tick % 3 + 1).padEnd(3);
2391
- return paint.dim(dots);
2392
- }
2393
2642
 
2394
- // packages/tui/src/composer.ts
2395
- var ROUND = { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" };
2396
- var MAX_SUGGESTIONS = 5;
2397
- function composer(opts) {
2398
- const width = Math.max(20, opts.width);
2399
- const colour = borderColour(opts.state);
2400
- const inner = width - 4;
2401
- const out = [];
2402
- for (const line of suggestionLines(opts.suggestions ?? [], inner)) out.push(line);
2403
- out.push(colour(`${ROUND.tl}${ROUND.h.repeat(width - 2)}${ROUND.tr}`));
2404
- out.push(`${colour(ROUND.v)} ${body(opts, inner)} ${colour(ROUND.v)}`);
2405
- out.push(colour(`${ROUND.bl}${ROUND.h.repeat(width - 2)}${ROUND.br}`));
2406
- if (opts.hint) out.push(` ${truncate(opts.hint, width - 2)}`);
2407
- if (opts.status) out.push(opts.status);
2408
- return out;
2409
- }
2410
- function body(opts, inner) {
2411
- const sigil = opts.state === "busy" ? paint.dim(">") : paint.accent(">");
2412
- const room = inner - 2;
2413
- if (!opts.view.text && opts.placeholder && opts.state !== "busy") {
2414
- const text2 = truncate(paint.dim(opts.placeholder), room);
2415
- return `${sigil} ${pad(text2, room)}`;
2416
- }
2417
- const text = withCursor(opts.view, room, opts.state === "busy");
2418
- return `${sigil} ${pad(text, room)}`;
2419
- }
2420
- function withCursor(view, room, hidden) {
2421
- const text = view.text.slice(0, room);
2422
- if (hidden) return text;
2423
- const at2 = Math.min(view.column, room - 1 < 0 ? 0 : room);
2424
- const before = text.slice(0, at2);
2425
- const under = text.slice(at2, at2 + 1) || " ";
2426
- const after = text.slice(at2 + 1);
2427
- return `${before}${paint.invert(under)}${after}`;
2428
- }
2429
- function suggestionLines(all, inner) {
2430
- if (!all.length) return [];
2431
- const shown = all.slice(0, MAX_SUGGESTIONS);
2432
- const column = Math.max(...shown.map((s) => s.name.length + 1));
2433
- const out = shown.map((s, i) => {
2434
- const name = `/${s.name}`.padEnd(column + 1);
2435
- const mark = i === 0 ? paint.accent("-") : " ";
2436
- return ` ${mark} ${paint.accent(name)} ${paint.muted(truncate(s.what, inner - column - 4))}`;
2437
- });
2438
- if (all.length > shown.length) {
2439
- out.push(` ${paint.dim(`... ${all.length - shown.length} more`)}`);
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;
2440
2653
  }
2441
- return out;
2442
- }
2443
- function borderColour(state) {
2444
- switch (state) {
2445
- case "ok":
2446
- return paint.green;
2447
- case "unknown":
2448
- return paint.red;
2449
- case "partial":
2450
- return paint.cyan;
2451
- case "ask":
2452
- return paint.yellow;
2453
- case "busy":
2454
- return paint.accent;
2455
- default:
2456
- return paint.dim;
2654
+ get blocks() {
2655
+ return this.entries;
2457
2656
  }
2458
- }
2459
- function pad(text, width) {
2460
- const short = width - visibleWidth(text);
2461
- return short > 0 ? `${text}${" ".repeat(short)}` : text;
2462
- }
2463
-
2464
- // packages/tui/src/edit.ts
2465
- function sanitize(text) {
2466
- return text.replace(/\r\n?|\n|\t/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
2467
- }
2468
- var PRINTABLE = /^[^\u0000-\u001f\u007f]+$/;
2469
- var segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
2470
- function boundaries(text) {
2471
- const out = [0];
2472
- for (const { index, segment } of segmenter.segment(text)) out.push(index + segment.length);
2473
- return out;
2474
- }
2475
- function prevBoundary(text, at2) {
2476
- const marks = boundaries(text);
2477
- let last = 0;
2478
- for (const mark of marks) {
2479
- if (mark >= at2) return last;
2480
- last = mark;
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;
2481
2662
  }
2482
- return last;
2483
- }
2484
- function nextBoundary(text, at2) {
2485
- for (const mark of boundaries(text)) if (mark > at2) return mark;
2486
- return text.length;
2487
- }
2488
- function wordStart(text, at2) {
2489
- let i = at2;
2490
- while (i > 0 && /\s/.test(text[i - 1])) i--;
2491
- while (i > 0 && !/\s/.test(text[i - 1])) i--;
2492
- return i;
2493
- }
2494
- function wordEnd(text, at2) {
2495
- let i = at2;
2496
- while (i < text.length && /\s/.test(text[i])) i++;
2497
- while (i < text.length && !/\s/.test(text[i])) i++;
2498
- return i;
2499
- }
2500
- var LineEditor = class {
2501
- value = "";
2502
- at = 0;
2503
- get text() {
2504
- return this.value;
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);
2505
2673
  }
2506
- /** Cursor position, as an offset into `text`, always on a grapheme boundary. */
2507
- get cursor() {
2508
- return this.at;
2674
+ /** Say a block changed, so its cached lines are dropped on the next render. */
2675
+ touch(entry) {
2676
+ entry.version++;
2509
2677
  }
2510
- /** Set the whole line - from history, or a completion. Cursor lands at the end. */
2511
- setText(text) {
2512
- this.value = sanitize(text);
2513
- this.at = this.value.length;
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();
2514
2683
  }
2515
- reset() {
2516
- this.value = "";
2517
- this.at = 0;
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);
2693
+ }
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;
2518
2697
  }
2519
- /** Insert at the cursor, newlines removed. Used for typing and for paste. */
2520
- insert(text) {
2521
- const clean = sanitize(text);
2522
- if (!clean) return "same";
2523
- this.value = this.value.slice(0, this.at) + clean + this.value.slice(this.at);
2524
- this.at += clean.length;
2525
- 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;
2526
2704
  }
2527
2705
  /**
2528
- * Apply one key.
2706
+ * An answer, streaming or finished.
2529
2707
  *
2530
- * Returns `unhandled` for anything this does not claim - Enter, ctrl-c, Tab -
2531
- * so the caller can decide what submitting, cancelling, and completing mean.
2532
- * Those are session concerns, not editing ones.
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.
2533
2711
  */
2534
- apply(key) {
2535
- const { name, ctrl, meta } = key;
2536
- if (name === "left" && !ctrl && !meta) return this.moveTo(prevBoundary(this.value, this.at));
2537
- if (name === "right" && !ctrl && !meta) return this.moveTo(nextBoundary(this.value, this.at));
2538
- if (name === "home" || ctrl && name === "a") return this.moveTo(0);
2539
- if (name === "end" || ctrl && name === "e") return this.moveTo(this.value.length);
2540
- if ((meta || ctrl) && (name === "left" || name === "b")) {
2541
- return this.moveTo(wordStart(this.value, this.at));
2542
- }
2543
- if ((meta || ctrl) && (name === "right" || name === "f")) {
2544
- return this.moveTo(wordEnd(this.value, this.at));
2545
- }
2546
- if (name === "backspace" && (meta || ctrl)) return this.cut(wordStart(this.value, this.at), this.at);
2547
- if (ctrl && name === "w") return this.cut(wordStart(this.value, this.at), this.at);
2548
- if (name === "backspace") return this.cut(prevBoundary(this.value, this.at), this.at);
2549
- if (name === "delete") return this.cut(this.at, nextBoundary(this.value, this.at));
2550
- if (ctrl && name === "u") return this.cut(0, this.at);
2551
- if (ctrl && name === "k") return this.cut(this.at, this.value.length);
2552
- if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) {
2553
- return this.insert(key.sequence);
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);
2554
2725
  }
2555
- if (key.paste !== void 0) return this.insert(key.paste);
2556
- return "unhandled";
2726
+ const raw = tail ? tail.split("\n").flatMap((line) => wrapPlain(line, inner - 2)).map((line) => ` ${paint.dim(line)}`) : [];
2727
+ return [...head.lines, ...raw];
2557
2728
  }
2558
- moveTo(next) {
2559
- if (next === this.at) return "same";
2560
- this.at = next;
2561
- return "cursor";
2729
+ };
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);
2562
2741
  }
2563
- cut(from, to) {
2564
- if (from >= to) return "same";
2565
- this.value = this.value.slice(0, from) + this.value.slice(to);
2566
- this.at = from;
2567
- return "text";
2568
- }
2569
- /**
2570
- * The slice of the line that fits in `width`, and where the cursor sits in it.
2571
- *
2572
- * A line longer than the box scrolls horizontally rather than wrapping, and
2573
- * the window is chosen so the cursor is always inside it - typing past the
2574
- * right edge must not take the cursor somewhere the reader cannot see it.
2575
- */
2576
- viewport(width) {
2577
- if (width <= 0) return { text: "", column: 0 };
2578
- if (this.value.length <= width) return { text: this.value, column: this.at };
2579
- let start = Math.max(0, this.at - width + 1);
2580
- start = Math.min(start, this.value.length - width);
2581
- return { text: this.value.slice(start, start + width), column: this.at - start };
2582
- }
2583
- };
2584
- var History = class {
2585
- constructor(limit = 200) {
2586
- this.limit = limit;
2587
- }
2588
- entries = [];
2589
- index = -1;
2590
- draft = "";
2591
- add(line) {
2592
- const clean = sanitize(line).trim();
2593
- if (clean && this.entries.at(-1) !== clean) {
2594
- this.entries.push(clean);
2595
- 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))}`)];
2596
2786
  }
2597
- this.index = -1;
2598
- this.draft = "";
2599
2787
  }
2600
- /** The line to show, or null when there is nowhere further to go. */
2601
- up(current) {
2602
- if (!this.entries.length) return null;
2603
- if (this.index === -1) {
2604
- this.draft = current;
2605
- this.index = this.entries.length - 1;
2606
- } else if (this.index > 0) {
2607
- 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;
2608
2798
  } else {
2609
- return null;
2799
+ line = line ? `${line} ${word}` : word;
2610
2800
  }
2611
- return this.entries[this.index];
2612
- }
2613
- down() {
2614
- if (this.index === -1) return null;
2615
- if (this.index >= this.entries.length - 1) {
2616
- this.index = -1;
2617
- return this.draft;
2801
+ while (line.length > limit) {
2802
+ out.push(line.slice(0, limit));
2803
+ line = line.slice(limit);
2618
2804
  }
2619
- this.index++;
2620
- return this.entries[this.index];
2621
2805
  }
2622
- };
2623
-
2624
- // packages/tui/src/editor.ts
2625
- function sanitizeMultiline(text) {
2626
- 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;
2627
2808
  }
2628
- var TextEditor = class {
2629
- lines = [""];
2630
- row = 0;
2631
- col = 0;
2632
- /** 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
+ }
2633
2813
  top = 0;
2634
- get text() {
2635
- 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);
2636
2821
  }
2637
- get cursor() {
2638
- 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));
2639
2828
  }
2640
- get lineCount() {
2641
- 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();
2642
2836
  }
2643
- get isEmpty() {
2644
- return this.lines.length === 1 && this.lines[0] === "";
2837
+ pageUp() {
2838
+ this.scroll(-Math.max(1, this.height - 1));
2645
2839
  }
2646
- /** The line the caret is on. Used to work out whether a `/` or `@` is being typed. */
2647
- get currentLine() {
2648
- return this.lines[this.row];
2840
+ pageDown() {
2841
+ this.scroll(Math.max(1, this.height - 1));
2649
2842
  }
2650
- /** Replace everything - from history, or a completion. The caret lands at the end. */
2651
- setText(text) {
2652
- this.lines = sanitizeMultiline(text).split("\n");
2653
- this.row = this.lines.length - 1;
2654
- this.col = this.lines[this.row].length;
2843
+ home() {
2844
+ this.top = 0;
2845
+ if (this.total > this.height) this.unfollow();
2655
2846
  }
2656
- /** Replace just the caret's line, keeping the rest. For a completion of `/cmd` or `@path`. */
2657
- setCurrentLine(text) {
2658
- this.lines[this.row] = sanitizeMultiline(text).replace(/\n/g, " ");
2659
- this.col = this.lines[this.row].length;
2847
+ end() {
2848
+ this.refollow();
2849
+ this.top = Math.max(0, this.total - this.height);
2660
2850
  }
2661
- reset() {
2662
- this.lines = [""];
2663
- this.row = 0;
2664
- this.col = 0;
2665
- this.top = 0;
2851
+ /** The slice of `lines` on screen. */
2852
+ slice(lines) {
2853
+ return lines.slice(this.top, this.top + this.height);
2666
2854
  }
2667
- /** Insert at the caret. Newlines in `text` become new lines. */
2668
- insert(text) {
2669
- const clean = sanitizeMultiline(text);
2670
- if (!clean) return "same";
2671
- const parts = clean.split("\n");
2672
- const line = this.lines[this.row];
2673
- const before = line.slice(0, this.col);
2674
- const after = line.slice(this.col);
2675
- if (parts.length === 1) {
2676
- this.lines[this.row] = before + parts[0] + after;
2677
- this.col += parts[0].length;
2678
- return "text";
2679
- }
2680
- const inserted = [before + parts[0], ...parts.slice(1, -1), parts[parts.length - 1] + after];
2681
- this.lines.splice(this.row, 1, ...inserted);
2682
- this.row += parts.length - 1;
2683
- this.col = parts[parts.length - 1].length;
2684
- return "text";
2855
+ unfollow() {
2856
+ if (this.follow) this.anchor = this.total;
2857
+ this.follow = false;
2685
2858
  }
2686
- newline() {
2687
- return this.insert("\n");
2859
+ refollow() {
2860
+ this.follow = true;
2688
2861
  }
2689
- /**
2690
- * Apply one key. `unhandled` for a plain Enter (the caller sends), for Up on
2691
- * the first line and Down on the last (the caller walks history), and for
2692
- * anything else the editor does not claim.
2693
- */
2694
- apply(key) {
2695
- const { name, ctrl, meta, shift } = key;
2696
- const line = this.lines[this.row];
2697
- if (key.paste !== void 0) return this.insert(key.paste);
2698
- if (name === "return") {
2699
- if (shift || ctrl || meta) return this.newline();
2700
- if (/(^|[^\\])\\$/.test(line)) {
2701
- this.lines[this.row] = line.slice(0, -1);
2702
- this.col = Math.min(this.col, this.lines[this.row].length);
2703
- return this.newline();
2704
- }
2705
- return "unhandled";
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`)}`);
2706
2949
  }
2707
- if (ctrl && name === "j") return this.newline();
2708
- if (name === "up" && !ctrl && !meta) {
2709
- if (this.row === 0) return "unhandled";
2710
- this.row--;
2711
- this.col = Math.min(this.col, this.lines[this.row].length);
2712
- return "cursor";
2713
- }
2714
- if (name === "down" && !ctrl && !meta) {
2715
- if (this.row === this.lines.length - 1) return "unhandled";
2716
- this.row++;
2717
- this.col = Math.min(this.col, this.lines[this.row].length);
2718
- return "cursor";
2719
- }
2720
- if (name === "left" && !ctrl && !meta) {
2721
- if (this.col === 0) {
2722
- if (this.row === 0) return "same";
2723
- this.row--;
2724
- this.col = this.lines[this.row].length;
2725
- return "cursor";
2726
- }
2727
- return this.moveTo(prevBoundary(line, this.col));
2728
- }
2729
- if (name === "right" && !ctrl && !meta) {
2730
- if (this.col >= line.length) {
2731
- if (this.row === this.lines.length - 1) return "same";
2732
- this.row++;
2733
- this.col = 0;
2734
- return "cursor";
2735
- }
2736
- return this.moveTo(nextBoundary(line, this.col));
2737
- }
2738
- if (name === "home" || ctrl && name === "a") return this.moveTo(0);
2739
- if (name === "end" || ctrl && name === "e") return this.moveTo(line.length);
2740
- if ((meta || ctrl) && (name === "left" || name === "b")) return this.moveTo(wordStart(line, this.col));
2741
- if ((meta || ctrl) && (name === "right" || name === "f")) return this.moveTo(wordEnd(line, this.col));
2742
- if (name === "backspace" && (meta || ctrl)) {
2743
- if (this.col === 0) return this.joinUp();
2744
- return this.cut(wordStart(line, this.col), this.col);
2745
- }
2746
- if (ctrl && name === "w") {
2747
- if (this.col === 0) return this.joinUp();
2748
- return this.cut(wordStart(line, this.col), this.col);
2749
- }
2750
- if (name === "backspace") {
2751
- if (this.col === 0) return this.joinUp();
2752
- return this.cut(prevBoundary(line, this.col), this.col);
2753
- }
2754
- if (name === "delete") {
2755
- if (this.col >= line.length) return this.joinDown();
2756
- return this.cut(this.col, nextBoundary(line, this.col));
2757
- }
2758
- if (ctrl && name === "u") return this.cut(0, this.col);
2759
- if (ctrl && name === "k") {
2760
- if (this.col >= line.length) return this.joinDown();
2761
- return this.cut(this.col, line.length);
2762
- }
2763
- if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) return this.insert(key.sequence);
2764
- return "unhandled";
2765
- }
2766
- /**
2767
- * The lines to draw and where the caret is among them.
2768
- *
2769
- * At most `maxLines` rows, chosen so the caret's row is always one of them;
2770
- * each row scrolls horizontally on its own so a long line never wraps into the
2771
- * row below and the caret never leaves the box.
2772
- */
2773
- view(width, maxLines) {
2774
- const rows = Math.max(1, maxLines);
2775
- if (this.row < this.top) this.top = this.row;
2776
- if (this.row >= this.top + rows) this.top = this.row - rows + 1;
2777
- this.top = Math.max(0, Math.min(this.top, this.lines.length - rows));
2778
- const shown = this.lines.slice(this.top, this.top + rows);
2779
- let cursorCol = this.col;
2780
- const lines = shown.map((line, i) => {
2781
- if (width <= 0) return "";
2782
- if (line.length <= width) return line;
2783
- if (this.top + i !== this.row) return line.slice(0, width);
2784
- const start = Math.max(0, this.col - width + 1);
2785
- cursorCol = this.col - start;
2786
- return line.slice(start, start + width);
2787
- });
2788
- return { lines, cursor: { row: this.row - this.top, col: cursorCol } };
2789
- }
2790
- moveTo(next) {
2791
- if (next === this.col) return "same";
2792
- this.col = next;
2793
- return "cursor";
2794
2950
  }
2795
- cut(from, to) {
2796
- if (from >= to) return "same";
2797
- const line = this.lines[this.row];
2798
- this.lines[this.row] = line.slice(0, from) + line.slice(to);
2799
- this.col = from;
2800
- return "text";
2801
- }
2802
- joinUp() {
2803
- if (this.row === 0) return "same";
2804
- const above = this.lines[this.row - 1];
2805
- this.lines.splice(this.row - 1, 2, above + this.lines[this.row]);
2806
- this.row--;
2807
- this.col = above.length;
2808
- return "text";
2809
- }
2810
- joinDown() {
2811
- if (this.row >= this.lines.length - 1) return "same";
2812
- this.lines.splice(this.row, 2, this.lines[this.row] + this.lines[this.row + 1]);
2813
- return "text";
2814
- }
2815
- };
2816
-
2817
- // packages/tui/src/keys.ts
2818
- var ESC3 = "\x1B";
2819
- var PASTE_START = `${ESC3}[200~`;
2820
- var PASTE_END = `${ESC3}[201~`;
2821
- var CSI_FINAL = {
2822
- A: "up",
2823
- B: "down",
2824
- C: "right",
2825
- D: "left",
2826
- H: "home",
2827
- F: "end",
2828
- Z: "tab"
2829
- };
2830
- var TILDE = {
2831
- "1": "home",
2832
- "2": "insert",
2833
- "3": "delete",
2834
- "4": "end",
2835
- "5": "pageup",
2836
- "6": "pagedown",
2837
- "7": "home",
2838
- "8": "end"
2839
- };
2840
- function modifiers(param) {
2841
- const n = (Number(param) || 1) - 1;
2842
- return {
2843
- ...n & 1 ? { shift: true } : {},
2844
- ...n & 2 ? { meta: true } : {},
2845
- ...n & 4 ? { ctrl: true } : {}
2846
- };
2847
- }
2848
- function coded(code, mods, sequence) {
2849
- switch (code) {
2850
- case 13:
2851
- return { name: "return", ...mods, sequence };
2852
- case 9:
2853
- return { name: "tab", ...mods, sequence };
2854
- case 27:
2855
- return { name: "escape", ...mods, sequence };
2856
- case 127:
2857
- case 8:
2858
- return { name: "backspace", ...mods, sequence };
2859
- default:
2860
- if (code >= 32) {
2861
- const ch = String.fromCodePoint(code);
2862
- return { name: ch.toLowerCase(), ...mods, sequence: mods.ctrl || mods.meta ? sequence : ch };
2863
- }
2864
- return null;
2865
- }
2866
- }
2867
- function isPrefix(text, whole) {
2868
- return text.length <= whole.length && whole.startsWith(text);
2869
- }
2870
- function partialMarker(buf, marker) {
2871
- for (let n = Math.min(marker.length - 1, buf.length); n > 0; n--) {
2872
- if (isPrefix(buf.slice(buf.length - n), marker)) return n;
2873
- }
2874
- return 0;
2951
+ return out;
2875
2952
  }
2876
- var KeyDecoder = class {
2877
- /** Bytes that could still turn out to be the start of something. */
2878
- pending = "";
2879
- /** Inside a bracketed paste: the text so far. Null outside one. */
2880
- pasting = null;
2881
- /**
2882
- * Decode a chunk. Sequences split across two reads are held until the next
2883
- * one; a lone ESC at the END of a chunk is reported as Escape at once, because
2884
- * a terminal sends a whole sequence in one write and a person pressing the key
2885
- * should not have to press another to be heard.
2886
- */
2887
- feed(chunk) {
2888
- const out = [];
2889
- let buf = this.pending + chunk;
2890
- this.pending = "";
2891
- let i = 0;
2892
- while (i < buf.length) {
2893
- if (this.pasting !== null) {
2894
- const end = buf.indexOf(PASTE_END, i);
2895
- if (end === -1) {
2896
- const hold = partialMarker(buf, PASTE_END);
2897
- this.pasting += buf.slice(i, buf.length - hold);
2898
- this.pending = buf.slice(buf.length - hold);
2899
- return out;
2900
- }
2901
- this.pasting += buf.slice(i, end);
2902
- out.push({ name: "paste", paste: this.pasting.replace(/\r\n?/g, "\n") });
2903
- this.pasting = null;
2904
- i = end + PASTE_END.length;
2905
- continue;
2906
- }
2907
- const ch = buf[i];
2908
- if (ch !== ESC3) {
2909
- const code2 = ch.charCodeAt(0);
2910
- if (code2 === 13) out.push({ name: "return", sequence: ch });
2911
- else if (code2 === 10) out.push({ name: "j", ctrl: true, sequence: ch });
2912
- else if (code2 === 9) out.push({ name: "tab", sequence: ch });
2913
- else if (code2 === 127 || code2 === 8) out.push({ name: "backspace", sequence: ch });
2914
- else if (code2 < 32) {
2915
- if (code2 > 0) out.push({ name: String.fromCharCode(96 + code2), ctrl: true, sequence: ch });
2916
- } else {
2917
- let j = i + 1;
2918
- while (j < buf.length) {
2919
- const c = buf.charCodeAt(j);
2920
- if (c < 32 || c === 127 || buf[j] === ESC3) break;
2921
- j++;
2922
- }
2923
- out.push({ sequence: buf.slice(i, j) });
2924
- i = j;
2925
- continue;
2926
- }
2927
- i++;
2928
- continue;
2929
- }
2930
- if (i === buf.length - 1) {
2931
- out.push({ name: "escape", sequence: ESC3 });
2932
- i++;
2933
- continue;
2934
- }
2935
- const next = buf[i + 1];
2936
- if (next === "[") {
2937
- let j = i + 2;
2938
- while (j < buf.length && !(buf.charCodeAt(j) >= 64 && buf.charCodeAt(j) <= 126)) j++;
2939
- if (j >= buf.length) {
2940
- this.pending = buf.slice(i);
2941
- return out;
2942
- }
2943
- const seq = buf.slice(i, j + 1);
2944
- const body2 = buf.slice(i + 2, j);
2945
- const final = buf[j];
2946
- i = j + 1;
2947
- if (seq === PASTE_START) {
2948
- this.pasting = "";
2949
- continue;
2950
- }
2951
- const key = decodeCsi(body2, final, seq);
2952
- if (key) out.push(key);
2953
- continue;
2954
- }
2955
- if (next === "O") {
2956
- const final = buf[i + 2];
2957
- if (final === void 0) {
2958
- this.pending = buf.slice(i);
2959
- return out;
2960
- }
2961
- const name = CSI_FINAL[final];
2962
- if (name) out.push({ name, sequence: buf.slice(i, i + 3) });
2963
- i += 3;
2964
- continue;
2965
- }
2966
- if (next === "\x7F" || next === "\b") {
2967
- out.push({ name: "backspace", meta: true, sequence: buf.slice(i, i + 2) });
2968
- i += 2;
2969
- continue;
2970
- }
2971
- if (next === ESC3) {
2972
- out.push({ name: "escape", sequence: ESC3 });
2973
- i++;
2974
- continue;
2975
- }
2976
- const code = next.charCodeAt(0);
2977
- if (code >= 32 && code !== 127) {
2978
- out.push({
2979
- name: next.toLowerCase(),
2980
- meta: true,
2981
- ...next !== next.toLowerCase() ? { shift: true } : {},
2982
- sequence: buf.slice(i, i + 2)
2983
- });
2984
- i += 2;
2985
- continue;
2986
- }
2987
- out.push({ name: "escape", sequence: ESC3 });
2988
- i++;
2989
- }
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
+ );
2990
2979
  return out;
2991
2980
  }
2992
- /** Anything still held back - the tail of a paste, an escape that never finished. */
2993
- flush() {
2994
- const out = [];
2995
- if (this.pasting !== null) {
2996
- out.push({ name: "paste", paste: this.pasting + this.pending });
2997
- this.pasting = null;
2998
- } else if (this.pending === ESC3) {
2999
- out.push({ name: "escape", sequence: ESC3 });
3000
- }
3001
- this.pending = "";
3002
- return out;
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}`);
3003
2995
  }
3004
- };
3005
- function decodeCsi(body2, final, sequence) {
3006
- if (body2.startsWith("<") && (final === "M" || final === "m")) {
3007
- const [b, x, y] = body2.slice(1).split(";").map(Number);
3008
- if (final === "m") return null;
3009
- if (b & 32) return null;
3010
- const button = b & 3;
3011
- if (b & 64) {
3012
- return { name: "mouse", sequence, mouse: { kind: button === 0 ? "wheel-up" : "wheel-down", button: b, x, y } };
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;
3003
+ }
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);
3013
3039
  }
3014
- return { name: "mouse", sequence, mouse: { kind: "click", button, x, y } };
3015
3040
  }
3016
- const params = body2.split(";");
3017
- if (final === "~") {
3018
- if (params[0] === "27") return coded(Number(params[2]), modifiers(params[1]), sequence);
3019
- if (params[0] === "200" || params[0] === "201") return null;
3020
- const name2 = TILDE[params[0]];
3021
- return name2 ? { name: name2, ...modifiers(params[1]), sequence } : null;
3041
+ if (line) out.push(line);
3042
+ return out;
3043
+ }
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;
3049
+ }
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`));
3022
3055
  }
3023
- if (final === "u") return coded(Number(params[0]), modifiers(params[1]), sequence);
3024
- const name = CSI_FINAL[final];
3025
- if (!name) return null;
3026
- const mods = final === "Z" ? { shift: true } : modifiers(params[1]);
3027
- return { name, ...mods, sequence };
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(" | "))}`;
3063
+ }
3064
+ function spinnerFrame(tick) {
3065
+ const dots = ".".repeat(tick % 3 + 1).padEnd(3);
3066
+ return paint.dim(dots);
3028
3067
  }
3029
3068
 
3030
- // packages/tui/src/layout.ts
3031
- var MAX_COMPOSER_ROWS = 8;
3032
- var SIDEBAR_MIN_COLUMNS = 120;
3033
- var SIDEBAR_WIDTH = 32;
3034
- function layout(opts) {
3035
- const rows = Math.max(4, opts.rows);
3036
- const columns = Math.max(20, opts.columns);
3037
- const wanted = Math.min(MAX_COMPOSER_ROWS, Math.max(1, opts.editorLines) + 2);
3038
- const composerHeight = Math.max(3, Math.min(wanted, rows - 3));
3039
- const header = { top: 0, left: 0, width: columns, height: 1 };
3040
- const footer = { top: rows - 1, left: 0, width: columns, height: 1 };
3041
- const composer2 = { top: rows - 1 - composerHeight, left: 0, width: columns, height: composerHeight };
3042
- const transcriptHeight = Math.max(0, composer2.top - 1);
3043
- const withSidebar = Boolean(opts.sidebar) && columns >= SIDEBAR_MIN_COLUMNS;
3044
- const mainWidth = withSidebar ? columns - SIDEBAR_WIDTH - 1 : columns;
3045
- const out = {
3046
- header,
3047
- transcript: { top: 1, left: 0, width: mainWidth, height: transcriptHeight },
3048
- composer: { ...composer2, width: mainWidth },
3049
- footer
3050
- };
3051
- if (withSidebar) {
3052
- out.sidebar = { top: 1, left: mainWidth + 1, width: SIDEBAR_WIDTH, height: composer2.top + composerHeight - 1 };
3053
- }
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);
3054
3083
  return out;
3055
3084
  }
3056
-
3057
- // packages/tui/src/popup.ts
3058
- var MAX_POPUP_ROWS = 8;
3059
- var Popup = class {
3060
- constructor(kind, items) {
3061
- this.kind = kind;
3062
- this.items = items;
3063
- }
3064
- selected = 0;
3065
- get current() {
3066
- 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)}`;
3067
3091
  }
3068
- move(delta) {
3069
- if (!this.items.length) return;
3070
- 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`)}`);
3071
3115
  }
3072
- /** Rows to draw, at most `MAX_POPUP_ROWS`, keeping the selection in view. */
3073
- render(width, max = MAX_POPUP_ROWS) {
3074
- if (!this.items.length) return [];
3075
- const rows = Math.max(1, max);
3076
- const start = Math.max(0, Math.min(this.selected - rows + 1, this.items.length - rows));
3077
- const shown = this.items.slice(start, start + rows);
3078
- const column = Math.min(Math.max(...shown.map((s) => s.label.length)), Math.max(10, Math.floor(width / 2)));
3079
- return shown.map((item, i) => {
3080
- const isSelected = start + i === this.selected;
3081
- const mark = isSelected ? paint.accent("\u25B8") : " ";
3082
- const label2 = item.label.padEnd(column);
3083
- const room = width - column - 6;
3084
- const what = item.what ? paint.muted(truncate(item.what, Math.max(0, room))) : "";
3085
- const line = ` ${mark} ${isSelected ? paint.bold(paint.accent(label2)) : paint.accent(label2)} ${what}`;
3086
- return pad2(truncate(line, width), width);
3087
- });
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;
3088
3132
  }
3089
- };
3090
- function pad2(text, width) {
3133
+ }
3134
+ function pad(text, width) {
3091
3135
  const short = width - visibleWidth(text);
3092
3136
  return short > 0 ? `${text}${" ".repeat(short)}` : text;
3093
3137
  }
3094
-
3095
- // packages/tui/src/dialog.ts
3096
- function pick(title, items, onSelect) {
3097
- return { kind: "pick", title, items, query: "", selected: 0, onSelect };
3138
+
3139
+ // packages/tui/src/edit.ts
3140
+ function sanitize(text) {
3141
+ return text.replace(/\r\n?|\n|\t/g, " ").replace(/[\u0000-\u001f\u007f]/g, "");
3142
+ }
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;
3149
+ }
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;
3156
+ }
3157
+ return last;
3158
+ }
3159
+ function nextBoundary(text, at2) {
3160
+ for (const mark of boundaries(text)) if (mark > at2) return mark;
3161
+ return text.length;
3098
3162
  }
3099
- function info(title, lines) {
3100
- return { kind: "info", title, lines, scroll: 0 };
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;
3101
3168
  }
3102
- function filterItems(items, query) {
3103
- const words = query.toLowerCase().split(/\s+/).filter(Boolean);
3104
- if (!words.length) return items;
3105
- return items.filter((item) => {
3106
- const hay = `${item.label} ${item.hint ?? ""}`.toLowerCase();
3107
- return words.every((w) => hay.includes(w));
3108
- });
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;
3109
3174
  }
3110
- function dialogKey(dialog, key) {
3111
- if (key.name === "escape" || key.ctrl && (key.name === "c" || key.name === "g")) return "close";
3112
- if (dialog.kind === "info") {
3113
- if (key.name === "up" || key.name === "k") dialog.scroll = Math.max(0, dialog.scroll - 1);
3114
- else if (key.name === "down" || key.name === "j") dialog.scroll++;
3115
- else if (key.name === "pageup") dialog.scroll = Math.max(0, dialog.scroll - 10);
3116
- else if (key.name === "pagedown") dialog.scroll += 10;
3117
- else if (key.name === "home") dialog.scroll = 0;
3118
- else if (key.name === "return" || key.name === "q") return "close";
3119
- else if (key.mouse?.kind === "wheel-up") dialog.scroll = Math.max(0, dialog.scroll - 3);
3120
- else if (key.mouse?.kind === "wheel-down") dialog.scroll += 3;
3121
- else return "same";
3122
- return "changed";
3175
+ var LineEditor = class {
3176
+ value = "";
3177
+ at = 0;
3178
+ get text() {
3179
+ return this.value;
3123
3180
  }
3124
- const shown = filterItems(dialog.items, dialog.query);
3125
- if (key.name === "up" || key.mouse?.kind === "wheel-up") {
3126
- dialog.selected = shown.length ? (dialog.selected - 1 + shown.length) % shown.length : 0;
3127
- return "changed";
3181
+ /** Cursor position, as an offset into `text`, always on a grapheme boundary. */
3182
+ get cursor() {
3183
+ return this.at;
3128
3184
  }
3129
- if (key.name === "down" || key.mouse?.kind === "wheel-down" || key.name === "tab") {
3130
- dialog.selected = shown.length ? (dialog.selected + 1) % shown.length : 0;
3131
- return "changed";
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;
3132
3189
  }
3133
- if (key.name === "return") {
3134
- const item = shown[dialog.selected];
3135
- if (!item) return "same";
3136
- dialog.onSelect(item);
3137
- return "select";
3190
+ reset() {
3191
+ this.value = "";
3192
+ this.at = 0;
3138
3193
  }
3139
- if (key.name === "backspace") {
3140
- if (!dialog.query) return "same";
3141
- dialog.query = dialog.query.slice(0, -1);
3142
- dialog.selected = 0;
3143
- return "changed";
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";
3144
3201
  }
3145
- if (!key.ctrl && !key.meta && key.sequence && /^[^\u0000-\u001f\u007f]+$/.test(key.sequence)) {
3146
- dialog.query += key.sequence;
3147
- dialog.selected = 0;
3148
- return "changed";
3202
+ /**
3203
+ * Apply one key.
3204
+ *
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.
3208
+ */
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));
3217
+ }
3218
+ if ((meta || ctrl) && (name === "right" || name === "f")) {
3219
+ return this.moveTo(wordEnd(this.value, this.at));
3220
+ }
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";
3149
3232
  }
3150
- return "same";
3151
- }
3152
- var BOX = { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" };
3153
- function renderDialog(dialog, rows, columns) {
3154
- const width = Math.max(24, Math.min(columns - 4, 84));
3155
- const inner = width - 4;
3156
- const maxBody = Math.max(3, rows - 6);
3157
- const body2 = [];
3158
- if (dialog.kind === "pick") {
3159
- const shown = filterItems(dialog.items, dialog.query);
3160
- const listRows = Math.max(1, Math.min(shown.length, maxBody - 2));
3161
- const start = Math.max(0, Math.min(dialog.selected - listRows + 1, shown.length - listRows));
3162
- body2.push(`${paint.accent("search")} ${dialog.query}${paint.invert(" ")}`);
3163
- body2.push("");
3164
- if (!shown.length) body2.push(paint.muted("nothing matches"));
3165
- for (const [i, item] of shown.slice(start, start + listRows).entries()) {
3166
- const isSelected = start + i === dialog.selected;
3167
- const mark = isSelected ? paint.accent("\u25B8") : " ";
3168
- const label2 = isSelected ? paint.bold(item.label) : item.label;
3169
- body2.push(truncate(`${mark} ${label2}${item.hint ? ` ${paint.muted(item.hint)}` : ""}`, inner));
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 };
3257
+ }
3258
+ };
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();
3170
3271
  }
3171
- if (shown.length > listRows) body2.push(paint.dim(`\u2026 ${shown.length - listRows} more; type to narrow`));
3172
- } else {
3173
- const visible = Math.min(dialog.lines.length, maxBody);
3174
- dialog.scroll = Math.max(0, Math.min(dialog.scroll, dialog.lines.length - visible));
3175
- for (const line of dialog.lines.slice(dialog.scroll, dialog.scroll + visible)) body2.push(truncate(line, inner));
3176
- if (dialog.lines.length > visible) {
3177
- body2.push(paint.dim(`\u2026 ${dialog.scroll + visible} of ${dialog.lines.length} lines \xB7 \u2191\u2193 to scroll`));
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;
3178
3285
  }
3286
+ return this.entries[this.index];
3179
3287
  }
3180
- const title = ` ${dialog.title} `;
3181
- const rule = BOX.h.repeat(Math.max(0, width - 2 - title.length - 1));
3182
- const lines = [
3183
- paint.dim(`${BOX.tl}${BOX.h}`) + paint.bold(title) + paint.dim(`${rule}${BOX.tr}`),
3184
- ...body2.map((line) => `${paint.dim(BOX.v)} ${pad3(truncate(line, inner), inner)} ${paint.dim(BOX.v)}`),
3185
- paint.dim(`${BOX.bl}${BOX.h.repeat(width - 2)}${BOX.br}`)
3186
- ];
3187
- const top = Math.max(0, Math.floor((rows - lines.length) / 2));
3188
- const left = Math.max(0, Math.floor((columns - width) / 2));
3189
- return { top, left, lines };
3190
- }
3191
- function pad3(text, width) {
3192
- const short = width - visibleWidth(text);
3193
- return short > 0 ? `${text}${" ".repeat(short)}` : text;
3194
- }
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
+ };
3195
3298
 
3196
- // packages/tui/src/screen.ts
3197
- import { EventEmitter } from "node:events";
3198
- var ESC4 = "\x1B";
3199
- var ALT_SCREEN_ON = `${ESC4}[?1049h`;
3200
- var ALT_SCREEN_OFF = `${ESC4}[?1049l`;
3201
- var CURSOR_HIDE = `${ESC4}[?25l`;
3202
- var CURSOR_SHOW = `${ESC4}[?25h`;
3203
- var PASTE_ON = `${ESC4}[?2004h`;
3204
- var PASTE_OFF = `${ESC4}[?2004l`;
3205
- var MOUSE_ON = `${ESC4}[?1000h${ESC4}[?1006h`;
3206
- var MOUSE_OFF = `${ESC4}[?1006l${ESC4}[?1000l`;
3207
- var CLEAR = `${ESC4}[2J`;
3208
- var RESET = `${ESC4}[0m`;
3209
- var at = (row, col) => `${ESC4}[${row + 1};${col + 1}H`;
3210
- var Screen = class extends EventEmitter {
3211
- constructor(out = process.stdout, input = process.stdin, opts = {}) {
3212
- super();
3213
- this.out = out;
3214
- this.input = input;
3215
- this.opts = opts;
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, "");
3302
+ }
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");
3311
+ }
3312
+ get cursor() {
3313
+ return { row: this.row, col: this.col };
3314
+ }
3315
+ get lineCount() {
3316
+ return this.lines.length;
3216
3317
  }
3217
- last = null;
3218
- lastSize = "";
3219
- entered = false;
3220
- decoder = new KeyDecoder();
3221
- get rows() {
3222
- const n = this.opts.rows ?? this.out.rows;
3223
- return typeof n === "number" && n > 0 ? n : 24;
3318
+ get isEmpty() {
3319
+ return this.lines.length === 1 && this.lines[0] === "";
3224
3320
  }
3225
- get columns() {
3226
- const n = this.opts.columns ?? this.out.columns;
3227
- return typeof n === "number" && n > 0 ? n : 80;
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];
3228
3324
  }
3229
- /** Take the terminal: alternate screen, raw keys, bracketed paste, the mouse if asked. */
3230
- enter() {
3231
- if (this.entered) return;
3232
- this.entered = true;
3233
- this.out.write(`${ALT_SCREEN_ON}${CURSOR_HIDE}${CLEAR}${PASTE_ON}${this.opts.mouse ? MOUSE_ON : ""}`);
3234
- try {
3235
- this.input.setRawMode?.(true);
3236
- } catch {
3237
- }
3238
- this.input.setEncoding?.("utf8");
3239
- this.input.on("data", this.onData);
3240
- this.input.on("end", this.onEnd);
3241
- this.input.resume();
3242
- this.out.on?.("resize", this.onResize);
3243
- process.once("exit", this.leave);
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;
3244
3330
  }
3245
- /** Give the terminal back, in the reverse order. Safe to call more than once. */
3246
- leave = () => {
3247
- if (!this.entered) return;
3248
- this.entered = false;
3249
- process.off("exit", this.leave);
3250
- this.out.off?.("resize", this.onResize);
3251
- this.input.off("data", this.onData);
3252
- this.input.off("end", this.onEnd);
3253
- try {
3254
- this.input.pause();
3255
- this.input.setRawMode?.(false);
3256
- } catch {
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";
3257
3354
  }
3258
- this.out.write(`${this.opts.mouse ? MOUSE_OFF : ""}${PASTE_OFF}${RESET}${CURSOR_SHOW}${ALT_SCREEN_OFF}`);
3259
- this.last = null;
3260
- };
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
+ }
3261
3364
  /**
3262
- * Draw a whole frame, writing only the rows that changed.
3263
- *
3264
- * `frame` is read as `rows` lines; missing ones are blank, extra ones are
3265
- * dropped. A size change since the last paint clears and redraws everything,
3266
- * because a shorter line leaves the old one's tail where a diff cannot see it.
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.
3267
3368
  */
3268
- paint(frame, cursor = null) {
3269
- if (!this.entered) return;
3270
- const rows = this.rows;
3271
- const columns = this.columns;
3272
- const size = `${rows}x${columns}`;
3273
- let out = CURSOR_HIDE;
3274
- if (!this.last || this.lastSize !== size) {
3275
- out += CLEAR;
3276
- this.last = new Array(rows).fill(null);
3277
- this.lastSize = size;
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";
3278
3381
  }
3279
- for (let r = 0; r < rows; r++) {
3280
- const line = fit(frame[r] ?? "", columns);
3281
- if (this.last[r] === line) continue;
3282
- this.last[r] = line;
3283
- out += `${at(r, 0)}${line}${RESET}`;
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";
3284
3388
  }
3285
- if (cursor) out += `${at(Math.min(cursor.row, rows - 1), Math.min(cursor.col, columns - 1))}${CURSOR_SHOW}`;
3286
- this.out.write(out);
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";
3287
3440
  }
3288
- /** Forget the last frame, so the next paint redraws every row. For ctrl-l. */
3289
- invalidate() {
3290
- this.last = null;
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 } };
3291
3464
  }
3292
- onData = (chunk) => {
3293
- const keys = this.decoder.feed(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
3294
- if (keys.length) this.emit("keys", keys);
3295
- };
3296
- onEnd = () => {
3297
- this.emit("end");
3298
- };
3299
- onResize = () => {
3300
- this.last = null;
3301
- this.emit("resize");
3302
- };
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"
3303
3514
  };
3304
- function fit(line, width) {
3305
- const cut = truncate(line, width);
3306
- const short = width - visibleWidth(cut);
3307
- return short > 0 ? `${cut}${" ".repeat(short)}` : cut;
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
+ };
3308
3522
  }
3309
-
3310
- // packages/tui/src/markdown.ts
3311
- function renderMarkdown(text, opts) {
3312
- const lines = text.replace(/\r\n/g, "\n").split("\n");
3313
- const pad4 = opts.indent ?? "";
3314
- const out = [];
3315
- let inFence = false;
3316
- let fenceLang = "";
3317
- for (const line of lines) {
3318
- const fence = /^\s*```(\w*)/.exec(line);
3319
- if (fence) {
3320
- if (!inFence) {
3321
- inFence = true;
3322
- fenceLang = fence[1] ?? "";
3323
- out.push(paint.dim(`${pad4}${fenceLang || "code"}`));
3324
- } else {
3325
- inFence = false;
3326
- fenceLang = "";
3327
- out.push("");
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 };
3328
3538
  }
3329
- continue;
3330
- }
3331
- if (inFence) {
3332
- out.push(`${paint.dim(`${pad4}|`)} ${line}`);
3333
- continue;
3334
- }
3335
- out.push(...prose(line, opts.width, pad4));
3539
+ return null;
3336
3540
  }
3337
- return out.join("\n");
3338
3541
  }
3339
- function prose(line, width, pad4) {
3340
- const heading = /^(#{1,6})\s+(.*)$/.exec(line);
3341
- if (heading) return [`${pad4}${paint.bold(paint.accent(heading[2]))}`];
3342
- if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
3343
- return [`${pad4}${paint.dim("-".repeat(Math.min(width, 40)))}`];
3344
- }
3345
- const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
3346
- if (bullet) {
3347
- const indent2 = `${pad4}${bullet[1]}${paint.accent("-")} `;
3348
- return wrap2(inline(bullet[2]), width, indent2, `${pad4}${bullet[1]} `);
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;
3349
3548
  }
3350
- const numbered = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
3351
- if (numbered) {
3352
- const indent2 = `${pad4}${numbered[1]}${paint.accent(`${numbered[2]}.`)} `;
3353
- return wrap2(inline(numbered[3]), width, indent2, `${pad4}${numbered[1]} `);
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;
3354
3666
  }
3355
- const quote = /^\s*>\s?(.*)$/.exec(line);
3356
- if (quote) {
3357
- const gutter = `${pad4}${paint.dim("|")} `;
3358
- return wrap2(paint.muted(inline(quote[1])), width, gutter, gutter);
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;
3359
3678
  }
3360
- if (!line.trim()) return [""];
3361
- return wrap2(inline(line), width, pad4, pad4);
3362
- }
3363
- var HOLD = "\0";
3364
- function inline(text) {
3365
- const code = [];
3366
- let held = text.replace(/`([^`]+)`/g, (_, body2) => {
3367
- code.push(body2);
3368
- return `${HOLD}${code.length - 1}${HOLD}`;
3369
- });
3370
- 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})`)}`);
3371
- return held.replace(
3372
- new RegExp(`${HOLD}(\\d+)${HOLD}`, "g"),
3373
- (_, n) => paint.cyan(code[Number(n)] ?? "")
3374
- );
3375
- }
3376
- function wrap2(text, width, first, rest) {
3377
- const limit = Math.max(20, width - visibleWidth(first));
3378
- if (visibleWidth(text) <= limit) return [`${first}${text}`];
3379
- const out = [];
3380
- let line = "";
3381
- for (const word of text.split(" ")) {
3382
- if (line && visibleWidth(line) + 1 + visibleWidth(word) > limit) {
3383
- out.push(`${out.length === 0 ? first : rest}${line}`);
3384
- line = word;
3385
- } else {
3386
- line = line ? `${line} ${word}` : word;
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 } };
3387
3688
  }
3689
+ return { name: "mouse", sequence, mouse: { kind: "click", button, x, y } };
3690
+ }
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 };
3703
+ }
3704
+
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 };
3388
3728
  }
3389
- if (line) out.push(`${out.length === 0 ? first : rest}${line}`);
3390
3729
  return out;
3391
3730
  }
3392
3731
 
3393
- // packages/tui/src/transcript.ts
3394
- var LIVE_LINES = 8;
3395
- var Transcript = class {
3396
- entries = [];
3397
- nextId = 1;
3398
- cache = /* @__PURE__ */ new Map();
3399
- /** For a streaming answer: the rendered COMPLETE part, so only the tail is re-rendered per chunk. */
3400
- prose = /* @__PURE__ */ new Map();
3401
- get length() {
3402
- return this.entries.length;
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;
3403
3738
  }
3404
- get blocks() {
3405
- return this.entries;
3739
+ selected = 0;
3740
+ get current() {
3741
+ return this.items[this.selected] ?? null;
3406
3742
  }
3407
- /** Typed by kind rather than by the literal passed, so `done: false` stays a boolean the caller can flip. */
3408
- add(block) {
3409
- const entry = { id: this.nextId++, version: 0, block };
3410
- this.entries.push(entry);
3411
- return entry;
3743
+ move(delta) {
3744
+ if (!this.items.length) return;
3745
+ this.selected = (this.selected + delta + this.items.length) % this.items.length;
3412
3746
  }
3413
- /**
3414
- * Move a block to the bottom. For an approval card, which must stay the last
3415
- * thing on screen while it waits: a helper that keeps working adds rows below
3416
- * it, and a card pushed up past the fold is one approved without being read.
3417
- */
3418
- toEnd(entry) {
3419
- const at2 = this.entries.indexOf(entry);
3420
- if (at2 === -1 || at2 === this.entries.length - 1) return;
3421
- this.entries.splice(at2, 1);
3422
- this.entries.push(entry);
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
+ });
3423
3763
  }
3424
- /** Say a block changed, so its cached lines are dropped on the next render. */
3425
- touch(entry) {
3426
- entry.version++;
3764
+ };
3765
+ function pad2(text, width) {
3766
+ const short = width - visibleWidth(text);
3767
+ return short > 0 ? `${text}${" ".repeat(short)}` : text;
3768
+ }
3769
+
3770
+ // packages/tui/src/dialog.ts
3771
+ function pick(title, items, onSelect) {
3772
+ return { kind: "pick", title, items, query: "", selected: 0, onSelect };
3773
+ }
3774
+ function info(title, lines) {
3775
+ return { kind: "info", title, lines, scroll: 0 };
3776
+ }
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";
3427
3798
  }
3428
- /** Forget everything shown. The session file is untouched; this is the view. */
3429
- clear() {
3430
- this.entries = [];
3431
- this.cache.clear();
3432
- this.prose.clear();
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";
3433
3803
  }
3434
- /** Every line, top to bottom, with one blank line between blocks. */
3435
- render(width) {
3436
- const out = [];
3437
- const live = /* @__PURE__ */ new Set();
3438
- for (const entry of this.entries) {
3439
- live.add(entry.id);
3440
- const lines = this.lines(entry, width);
3441
- if (out.length && lines.length) out.push("");
3442
- out.push(...lines);
3443
- }
3444
- for (const id of this.cache.keys()) if (!live.has(id)) this.cache.delete(id);
3445
- for (const id of this.prose.keys()) if (!live.has(id)) this.prose.delete(id);
3446
- return out;
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";
3447
3807
  }
3448
- lines(entry, width) {
3449
- const hit = this.cache.get(entry.id);
3450
- if (hit && hit.version === entry.version && hit.width === width) return hit.lines;
3451
- const lines = entry.block.kind === "assistant" ? this.assistant(entry, width) : renderBlock(entry.block, width);
3452
- this.cache.set(entry.id, { version: entry.version, width, lines });
3453
- return lines;
3808
+ if (key.name === "return") {
3809
+ const item = shown[dialog.selected];
3810
+ if (!item) return "same";
3811
+ dialog.onSelect(item);
3812
+ return "select";
3454
3813
  }
3455
- /**
3456
- * An answer, streaming or finished.
3457
- *
3458
- * Complete markdown blocks are rendered; the unfinished tail - a line still
3459
- * arriving, a fence not yet closed - is shown raw and dimmed, so the reader
3460
- * sees the words land without a heading flickering between plain and bold.
3461
- */
3462
- assistant(entry, width) {
3463
- const inner = proseWidth(width);
3464
- const { text, done } = entry.block;
3465
- if (done) return text.trim() ? renderMarkdown(text.replace(/\s+$/, ""), { width: inner, indent: " " }).split("\n") : [];
3466
- const { complete: complete2, tail } = splitStreaming(text);
3467
- let head = this.prose.get(entry.id);
3468
- if (!head || head.length !== complete2.length || head.width !== width) {
3469
- head = {
3470
- length: complete2.length,
3471
- width,
3472
- lines: complete2 ? renderMarkdown(complete2, { width: inner, indent: " " }).split("\n") : []
3473
- };
3474
- this.prose.set(entry.id, head);
3475
- }
3476
- const raw = tail ? tail.split("\n").flatMap((line) => wrapPlain(line, inner - 2)).map((line) => ` ${paint.dim(line)}`) : [];
3477
- return [...head.lines, ...raw];
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";
3478
3819
  }
3479
- };
3480
- function splitStreaming(text) {
3481
- const lines = text.replace(/\r\n?/g, "\n").split("\n");
3482
- let tail = lines.pop() ?? "";
3483
- let open = -1;
3484
- lines.forEach((line, i) => {
3485
- if (/^\s*```/.test(line)) open = open === -1 ? i : -1;
3486
- });
3487
- let complete2 = lines;
3488
- if (open !== -1) {
3489
- tail = [...lines.slice(open), tail].join("\n");
3490
- complete2 = lines.slice(0, open);
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";
3491
3824
  }
3492
- return { complete: complete2.join("\n"), tail };
3493
- }
3494
- function proseWidth(width) {
3495
- return Math.max(20, width - 2);
3825
+ return "same";
3496
3826
  }
3497
- function renderBlock(block, width) {
3498
- const inner = proseWidth(width);
3499
- switch (block.kind) {
3500
- case "user":
3501
- return block.text.split("\n").flatMap(
3502
- (line, i) => wrapPlain(line, inner - 2).map((piece, j) => {
3503
- const text = i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`;
3504
- return band(text, visibleWidth(text), width);
3505
- })
3506
- );
3507
- case "assistant":
3508
- return [];
3509
- case "tool": {
3510
- const out = toolBlock(block.name, block.detail, block.status, { input: block.input, output: block.output }, inner);
3511
- if (block.live.length && !block.output) {
3512
- const shown = block.live.slice(-LIVE_LINES);
3513
- if (block.live.length > LIVE_LINES) out.push(` ${paint.dim(`\u2026 ${block.live.length - LIVE_LINES} more lines`)}`);
3514
- for (const line of shown) out.push(` ${paint.dim(line)}`);
3515
- }
3516
- return out;
3517
- }
3518
- case "approval": {
3519
- const out = [
3520
- ...block.helper ? [` ${paint.accent("from a helper:")} ${paint.muted(block.helper)}`] : [],
3521
- ...approvalCard(block.preview, block.call, block.mode, inner)
3522
- ];
3523
- out.push(
3524
- 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")}`
3525
- );
3526
- return out;
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));
3527
3845
  }
3528
- case "error":
3529
- return block.text.split("\n").map((line, i) => i === 0 ? `${paint.red(" labd:")} ${line}` : ` ${line}`);
3530
- case "note":
3531
- return block.text.replace(/\n$/, "").split("\n");
3532
- case "compaction": {
3533
- const label2 = " summarised the conversation so far ";
3534
- const room = Math.max(0, inner - label2.length);
3535
- return [paint.dim(` ${"\u2500".repeat(Math.floor(room / 2))}${label2}${"\u2500".repeat(Math.ceil(room / 2))}`)];
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`));
3536
3853
  }
3537
3854
  }
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 };
3538
3865
  }
3539
- function wrapPlain(text, width) {
3540
- const limit = Math.max(10, width);
3541
- if (visibleWidth(text) <= limit) return [text];
3542
- const out = [];
3543
- let line = "";
3544
- for (const word of text.split(" ")) {
3545
- if (line && line.length + 1 + word.length > limit) {
3546
- out.push(line);
3547
- line = word;
3548
- } else {
3549
- line = line ? `${line} ${word}` : word;
3550
- }
3551
- while (line.length > limit) {
3552
- out.push(line.slice(0, limit));
3553
- line = line.slice(limit);
3554
- }
3555
- }
3556
- if (line || !out.length) out.push(line);
3557
- return out;
3866
+ function pad3(text, width) {
3867
+ const short = width - visibleWidth(text);
3868
+ return short > 0 ? `${text}${" ".repeat(short)}` : text;
3558
3869
  }
3559
- var Viewport = class {
3560
- constructor(height) {
3561
- this.height = height;
3562
- }
3563
- top = 0;
3564
- follow = true;
3565
- total = 0;
3566
- /** The line count when the reader stopped following, so `newLines` can be counted from it. */
3567
- anchor = 0;
3568
- /** Lines that arrived since the reader scrolled away. Zero while following. */
3569
- get newLines() {
3570
- return this.follow ? 0 : Math.max(0, this.total - this.anchor);
3571
- }
3572
- /** Tell the viewport how many lines there are now, and how tall it is. */
3573
- update(total, height = this.height) {
3574
- this.height = height;
3575
- this.total = total;
3576
- if (this.follow) this.top = Math.max(0, total - height);
3577
- else this.top = Math.max(0, Math.min(this.top, total - height));
3578
- }
3579
- scroll(delta) {
3580
- const max = Math.max(0, this.total - this.height);
3581
- const next = Math.max(0, Math.min(max, this.top + delta));
3582
- if (next === this.top && this.follow) return;
3583
- this.top = next;
3584
- if (next >= max) this.refollow();
3585
- else this.unfollow();
3586
- }
3587
- pageUp() {
3588
- this.scroll(-Math.max(1, this.height - 1));
3589
- }
3590
- pageDown() {
3591
- this.scroll(Math.max(1, this.height - 1));
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;
3592
3891
  }
3593
- home() {
3594
- this.top = 0;
3595
- 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;
3596
3899
  }
3597
- end() {
3598
- this.refollow();
3599
- 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;
3600
3903
  }
3601
- /** The slice of `lines` on screen. */
3602
- slice(lines) {
3603
- 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);
3604
3919
  }
3605
- unfollow() {
3606
- if (this.follow) this.anchor = this.total;
3607
- 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);
3608
3962
  }
3609
- refollow() {
3610
- this.follow = true;
3963
+ /** Forget the last frame, so the next paint redraws every row. For ctrl-l. */
3964
+ invalidate() {
3965
+ this.last = null;
3611
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
+ };
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
+ }
3984
+
3985
+ // packages/tui/src/heatmap.ts
3986
+ var INDENT = " ";
3987
+ var DAY_LABEL = ["Mon", " ", "Wed", " ", "Fri", " ", "Sun"];
3988
+ var GUTTER = INDENT.length + 4;
3989
+ var MIN_WEEKS = 4;
3990
+ var GLYPH = { 0: "\xB7", 1: "\u2591", 2: "\u2592", 3: "\u2593", 4: "\u2588" };
3991
+ var SHADE = {
3992
+ 0: paint.dim,
3993
+ 1: paint.heat1,
3994
+ 2: paint.heat2,
3995
+ 3: paint.heat3,
3996
+ 4: paint.accent
3612
3997
  };
3998
+ function heatmapWeeks(width) {
3999
+ return Math.max(MIN_WEEKS, Math.min(ACTIVITY_WEEKS, Math.floor((width - GUTTER) / 2)));
4000
+ }
4001
+ function heatmapLines(summary, width) {
4002
+ const { days, totals, streak } = summary;
4003
+ const columns = Math.ceil(days.length / 7);
4004
+ const lines = [`${INDENT}${paint.muted(`last ${columns} weeks`)}`, `${" ".repeat(GUTTER)}${monthRow(days, columns)}`];
4005
+ for (let row2 = 0; row2 < 7; row2++) {
4006
+ let cells = "";
4007
+ for (let column = 0; column < columns; column++) {
4008
+ const day = days[column * 7 + row2];
4009
+ cells += day ? `${SHADE[day.level](GLYPH[day.level])} ` : " ";
4010
+ }
4011
+ lines.push(`${INDENT}${paint.dim(DAY_LABEL[row2])} ${cells.trimEnd()}`);
4012
+ }
4013
+ const legend = [0, 1, 2, 3, 4].map((l) => SHADE[l](GLYPH[l])).join(" ");
4014
+ lines.push(`${" ".repeat(GUTTER)}${paint.dim("less")} ${legend} ${paint.dim("more")}`, "");
4015
+ const row = (label2, value) => `${INDENT}${paint.muted(label2.padEnd(10))}${value}`;
4016
+ lines.push(
4017
+ row("tokens", `${paint.bold(formatTokens(totals.input + totals.output))} ${paint.dim(`${formatTokens(totals.input)} in \xB7 ${formatTokens(totals.output)} out`)}`),
4018
+ row("requests", `${paint.bold(String(totals.turns))} ${paint.dim(`on ${plural(totals.activeDays, "day")}`)}`),
4019
+ row("streak", `${paint.bold(plural(streak.current, "day"))} ${paint.dim(`longest ${plural(streak.longest, "day")}`)}`)
4020
+ );
4021
+ if (summary.busiest) {
4022
+ lines.push(row("busiest", `${paint.bold(dayName(summary.busiest.date))} ${paint.dim(`${formatTokens(summary.busiest.tokens)} tokens`)}`));
4023
+ }
4024
+ if (summary.peakHour !== null) lines.push(row("peak hour", paint.bold(formatHour(summary.peakHour))));
4025
+ const where = summary.surfaces.map((s) => `${surfaceLabel(s.surface)} ${paint.bold(formatTokens(s.tokens))}`);
4026
+ const joined = row("where", where.join(paint.dim(" \xB7 ")));
4027
+ if (!where.length) return lines;
4028
+ if (visibleWidth(joined) <= width) lines.push(joined);
4029
+ else where.forEach((w, i) => lines.push(row(i === 0 ? "where" : "", w)));
4030
+ return lines;
4031
+ }
4032
+ function monthRow(days, columns) {
4033
+ const starts = [];
4034
+ for (let column = 0; column < columns; column++) {
4035
+ const week = days.slice(column * 7, column * 7 + 7);
4036
+ const first = column === 0 ? week[0] : week.find((d) => d.date.endsWith("-01"));
4037
+ if (first && column + 1 < columns) starts.push({ column, date: first.date });
4038
+ }
4039
+ if (starts.length > 1 && starts[1].column < 2) starts.shift();
4040
+ let out = "";
4041
+ for (const { column, date } of starts) {
4042
+ out = out.padEnd(column * 2) + localDay(date).toLocaleDateString("en-GB", { month: "short" }).slice(0, 3);
4043
+ }
4044
+ return paint.dim(out);
4045
+ }
4046
+ function localDay(date) {
4047
+ const [y, m, d] = date.split("-").map(Number);
4048
+ return new Date(y, m - 1, d);
4049
+ }
4050
+ function dayName(date) {
4051
+ return localDay(date).toLocaleDateString("en-GB", { day: "numeric", month: "short" });
4052
+ }
4053
+ function plural(n, word) {
4054
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
4055
+ }
3613
4056
 
3614
4057
  // packages/tui/src/face.ts
3615
4058
  var FACE_ART = [
@@ -3696,8 +4139,8 @@ var Prompt = class {
3696
4139
  this.busyLabel = null;
3697
4140
  this.editor.reset();
3698
4141
  this.draw();
3699
- return new Promise((resolve2) => {
3700
- this.resolve = resolve2;
4142
+ return new Promise((resolve3) => {
4143
+ this.resolve = resolve3;
3701
4144
  });
3702
4145
  }
3703
4146
  /** Put the box in its working state, or take it out of one. */
@@ -3815,11 +4258,11 @@ var Prompt = class {
3815
4258
  this.finish({ kind: "exit" });
3816
4259
  }
3817
4260
  finish(result) {
3818
- const resolve2 = this.resolve;
4261
+ const resolve3 = this.resolve;
3819
4262
  this.resolve = null;
3820
4263
  this.label = null;
3821
4264
  this.renderer.clear();
3822
- resolve2?.(result);
4265
+ resolve3?.(result);
3823
4266
  }
3824
4267
  };
3825
4268
 
@@ -4020,11 +4463,11 @@ var Auth = class {
4020
4463
  }
4021
4464
  };
4022
4465
  function sleep(ms, signal) {
4023
- return new Promise((resolve2) => {
4024
- const timer = setTimeout(resolve2, ms);
4466
+ return new Promise((resolve3) => {
4467
+ const timer = setTimeout(resolve3, ms);
4025
4468
  signal?.addEventListener("abort", () => {
4026
4469
  clearTimeout(timer);
4027
- resolve2();
4470
+ resolve3();
4028
4471
  }, { once: true });
4029
4472
  });
4030
4473
  }
@@ -4064,11 +4507,18 @@ function stateDir() {
4064
4507
  function projectDir(root) {
4065
4508
  return join6(root, ".labd");
4066
4509
  }
4067
- 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 = {}) {
4068
4515
  const global = read(join6(stateDir(), "settings.json"));
4069
4516
  const project = read(join6(projectDir(root), "settings.json"));
4070
- const projectServerUrl = asString(project.serverUrl);
4071
- 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) {
4072
4522
  return {
4073
4523
  // Global, then the command line. The project is deliberately not consulted:
4074
4524
  // a checked-in file must not be able to turn approvals off for whoever runs
@@ -4079,15 +4529,14 @@ function loadSettings(root, overrides = {}, opts = {}) {
4079
4529
  // settings file is not a choice, which is why this tests the parse rather
4080
4530
  // than the presence of the key.
4081
4531
  modeChosen: overrides.mode !== void 0 || AGENT_MODES.includes(global.mode) || global.mode === "approve",
4082
- effort: overrides.effort ?? pickEffort(project.effort ?? global.effort),
4532
+ effort: overrides.effort ?? pickEffort(global.effort),
4083
4533
  serverUrl: stripSlash(
4084
- 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
4085
4535
  ),
4086
- ...projectServerUrl && !honourProjectUrl ? { ignoredProjectServerUrl: projectServerUrl } : {},
4087
4536
  commandTimeoutSeconds: clampTimeout(
4088
4537
  overrides.commandTimeoutSeconds ?? asNumber(project.commandTimeoutSeconds) ?? asNumber(global.commandTimeoutSeconds) ?? DEFAULTS.commandTimeoutSeconds
4089
4538
  ),
4090
- checkpoints: overrides.checkpoints ?? asBoolean(project.checkpoints) ?? asBoolean(global.checkpoints) ?? DEFAULTS.checkpoints,
4539
+ checkpoints: overrides.checkpoints ?? asBoolean(global.checkpoints) ?? DEFAULTS.checkpoints,
4091
4540
  // Screen preferences. Harmless from either file: neither changes what labd
4092
4541
  // may do, only how the session is drawn.
4093
4542
  mouse: overrides.mouse ?? asBoolean(project.mouse) ?? asBoolean(global.mouse) ?? DEFAULTS.mouse,
@@ -4130,7 +4579,8 @@ var BUILTIN_COMMANDS = [
4130
4579
  { name: "session", what: "where this one is stored" },
4131
4580
  { name: "rename", args: "<name>", what: "name this session, so /sessions and --resume show it" },
4132
4581
  { name: "new", what: "start a fresh session; this one stays saved" },
4133
- { name: "usage", what: "what's left of this month's allowance" },
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" },
4134
4584
  { name: "trust", what: "let this project load its own extensions" },
4135
4585
  { name: "logout", what: "sign this machine out and leave" },
4136
4586
  { name: "reload", what: "reload extensions and the file list" },
@@ -4272,7 +4722,7 @@ async function createRuntime(opts) {
4272
4722
  const root = opts.root;
4273
4723
  const state = stateDir();
4274
4724
  const trusted = isTrusted(state, root);
4275
- const settings = loadSettings(root, opts.overrides, { trusted });
4725
+ const settings = loadSettings(root, opts.overrides);
4276
4726
  const auth = new Auth(state, settings.serverUrl);
4277
4727
  const client = new AgentClient({
4278
4728
  baseUrl: settings.serverUrl,
@@ -4349,6 +4799,44 @@ import { basename as basename4 } from "node:path";
4349
4799
  // packages/cli/src/commands.ts
4350
4800
  import { dirname as dirname3 } from "node:path";
4351
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
+
4352
4840
  // packages/cli/src/files.ts
4353
4841
  import { readdirSync as readdirSync6 } from "node:fs";
4354
4842
  import { join as join8, relative as relative4 } from "node:path";
@@ -4440,13 +4928,41 @@ async function handleCommand(input, ctx) {
4440
4928
  return { effort: args };
4441
4929
  }
4442
4930
  case "usage": {
4443
- const credits = await runtime.client.credits();
4931
+ const [credits, activity] = await Promise.all([
4932
+ runtime.client.credits(),
4933
+ runtime.client.activity(utcOffsetMinutes())
4934
+ ]);
4444
4935
  if (!credits) {
4445
4936
  print(` ${paint.muted("Couldn't check that just now.")}`);
4446
4937
  } else {
4447
4938
  print(` ${paint.bold(`${credits.percentLeft}%`)} ${paint.muted("of this month left")}`);
4939
+ printAllowance(credits, print);
4448
4940
  }
4449
- return {};
4941
+ if (activity && !activity.days.length) {
4942
+ print(` ${paint.muted("No activity yet. Ask labd something and it shows up here.")}`);
4943
+ } else if (activity) {
4944
+ print("");
4945
+ print(heatmapLines(summariseActivity(activity, heatmapWeeks(ctx.width)), ctx.width).join("\n"));
4946
+ } else if (credits) {
4947
+ print(` ${paint.muted("Couldn't read your activity just now.")}`);
4948
+ }
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 };
4450
4966
  }
4451
4967
  case "tree": {
4452
4968
  const nodes = runtime.session.all();
@@ -4474,9 +4990,12 @@ async function handleCommand(input, ctx) {
4474
4990
  print(` ${paint.yellow("!")} ${paint.muted(`no point here starts with "${args}"`)}`);
4475
4991
  return {};
4476
4992
  }
4993
+ const moved = match.id !== runtime.session.headId;
4477
4994
  runtime.session.moveTo(match.id);
4478
- print(` ${paint.muted(`continuing from ${match.id.slice(0, 8)}. The other branch is still in the file.`)}`);
4479
- return {};
4995
+ print(
4996
+ ` ${paint.muted(moved ? `continuing from ${match.id.slice(0, 8)}. The other branch is still in the file.` : `already at ${match.id.slice(0, 8)}.`)}`
4997
+ );
4998
+ return { moved };
4480
4999
  }
4481
5000
  case "sessions": {
4482
5001
  const bucket = dirname3(runtime.session.file);
@@ -4622,6 +5141,12 @@ async function handleCommand(input, ctx) {
4622
5141
  }
4623
5142
  }
4624
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
+ }
4625
5150
  function summarise(body2) {
4626
5151
  const text = typeof body2.text === "string" ? body2.text : "";
4627
5152
  if (body2.kind === "tool") {
@@ -4739,7 +5264,15 @@ async function runInteractive(runtime) {
4739
5264
  credits: null,
4740
5265
  tick: 0
4741
5266
  };
5267
+ const opening = runtime.client.credits();
4742
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
+ }
4743
5276
  let abort = null;
4744
5277
  const prompt = new Prompt(renderer, {
4745
5278
  describe: (line) => describe(line, runtime),
@@ -4748,7 +5281,7 @@ async function runInteractive(runtime) {
4748
5281
  project: basename4(runtime.root),
4749
5282
  branch: runtime.branch,
4750
5283
  contextPercent: state.context?.percentUsed ?? null,
4751
- creditsPercent: state.credits?.percentLeft ?? null,
5284
+ credits: state.credits,
4752
5285
  mode: state.mode,
4753
5286
  effort: state.effort ?? null
4754
5287
  }),
@@ -4792,6 +5325,7 @@ async function runInteractive(runtime) {
4792
5325
  mode: state.mode,
4793
5326
  effort: state.effort,
4794
5327
  asked: answered > 0,
5328
+ width: process.stdout.columns ?? 80,
4795
5329
  print: (text) => renderer.print(text)
4796
5330
  });
4797
5331
  if (outcome.farewell) process.stderr.write(`${outcome.farewell}
@@ -4799,6 +5333,8 @@ async function runInteractive(runtime) {
4799
5333
  if (outcome.exit) break;
4800
5334
  if (outcome.mode) state.mode = outcome.mode;
4801
5335
  if (outcome.effort !== void 0) state.effort = outcome.effort;
5336
+ if (outcome.moved) state.context = null;
5337
+ if (outcome.credits) state.credits = outcome.credits;
4802
5338
  continue;
4803
5339
  }
4804
5340
  const tags = taggedPaths(input);
@@ -4870,9 +5406,19 @@ async function runInteractive(runtime) {
4870
5406
  for (const line of waiting) renderer.print(line);
4871
5407
  }
4872
5408
  },
4873
- error(message) {
5409
+ error(message, info2) {
4874
5410
  stopSpinner();
4875
- 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;
4876
5422
  },
4877
5423
  done(info2) {
4878
5424
  if (info2.context) state.context = info2.context;
@@ -4913,6 +5459,7 @@ async function runInteractive(runtime) {
4913
5459
  if (state.context && state.context.percentUsed >= COMPACT_AT) {
4914
5460
  startSpinner("summarising, to make room");
4915
5461
  const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
5462
+ if (ok) state.context = null;
4916
5463
  stopSpinner();
4917
5464
  renderer.print(paint.dim(ok ? " summarised the conversation so far." : " (could not summarise; carrying on)"));
4918
5465
  }
@@ -4947,10 +5494,10 @@ async function banner(renderer, runtime, mode) {
4947
5494
  renderer.print("");
4948
5495
  renderer.print(`${paint.accent(FACE)} ${paint.bold("labd")} ${paint.dim(`in ${where}${runtime.branch ? ` on ${runtime.branch}` : ""}`)}`);
4949
5496
  renderer.print(` ${paint.dim(MODE_CAPTION[mode])}`);
4950
- if (runtime.settings.ignoredProjectServerUrl) {
5497
+ if (runtime.settings.ignoredProjectKeys) {
4951
5498
  renderer.print(
4952
5499
  ` ${paint.yellow("!")} ${paint.muted(
4953
- `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)
4954
5501
  )}`
4955
5502
  );
4956
5503
  }
@@ -4960,7 +5507,7 @@ async function banner(renderer, runtime, mode) {
4960
5507
  renderer.print("");
4961
5508
  }
4962
5509
  function sleep2(ms) {
4963
- return new Promise((resolve2) => setTimeout(resolve2, ms));
5510
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
4964
5511
  }
4965
5512
 
4966
5513
  // packages/cli/src/modes/screen.ts
@@ -5073,6 +5620,14 @@ async function runScreen(runtime, opts) {
5073
5620
  effort: runtime.settings.effort,
5074
5621
  context: null,
5075
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,
5076
5631
  sidebar: opts.sidebar,
5077
5632
  tick: 0,
5078
5633
  /** What labd is doing right now, for the footer. Null when idle. */
@@ -5131,8 +5686,8 @@ async function runScreen(runtime, opts) {
5131
5686
  schedule();
5132
5687
  };
5133
5688
  let resolveExit = null;
5134
- const exited = new Promise((resolve2) => {
5135
- resolveExit = resolve2;
5689
+ const exited = new Promise((resolve3) => {
5690
+ resolveExit = resolve3;
5136
5691
  });
5137
5692
  const quit = () => {
5138
5693
  if (state.quitting) return;
@@ -5218,6 +5773,7 @@ async function runScreen(runtime, opts) {
5218
5773
  else if (state.approval) left = `${paint.yellow("y")} ${d("allow \xB7")} ${paint.yellow("n")} ${d("decline \xB7 ctrl+c stop")}`;
5219
5774
  else if (state.busy) left = `${paint.accent(state.busy)}${spinnerFrame(state.tick)} ${d("enter queues \xB7 ctrl+c stop \xB7 pgup/pgdn scroll")}`;
5220
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")}`;
5221
5777
  else {
5222
5778
  const described = describe(editor.currentLine, runtime);
5223
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");
@@ -5227,13 +5783,13 @@ async function runScreen(runtime, opts) {
5227
5783
  const crowded = state.context.percentUsed >= 75;
5228
5784
  bits.push((crowded ? paint.yellow : paint.muted)(`context ${state.context.percentUsed}%`));
5229
5785
  }
5230
- if (state.credits) bits.push(paint.muted(`credits ${state.credits.percentLeft}%`));
5786
+ if (state.credits) bits.push(creditsReading(state.credits, `credits ${state.credits.percentLeft}%`));
5231
5787
  return spread(` ${left}`, `${bits.join(paint.dim(" \xB7 "))} `, width);
5232
5788
  }
5233
5789
  function composer2(width, height) {
5234
5790
  const inner = width - 4;
5235
5791
  const textRows = Math.max(1, height - 2);
5236
- 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);
5237
5793
  const out = [];
5238
5794
  out.push(colour(`\u256D${"\u2500".repeat(Math.max(0, width - 2))}\u256E`));
5239
5795
  const view = editor.view(inner - 2, textRows);
@@ -5244,7 +5800,7 @@ async function runScreen(runtime, opts) {
5244
5800
  const sigil = first ? state.busy ? paint.dim(">") : paint.accent(">") : " ";
5245
5801
  let text = line;
5246
5802
  if (editor.isEmpty && i === 0) {
5247
- 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");
5248
5804
  }
5249
5805
  out.push(`${colour("\u2502")} ${sigil} ${fit(text, inner - 2)} ${colour("\u2502")}`);
5250
5806
  });
@@ -5330,6 +5886,10 @@ async function runScreen(runtime, opts) {
5330
5886
  refreshPopup();
5331
5887
  return schedule();
5332
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();
5333
5893
  const outcome = editor.apply(key);
5334
5894
  if (outcome === "unhandled") {
5335
5895
  if (key.name === "return") return submit();
@@ -5511,8 +6071,10 @@ async function runScreen(runtime, opts) {
5511
6071
  mode: state.mode,
5512
6072
  effort: state.effort,
5513
6073
  asked: state.asked,
6074
+ width: transcriptWidth(),
5514
6075
  print: (text) => note(text)
5515
6076
  });
6077
+ if (outcome.credits) setCredits(outcome.credits);
5516
6078
  if (outcome.farewell) state.farewell = outcome.farewell;
5517
6079
  if (outcome.exit) return quit();
5518
6080
  if (outcome.mode) {
@@ -5520,6 +6082,7 @@ async function runScreen(runtime, opts) {
5520
6082
  state.heldMode = null;
5521
6083
  }
5522
6084
  if (outcome.effort !== void 0) state.effort = outcome.effort;
6085
+ if (outcome.moved) state.context = null;
5523
6086
  schedule();
5524
6087
  }
5525
6088
  function helpLines() {
@@ -5592,6 +6155,7 @@ async function runScreen(runtime, opts) {
5592
6155
  if (state.asked) await closeConversation(runtime);
5593
6156
  runtime.session = Session.open(file);
5594
6157
  state.asked = false;
6158
+ state.context = null;
5595
6159
  transcript.clear();
5596
6160
  state.toolBlocks.clear();
5597
6161
  state.answer = null;
@@ -5602,6 +6166,7 @@ async function runScreen(runtime, opts) {
5602
6166
  if (state.asked) await closeConversation(runtime);
5603
6167
  runtime.session = Session.create(bucketFor(runtime.stateDir, runtime.root));
5604
6168
  state.asked = false;
6169
+ state.context = null;
5605
6170
  transcript.clear();
5606
6171
  state.toolBlocks.clear();
5607
6172
  state.answer = null;
@@ -5620,8 +6185,8 @@ async function runScreen(runtime, opts) {
5620
6185
  state.answer = null;
5621
6186
  const tags = taggedPaths(input);
5622
6187
  const attachments = tags.length ? attachTagged(runtime.root, tags) : void 0;
5623
- for (const refused of attachments?.filter((a) => a.omitted) ?? []) {
5624
- 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}`)}`);
5625
6190
  }
5626
6191
  for (const handler of runtime.extensions.onTurn) {
5627
6192
  try {
@@ -5682,19 +6247,23 @@ async function runScreen(runtime, opts) {
5682
6247
  closeAnswer();
5683
6248
  const entry = transcript.add({ kind: "approval", call, preview, helper, mode: effectiveMode(), answer: null });
5684
6249
  viewport.end();
5685
- return new Promise((resolve2) => {
5686
- state.approval = { entry, resolve: resolve2 };
6250
+ return new Promise((resolve3) => {
6251
+ state.approval = { entry, resolve: resolve3 };
5687
6252
  schedule();
5688
6253
  });
5689
6254
  },
5690
- error(message) {
6255
+ error(message, info2) {
5691
6256
  setBusy(null);
5692
- transcript.add({ kind: "error", text: message });
6257
+ if (info2?.code === "no_credits") refused(message, info2);
6258
+ else transcript.add({ kind: "error", text: message });
5693
6259
  schedule();
5694
6260
  },
6261
+ ready(info2) {
6262
+ if (info2.credits) setCredits(info2.credits);
6263
+ },
5695
6264
  done(info2) {
5696
6265
  if (info2.context) state.context = info2.context;
5697
- if (info2.credits) state.credits = info2.credits;
6266
+ if (info2.credits) setCredits(info2.credits);
5698
6267
  schedule();
5699
6268
  },
5700
6269
  commandOutput(line, key) {
@@ -5728,8 +6297,10 @@ async function runScreen(runtime, opts) {
5728
6297
  if (!abort.signal.aborted && state.context && state.context.percentUsed >= COMPACT_AT) {
5729
6298
  setBusy("summarising, to make room");
5730
6299
  const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
5731
- if (ok) transcript.add({ kind: "compaction" });
5732
- else note(paint.dim(" (could not summarise; carrying on)"));
6300
+ if (ok) {
6301
+ state.context = null;
6302
+ transcript.add({ kind: "compaction" });
6303
+ } else note(paint.dim(" (could not summarise; carrying on)"));
5733
6304
  }
5734
6305
  } catch (err) {
5735
6306
  transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) });
@@ -5740,6 +6311,64 @@ async function runScreen(runtime, opts) {
5740
6311
  setBusy(null);
5741
6312
  }
5742
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
+ }
5743
6372
  function closeAnswer() {
5744
6373
  const answer = state.answer;
5745
6374
  if (!answer || answer.block.done) return;
@@ -5784,10 +6413,10 @@ async function runScreen(runtime, opts) {
5784
6413
  process.on("SIGHUP", onHup);
5785
6414
  screen.enter();
5786
6415
  if (state.sidebar) refreshProjectFacts();
5787
- if (runtime.settings.ignoredProjectServerUrl) {
6416
+ if (runtime.settings.ignoredProjectKeys) {
5788
6417
  note(
5789
6418
  ` ${paint.yellow("!")} ${paint.muted(
5790
- `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)
5791
6420
  )}`
5792
6421
  );
5793
6422
  }
@@ -5806,11 +6435,13 @@ async function runScreen(runtime, opts) {
5806
6435
  }
5807
6436
  if (opts.pickSession) openSessions();
5808
6437
  draw();
6438
+ fetchCredits();
5809
6439
  try {
5810
6440
  await exited;
5811
6441
  if (serial.running) await Promise.race([serial.running, sleep3(1500)]);
5812
6442
  } finally {
5813
6443
  if (faceTimer) clearInterval(faceTimer);
6444
+ if (state.resetTimer) clearTimeout(state.resetTimer);
5814
6445
  if (frameTimer) clearTimeout(frameTimer);
5815
6446
  setBusy(null);
5816
6447
  process.off("SIGTERM", onTerm);
@@ -5883,13 +6514,14 @@ function summarise2(body2) {
5883
6514
  return text.replace(/\s+/g, " ").slice(0, 60);
5884
6515
  }
5885
6516
  function sleep3(ms) {
5886
- return new Promise((resolve2) => setTimeout(resolve2, ms));
6517
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
5887
6518
  }
5888
6519
 
5889
6520
  // packages/cli/src/modes/print.ts
5890
6521
  function toStderr(text) {
5891
6522
  process.stderr.write(process.stderr.isTTY ? text : stripColor(text));
5892
6523
  }
6524
+ var EXIT_NO_CREDITS = 3;
5893
6525
  async function runPrint(runtime, opts) {
5894
6526
  const question = await withStdin(opts.question);
5895
6527
  if (!question.trim()) {
@@ -5899,6 +6531,7 @@ async function runPrint(runtime, opts) {
5899
6531
  }
5900
6532
  let failed = false;
5901
6533
  let declined = false;
6534
+ let outOfCredits = false;
5902
6535
  let answer = "";
5903
6536
  const emit = (event) => {
5904
6537
  if (opts.json) process.stdout.write(`${JSON.stringify(event)}
@@ -5934,9 +6567,10 @@ async function runPrint(runtime, opts) {
5934
6567
  }
5935
6568
  if (step.status === "skipped") declined = true;
5936
6569
  },
5937
- error(message) {
6570
+ error(message, info2) {
5938
6571
  failed = true;
5939
- if (opts.json) emit({ type: "error", message });
6572
+ if (info2?.code === "no_credits") outOfCredits = true;
6573
+ if (opts.json) emit({ type: "error", message, ...info2 });
5940
6574
  else toStderr(`${paint.red("labd:")} ${message}
5941
6575
  `);
5942
6576
  },
@@ -5965,6 +6599,7 @@ async function runPrint(runtime, opts) {
5965
6599
  });
5966
6600
  if (!opts.json && answer && !answer.endsWith("\n")) process.stdout.write("\n");
5967
6601
  await closeConversation(runtime);
6602
+ if (outOfCredits) return EXIT_NO_CREDITS;
5968
6603
  if (failed) return 1;
5969
6604
  if (declined) return 2;
5970
6605
  return result.text.trim() ? 0 : 1;
@@ -6017,11 +6652,11 @@ async function runRpc(runtime) {
6017
6652
  return;
6018
6653
  }
6019
6654
  if (req.method === void 0 && req.id !== void 0 && req.id !== null) {
6020
- const resolve2 = pending.get(Number(req.id));
6021
- if (resolve2) {
6655
+ const resolve3 = pending.get(Number(req.id));
6656
+ if (resolve3) {
6022
6657
  pending.delete(Number(req.id));
6023
6658
  const result = req.result;
6024
- resolve2(result?.approved === true);
6659
+ resolve3(result?.approved === true);
6025
6660
  }
6026
6661
  return;
6027
6662
  }
@@ -6097,8 +6732,8 @@ async function runRpc(runtime) {
6097
6732
  output: step.output
6098
6733
  });
6099
6734
  },
6100
- error(message) {
6101
- notify("labd/error", { message });
6735
+ error(message, info2) {
6736
+ notify("labd/error", { message, ...info2 });
6102
6737
  },
6103
6738
  done(info2) {
6104
6739
  notify("labd/done", info2);
@@ -6113,15 +6748,15 @@ async function runRpc(runtime) {
6113
6748
  * asleep.
6114
6749
  */
6115
6750
  approve(call, preview, helper) {
6116
- return new Promise((resolve2) => {
6117
- if (closed) return resolve2(false);
6751
+ return new Promise((resolve3) => {
6752
+ if (closed) return resolve3(false);
6118
6753
  const id = nextId++;
6119
6754
  const timer = setTimeout(() => {
6120
- if (pending.delete(id)) resolve2(false);
6755
+ if (pending.delete(id)) resolve3(false);
6121
6756
  }, APPROVAL_TIMEOUT_MS);
6122
6757
  pending.set(id, (approved) => {
6123
6758
  clearTimeout(timer);
6124
- resolve2(approved);
6759
+ resolve3(approved);
6125
6760
  });
6126
6761
  out({ jsonrpc: "2.0", id, method: "labd/approve", params: { call, preview, ...helper ? { helper } : {} } });
6127
6762
  });
@@ -6146,14 +6781,14 @@ async function runRpc(runtime) {
6146
6781
  mode: unattendedMode(runtime.settings),
6147
6782
  root: runtime.root
6148
6783
  });
6149
- await new Promise((resolve2) => process.stdin.on("end", resolve2));
6784
+ await new Promise((resolve3) => process.stdin.on("end", resolve3));
6150
6785
  await busy;
6151
6786
  await closeConversation(runtime);
6152
6787
  return 0;
6153
6788
  }
6154
6789
 
6155
6790
  // packages/cli/src/index.ts
6156
- var VERSION = true ? "0.6.1" : "0.0.0-dev";
6791
+ var VERSION = true ? "0.8.0" : "0.0.0-dev";
6157
6792
  async function main(argv) {
6158
6793
  const args = parse(argv);
6159
6794
  if (args.color !== void 0) setColor(args.color);
@@ -6166,7 +6801,7 @@ async function main(argv) {
6166
6801
  `);
6167
6802
  return 0;
6168
6803
  }
6169
- const root = resolve(args.cwd ?? process.cwd());
6804
+ const root = resolve2(args.cwd ?? process.cwd());
6170
6805
  switch (args.command) {
6171
6806
  case "login":
6172
6807
  return login(root);
@@ -6213,7 +6848,7 @@ async function main(argv) {
6213
6848
  return runScreen(runtime, { mouse: runtime.settings.mouse, sidebar: runtime.settings.sidebar, pickSession: args.pickSession });
6214
6849
  }
6215
6850
  async function login(root) {
6216
- const settings = loadSettings(root, {}, { trusted: isTrusted(stateDir(), root) });
6851
+ const settings = loadSettings(root);
6217
6852
  const auth = new Auth(stateDir(), settings.serverUrl);
6218
6853
  const say = (text) => process.stderr.write(text);
6219
6854
  say(`
@@ -6244,7 +6879,7 @@ ${paint.red("labd:")} ${result.reason ?? "that did not complete"}.
6244
6879
  return 0;
6245
6880
  }
6246
6881
  async function logout(root) {
6247
- const settings = loadSettings(root, {}, { trusted: isTrusted(stateDir(), root) });
6882
+ const settings = loadSettings(root);
6248
6883
  const hadStored = await new Auth(stateDir(), settings.serverUrl).signOut();
6249
6884
  for (const line of signedOutMessage(hadStored, Boolean(process.env.LABD_TOKEN)).split("\n")) {
6250
6885
  process.stderr.write(` ${paint.muted(line)}
@@ -6274,7 +6909,7 @@ function trustHere(root) {
6274
6909
  trust(stateDir(), root);
6275
6910
  process.stdout.write(` ${paint.bold("trusted")} ${paint.muted(root)}
6276
6911
  `);
6277
- 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.")}
6278
6913
  `);
6279
6914
  return 0;
6280
6915
  }