@thedevlabs-io/labd 0.3.1 → 0.6.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
@@ -43,12 +43,12 @@ function scorePath(query, path) {
43
43
  var FILENAME_BONUS = 20;
44
44
  function scan(q, text) {
45
45
  let score = 0;
46
- let at = 0;
46
+ let at2 = 0;
47
47
  let run = 0;
48
48
  for (const ch of q) {
49
- const found = text.indexOf(ch, at);
49
+ const found = text.indexOf(ch, at2);
50
50
  if (found === -1) return null;
51
- if (found === at && at > 0) {
51
+ if (found === at2 && at2 > 0) {
52
52
  run += 1;
53
53
  score += 4 + run * 2;
54
54
  } else {
@@ -57,7 +57,7 @@ function scan(q, text) {
57
57
  }
58
58
  const prev = found === 0 ? "/" : text[found - 1];
59
59
  if (prev === "/" || prev === "." || prev === "-" || prev === "_") score += 6;
60
- at = found + 1;
60
+ at2 = found + 1;
61
61
  }
62
62
  return score;
63
63
  }
@@ -433,6 +433,36 @@ var EXEC_TOOLS = ["run_command"];
433
433
  function isExecTool(name) {
434
434
  return EXEC_TOOLS.includes(name);
435
435
  }
436
+ var AGENT_TOOLS = ["spawn_agent"];
437
+ function isAgentTool(name) {
438
+ return AGENT_TOOLS.includes(name);
439
+ }
440
+ var MAX_SUBAGENTS = 3;
441
+ var SUBAGENT_REPORT_CHARS = 8e3;
442
+ function subAgentCapabilities(parent) {
443
+ return parent.filter((c) => !isAgentTool(c));
444
+ }
445
+ function subAgentConversationId(parentId, callId) {
446
+ return parentId ? `${parentId}~helper~${callId}` : void 0;
447
+ }
448
+ function subAgentBrief(task) {
449
+ return `You are working as a helper for labd, the assistant the learner is talking to. It has handed you one piece of a larger job and is waiting on your report. The learner is the same person, in the same project, under the same rules: the same tools, the same approvals, and the same things you will not do.
450
+
451
+ Your task:
452
+
453
+ ${task.trim()}
454
+
455
+ How to work:
456
+ - You start with none of the parent conversation, so the task above is all you know about it. If it is not enough to act on, say what is missing in your report rather than guessing at the intent.
457
+ - Do the task, and only the task. The parent decides what happens next, so do not widen the job, and do not ask the learner questions - your report goes to the parent, not to them.
458
+ - If a write or command you need is declined, do not look for another way round it. Stop and say so.
459
+ - End with a report the parent can act on without redoing your work: what you found or changed, file by file and with line numbers where they matter, and anything you could not finish and why. Keep it tight. Leave out how you went about it.`;
460
+ }
461
+ function subAgentReport(text) {
462
+ const clean = text.trim() || "The helper finished without a report.";
463
+ return clean.length <= SUBAGENT_REPORT_CHARS ? clean : `${clean.slice(0, SUBAGENT_REPORT_CHARS)}
464
+ ... the helper's report was longer than this and was trimmed.`;
465
+ }
436
466
  var AGENT_MODES = ["ask", "auto", "agent"];
437
467
  var DEFAULT_AGENT_MODE = "auto";
438
468
  function toAgentMode(raw) {
@@ -454,8 +484,8 @@ function danglingToolCallAt(messages) {
454
484
  return null;
455
485
  }
456
486
  function sendableMessages(messages) {
457
- const at = danglingToolCallAt(messages);
458
- return at === null ? messages : messages.slice(0, at);
487
+ const at2 = danglingToolCallAt(messages);
488
+ return at2 === null ? messages : messages.slice(0, at2);
459
489
  }
460
490
  function forExtraction(messages) {
461
491
  return messages.map((m) => {
@@ -678,6 +708,11 @@ function attachOne(root, path, budget) {
678
708
  import { appendFileSync, mkdirSync, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync3, writeFileSync } from "node:fs";
679
709
  import { createHash, randomUUID } from "node:crypto";
680
710
  import { basename as basename2, join as join2 } from "node:path";
711
+ function sessionTitle(summary, width = 60) {
712
+ const text = (summary.name ?? summary.preview ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
713
+ if (!text) return "(no questions yet)";
714
+ return text.length > width ? `${text.slice(0, width - 1)}\u2026` : text;
715
+ }
681
716
  function bucketFor(stateDir2, root) {
682
717
  const bucket = join2(stateDir2, "sessions", createHash("sha256").update(root).digest("hex").slice(0, 16));
683
718
  mkdirSync(bucket, { recursive: true });
@@ -749,6 +784,7 @@ var Session = class _Session {
749
784
  id: session.id,
750
785
  file: join2(bucket, f),
751
786
  name: session.name,
787
+ preview: session.firstQuestion(),
752
788
  at: statSync3(join2(bucket, f)).mtimeMs,
753
789
  messages: session.nodes.size
754
790
  };
@@ -763,6 +799,14 @@ var Session = class _Session {
763
799
  size() {
764
800
  return this.nodes.size;
765
801
  }
802
+ /** The earliest question in the file, on any branch. */
803
+ firstQuestion() {
804
+ let first;
805
+ for (const node of this.nodes.values()) {
806
+ if (node.body.kind === "user" && (!first || node.at < first.at)) first = node;
807
+ }
808
+ return first?.body.kind === "user" ? first.body.text : void 0;
809
+ }
766
810
  rename(name) {
767
811
  this.name = name;
768
812
  this.append({ t: "meta", name });
@@ -802,15 +846,15 @@ var Session = class _Session {
802
846
  /** Root-to-head, which is the conversation as the model will see it. */
803
847
  path(from = this.head) {
804
848
  const out = [];
805
- let at = from;
849
+ let at2 = from;
806
850
  const seen = /* @__PURE__ */ new Set();
807
- while (at) {
808
- if (seen.has(at)) break;
809
- seen.add(at);
810
- const node = this.nodes.get(at);
851
+ while (at2) {
852
+ if (seen.has(at2)) break;
853
+ seen.add(at2);
854
+ const node = this.nodes.get(at2);
811
855
  if (!node) break;
812
856
  out.push(node);
813
- at = node.parentId;
857
+ at2 = node.parentId;
814
858
  }
815
859
  return out.reverse();
816
860
  }
@@ -1305,6 +1349,154 @@ function trim(output) {
1305
1349
  ].join("");
1306
1350
  }
1307
1351
 
1352
+ // packages/core/src/calls.ts
1353
+ var TOOL_OUTPUT_KEPT = 8e3;
1354
+ function keepOutput(text) {
1355
+ const clean = text.replace(/\s+$/, "");
1356
+ return clean.length <= TOOL_OUTPUT_KEPT ? clean : `${clean.slice(0, TOOL_OUTPUT_KEPT)}
1357
+ \u2026 trimmed here.`;
1358
+ }
1359
+ async function executeCall(call, env) {
1360
+ let preview;
1361
+ if (isWriteTool(call.name) || isExecTool(call.name)) {
1362
+ try {
1363
+ preview = previewTool(call, env.toolCtx);
1364
+ env.step({ status: "running", preview });
1365
+ } catch (err) {
1366
+ const message = err instanceof Error ? err.message : String(err);
1367
+ env.step({ status: "error", detail: message });
1368
+ return { id: call.id, content: message, isError: true };
1369
+ }
1370
+ } else {
1371
+ env.step({ status: "running" });
1372
+ }
1373
+ if (needsApproval(env.mode, call)) {
1374
+ env.step({ status: "awaiting", preview });
1375
+ const ok = env.approve ? await env.approve(call, preview) : false;
1376
+ if (env.signal.aborted) return "aborted";
1377
+ if (!ok) {
1378
+ env.step({ status: "skipped", preview });
1379
+ return {
1380
+ id: call.id,
1381
+ content: env.approve ? "The learner declined this, so nothing was done. Do not retry it. Explain what you would have done and why, and let them decide what to do next." : "This needed the learner to approve it and labd is running unattended, so nothing was done. Say what you would have done; do not try another way round it."
1382
+ };
1383
+ }
1384
+ }
1385
+ if (env.signal.aborted) return "aborted";
1386
+ try {
1387
+ const content = await runTool(call, env.toolCtx);
1388
+ if (isWriteTool(call.name)) {
1389
+ const input = call.input;
1390
+ env.wrote(String(input.path ?? input.from ?? ""));
1391
+ }
1392
+ if (isExecTool(call.name)) env.ran(String(call.input.command ?? ""));
1393
+ env.step({ status: isWriteTool(call.name) ? "applied" : "done", preview, output: keepOutput(content) });
1394
+ return { id: call.id, content };
1395
+ } catch (err) {
1396
+ const message = err instanceof Error ? err.message : String(err);
1397
+ env.step({ status: "error", preview, detail: message });
1398
+ return { id: call.id, content: message, isError: true };
1399
+ }
1400
+ }
1401
+
1402
+ // packages/core/src/helper.ts
1403
+ var STALL_ROUNDS = 3;
1404
+ var LABEL_MAX = 80;
1405
+ function helperLabel(call) {
1406
+ const description = call.input.description;
1407
+ const text = typeof description === "string" ? description.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim() : "";
1408
+ return text ? text.length > LABEL_MAX ? `${text.slice(0, LABEL_MAX - 1)}\u2026` : text : "helper";
1409
+ }
1410
+ async function runHelper(call, env) {
1411
+ const task = call.input.task;
1412
+ if (typeof task !== "string" || !task.trim()) {
1413
+ return { id: call.id, content: "spawn_agent needs a `task`: the complete brief for the helper.", isError: true };
1414
+ }
1415
+ const label2 = helperLabel(call);
1416
+ const helper = { id: call.id, label: label2 };
1417
+ const ask = env.approve;
1418
+ const capabilities = subAgentCapabilities(env.capabilities);
1419
+ const conversationId = subAgentConversationId(env.sessionId, call.id);
1420
+ let wire = [{ role: "user", text: subAgentBrief(task) }];
1421
+ let report = "";
1422
+ const asked = /* @__PURE__ */ new Set();
1423
+ let stalled = 0;
1424
+ for (; ; ) {
1425
+ let text = "";
1426
+ const calls = [];
1427
+ let needTools = false;
1428
+ let failure = null;
1429
+ const stream = env.client.stream(
1430
+ {
1431
+ protocol: PROTOCOL_VERSION,
1432
+ messages: wire,
1433
+ context: buildContext(env.root),
1434
+ capabilities,
1435
+ ...conversationId ? { conversationId } : {},
1436
+ ...env.effort ? { effort: env.effort } : {}
1437
+ },
1438
+ env.signal
1439
+ );
1440
+ for await (const event of stream) {
1441
+ if (env.signal.aborted) break;
1442
+ if (event.type === "text") text += event.text;
1443
+ else if (event.type === "tool_call") calls.push(event.call);
1444
+ else if (event.type === "need_tools") needTools = true;
1445
+ else if (event.type === "error") failure = event.message;
1446
+ }
1447
+ if (env.signal.aborted) return "aborted";
1448
+ if (failure) {
1449
+ const sofar = report ? `
1450
+
1451
+ What it had reported before that:
1452
+ ${subAgentReport(report)}` : "";
1453
+ return { id: call.id, content: `The helper stopped: ${failure}${sofar}`, isError: true };
1454
+ }
1455
+ wire = [...wire, { role: "assistant", text, ...calls.length ? { toolCalls: calls } : {} }];
1456
+ if (text.trim()) report = text;
1457
+ if (!needTools || !calls.length) break;
1458
+ const fresh = calls.some((c) => {
1459
+ if (isWriteTool(c.name) || isExecTool(c.name)) return true;
1460
+ const key = `${c.name}:${JSON.stringify(c.input)}`;
1461
+ if (asked.has(key)) return false;
1462
+ asked.add(key);
1463
+ return true;
1464
+ });
1465
+ stalled = fresh ? 0 : stalled + 1;
1466
+ if (stalled >= STALL_ROUNDS) {
1467
+ report = `${report}
1468
+
1469
+ (The helper kept asking for what it had already read, so it was stopped.)`.trim();
1470
+ break;
1471
+ }
1472
+ const results = [];
1473
+ for (const c of calls) {
1474
+ if (env.signal.aborted) return "aborted";
1475
+ if (isAgentTool(c.name)) {
1476
+ results.push({ id: c.id, content: "A helper cannot start helpers of its own. Do this part yourself.", isError: true });
1477
+ continue;
1478
+ }
1479
+ const outcome = await executeCall(c, {
1480
+ mode: env.mode(),
1481
+ // Routed to this call's row: two helpers can run commands at once.
1482
+ toolCtx: { ...env.toolCtx, onCommandOutput: (line) => env.toolCtx.onCommandOutput?.(line, `${call.id}/${c.id}`) },
1483
+ signal: env.signal,
1484
+ approve: ask ? (pending, preview) => ask(pending, preview, label2) : void 0,
1485
+ step: (s) => env.step({ ...s, call: c, helper }),
1486
+ wrote: env.wrote,
1487
+ ran: env.ran
1488
+ });
1489
+ if (outcome === "aborted") {
1490
+ env.step({ call: c, helper, status: "skipped", detail: "Stopped." });
1491
+ return "aborted";
1492
+ }
1493
+ results.push(outcome);
1494
+ }
1495
+ wire = [...wire, { role: "tool", results }];
1496
+ }
1497
+ return { id: call.id, content: subAgentReport(report) };
1498
+ }
1499
+
1308
1500
  // packages/core/src/agent.ts
1309
1501
  var CLI_CAPABILITIES = [
1310
1502
  "read_file",
@@ -1314,18 +1506,15 @@ var CLI_CAPABILITIES = [
1314
1506
  "create_file",
1315
1507
  "move_file",
1316
1508
  "delete_file",
1317
- "run_command"
1509
+ "run_command",
1510
+ // Helpers run here, in this process, on the same rules - see helper.ts.
1511
+ "spawn_agent"
1318
1512
  ];
1319
- var TOOL_OUTPUT_KEPT = 8e3;
1320
- function keepOutput(text) {
1321
- const clean = text.replace(/\s+$/, "");
1322
- return clean.length <= TOOL_OUTPUT_KEPT ? clean : `${clean.slice(0, TOOL_OUTPUT_KEPT)}
1323
- \u2026 trimmed here.`;
1324
- }
1325
- var STALL_ROUNDS = 3;
1513
+ var STALL_ROUNDS2 = 3;
1326
1514
  var COMPACT_AT = 75;
1327
1515
  async function runTurn(question, opts) {
1328
- const { session, client, ui, mode } = opts;
1516
+ const { session, client, ui } = opts;
1517
+ const modeNow = () => opts.currentMode?.() ?? opts.mode;
1329
1518
  const turnId = randomUUID2();
1330
1519
  const controller = new AbortController();
1331
1520
  opts.signal?.addEventListener("abort", () => controller.abort(), { once: true });
@@ -1334,10 +1523,17 @@ async function runTurn(question, opts) {
1334
1523
  checkpoints: opts.checkpoints,
1335
1524
  turn: { sessionId: session.id, turnId },
1336
1525
  commandTimeoutMs: opts.commandTimeoutMs ?? 12e4,
1337
- onCommandOutput: (line) => ui.commandOutput?.(line)
1526
+ onCommandOutput: (line2, key) => ui.commandOutput?.(line2, key)
1338
1527
  };
1339
1528
  session.add({ kind: "user", text: question, ...opts.attachments?.length ? { attachments: opts.attachments.map((a) => ({ path: a.path })) } : {} }, turnId);
1340
1529
  const result = { text: "", turnId, wrote: [], ran: [], aborted: false };
1530
+ const ask = ui.approve;
1531
+ let line = Promise.resolve();
1532
+ const approve = ask ? (call, preview, helper) => {
1533
+ const answer = line.then(() => controller.signal.aborted ? false : ask(call, preview, helper));
1534
+ line = answer.catch(() => void 0);
1535
+ return answer;
1536
+ } : void 0;
1341
1537
  const asked = /* @__PURE__ */ new Set();
1342
1538
  let stalled = 0;
1343
1539
  for (let round = 0; ; round++) {
@@ -1416,20 +1612,79 @@ async function runTurn(question, opts) {
1416
1612
  return true;
1417
1613
  });
1418
1614
  stalled = fresh ? 0 : stalled + 1;
1419
- if (stalled >= STALL_ROUNDS) {
1615
+ if (stalled >= STALL_ROUNDS2) {
1420
1616
  ui.error(
1421
1617
  `labd asked for the same things ${stalled} rounds running, so it stopped rather than keep going round. Tell it which file to start in, or ask for a narrower piece of this.`
1422
1618
  );
1423
1619
  session.add({ kind: "tool", results: calls.map((c) => stopped(c, "error", "Stopped: labd was repeating itself.")) }, turnId);
1424
1620
  return result;
1425
1621
  }
1426
- const results = [];
1427
- for (const call of calls) {
1428
- if (controller.signal.aborted) {
1429
- results.push(stopped(call));
1430
- continue;
1622
+ const results = new Array(calls.length);
1623
+ const working = [];
1624
+ try {
1625
+ let helpers = 0;
1626
+ calls.forEach((call, at2) => {
1627
+ if (!isAgentTool(call.name)) return;
1628
+ const label2 = helperLabel(call);
1629
+ if (helpers++ >= MAX_SUBAGENTS) {
1630
+ const why = `Only ${MAX_SUBAGENTS} helpers can run at once, so this one was not started. Do it yourself, or start it once the others have reported.`;
1631
+ ui.tool({ call, status: "error", detail: why });
1632
+ results[at2] = { id: call.id, content: why, isError: true };
1633
+ return;
1634
+ }
1635
+ ui.tool({ call, status: "running", detail: label2 });
1636
+ working.push(
1637
+ runHelper(call, {
1638
+ client,
1639
+ root: opts.root,
1640
+ mode: modeNow,
1641
+ effort: opts.effort,
1642
+ capabilities: CLI_CAPABILITIES,
1643
+ sessionId: session.id,
1644
+ toolCtx,
1645
+ signal: controller.signal,
1646
+ approve,
1647
+ step: (step) => ui.tool(step),
1648
+ wrote: (path) => result.wrote.push(path),
1649
+ ran: (command) => result.ran.push(command)
1650
+ }).catch((err) => ({
1651
+ id: call.id,
1652
+ content: `The helper failed: ${err instanceof Error ? err.message : String(err)}`,
1653
+ isError: true
1654
+ })).then((outcome) => {
1655
+ if (outcome === "aborted") {
1656
+ results[at2] = stopped(call);
1657
+ return;
1658
+ }
1659
+ results[at2] = outcome;
1660
+ ui.tool({ call, status: outcome.isError ? "error" : "done", detail: label2, output: keepOutput(outcome.content) });
1661
+ })
1662
+ );
1663
+ });
1664
+ for (const [at2, call] of calls.entries()) {
1665
+ if (isAgentTool(call.name)) continue;
1666
+ if (controller.signal.aborted) {
1667
+ results[at2] = stopped(call);
1668
+ continue;
1669
+ }
1670
+ const outcome = await executeCall(call, {
1671
+ mode: modeNow(),
1672
+ // Output names its row, so it cannot land in a helper's command
1673
+ // running at the same moment.
1674
+ toolCtx: { ...toolCtx, onCommandOutput: (line2) => ui.commandOutput?.(line2, call.id) },
1675
+ signal: controller.signal,
1676
+ approve,
1677
+ step: (step) => ui.tool({ ...step, call }),
1678
+ wrote: (path) => result.wrote.push(path),
1679
+ ran: (command) => result.ran.push(command)
1680
+ });
1681
+ results[at2] = outcome === "aborted" ? stopped(call) : outcome;
1431
1682
  }
1432
- results.push(await runOne(call));
1683
+ await Promise.all(working);
1684
+ } catch (err) {
1685
+ controller.abort();
1686
+ await Promise.allSettled(working);
1687
+ throw err;
1433
1688
  }
1434
1689
  session.add({ kind: "tool", results }, turnId);
1435
1690
  if (controller.signal.aborted) {
@@ -1441,52 +1696,6 @@ async function runTurn(question, opts) {
1441
1696
  ui.tool({ call, status, detail: why });
1442
1697
  return { id: call.id, content: why, isError: true };
1443
1698
  }
1444
- async function runOne(call) {
1445
- let preview;
1446
- if (isWriteTool(call.name) || isExecTool(call.name)) {
1447
- try {
1448
- preview = previewTool(call, toolCtx);
1449
- ui.tool({ call, status: "running", preview });
1450
- } catch (err) {
1451
- const message = err instanceof Error ? err.message : String(err);
1452
- ui.tool({ call, status: "error", detail: message });
1453
- return { id: call.id, content: message, isError: true };
1454
- }
1455
- } else {
1456
- ui.tool({ call, status: "running" });
1457
- }
1458
- if (needsApproval(mode, call)) {
1459
- ui.tool({ call, status: "awaiting", preview });
1460
- const ok = ui.approve ? await ui.approve(call, preview) : false;
1461
- if (controller.signal.aborted) return stopped(call);
1462
- if (!ok) {
1463
- ui.tool({ call, status: "skipped", preview });
1464
- return {
1465
- id: call.id,
1466
- content: ui.approve ? "The learner declined this, so nothing was done. Do not retry it. Explain what you would have done and why, and let them decide what to do next." : "This needed the learner to approve it and labd is running unattended, so nothing was done. Say what you would have done; do not try another way round it."
1467
- };
1468
- }
1469
- }
1470
- try {
1471
- const content = await runTool(call, toolCtx);
1472
- if (isWriteTool(call.name)) {
1473
- const input = call.input;
1474
- result.wrote.push(String(input.path ?? input.from ?? ""));
1475
- }
1476
- if (isExecTool(call.name)) result.ran.push(String(call.input.command ?? ""));
1477
- ui.tool({
1478
- call,
1479
- status: isWriteTool(call.name) ? "applied" : "done",
1480
- preview,
1481
- output: keepOutput(content)
1482
- });
1483
- return { id: call.id, content };
1484
- } catch (err) {
1485
- const message = err instanceof Error ? err.message : String(err);
1486
- ui.tool({ call, status: "error", preview, detail: message });
1487
- return { id: call.id, content: message, isError: true };
1488
- }
1489
- }
1490
1699
  }
1491
1700
  async function compact(opts) {
1492
1701
  const controller = new AbortController();
@@ -1884,9 +2093,16 @@ function setColor(value) {
1884
2093
  on = value;
1885
2094
  }
1886
2095
  var wrap = (open, close = "0") => (text) => on ? `${ESC}[${open}m${text}${ESC}[${close}m` : text;
2096
+ function lightBackground() {
2097
+ const bg = Number(process.env.COLORFGBG?.split(";").at(-1));
2098
+ return bg === 7 || bg === 15;
2099
+ }
1887
2100
  var paint = {
1888
- /** labd's accent. 214 is the nearest xterm cube entry to the brand orange. */
1889
- accent: wrap("38;5;214"),
2101
+ /**
2102
+ * labd's accent. 214 is the nearest xterm cube entry to the brand orange.
2103
+ * Closes the foreground only, so it can sit inside a `band` without ending it.
2104
+ */
2105
+ accent: wrap("38;5;214", "39"),
1890
2106
  bold: wrap("1", "22"),
1891
2107
  dim: wrap("2", "22"),
1892
2108
  muted: wrap("90"),
@@ -1898,6 +2114,11 @@ var paint = {
1898
2114
  /** For the one place a background is worth it: the cursor cell in the box. */
1899
2115
  invert: wrap("7", "27")
1900
2116
  };
2117
+ function band(text, visible, width) {
2118
+ if (!on) return text;
2119
+ const bg = lightBackground() ? "48;5;254" : "48;5;236";
2120
+ return `${ESC}[${bg}m${text}${" ".repeat(Math.max(0, width - visible))}${ESC}[49m`;
2121
+ }
1901
2122
  function stripColor(text) {
1902
2123
  return text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, "g"), "");
1903
2124
  }
@@ -2059,7 +2280,8 @@ var LABELS = {
2059
2280
  create_file: "Create",
2060
2281
  move_file: "Move",
2061
2282
  delete_file: "Delete",
2062
- run_command: "Run"
2283
+ run_command: "Run",
2284
+ spawn_agent: "Helper"
2063
2285
  };
2064
2286
  function approvalCard(preview, call, mode, width) {
2065
2287
  const out = [""];
@@ -2147,21 +2369,21 @@ function wrapCommand(command, width) {
2147
2369
  for (let i = 0; i < command.length; i += width) out.push(command.slice(i, i + width));
2148
2370
  return out;
2149
2371
  }
2150
- function statusLine(info) {
2372
+ function statusLine(info2) {
2151
2373
  const bits = [];
2152
- if (info.contextPercent !== null && info.contextPercent !== void 0) {
2153
- const crowded = info.contextPercent >= 75;
2154
- bits.push((crowded ? paint.yellow : paint.muted)(`${info.contextPercent}% full`));
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`));
2155
2377
  }
2156
- bits.push(paint.muted(info.project));
2157
- if (info.branch) bits.push(paint.muted(info.branch));
2158
- if (info.creditsPercent !== null && info.creditsPercent !== void 0) {
2159
- bits.push(paint.muted(`${info.creditsPercent}% left`));
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`));
2160
2382
  }
2161
- if (info.effort) bits.push(paint.muted(`think ${info.effort}`));
2162
- const mode = info.mode === "agent" ? paint.red(paint.bold("agent")) : info.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
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");
2163
2385
  bits.push(mode);
2164
- const left = info.busy ? `${paint.accent(FACE)} ${paint.dim(info.busy)}` : paint.dim(FACE);
2386
+ const left = info2.busy ? `${paint.accent(FACE)} ${paint.dim(info2.busy)}` : paint.dim(FACE);
2165
2387
  return `${left} ${paint.dim("|")} ${bits.join(paint.dim(" | "))}`;
2166
2388
  }
2167
2389
  function spinnerFrame(tick) {
@@ -2198,10 +2420,10 @@ function body(opts, inner) {
2198
2420
  function withCursor(view, room, hidden) {
2199
2421
  const text = view.text.slice(0, room);
2200
2422
  if (hidden) return text;
2201
- const at = Math.min(view.column, room - 1 < 0 ? 0 : room);
2202
- const before = text.slice(0, at);
2203
- const under = text.slice(at, at + 1) || " ";
2204
- const after = text.slice(at + 1);
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);
2205
2427
  return `${before}${paint.invert(under)}${after}`;
2206
2428
  }
2207
2429
  function suggestionLines(all, inner) {
@@ -2250,27 +2472,27 @@ function boundaries(text) {
2250
2472
  for (const { index, segment } of segmenter.segment(text)) out.push(index + segment.length);
2251
2473
  return out;
2252
2474
  }
2253
- function prevBoundary(text, at) {
2475
+ function prevBoundary(text, at2) {
2254
2476
  const marks = boundaries(text);
2255
2477
  let last = 0;
2256
2478
  for (const mark of marks) {
2257
- if (mark >= at) return last;
2479
+ if (mark >= at2) return last;
2258
2480
  last = mark;
2259
2481
  }
2260
2482
  return last;
2261
2483
  }
2262
- function nextBoundary(text, at) {
2263
- for (const mark of boundaries(text)) if (mark > at) return mark;
2484
+ function nextBoundary(text, at2) {
2485
+ for (const mark of boundaries(text)) if (mark > at2) return mark;
2264
2486
  return text.length;
2265
2487
  }
2266
- function wordStart(text, at) {
2267
- let i = at;
2488
+ function wordStart(text, at2) {
2489
+ let i = at2;
2268
2490
  while (i > 0 && /\s/.test(text[i - 1])) i--;
2269
2491
  while (i > 0 && !/\s/.test(text[i - 1])) i--;
2270
2492
  return i;
2271
2493
  }
2272
- function wordEnd(text, at) {
2273
- let i = at;
2494
+ function wordEnd(text, at2) {
2495
+ let i = at2;
2274
2496
  while (i < text.length && /\s/.test(text[i])) i++;
2275
2497
  while (i < text.length && !/\s/.test(text[i])) i++;
2276
2498
  return i;
@@ -2330,6 +2552,7 @@ var LineEditor = class {
2330
2552
  if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) {
2331
2553
  return this.insert(key.sequence);
2332
2554
  }
2555
+ if (key.paste !== void 0) return this.insert(key.paste);
2333
2556
  return "unhandled";
2334
2557
  }
2335
2558
  moveTo(next) {
@@ -2398,148 +2621,1056 @@ var History = class {
2398
2621
  }
2399
2622
  };
2400
2623
 
2401
- // packages/tui/src/face.ts
2402
- var FACE_ART = [
2403
- " \u250F\u2501 \u2501\u2513 ",
2404
- " \u2503 \u2584\u2584\u2584\u2584\u2584 \u2584\u2584\u2584\u2584\u2584 \u2503 ",
2405
- " \u2503 \u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588 \u2503 ",
2406
- " \u2503 \u2580\u2580\u2580\u2580\u2580 \u2580\u2580\u2580\u2580\u2580 \u2503 ",
2407
- " \u2503 \u2503 ",
2408
- " \u2503 \u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584 \u2503 ",
2409
- " \u2517\u2501 \u2501\u251B "
2410
- ];
2411
- var NOISE = "$#@%&*+=-:.<>/\\|_01x?";
2412
- var FACE_FRAME_MS = 34;
2413
- function noiseChar() {
2414
- return NOISE[Math.floor(Math.random() * NOISE.length)];
2415
- }
2416
- function faceLines() {
2417
- return FACE_ART.map((row) => paint.accent(row));
2418
- }
2419
- function decodeFrames() {
2420
- const cells = FACE_ART.map((row) => [...row]);
2421
- const lockAt = cells.map(
2422
- (row, y) => row.map((_, x) => Math.round(x * 0.55 + y * 0.9 + Math.random() * 7))
2423
- );
2424
- const last = Math.max(...lockAt.flat());
2425
- const frames = [];
2426
- for (let f = 1; f <= last + 1; f++) {
2427
- frames.push(
2428
- cells.map(
2429
- (row, y) => paint.accent(
2430
- row.map((char, x) => {
2431
- if (char === " ") return " ";
2432
- return f >= lockAt[y][x] ? char : noiseChar();
2433
- }).join("")
2434
- )
2435
- )
2436
- );
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, "");
2627
+ }
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. */
2633
+ top = 0;
2634
+ get text() {
2635
+ return this.lines.join("\n");
2437
2636
  }
2438
- frames.push(faceLines());
2439
- return frames;
2440
- }
2441
-
2442
- // packages/tui/src/markdown.ts
2443
- function renderMarkdown(text, opts) {
2444
- const lines = text.replace(/\r\n/g, "\n").split("\n");
2445
- const pad2 = opts.indent ?? "";
2446
- const out = [];
2447
- let inFence = false;
2448
- let fenceLang = "";
2449
- for (const line of lines) {
2450
- const fence = /^\s*```(\w*)/.exec(line);
2451
- if (fence) {
2452
- if (!inFence) {
2453
- inFence = true;
2454
- fenceLang = fence[1] ?? "";
2455
- out.push(paint.dim(`${pad2}${fenceLang || "code"}`));
2456
- } else {
2457
- inFence = false;
2458
- fenceLang = "";
2459
- out.push("");
2637
+ get cursor() {
2638
+ return { row: this.row, col: this.col };
2639
+ }
2640
+ get lineCount() {
2641
+ return this.lines.length;
2642
+ }
2643
+ get isEmpty() {
2644
+ return this.lines.length === 1 && this.lines[0] === "";
2645
+ }
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];
2649
+ }
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;
2655
+ }
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;
2660
+ }
2661
+ reset() {
2662
+ this.lines = [""];
2663
+ this.row = 0;
2664
+ this.col = 0;
2665
+ this.top = 0;
2666
+ }
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";
2685
+ }
2686
+ newline() {
2687
+ return this.insert("\n");
2688
+ }
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();
2460
2704
  }
2461
- continue;
2705
+ return "unhandled";
2462
2706
  }
2463
- if (inFence) {
2464
- out.push(`${paint.dim(`${pad2}|`)} ${line}`);
2465
- continue;
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);
2466
2745
  }
2467
- out.push(...prose(line, opts.width, pad2));
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";
2468
2765
  }
2469
- return out.join("\n");
2470
- }
2471
- function prose(line, width, pad2) {
2472
- const heading = /^(#{1,6})\s+(.*)$/.exec(line);
2473
- if (heading) return [`${pad2}${paint.bold(paint.accent(heading[2]))}`];
2474
- if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
2475
- return [`${pad2}${paint.dim("-".repeat(Math.min(width, 40)))}`];
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 } };
2476
2789
  }
2477
- const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
2478
- if (bullet) {
2479
- const indent2 = `${pad2}${bullet[1]}${paint.accent("-")} `;
2480
- return wrap2(inline(bullet[2]), width, indent2, `${pad2}${bullet[1]} `);
2790
+ moveTo(next) {
2791
+ if (next === this.col) return "same";
2792
+ this.col = next;
2793
+ return "cursor";
2481
2794
  }
2482
- const numbered = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
2483
- if (numbered) {
2484
- const indent2 = `${pad2}${numbered[1]}${paint.accent(`${numbered[2]}.`)} `;
2485
- return wrap2(inline(numbered[3]), width, indent2, `${pad2}${numbered[1]} `);
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";
2486
2801
  }
2487
- const quote = /^\s*>\s?(.*)$/.exec(line);
2488
- if (quote) {
2489
- const gutter = `${pad2}${paint.dim("|")} `;
2490
- return wrap2(paint.muted(inline(quote[1])), width, gutter, gutter);
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";
2491
2809
  }
2492
- if (!line.trim()) return [""];
2493
- return wrap2(inline(line), width, pad2, pad2);
2494
- }
2495
- var HOLD = "\0";
2496
- function inline(text) {
2497
- const code = [];
2498
- let held = text.replace(/`([^`]+)`/g, (_, body2) => {
2499
- code.push(body2);
2500
- return `${HOLD}${code.length - 1}${HOLD}`;
2501
- });
2502
- 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})`)}`);
2503
- return held.replace(
2504
- new RegExp(`${HOLD}(\\d+)${HOLD}`, "g"),
2505
- (_, n) => paint.cyan(code[Number(n)] ?? "")
2506
- );
2507
- }
2508
- function wrap2(text, width, first, rest) {
2509
- const limit = Math.max(20, width - visibleWidth(first));
2510
- if (visibleWidth(text) <= limit) return [`${first}${text}`];
2511
- const out = [];
2512
- let line = "";
2513
- for (const word of text.split(" ")) {
2514
- if (line && visibleWidth(line) + 1 + visibleWidth(word) > limit) {
2515
- out.push(`${out.length === 0 ? first : rest}${line}`);
2516
- line = word;
2517
- } else {
2518
- line = line ? `${line} ${word}` : word;
2519
- }
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";
2520
2814
  }
2521
- if (line) out.push(`${out.length === 0 ? first : rest}${line}`);
2522
- return out;
2523
- }
2815
+ };
2524
2816
 
2525
- // packages/tui/src/prompt.ts
2526
- import { emitKeypressEvents } from "node:readline";
2817
+ // packages/tui/src/keys.ts
2527
2818
  var ESC3 = "\x1B";
2528
- var PASTE_ON = `${ESC3}[?2004h`;
2529
- var PASTE_OFF = `${ESC3}[?2004l`;
2530
2819
  var PASTE_START = `${ESC3}[200~`;
2531
2820
  var PASTE_END = `${ESC3}[201~`;
2532
- var Prompt = class {
2533
- constructor(renderer, hooks, input = process.stdin) {
2534
- this.renderer = renderer;
2535
- this.hooks = hooks;
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;
2875
+ }
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
+ }
2990
+ return out;
2991
+ }
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;
3003
+ }
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 } };
3013
+ }
3014
+ return { name: "mouse", sequence, mouse: { kind: "click", button, x, y } };
3015
+ }
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;
3022
+ }
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 };
3028
+ }
3029
+
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
+ }
3054
+ return out;
3055
+ }
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;
3067
+ }
3068
+ move(delta) {
3069
+ if (!this.items.length) return;
3070
+ this.selected = (this.selected + delta + this.items.length) % this.items.length;
3071
+ }
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
+ });
3088
+ }
3089
+ };
3090
+ function pad2(text, width) {
3091
+ const short = width - visibleWidth(text);
3092
+ return short > 0 ? `${text}${" ".repeat(short)}` : text;
3093
+ }
3094
+
3095
+ // packages/tui/src/dialog.ts
3096
+ function pick(title, items, onSelect) {
3097
+ return { kind: "pick", title, items, query: "", selected: 0, onSelect };
3098
+ }
3099
+ function info(title, lines) {
3100
+ return { kind: "info", title, lines, scroll: 0 };
3101
+ }
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
+ });
3109
+ }
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";
3123
+ }
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";
3128
+ }
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";
3132
+ }
3133
+ if (key.name === "return") {
3134
+ const item = shown[dialog.selected];
3135
+ if (!item) return "same";
3136
+ dialog.onSelect(item);
3137
+ return "select";
3138
+ }
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";
3144
+ }
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";
3149
+ }
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));
3170
+ }
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`));
3178
+ }
3179
+ }
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
+ }
3195
+
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;
3216
+ }
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;
3224
+ }
3225
+ get columns() {
3226
+ const n = this.opts.columns ?? this.out.columns;
3227
+ return typeof n === "number" && n > 0 ? n : 80;
3228
+ }
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);
3244
+ }
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 {
3257
+ }
3258
+ this.out.write(`${this.opts.mouse ? MOUSE_OFF : ""}${PASTE_OFF}${RESET}${CURSOR_SHOW}${ALT_SCREEN_OFF}`);
3259
+ this.last = null;
3260
+ };
3261
+ /**
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.
3267
+ */
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;
3278
+ }
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}`;
3284
+ }
3285
+ if (cursor) out += `${at(Math.min(cursor.row, rows - 1), Math.min(cursor.col, columns - 1))}${CURSOR_SHOW}`;
3286
+ this.out.write(out);
3287
+ }
3288
+ /** Forget the last frame, so the next paint redraws every row. For ctrl-l. */
3289
+ invalidate() {
3290
+ this.last = null;
3291
+ }
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
+ };
3303
+ };
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;
3308
+ }
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("");
3328
+ }
3329
+ continue;
3330
+ }
3331
+ if (inFence) {
3332
+ out.push(`${paint.dim(`${pad4}|`)} ${line}`);
3333
+ continue;
3334
+ }
3335
+ out.push(...prose(line, opts.width, pad4));
3336
+ }
3337
+ return out.join("\n");
3338
+ }
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]} `);
3349
+ }
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]} `);
3354
+ }
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);
3359
+ }
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;
3387
+ }
3388
+ }
3389
+ if (line) out.push(`${out.length === 0 ? first : rest}${line}`);
3390
+ return out;
3391
+ }
3392
+
3393
+ // packages/tui/src/transcript.ts
3394
+ var MAX_PROSE_WIDTH = 100;
3395
+ var LIVE_LINES = 8;
3396
+ var Transcript = class {
3397
+ entries = [];
3398
+ nextId = 1;
3399
+ cache = /* @__PURE__ */ new Map();
3400
+ /** For a streaming answer: the rendered COMPLETE part, so only the tail is re-rendered per chunk. */
3401
+ prose = /* @__PURE__ */ new Map();
3402
+ get length() {
3403
+ return this.entries.length;
3404
+ }
3405
+ get blocks() {
3406
+ return this.entries;
3407
+ }
3408
+ /** Typed by kind rather than by the literal passed, so `done: false` stays a boolean the caller can flip. */
3409
+ add(block) {
3410
+ const entry = { id: this.nextId++, version: 0, block };
3411
+ this.entries.push(entry);
3412
+ return entry;
3413
+ }
3414
+ /**
3415
+ * Move a block to the bottom. For an approval card, which must stay the last
3416
+ * thing on screen while it waits: a helper that keeps working adds rows below
3417
+ * it, and a card pushed up past the fold is one approved without being read.
3418
+ */
3419
+ toEnd(entry) {
3420
+ const at2 = this.entries.indexOf(entry);
3421
+ if (at2 === -1 || at2 === this.entries.length - 1) return;
3422
+ this.entries.splice(at2, 1);
3423
+ this.entries.push(entry);
3424
+ }
3425
+ /** Say a block changed, so its cached lines are dropped on the next render. */
3426
+ touch(entry) {
3427
+ entry.version++;
3428
+ }
3429
+ /** Forget everything shown. The session file is untouched; this is the view. */
3430
+ clear() {
3431
+ this.entries = [];
3432
+ this.cache.clear();
3433
+ this.prose.clear();
3434
+ }
3435
+ /** Every line, top to bottom, with one blank line between blocks. */
3436
+ render(width) {
3437
+ const out = [];
3438
+ const live = /* @__PURE__ */ new Set();
3439
+ for (const entry of this.entries) {
3440
+ live.add(entry.id);
3441
+ const lines = this.lines(entry, width);
3442
+ if (out.length && lines.length) out.push("");
3443
+ out.push(...lines);
3444
+ }
3445
+ for (const id of this.cache.keys()) if (!live.has(id)) this.cache.delete(id);
3446
+ for (const id of this.prose.keys()) if (!live.has(id)) this.prose.delete(id);
3447
+ return out;
3448
+ }
3449
+ lines(entry, width) {
3450
+ const hit = this.cache.get(entry.id);
3451
+ if (hit && hit.version === entry.version && hit.width === width) return hit.lines;
3452
+ const lines = entry.block.kind === "assistant" ? this.assistant(entry, width) : renderBlock(entry.block, width);
3453
+ this.cache.set(entry.id, { version: entry.version, width, lines });
3454
+ return lines;
3455
+ }
3456
+ /**
3457
+ * An answer, streaming or finished.
3458
+ *
3459
+ * Complete markdown blocks are rendered; the unfinished tail - a line still
3460
+ * arriving, a fence not yet closed - is shown raw and dimmed, so the reader
3461
+ * sees the words land without a heading flickering between plain and bold.
3462
+ */
3463
+ assistant(entry, width) {
3464
+ const inner = proseWidth(width);
3465
+ const { text, done } = entry.block;
3466
+ if (done) return text.trim() ? renderMarkdown(text.replace(/\s+$/, ""), { width: inner, indent: " " }).split("\n") : [];
3467
+ const { complete: complete2, tail } = splitStreaming(text);
3468
+ let head = this.prose.get(entry.id);
3469
+ if (!head || head.length !== complete2.length || head.width !== width) {
3470
+ head = {
3471
+ length: complete2.length,
3472
+ width,
3473
+ lines: complete2 ? renderMarkdown(complete2, { width: inner, indent: " " }).split("\n") : []
3474
+ };
3475
+ this.prose.set(entry.id, head);
3476
+ }
3477
+ const raw = tail ? tail.split("\n").flatMap((line) => wrapPlain(line, inner - 2)).map((line) => ` ${paint.dim(line)}`) : [];
3478
+ return [...head.lines, ...raw];
3479
+ }
3480
+ };
3481
+ function splitStreaming(text) {
3482
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
3483
+ let tail = lines.pop() ?? "";
3484
+ let open = -1;
3485
+ lines.forEach((line, i) => {
3486
+ if (/^\s*```/.test(line)) open = open === -1 ? i : -1;
3487
+ });
3488
+ let complete2 = lines;
3489
+ if (open !== -1) {
3490
+ tail = [...lines.slice(open), tail].join("\n");
3491
+ complete2 = lines.slice(0, open);
3492
+ }
3493
+ return { complete: complete2.join("\n"), tail };
3494
+ }
3495
+ function proseWidth(width) {
3496
+ return Math.max(20, Math.min(width - 2, MAX_PROSE_WIDTH));
3497
+ }
3498
+ function renderBlock(block, width) {
3499
+ const inner = proseWidth(width);
3500
+ switch (block.kind) {
3501
+ case "user":
3502
+ return block.text.split("\n").flatMap(
3503
+ (line, i) => wrapPlain(line, inner - 2).map((piece, j) => {
3504
+ const text = i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`;
3505
+ return band(text, visibleWidth(text), inner);
3506
+ })
3507
+ );
3508
+ case "assistant":
3509
+ return [];
3510
+ case "tool": {
3511
+ const out = toolBlock(block.name, block.detail, block.status, { input: block.input, output: block.output }, inner);
3512
+ if (block.live.length && !block.output) {
3513
+ const shown = block.live.slice(-LIVE_LINES);
3514
+ if (block.live.length > LIVE_LINES) out.push(` ${paint.dim(`\u2026 ${block.live.length - LIVE_LINES} more lines`)}`);
3515
+ for (const line of shown) out.push(` ${paint.dim(line)}`);
3516
+ }
3517
+ return out;
3518
+ }
3519
+ case "approval": {
3520
+ const out = [
3521
+ ...block.helper ? [` ${paint.accent("from a helper:")} ${paint.muted(block.helper)}`] : [],
3522
+ ...approvalCard(block.preview, block.call, block.mode, inner)
3523
+ ];
3524
+ out.push(
3525
+ 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")}`
3526
+ );
3527
+ return out;
3528
+ }
3529
+ case "error":
3530
+ return block.text.split("\n").map((line, i) => i === 0 ? `${paint.red(" labd:")} ${line}` : ` ${line}`);
3531
+ case "note":
3532
+ return block.text.replace(/\n$/, "").split("\n");
3533
+ case "compaction": {
3534
+ const label2 = " summarised the conversation so far ";
3535
+ const room = Math.max(0, inner - label2.length);
3536
+ return [paint.dim(` ${"\u2500".repeat(Math.floor(room / 2))}${label2}${"\u2500".repeat(Math.ceil(room / 2))}`)];
3537
+ }
3538
+ }
3539
+ }
3540
+ function wrapPlain(text, width) {
3541
+ const limit = Math.max(10, width);
3542
+ if (visibleWidth(text) <= limit) return [text];
3543
+ const out = [];
3544
+ let line = "";
3545
+ for (const word of text.split(" ")) {
3546
+ if (line && line.length + 1 + word.length > limit) {
3547
+ out.push(line);
3548
+ line = word;
3549
+ } else {
3550
+ line = line ? `${line} ${word}` : word;
3551
+ }
3552
+ while (line.length > limit) {
3553
+ out.push(line.slice(0, limit));
3554
+ line = line.slice(limit);
3555
+ }
3556
+ }
3557
+ if (line || !out.length) out.push(line);
3558
+ return out;
3559
+ }
3560
+ var Viewport = class {
3561
+ constructor(height) {
3562
+ this.height = height;
3563
+ }
3564
+ top = 0;
3565
+ follow = true;
3566
+ total = 0;
3567
+ /** The line count when the reader stopped following, so `newLines` can be counted from it. */
3568
+ anchor = 0;
3569
+ /** Lines that arrived since the reader scrolled away. Zero while following. */
3570
+ get newLines() {
3571
+ return this.follow ? 0 : Math.max(0, this.total - this.anchor);
3572
+ }
3573
+ /** Tell the viewport how many lines there are now, and how tall it is. */
3574
+ update(total, height = this.height) {
3575
+ this.height = height;
3576
+ this.total = total;
3577
+ if (this.follow) this.top = Math.max(0, total - height);
3578
+ else this.top = Math.max(0, Math.min(this.top, total - height));
3579
+ }
3580
+ scroll(delta) {
3581
+ const max = Math.max(0, this.total - this.height);
3582
+ const next = Math.max(0, Math.min(max, this.top + delta));
3583
+ if (next === this.top && this.follow) return;
3584
+ this.top = next;
3585
+ if (next >= max) this.refollow();
3586
+ else this.unfollow();
3587
+ }
3588
+ pageUp() {
3589
+ this.scroll(-Math.max(1, this.height - 1));
3590
+ }
3591
+ pageDown() {
3592
+ this.scroll(Math.max(1, this.height - 1));
3593
+ }
3594
+ home() {
3595
+ this.top = 0;
3596
+ if (this.total > this.height) this.unfollow();
3597
+ }
3598
+ end() {
3599
+ this.refollow();
3600
+ this.top = Math.max(0, this.total - this.height);
3601
+ }
3602
+ /** The slice of `lines` on screen. */
3603
+ slice(lines) {
3604
+ return lines.slice(this.top, this.top + this.height);
3605
+ }
3606
+ unfollow() {
3607
+ if (this.follow) this.anchor = this.total;
3608
+ this.follow = false;
3609
+ }
3610
+ refollow() {
3611
+ this.follow = true;
3612
+ }
3613
+ };
3614
+
3615
+ // packages/tui/src/face.ts
3616
+ var FACE_ART = [
3617
+ " \u250F\u2501 \u2501\u2513 ",
3618
+ " \u2503 \u2584\u2584\u2584\u2584\u2584 \u2584\u2584\u2584\u2584\u2584 \u2503 ",
3619
+ " \u2503 \u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588 \u2503 ",
3620
+ " \u2503 \u2580\u2580\u2580\u2580\u2580 \u2580\u2580\u2580\u2580\u2580 \u2503 ",
3621
+ " \u2503 \u2503 ",
3622
+ " \u2503 \u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584 \u2503 ",
3623
+ " \u2517\u2501 \u2501\u251B "
3624
+ ];
3625
+ var NOISE = "$#@%&*+=-:.<>/\\|_01x?";
3626
+ var FACE_FRAME_MS = 34;
3627
+ function noiseChar() {
3628
+ return NOISE[Math.floor(Math.random() * NOISE.length)];
3629
+ }
3630
+ function faceLines() {
3631
+ return FACE_ART.map((row) => paint.accent(row));
3632
+ }
3633
+ function decodeFrames() {
3634
+ const cells = FACE_ART.map((row) => [...row]);
3635
+ const lockAt = cells.map(
3636
+ (row, y) => row.map((_, x) => Math.round(x * 0.55 + y * 0.9 + Math.random() * 7))
3637
+ );
3638
+ const last = Math.max(...lockAt.flat());
3639
+ const frames = [];
3640
+ for (let f = 1; f <= last + 1; f++) {
3641
+ frames.push(
3642
+ cells.map(
3643
+ (row, y) => paint.accent(
3644
+ row.map((char, x) => {
3645
+ if (char === " ") return " ";
3646
+ return f >= lockAt[y][x] ? char : noiseChar();
3647
+ }).join("")
3648
+ )
3649
+ )
3650
+ );
3651
+ }
3652
+ frames.push(faceLines());
3653
+ return frames;
3654
+ }
3655
+
3656
+ // packages/tui/src/prompt.ts
3657
+ import { emitKeypressEvents } from "node:readline";
3658
+ var ESC5 = "\x1B";
3659
+ var PASTE_ON2 = `${ESC5}[?2004h`;
3660
+ var PASTE_OFF2 = `${ESC5}[?2004l`;
3661
+ var PASTE_START2 = `${ESC5}[200~`;
3662
+ var PASTE_END2 = `${ESC5}[201~`;
3663
+ var Prompt = class {
3664
+ constructor(renderer, hooks, input = process.stdin) {
3665
+ this.renderer = renderer;
3666
+ this.hooks = hooks;
2536
3667
  this.input = input;
2537
3668
  emitKeypressEvents(this.input);
2538
3669
  if (this.input.isTTY) this.input.setRawMode(true);
2539
3670
  this.input.on("keypress", this.onKey);
2540
3671
  this.input.on("end", this.onEnd);
2541
3672
  this.input.resume();
2542
- if (this.renderer.isTty) process.stdout.write(PASTE_ON);
3673
+ if (this.renderer.isTty) process.stdout.write(PASTE_ON2);
2543
3674
  this.renderer.hideCursor();
2544
3675
  process.stdout.on("resize", this.onResize);
2545
3676
  process.once("exit", this.restore);
@@ -2613,7 +3744,7 @@ var Prompt = class {
2613
3744
  if (this.input.isTTY) this.input.setRawMode(false);
2614
3745
  } catch {
2615
3746
  }
2616
- if (this.renderer.isTty) process.stdout.write(PASTE_OFF);
3747
+ if (this.renderer.isTty) process.stdout.write(PASTE_OFF2);
2617
3748
  this.renderer.showCursor();
2618
3749
  };
2619
3750
  onResize = () => {
@@ -2626,11 +3757,11 @@ var Prompt = class {
2626
3757
  };
2627
3758
  onKey = (str, key = {}) => {
2628
3759
  const sequence = key.sequence ?? str ?? "";
2629
- if (sequence.includes(PASTE_START)) {
3760
+ if (sequence.includes(PASTE_START2)) {
2630
3761
  this.pasting = true;
2631
3762
  return;
2632
3763
  }
2633
- if (sequence.includes(PASTE_END)) {
3764
+ if (sequence.includes(PASTE_END2)) {
2634
3765
  this.pasting = false;
2635
3766
  this.draw();
2636
3767
  return;
@@ -2776,6 +3907,7 @@ import { chmodSync, existsSync, mkdirSync as mkdirSync4, readFileSync as readFil
2776
3907
  import { hostname } from "node:os";
2777
3908
  import { join as join5 } from "node:path";
2778
3909
  var POLL_INTERVAL_MS = 2500;
3910
+ var REVOKE_TIMEOUT_MS = 3e3;
2779
3911
  var Auth = class {
2780
3912
  constructor(stateDir2, baseUrl) {
2781
3913
  this.baseUrl = baseUrl;
@@ -2802,18 +3934,21 @@ var Auth = class {
2802
3934
  isReady() {
2803
3935
  return Boolean(this.token());
2804
3936
  }
3937
+ /** True when there was a stored token to sign out; `LABD_TOKEN` is never stored, so never revoked. */
2805
3938
  async signOut() {
2806
3939
  const token = this.read().token;
2807
3940
  if (token) {
2808
3941
  await fetch(`${this.baseUrl}/v1/auth/device/revoke`, {
2809
3942
  method: "POST",
2810
- headers: { Authorization: `Bearer ${token}` }
3943
+ headers: { Authorization: `Bearer ${token}` },
3944
+ signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
2811
3945
  }).catch(() => void 0);
2812
3946
  }
2813
3947
  try {
2814
3948
  rmSync3(this.file, { force: true });
2815
3949
  } catch {
2816
3950
  }
3951
+ return Boolean(token);
2817
3952
  }
2818
3953
  /**
2819
3954
  * Pair this machine with the learner's account.
@@ -2894,6 +4029,17 @@ function sleep(ms, signal) {
2894
4029
  }, { once: true });
2895
4030
  });
2896
4031
  }
4032
+ function signedOutMessage(hadStored, envToken) {
4033
+ const lines = [];
4034
+ if (hadStored) lines.push("signed out, and the stored token is gone. `labd login` pairs this machine again.");
4035
+ else if (!envToken) lines.push("this machine was not signed in.");
4036
+ if (envToken) {
4037
+ lines.push(
4038
+ hadStored ? "LABD_TOKEN is still set in this shell, so the next labd will be signed in with it." : "labd is signed in with LABD_TOKEN from this shell, which is never stored - unset it to sign out."
4039
+ );
4040
+ }
4041
+ return lines.join("\n");
4042
+ }
2897
4043
 
2898
4044
  // packages/cli/src/config.ts
2899
4045
  import { existsSync as existsSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync6 } from "node:fs";
@@ -2904,7 +4050,9 @@ var DEFAULTS = {
2904
4050
  modeChosen: false,
2905
4051
  serverUrl: "https://agent.thedevlabs.io",
2906
4052
  commandTimeoutSeconds: 120,
2907
- checkpoints: true
4053
+ checkpoints: true,
4054
+ mouse: true,
4055
+ sidebar: false
2908
4056
  };
2909
4057
  function unattendedMode(settings) {
2910
4058
  return settings.modeChosen ? settings.mode : "ask";
@@ -2940,7 +4088,11 @@ function loadSettings(root, overrides = {}, opts = {}) {
2940
4088
  commandTimeoutSeconds: clampTimeout(
2941
4089
  overrides.commandTimeoutSeconds ?? asNumber(project.commandTimeoutSeconds) ?? asNumber(global.commandTimeoutSeconds) ?? DEFAULTS.commandTimeoutSeconds
2942
4090
  ),
2943
- checkpoints: overrides.checkpoints ?? asBoolean(project.checkpoints) ?? asBoolean(global.checkpoints) ?? DEFAULTS.checkpoints
4091
+ checkpoints: overrides.checkpoints ?? asBoolean(project.checkpoints) ?? asBoolean(global.checkpoints) ?? DEFAULTS.checkpoints,
4092
+ // Screen preferences. Harmless from either file: neither changes what labd
4093
+ // may do, only how the session is drawn.
4094
+ mouse: overrides.mouse ?? asBoolean(project.mouse) ?? asBoolean(global.mouse) ?? DEFAULTS.mouse,
4095
+ sidebar: overrides.sidebar ?? asBoolean(project.sidebar) ?? asBoolean(global.sidebar) ?? DEFAULTS.sidebar
2944
4096
  };
2945
4097
  }
2946
4098
  function read(path) {
@@ -2977,10 +4129,14 @@ var BUILTIN_COMMANDS = [
2977
4129
  { name: "resume", args: "<id>", what: "continue from a point in /tree" },
2978
4130
  { name: "sessions", what: "earlier sessions in this project" },
2979
4131
  { name: "session", what: "where this one is stored" },
2980
- { name: "new", what: "how to start a fresh session" },
4132
+ { name: "rename", args: "<name>", what: "name this session, so /sessions and --resume show it" },
4133
+ { name: "new", what: "start a fresh session; this one stays saved" },
2981
4134
  { name: "usage", what: "what's left of this month's allowance" },
2982
4135
  { name: "trust", what: "let this project load its own extensions" },
4136
+ { name: "logout", what: "sign this machine out and leave" },
2983
4137
  { name: "reload", what: "reload extensions and the file list" },
4138
+ { name: "sidebar", what: "show or hide the side panel (full-screen session only)" },
4139
+ { name: "clear", what: "clear the screen; the session itself is kept" },
2984
4140
  { name: "quit", what: "leave" }
2985
4141
  ];
2986
4142
  function classify(line, extra = []) {
@@ -3167,9 +4323,12 @@ function branchOf(root) {
3167
4323
  if (abbrev === null) return void 0;
3168
4324
  return describeBranch(abbrev, abbrev === "HEAD" ? git("rev-parse", "--short", "HEAD") : null);
3169
4325
  }
4326
+ var closedAt = /* @__PURE__ */ new WeakMap();
3170
4327
  async function closeConversation(runtime) {
3171
4328
  const messages = runtime.session.wire();
3172
4329
  if (!messages.some((m) => m.role === "user")) return;
4330
+ if (closedAt.get(runtime.session) === messages.length) return;
4331
+ closedAt.set(runtime.session, messages.length);
3173
4332
  try {
3174
4333
  await runtime.client.close({
3175
4334
  protocol: PROTOCOL_VERSION,
@@ -3188,6 +4347,9 @@ async function closeConversation(runtime) {
3188
4347
  // packages/cli/src/modes/interactive.ts
3189
4348
  import { basename as basename4 } from "node:path";
3190
4349
 
4350
+ // packages/cli/src/commands.ts
4351
+ import { dirname as dirname3 } from "node:path";
4352
+
3191
4353
  // packages/cli/src/files.ts
3192
4354
  import { readdirSync as readdirSync6 } from "node:fs";
3193
4355
  import { join as join8, relative as relative4 } from "node:path";
@@ -3227,7 +4389,6 @@ function walk2(root, dir, out, budget) {
3227
4389
  }
3228
4390
 
3229
4391
  // packages/cli/src/commands.ts
3230
- import { dirname as dirname3 } from "node:path";
3231
4392
  async function handleCommand(input, ctx) {
3232
4393
  const [rawName = "", ...rest] = input.replace(/^\//, "").split(/\s+/);
3233
4394
  const name = rawName === "?" || rawName === "" ? "help" : rawName.toLowerCase();
@@ -3328,9 +4489,9 @@ async function handleCommand(input, ctx) {
3328
4489
  for (const s of all) {
3329
4490
  const when = new Date(s.at).toLocaleString();
3330
4491
  const mark = s.id === runtime.session.id ? paint.accent(">") : " ";
3331
- print(` ${mark} ${paint.dim(s.id.slice(0, 8))} ${paint.muted(`${when} ${s.messages} nodes`)} ${s.name ?? ""}`);
4492
+ print(` ${mark} ${paint.dim(s.id.slice(0, 8))} ${paint.muted(`${when} ${s.messages} nodes`)} ${sessionTitle(s)}`);
3332
4493
  }
3333
- print(` ${paint.dim("labd --resume <id> opens one.")}`);
4494
+ print(` ${paint.dim("labd --resume <id> opens one; labd --resume on its own lists them to pick from.")}`);
3334
4495
  return {};
3335
4496
  }
3336
4497
  case "undo": {
@@ -3427,6 +4588,27 @@ async function handleCommand(input, ctx) {
3427
4588
  for (const skipped of next.skipped) print(` ${paint.yellow("!")} ${paint.muted(skipped.why)}`);
3428
4589
  return {};
3429
4590
  }
4591
+ case "sidebar":
4592
+ case "clear":
4593
+ print(` ${paint.muted(`/${name} works in the full-screen session; this is the plain one (--plain).`)}`);
4594
+ return {};
4595
+ case "rename": {
4596
+ const name2 = sessionName(args);
4597
+ if (!name2) {
4598
+ const current = sessionName(runtime.session.displayName ?? "");
4599
+ print(` ${current ? paint.bold(current) : paint.muted("this session has no name yet")} ${paint.dim("- /rename <name> sets one")}`);
4600
+ return {};
4601
+ }
4602
+ runtime.session.rename(name2);
4603
+ print(` ${paint.muted("named")} ${paint.bold(name2)}`);
4604
+ return {};
4605
+ }
4606
+ case "logout": {
4607
+ if (ctx.asked) await closeConversation(runtime);
4608
+ const hadStored = await runtime.auth.signOut();
4609
+ const farewell = signedOutMessage(hadStored, Boolean(process.env.LABD_TOKEN)).split("\n").map((line) => ` ${paint.muted(line)}`).join("\n");
4610
+ return { exit: true, farewell };
4611
+ }
3430
4612
  case "session":
3431
4613
  print(` ${paint.muted(runtime.session.file)}`);
3432
4614
  print(` ${paint.muted(`${runtime.session.size()} nodes, head ${runtime.session.headId?.slice(0, 8) ?? "none"}`)}`);
@@ -3466,10 +4648,88 @@ function help(runtime) {
3466
4648
  ` ${paint.dim("@path tags a file into the question. ctrl-c stops an answer.")}`
3467
4649
  ].join("\n");
3468
4650
  }
4651
+ var MAX_SESSION_NAME = 80;
4652
+ function sessionName(raw) {
4653
+ return raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, MAX_SESSION_NAME);
4654
+ }
3469
4655
  function indent(text) {
3470
4656
  return text.split("\n").map((line) => ` ${line}`).join("\n");
3471
4657
  }
3472
4658
 
4659
+ // packages/cli/src/modes/shared.ts
4660
+ function describe(line, runtime) {
4661
+ const extras = runtime.extensions.commands.keys();
4662
+ const what = classify(line, extras);
4663
+ if (what.state === "none") return { state: "idle" };
4664
+ if (what.state === "ok") {
4665
+ return { state: "ok", hint: what.info ? paint.muted(what.info.what) : null };
4666
+ }
4667
+ if (what.state === "partial") {
4668
+ return { state: "partial", suggestions: matching(line, extras) };
4669
+ }
4670
+ const typed = line.trimStart().slice(1).split(/\s+/)[0] ?? "";
4671
+ const meant = didYouMean(typed, extras);
4672
+ return {
4673
+ state: "unknown",
4674
+ hint: meant ? `${paint.red("!")} ${paint.muted(`no /${typed}.`)} ${paint.dim("did you mean")} ${paint.accent(`/${meant}`)}${paint.dim("?")}` : `${paint.red("!")} ${paint.muted(`no /${typed}.`)} ${paint.dim("/help lists them all.")}`
4675
+ };
4676
+ }
4677
+ function complete(line, runtime) {
4678
+ if (line.startsWith("/") && !/\s/.test(line)) {
4679
+ const hits2 = matching(line, runtime.extensions.commands.keys());
4680
+ return hits2.length === 1 ? `/${hits2[0].name} ` : null;
4681
+ }
4682
+ const at2 = line.lastIndexOf("@");
4683
+ if (at2 === -1) return null;
4684
+ const partial = line.slice(at2 + 1);
4685
+ const hits = rankPaths(partial, listFiles(runtime.root));
4686
+ return hits.length ? `${line.slice(0, at2)}@${hits[0]} ` : null;
4687
+ }
4688
+ function fileTag(line) {
4689
+ const at2 = line.lastIndexOf("@");
4690
+ if (at2 === -1) return null;
4691
+ if (at2 > 0 && !/\s/.test(line[at2 - 1])) return null;
4692
+ const partial = line.slice(at2 + 1);
4693
+ if (/\s/.test(partial)) return null;
4694
+ return { at: at2, partial };
4695
+ }
4696
+ function label(step) {
4697
+ if (step.call.name === "spawn_agent") return `working with a helper: ${detail(step)}`;
4698
+ const own = `${step.call.name.replace(/_/g, " ")} ${detail(step)}`;
4699
+ return step.helper ? `${step.helper.label}: ${own}` : own;
4700
+ }
4701
+ function stepKey(step) {
4702
+ return step.helper ? `${step.helper.id}/${step.call.id}` : step.call.id;
4703
+ }
4704
+ function detail(step) {
4705
+ const input = step.call.input;
4706
+ if (step.call.name === "spawn_agent") return String(input.description ?? "helper");
4707
+ const own = String(input.why ?? input.path ?? input.from ?? input.query ?? "");
4708
+ return step.helper ? `\u21B3 ${step.helper.label} \xB7 ${own}` : own;
4709
+ }
4710
+ function verbatim(call) {
4711
+ const i = call.input;
4712
+ const str = (k) => typeof i[k] === "string" ? i[k] : "";
4713
+ switch (call.name) {
4714
+ case "run_command":
4715
+ return str("command");
4716
+ case "search_workspace": {
4717
+ const include = str("include");
4718
+ return include ? `${str("query")} in ${include}` : str("query");
4719
+ }
4720
+ case "move_file":
4721
+ return `${str("from")} \u2192 ${str("to")}`;
4722
+ case "list_dir":
4723
+ return str("path") || "/";
4724
+ // The whole brief: what the helper was actually told is what a learner
4725
+ // wants when its report surprises them.
4726
+ case "spawn_agent":
4727
+ return str("task");
4728
+ default:
4729
+ return str("path");
4730
+ }
4731
+ }
4732
+
3473
4733
  // packages/cli/src/modes/interactive.ts
3474
4734
  async function runInteractive(runtime) {
3475
4735
  const renderer = new Renderer();
@@ -3524,98 +4784,936 @@ async function runInteractive(runtime) {
3524
4784
  renderer.print(paint.dim(" bye."));
3525
4785
  break;
3526
4786
  }
3527
- const input = result.text;
3528
- if (!input) continue;
3529
- renderer.print(`${paint.accent(">")} ${input}`);
3530
- if (input.startsWith("/") || input === "?") {
3531
- const outcome = await handleCommand(input, {
3532
- runtime,
3533
- mode: state.mode,
3534
- effort: state.effort,
3535
- print: (text) => renderer.print(text)
3536
- });
3537
- if (outcome.exit) break;
3538
- if (outcome.mode) state.mode = outcome.mode;
3539
- if (outcome.effort !== void 0) state.effort = outcome.effort;
3540
- continue;
4787
+ const input = result.text;
4788
+ if (!input) continue;
4789
+ renderer.print(`${paint.accent(">")} ${input}`);
4790
+ if (input.startsWith("/") || input === "?") {
4791
+ const outcome = await handleCommand(input, {
4792
+ runtime,
4793
+ mode: state.mode,
4794
+ effort: state.effort,
4795
+ asked: answered > 0,
4796
+ print: (text) => renderer.print(text)
4797
+ });
4798
+ if (outcome.farewell) process.stderr.write(`${outcome.farewell}
4799
+ `);
4800
+ if (outcome.exit) break;
4801
+ if (outcome.mode) state.mode = outcome.mode;
4802
+ if (outcome.effort !== void 0) state.effort = outcome.effort;
4803
+ continue;
4804
+ }
4805
+ const tags = taggedPaths(input);
4806
+ const attachments = tags.length ? attachTagged(runtime.root, tags) : void 0;
4807
+ for (const refused of attachments?.filter((a) => a.omitted) ?? []) {
4808
+ renderer.print(` ${paint.yellow("!")} ${paint.muted(`${refused.path}: ${refused.omitted}`)}`);
4809
+ }
4810
+ for (const handler of runtime.extensions.onTurn) {
4811
+ try {
4812
+ handler({ question: input });
4813
+ } catch {
4814
+ }
4815
+ }
4816
+ abort = new AbortController();
4817
+ const markdown = new MarkdownStream(renderer.contentWidth);
4818
+ let opened = false;
4819
+ let held = null;
4820
+ const say = (line) => held ? held.push(line) : renderer.print(line);
4821
+ const ui = {
4822
+ text(chunk) {
4823
+ const ready = markdown.push(chunk);
4824
+ if (!ready) return;
4825
+ if (!opened) {
4826
+ renderer.print("");
4827
+ opened = true;
4828
+ }
4829
+ renderer.print(ready.replace(/\n$/, ""));
4830
+ },
4831
+ status(what) {
4832
+ startSpinner(what);
4833
+ },
4834
+ tool(step) {
4835
+ for (const handler of runtime.extensions.onTool) {
4836
+ try {
4837
+ handler(step);
4838
+ } catch {
4839
+ }
4840
+ }
4841
+ if (step.status === "running" || step.status === "awaiting") {
4842
+ if (!held) startSpinner(label(step));
4843
+ return;
4844
+ }
4845
+ for (const line of toolBlock(
4846
+ step.call.name,
4847
+ detail(step),
4848
+ step.status,
4849
+ { input: verbatim(step.call), output: step.output ?? step.detail },
4850
+ renderer.contentWidth
4851
+ )) {
4852
+ say(line);
4853
+ }
4854
+ },
4855
+ async approve(call, preview, helper) {
4856
+ stopSpinner();
4857
+ if (helper) renderer.print(` ${paint.accent("from a helper:")} ${paint.muted(helper)}`);
4858
+ for (const line of approvalCard(preview, call, state.mode, renderer.contentWidth)) {
4859
+ renderer.print(line);
4860
+ }
4861
+ const verb = call.name === "run_command" ? "run this command" : "apply this change";
4862
+ held = [];
4863
+ try {
4864
+ const answer = await prompt.read(`${verb}? [y/N]`);
4865
+ const ok = answer.kind === "line" && /^y(es)?$/i.test(answer.text.trim());
4866
+ renderer.print(` ${ok ? paint.green("allowed") : paint.muted("declined")}`);
4867
+ return ok;
4868
+ } finally {
4869
+ const waiting = held;
4870
+ held = null;
4871
+ for (const line of waiting) renderer.print(line);
4872
+ }
4873
+ },
4874
+ error(message) {
4875
+ stopSpinner();
4876
+ renderer.print(`${paint.red(" labd:")} ${message}`);
4877
+ },
4878
+ done(info2) {
4879
+ if (info2.context) state.context = info2.context;
4880
+ if (info2.credits) state.credits = info2.credits;
4881
+ },
4882
+ commandOutput(line) {
4883
+ say(paint.dim(` | ${line.replace(/\n/g, "\n | ")}`));
4884
+ }
4885
+ };
4886
+ startSpinner("thinking");
4887
+ const turn = await runTurn(input, {
4888
+ session: runtime.session,
4889
+ client: runtime.client,
4890
+ root: runtime.root,
4891
+ mode: state.mode,
4892
+ checkpoints: runtime.checkpoints,
4893
+ effort: state.effort,
4894
+ attachments,
4895
+ commandTimeoutMs: runtime.settings.commandTimeoutSeconds * 1e3,
4896
+ ui,
4897
+ signal: abort.signal
4898
+ });
4899
+ abort = null;
4900
+ stopSpinner();
4901
+ const tail = markdown.flush();
4902
+ if (tail) {
4903
+ if (!opened) renderer.print("");
4904
+ renderer.print(tail.replace(/\n$/, ""));
4905
+ }
4906
+ if (turn.wrote.length && runtime.checkpoints) {
4907
+ renderer.print(
4908
+ paint.dim(
4909
+ ` ${turn.wrote.length} file${turn.wrote.length === 1 ? "" : "s"} changed. /undo puts this answer back.`
4910
+ )
4911
+ );
4912
+ }
4913
+ answered++;
4914
+ if (state.context && state.context.percentUsed >= COMPACT_AT) {
4915
+ startSpinner("summarising, to make room");
4916
+ const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
4917
+ stopSpinner();
4918
+ renderer.print(paint.dim(ok ? " summarised the conversation so far." : " (could not summarise; carrying on)"));
4919
+ }
4920
+ }
4921
+ } finally {
4922
+ stopSpinner();
4923
+ prompt.close();
4924
+ }
4925
+ if (answered) {
4926
+ process.stderr.write(paint.dim(`
4927
+ ${FACE} session saved. Continue it with: labd --resume ${runtime.session.id.slice(0, 8)}
4928
+ `));
4929
+ await closeConversation(runtime);
4930
+ }
4931
+ return 0;
4932
+ }
4933
+ async function banner(renderer, runtime, mode) {
4934
+ const where = basename4(runtime.root);
4935
+ renderer.print("");
4936
+ const still = !renderer.isTty || Boolean(process.env.LABD_NO_MOTION);
4937
+ if (still) {
4938
+ for (const line of faceLines()) renderer.print(line);
4939
+ } else {
4940
+ renderer.hideCursor();
4941
+ for (const frame of decodeFrames()) {
4942
+ renderer.draw(frame);
4943
+ await sleep2(FACE_FRAME_MS);
4944
+ }
4945
+ renderer.clear();
4946
+ for (const line of faceLines()) renderer.print(line);
4947
+ }
4948
+ renderer.print("");
4949
+ renderer.print(`${paint.accent(FACE)} ${paint.bold("labd")} ${paint.dim(`in ${where}${runtime.branch ? ` on ${runtime.branch}` : ""}`)}`);
4950
+ renderer.print(` ${paint.dim(MODE_CAPTION[mode])}`);
4951
+ if (runtime.settings.ignoredProjectServerUrl) {
4952
+ renderer.print(
4953
+ ` ${paint.yellow("!")} ${paint.muted(
4954
+ `ignoring serverUrl in .labd/settings.json (${runtime.settings.ignoredProjectServerUrl}) - this project isn't trusted; \`labd trust\` if you meant it`
4955
+ )}`
4956
+ );
4957
+ }
4958
+ for (const skipped of runtime.extensions.skipped) {
4959
+ renderer.print(` ${paint.yellow("!")} ${paint.muted(`extensions: ${skipped.why}`)}`);
4960
+ }
4961
+ renderer.print("");
4962
+ }
4963
+ function sleep2(ms) {
4964
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
4965
+ }
4966
+
4967
+ // packages/cli/src/modes/screen.ts
4968
+ import { execFileSync as execFileSync3 } from "node:child_process";
4969
+ import { basename as basename5, dirname as dirname4 } from "node:path";
4970
+
4971
+ // packages/cli/src/modes/serial.ts
4972
+ var Serial = class {
4973
+ constructor(opts) {
4974
+ this.opts = opts;
4975
+ }
4976
+ pending = [];
4977
+ current = null;
4978
+ get busy() {
4979
+ return this.current !== null;
4980
+ }
4981
+ /** What is waiting, oldest first. */
4982
+ get queued() {
4983
+ return this.pending;
4984
+ }
4985
+ /** Where the running work can be awaited, for a clean exit. */
4986
+ get running() {
4987
+ return this.current;
4988
+ }
4989
+ /** Send now if nothing is running and nothing holds the line, else wait behind it. */
4990
+ submit(text) {
4991
+ if (this.current || this.opts.held() || this.pending.length) {
4992
+ this.pending.push(text);
4993
+ this.opts.changed();
4994
+ return "queued";
4995
+ }
4996
+ this.start(() => this.opts.send(text));
4997
+ return "sent";
4998
+ }
4999
+ /**
5000
+ * Run something that is not a typed line - picking a session from a list, say
5001
+ * - under the same rule. Refused rather than queued when busy: the choice was
5002
+ * made against what was on screen, and by the time it ran that would be gone.
5003
+ */
5004
+ run(work) {
5005
+ if (this.current) return false;
5006
+ this.start(work);
5007
+ return true;
5008
+ }
5009
+ /** Start the next line, if there is one and nothing holds it. Call when a hold lifts. */
5010
+ drain() {
5011
+ if (this.current || this.opts.held()) return;
5012
+ const next = this.pending.shift();
5013
+ if (next === void 0) return;
5014
+ this.start(() => this.opts.send(next));
5015
+ }
5016
+ /**
5017
+ * Stop waiting for what is running, without stopping it. For a command that
5018
+ * never finishes - an extension's, say - which would otherwise hold the line
5019
+ * for good. Whatever it does later lands late; nothing waits on it.
5020
+ */
5021
+ release() {
5022
+ if (!this.current) return;
5023
+ this.current = null;
5024
+ this.opts.changed();
5025
+ this.drain();
5026
+ }
5027
+ /** Empty the line and hand back what was in it, so a stop does not lose it. */
5028
+ take() {
5029
+ const out = this.pending;
5030
+ this.pending = [];
5031
+ if (out.length) this.opts.changed();
5032
+ return out;
5033
+ }
5034
+ start(work) {
5035
+ const done = (async () => {
5036
+ try {
5037
+ await work();
5038
+ } catch (err) {
5039
+ this.opts.failed(err);
5040
+ }
5041
+ })();
5042
+ this.current = done;
5043
+ this.opts.changed();
5044
+ void done.then(() => {
5045
+ if (this.current === done) this.current = null;
5046
+ this.opts.changed();
5047
+ this.drain();
5048
+ });
5049
+ }
5050
+ };
5051
+
5052
+ // packages/cli/src/modes/screen.ts
5053
+ var FRAME_MS = 33;
5054
+ var QUIT_WINDOW_MS = 2e3;
5055
+ var WHEEL_LINES = 3;
5056
+ var MAX_QUEUED_SHOWN = 3;
5057
+ var ESC6 = "\x1B";
5058
+ async function runScreen(runtime, opts) {
5059
+ const screen = new Screen(process.stdout, process.stdin, { mouse: opts.mouse });
5060
+ const transcript = new Transcript();
5061
+ const viewport = new Viewport(10);
5062
+ const editor = new TextEditor();
5063
+ const history = new History();
5064
+ const state = {
5065
+ mode: runtime.settings.mode,
5066
+ /**
5067
+ * The mode the running turn is held to, when shift+tab has moved `mode` up
5068
+ * to `agent` mid-answer. Going up does not apply until the next question -
5069
+ * a habit keypress must not let the very next command run unasked - while
5070
+ * going down applies at once, being the safe direction. Null when the turn
5071
+ * simply follows `mode`.
5072
+ */
5073
+ heldMode: null,
5074
+ effort: runtime.settings.effort,
5075
+ context: null,
5076
+ credits: null,
5077
+ sidebar: opts.sidebar,
5078
+ tick: 0,
5079
+ /** What labd is doing right now, for the footer. Null when idle. */
5080
+ busy: null,
5081
+ abort: null,
5082
+ popup: null,
5083
+ /** The line the popup was dismissed on, so Esc is not undone by the next frame. */
5084
+ popupDismissed: null,
5085
+ dialog: null,
5086
+ approval: null,
5087
+ /** The assistant block a streaming answer is going into. */
5088
+ answer: null,
5089
+ toolBlocks: /* @__PURE__ */ new Map(),
5090
+ lastSteps: [],
5091
+ ctrlCAt: 0,
5092
+ /** A one-line message in the footer, and when it expires. */
5093
+ notice: null,
5094
+ /** Printed to stderr once the screen is given back. */
5095
+ farewell: null,
5096
+ /** A question was asked in the CURRENT session, so leaving should say so and close it. */
5097
+ asked: false,
5098
+ quitting: false,
5099
+ faceIndex: 0,
5100
+ dirty: null,
5101
+ conventions: 0,
5102
+ skills: 0
5103
+ };
5104
+ const serial = new Serial({
5105
+ send: (text) => text.startsWith("/") || text === "?" ? command(text) : ask(text),
5106
+ held: () => state.dialog !== null || state.quitting,
5107
+ failed: (err) => transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) }),
5108
+ changed: () => schedule()
5109
+ });
5110
+ const faceFrames = process.env.LABD_NO_MOTION ? [faceLines()] : decodeFrames();
5111
+ let frameTimer = null;
5112
+ const schedule = () => {
5113
+ if (frameTimer) return;
5114
+ frameTimer = setTimeout(() => {
5115
+ frameTimer = null;
5116
+ draw();
5117
+ }, FRAME_MS);
5118
+ };
5119
+ let spinner = null;
5120
+ const setBusy = (what) => {
5121
+ state.busy = what;
5122
+ if (what && !spinner) {
5123
+ spinner = setInterval(() => {
5124
+ state.tick++;
5125
+ schedule();
5126
+ }, 400);
5127
+ }
5128
+ if (!what && spinner) {
5129
+ clearInterval(spinner);
5130
+ spinner = null;
5131
+ }
5132
+ schedule();
5133
+ };
5134
+ let resolveExit = null;
5135
+ const exited = new Promise((resolve2) => {
5136
+ resolveExit = resolve2;
5137
+ });
5138
+ const quit = () => {
5139
+ if (state.quitting) return;
5140
+ state.quitting = true;
5141
+ state.abort?.abort();
5142
+ resolveExit?.();
5143
+ };
5144
+ const note = (text) => {
5145
+ transcript.add({ kind: "note", text });
5146
+ schedule();
5147
+ };
5148
+ const notice = (text, ms = QUIT_WINDOW_MS) => {
5149
+ state.notice = { text, until: Date.now() + ms };
5150
+ schedule();
5151
+ setTimeout(schedule, ms + 10);
5152
+ };
5153
+ function draw() {
5154
+ if (state.quitting) return;
5155
+ const rows = screen.rows;
5156
+ const columns = screen.columns;
5157
+ const L = layout({ rows, columns, editorLines: editor.lineCount, sidebar: state.sidebar });
5158
+ const frame = [];
5159
+ let cursor = null;
5160
+ frame.push(header(columns));
5161
+ const width = L.transcript.width;
5162
+ const lines = transcript.length ? transcript.render(width) : emptyState(width, L.transcript.height);
5163
+ viewport.update(lines.length, L.transcript.height);
5164
+ const visible = viewport.slice(lines);
5165
+ while (visible.length < L.transcript.height) visible.push("");
5166
+ if (!viewport.follow && viewport.newLines > 0 && visible.length) {
5167
+ const n = viewport.newLines;
5168
+ const tag = paint.accent(`\u2193 ${n} new line${n === 1 ? "" : "s"} \xB7 end to follow`);
5169
+ visible[visible.length - 1] = fit(" ".repeat(Math.max(0, width - visibleWidth(tag) - 1)) + tag, width);
5170
+ }
5171
+ const overlays = [];
5172
+ if (serial.queued.length && !state.approval) overlays.push(queuedLines(width));
5173
+ if (state.popup && !state.dialog) overlays.push(state.popup.render(width));
5174
+ for (const rowsOut of overlays) {
5175
+ const start = visible.length - rowsOut.length;
5176
+ rowsOut.forEach((line, i) => {
5177
+ if (start + i >= 0) visible[start + i] = line;
5178
+ });
5179
+ }
5180
+ const body2 = [...visible.map((l) => fit(l, width)), ...composer2(L.composer.width, L.composer.height)];
5181
+ if (L.sidebar) {
5182
+ const side = sidebar(L.sidebar.width);
5183
+ body2.forEach((line, i) => {
5184
+ body2[i] = `${line}${paint.dim("\u2502")}${fit(side[i] ?? "", L.sidebar.width)}`;
5185
+ });
5186
+ }
5187
+ frame.push(...body2);
5188
+ frame.push(footer(columns));
5189
+ if (!state.dialog && !state.approval && !state.quitting) {
5190
+ const view = editor.view(L.composer.width - 6, L.composer.height - 2);
5191
+ cursor = { row: L.composer.top + 1 + view.cursor.row, col: 4 + view.cursor.col };
5192
+ }
5193
+ if (state.dialog) {
5194
+ const placed = renderDialog(state.dialog, rows, columns);
5195
+ placed.lines.forEach((line, i) => {
5196
+ const row = placed.top + i;
5197
+ if (row < frame.length) frame[row] = overlay(frame[row] ?? "", placed.left, line, columns);
5198
+ });
5199
+ }
5200
+ screen.paint(frame, cursor);
5201
+ }
5202
+ function header(width) {
5203
+ const session = runtime.session.id.slice(0, 8);
5204
+ const left = [
5205
+ `${paint.accent(FACE)} ${paint.bold("labd")}`,
5206
+ `${paint.muted(basename5(runtime.root))}${runtime.branch ? ` ${paint.muted(runtime.branch)}` : ""}`,
5207
+ paint.dim(`session ${session}`)
5208
+ ].join(paint.dim(" \xB7 "));
5209
+ const shown = state.mode === "agent" ? paint.red(paint.bold("agent")) : state.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
5210
+ const mode = state.heldMode ? `${shown}${paint.dim(" (next question)")}` : shown;
5211
+ const right = `${mode}${state.effort ? paint.dim(` \xB7 think ${state.effort}`) : ""} `;
5212
+ return spread(` ${left}`, right, width);
5213
+ }
5214
+ function footer(width) {
5215
+ const d = paint.dim;
5216
+ let left;
5217
+ if (state.notice && state.notice.until > Date.now()) left = paint.yellow(state.notice.text);
5218
+ else if (state.dialog) left = d("\u2191\u2193 move \xB7 enter choose \xB7 type to search \xB7 esc close");
5219
+ else if (state.approval) left = `${paint.yellow("y")} ${d("allow \xB7")} ${paint.yellow("n")} ${d("decline \xB7 ctrl+c stop")}`;
5220
+ else if (state.busy) left = `${paint.accent(state.busy)}${spinnerFrame(state.tick)} ${d("enter queues \xB7 ctrl+c stop \xB7 pgup/pgdn scroll")}`;
5221
+ else if (state.popup) left = d("\u2191\u2193 move \xB7 tab/enter accept \xB7 esc close");
5222
+ else {
5223
+ const described = describe(editor.currentLine, runtime);
5224
+ 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");
5225
+ }
5226
+ const bits = [];
5227
+ if (state.context) {
5228
+ const crowded = state.context.percentUsed >= 75;
5229
+ bits.push((crowded ? paint.yellow : paint.muted)(`context ${state.context.percentUsed}%`));
5230
+ }
5231
+ if (state.credits) bits.push(paint.muted(`credits ${state.credits.percentLeft}%`));
5232
+ return spread(` ${left}`, `${bits.join(paint.dim(" \xB7 "))} `, width);
5233
+ }
5234
+ function composer2(width, height) {
5235
+ const inner = width - 4;
5236
+ const textRows = Math.max(1, height - 2);
5237
+ const colour = state.approval ? paint.yellow : state.busy ? paint.accent : borderColour2(describe(editor.currentLine, runtime).state);
5238
+ const out = [];
5239
+ out.push(colour(`\u256D${"\u2500".repeat(Math.max(0, width - 2))}\u256E`));
5240
+ const view = editor.view(inner - 2, textRows);
5241
+ const lines = [...view.lines];
5242
+ while (lines.length < textRows) lines.push("");
5243
+ lines.forEach((line, i) => {
5244
+ const first = i === 0 && editor.cursor.row - view.cursor.row === 0;
5245
+ const sigil = first ? state.busy ? paint.dim(">") : paint.accent(">") : " ";
5246
+ let text = line;
5247
+ if (editor.isEmpty && i === 0) {
5248
+ 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");
5249
+ }
5250
+ out.push(`${colour("\u2502")} ${sigil} ${fit(text, inner - 2)} ${colour("\u2502")}`);
5251
+ });
5252
+ out.push(colour(`\u2570${"\u2500".repeat(Math.max(0, width - 2))}\u256F`));
5253
+ return out;
5254
+ }
5255
+ function emptyState(width, height) {
5256
+ const face = faceFrames[Math.min(state.faceIndex, faceFrames.length - 1)];
5257
+ const hint = paint.dim("ask labd something - / for commands - @ to tag a file");
5258
+ const caption = wrapPlain(MODE_CAPTION[state.mode], Math.max(20, width - 4)).map((l) => paint.dim(l));
5259
+ const body2 = [...face, "", hint, "", ...caption];
5260
+ const top = Math.max(0, Math.floor((height - body2.length) / 2));
5261
+ return [...Array(top).fill(""), ...body2.map((line) => centre(line, width))];
5262
+ }
5263
+ function sidebar(width) {
5264
+ const row = (k, v) => `${paint.muted(k.padEnd(12))}${v}`;
5265
+ const lines = [
5266
+ ` ${paint.bold("project")}`,
5267
+ ` ${row("name", basename5(runtime.root))}`,
5268
+ ` ${row("branch", runtime.branch ?? paint.dim("none"))}`,
5269
+ ` ${row("changed", state.dirty === null ? paint.dim("-") : `${state.dirty} file${state.dirty === 1 ? "" : "s"}`)}`,
5270
+ ` ${row("conventions", String(state.conventions))}`,
5271
+ ` ${row("skills", String(state.skills))}`,
5272
+ "",
5273
+ ` ${paint.bold("session")}`,
5274
+ ` ${row("mode", state.mode)}`,
5275
+ ` ${row("think", state.effort ?? paint.dim("default"))}`,
5276
+ ` ${row("context", state.context ? `${state.context.percentUsed}%` : paint.dim("-"))}`,
5277
+ ` ${row("credits", state.credits ? `${state.credits.percentLeft}% left` : paint.dim("-"))}`,
5278
+ "",
5279
+ ` ${paint.bold("recent steps")}`,
5280
+ ...state.lastSteps.length ? state.lastSteps.map((s) => ` ${paint.muted(s)}`) : [` ${paint.dim("none yet")}`]
5281
+ ];
5282
+ return lines.map((l) => fit(l, width));
5283
+ }
5284
+ function onKeys(keys) {
5285
+ for (const key of keys) onKey(key);
5286
+ }
5287
+ function onKey(key) {
5288
+ if (key.ctrl && key.name === "c") return interrupt();
5289
+ if (state.dialog) {
5290
+ const r = dialogKey(state.dialog, key);
5291
+ if (r === "close" || r === "select") closeDialog();
5292
+ return schedule();
5293
+ }
5294
+ if (key.mouse) {
5295
+ if (key.mouse.kind === "wheel-up") viewport.scroll(-WHEEL_LINES);
5296
+ else if (key.mouse.kind === "wheel-down") viewport.scroll(WHEEL_LINES);
5297
+ return schedule();
5298
+ }
5299
+ if (key.name === "pageup") return viewport.pageUp(), schedule();
5300
+ if (key.name === "pagedown") return viewport.pageDown(), schedule();
5301
+ if (key.name === "home" && editor.isEmpty) return viewport.home(), schedule();
5302
+ if (key.name === "end" && editor.isEmpty) return viewport.end(), schedule();
5303
+ if (state.approval) {
5304
+ if (key.sequence === "y" || key.sequence === "Y") return answerApproval(true);
5305
+ if (key.sequence === "n" || key.sequence === "N" || key.name === "escape") return answerApproval(false);
5306
+ return;
5307
+ }
5308
+ if (key.name === "tab" && key.shift) return cycleMode();
5309
+ if (key.ctrl && key.name === "d") {
5310
+ if (editor.isEmpty) quit();
5311
+ return;
5312
+ }
5313
+ if (key.ctrl && key.name === "l") return screen.invalidate(), schedule();
5314
+ if (key.ctrl && key.name === "n") return void serial.submit("/new");
5315
+ if (key.ctrl && key.name === "o") return void serial.submit("/sessions");
5316
+ if (key.name === "escape") {
5317
+ if (state.popup) {
5318
+ state.popup = null;
5319
+ state.popupDismissed = editor.currentLine;
5320
+ } else if (!viewport.follow) viewport.end();
5321
+ return schedule();
5322
+ }
5323
+ if (state.popup) {
5324
+ if (key.name === "up") return state.popup.move(-1), schedule();
5325
+ if (key.name === "down") return state.popup.move(1), schedule();
5326
+ if (key.name === "tab" || key.name === "return" && !key.shift && !key.ctrl) return acceptPopup();
5327
+ }
5328
+ if (key.name === "tab") {
5329
+ const completed = complete(editor.currentLine, runtime);
5330
+ if (completed !== null) editor.setCurrentLine(completed);
5331
+ refreshPopup();
5332
+ return schedule();
5333
+ }
5334
+ const outcome = editor.apply(key);
5335
+ if (outcome === "unhandled") {
5336
+ if (key.name === "return") return submit();
5337
+ if (key.name === "up" || key.name === "down") {
5338
+ const line = key.name === "up" ? history.up(editor.text) : history.down();
5339
+ if (line !== null) {
5340
+ editor.setText(line);
5341
+ refreshPopup();
5342
+ schedule();
5343
+ }
5344
+ }
5345
+ return;
5346
+ }
5347
+ if (outcome === "text") refreshPopup();
5348
+ if (outcome !== "same") schedule();
5349
+ }
5350
+ function interrupt() {
5351
+ if (state.approval) {
5352
+ answerApproval(false);
5353
+ }
5354
+ if (state.abort) {
5355
+ state.abort.abort();
5356
+ state.abort = null;
5357
+ note(paint.dim(" stopped."));
5358
+ unqueue();
5359
+ return;
5360
+ }
5361
+ if (serial.busy && serial.queued.length) {
5362
+ unqueue();
5363
+ notice("the queue is back in the box; ctrl+c again stops waiting for the command", 2500);
5364
+ return;
5365
+ }
5366
+ if (serial.busy) {
5367
+ serial.release();
5368
+ setBusy(null);
5369
+ note(paint.dim(" stopped waiting. The command may still finish in the background."));
5370
+ return;
5371
+ }
5372
+ if (state.dialog) {
5373
+ closeDialog();
5374
+ return schedule();
5375
+ }
5376
+ if (!editor.isEmpty) {
5377
+ editor.reset();
5378
+ state.popup = null;
5379
+ return schedule();
5380
+ }
5381
+ const now = Date.now();
5382
+ if (now - state.ctrlCAt < QUIT_WINDOW_MS) return quit();
5383
+ state.ctrlCAt = now;
5384
+ notice("press ctrl+c again to quit");
5385
+ }
5386
+ function effectiveMode() {
5387
+ return state.heldMode ?? state.mode;
5388
+ }
5389
+ function cycleMode() {
5390
+ const previous = effectiveMode();
5391
+ const next = AGENT_MODES[(AGENT_MODES.indexOf(state.mode) + 1) % AGENT_MODES.length];
5392
+ state.mode = next;
5393
+ const running = state.abort !== null;
5394
+ state.heldMode = next === "agent" && running ? previous : null;
5395
+ notice(`${next}: ${MODE_CAPTION[next]}`, 4e3);
5396
+ if (next === "agent") {
5397
+ note(
5398
+ ` ${paint.red(paint.bold("agent"))} ${paint.muted(
5399
+ running ? `- from your next question. This answer stays on ${previous}.` : "- commands now run without asking, and cannot be put back."
5400
+ )}
5401
+ ${paint.muted("For this session only. shift+tab again to leave it.")}`
5402
+ );
5403
+ }
5404
+ }
5405
+ function answerApproval(ok) {
5406
+ const approval = state.approval;
5407
+ if (!approval) return;
5408
+ state.approval = null;
5409
+ approval.entry.block.answer = ok ? "allowed" : "declined";
5410
+ transcript.touch(approval.entry);
5411
+ approval.resolve(ok);
5412
+ schedule();
5413
+ }
5414
+ function refreshPopup() {
5415
+ const line = editor.currentLine;
5416
+ if (state.popupDismissed !== null && state.popupDismissed === line) return;
5417
+ state.popupDismissed = null;
5418
+ if (line.startsWith("/") && !/\s/.test(line)) {
5419
+ if (classify(line, runtime.extensions.commands.keys()).state !== "partial") {
5420
+ state.popup = null;
5421
+ return;
5422
+ }
5423
+ const items = matching(line, runtime.extensions.commands.keys()).map((c) => ({
5424
+ label: `/${c.name}`,
5425
+ what: c.what,
5426
+ value: `/${c.name} `
5427
+ }));
5428
+ state.popup = items.length ? new Popup("command", items) : null;
5429
+ return;
5430
+ }
5431
+ const tag = fileTag(line);
5432
+ if (tag) {
5433
+ const hits = rankPaths(tag.partial, listFiles(runtime.root), 8);
5434
+ state.popup = hits.length ? new Popup(
5435
+ "file",
5436
+ hits.map((path) => ({ label: path, value: `${line.slice(0, tag.at)}@${path} ` }))
5437
+ ) : null;
5438
+ return;
5439
+ }
5440
+ state.popup = null;
5441
+ }
5442
+ function acceptPopup() {
5443
+ const item = state.popup?.current;
5444
+ state.popup = null;
5445
+ if (item) editor.setCurrentLine(item.value);
5446
+ schedule();
5447
+ }
5448
+ function submit() {
5449
+ const text = editor.text.trim();
5450
+ if (!text) return;
5451
+ history.add(text);
5452
+ editor.reset();
5453
+ state.popup = null;
5454
+ viewport.end();
5455
+ serial.submit(text);
5456
+ schedule();
5457
+ }
5458
+ async function command(text) {
5459
+ setBusy(`running ${text.split(/\s+/)[0]}`);
5460
+ try {
5461
+ await runCommand2(text);
5462
+ } finally {
5463
+ setBusy(null);
5464
+ }
5465
+ }
5466
+ function closeDialog() {
5467
+ state.dialog = null;
5468
+ serial.drain();
5469
+ }
5470
+ function unqueue() {
5471
+ const queued = serial.take();
5472
+ if (!queued.length) return;
5473
+ editor.setText([...queued, editor.text].filter(Boolean).join("\n"));
5474
+ state.popup = null;
5475
+ }
5476
+ function queuedLines(width) {
5477
+ const shown = serial.queued.slice(0, MAX_QUEUED_SHOWN);
5478
+ const lines = shown.map((text) => fit(` ${paint.dim("queued")} ${paint.muted(text.split("\n")[0] ?? "")}`, width));
5479
+ const more = serial.queued.length - shown.length;
5480
+ if (more > 0) lines.push(paint.dim(` and ${more} more`));
5481
+ return lines;
5482
+ }
5483
+ async function runCommand2(input) {
5484
+ transcript.add({ kind: "user", text: input });
5485
+ const [rawName = ""] = input.replace(/^\//, "").split(/\s+/);
5486
+ const name = rawName === "?" || rawName === "" ? "help" : rawName.toLowerCase();
5487
+ switch (name) {
5488
+ case "help":
5489
+ state.dialog = info("help", helpLines());
5490
+ return schedule();
5491
+ case "sessions":
5492
+ return openSessions();
5493
+ case "tree":
5494
+ return openTree();
5495
+ case "sidebar":
5496
+ state.sidebar = !state.sidebar;
5497
+ if (state.sidebar) refreshProjectFacts();
5498
+ note(paint.muted(state.sidebar ? " side panel on. It needs 120 columns to show." : " side panel off."));
5499
+ return;
5500
+ case "clear":
5501
+ transcript.clear();
5502
+ state.toolBlocks.clear();
5503
+ state.answer = null;
5504
+ return schedule();
5505
+ case "new":
5506
+ return newSession();
5507
+ default:
5508
+ break;
5509
+ }
5510
+ const outcome = await handleCommand(input, {
5511
+ runtime,
5512
+ mode: state.mode,
5513
+ effort: state.effort,
5514
+ asked: state.asked,
5515
+ print: (text) => note(text)
5516
+ });
5517
+ if (outcome.farewell) state.farewell = outcome.farewell;
5518
+ if (outcome.exit) return quit();
5519
+ if (outcome.mode) {
5520
+ state.mode = outcome.mode;
5521
+ state.heldMode = null;
5522
+ }
5523
+ if (outcome.effort !== void 0) state.effort = outcome.effort;
5524
+ schedule();
5525
+ }
5526
+ function helpLines() {
5527
+ const rows = BUILTIN_COMMANDS.map((c) => [`/${c.name}${c.args ? ` ${c.args}` : ""}`, c.what]);
5528
+ for (const [cmd, command2] of runtime.extensions.commands) rows.push([`/${cmd}`, `${command2.describe} ${paint.dim("(extension)")}`]);
5529
+ const width = Math.max(...rows.map(([n]) => n.length));
5530
+ const keys = [
5531
+ ["enter", "send"],
5532
+ ["shift+enter, ctrl+j, or a trailing \\", "new line"],
5533
+ ["/ and @", "commands and files, with a list"],
5534
+ ["tab", "complete"],
5535
+ ["shift+tab", "next mode: ask, auto, agent"],
5536
+ ["pgup / pgdn, wheel", "scroll the transcript"],
5537
+ ["home / end", "top / bottom (with an empty box)"],
5538
+ ["esc", "close a list or dialog; jump back to the bottom"],
5539
+ ["ctrl+c", "stop the answer; clear the box; twice to quit"],
5540
+ ["ctrl+d", "quit"],
5541
+ ["ctrl+n / ctrl+o", "new session / earlier sessions"],
5542
+ ["ctrl+l", "redraw"]
5543
+ ];
5544
+ const kwidth = Math.max(...keys.map(([k]) => k.length));
5545
+ return [
5546
+ paint.bold("commands"),
5547
+ ...rows.map(([n, w]) => `${paint.accent(n.padEnd(width))} ${paint.muted(w)}`),
5548
+ "",
5549
+ paint.bold("keys"),
5550
+ ...keys.map(([k, w]) => `${paint.accent(k.padEnd(kwidth))} ${paint.muted(w)}`),
5551
+ "",
5552
+ paint.dim("@path tags a file into the question. --plain runs the older print-style session.")
5553
+ ];
5554
+ }
5555
+ function openSessions() {
5556
+ const all = Session.list(dirname4(runtime.session.file));
5557
+ if (!all.length) return note(paint.muted(" no earlier sessions in this project"));
5558
+ state.dialog = pick(
5559
+ "sessions - enter resumes one",
5560
+ all.map((s) => ({
5561
+ id: s.file,
5562
+ label: `${s.id === runtime.session.id ? "> " : " "}${sessionTitle(s)}`,
5563
+ hint: `${s.id.slice(0, 8)} ${new Date(s.at).toLocaleString()} ${s.messages} nodes`
5564
+ })),
5565
+ (item) => {
5566
+ if (!serial.run(() => switchSession(item.id))) notice("labd is still working - pick again when it is done", 2500);
5567
+ }
5568
+ );
5569
+ schedule();
5570
+ }
5571
+ function openTree() {
5572
+ const nodes = runtime.session.all();
5573
+ if (!nodes.length) return note(paint.muted(" nothing in this session yet"));
5574
+ const head = runtime.session.headId;
5575
+ state.dialog = pick(
5576
+ "this session - enter continues from a point",
5577
+ nodes.map((n) => ({
5578
+ id: n.id,
5579
+ label: `${n.id === head ? "> " : " "}${n.id.slice(0, 8)} ${n.body.kind.padEnd(9)}`,
5580
+ hint: summarise2(n.body)
5581
+ })),
5582
+ (item) => {
5583
+ if (!serial.run(() => runCommand2(`/resume ${item.id.slice(0, 8)}`))) notice("labd is still working - pick again when it is done", 2500);
3541
5584
  }
3542
- const tags = taggedPaths(input);
3543
- const attachments = tags.length ? attachTagged(runtime.root, tags) : void 0;
3544
- for (const refused of attachments?.filter((a) => a.omitted) ?? []) {
3545
- renderer.print(` ${paint.yellow("!")} ${paint.muted(`${refused.path}: ${refused.omitted}`)}`);
5585
+ );
5586
+ schedule();
5587
+ }
5588
+ async function switchSession(file) {
5589
+ const current = runtime.session;
5590
+ if (Session.list(dirname4(current.file)).find((s) => s.file === file)?.id === current.id) {
5591
+ return note(paint.muted(" that is this session."));
5592
+ }
5593
+ if (state.asked) await closeConversation(runtime);
5594
+ runtime.session = Session.open(file);
5595
+ state.asked = false;
5596
+ transcript.clear();
5597
+ state.toolBlocks.clear();
5598
+ state.answer = null;
5599
+ replay();
5600
+ note(paint.muted(` resumed ${runtime.session.id.slice(0, 8)}.`));
5601
+ }
5602
+ async function newSession() {
5603
+ if (state.asked) await closeConversation(runtime);
5604
+ runtime.session = Session.create(bucketFor(runtime.stateDir, runtime.root));
5605
+ state.asked = false;
5606
+ transcript.clear();
5607
+ state.toolBlocks.clear();
5608
+ state.answer = null;
5609
+ note(paint.muted(` new session ${runtime.session.id.slice(0, 8)}. The previous one is saved; /sessions lists it.`));
5610
+ }
5611
+ function replay() {
5612
+ for (const m of runtime.session.wire()) {
5613
+ if (m.role === "user" && m.text) transcript.add({ kind: "user", text: m.text });
5614
+ else if (m.role === "assistant" && m.text) transcript.add({ kind: "assistant", text: m.text, done: true });
5615
+ }
5616
+ }
5617
+ async function ask(input) {
5618
+ state.heldMode = null;
5619
+ transcript.add({ kind: "user", text: input });
5620
+ state.asked = true;
5621
+ state.answer = null;
5622
+ const tags = taggedPaths(input);
5623
+ const attachments = tags.length ? attachTagged(runtime.root, tags) : void 0;
5624
+ for (const refused of attachments?.filter((a) => a.omitted) ?? []) {
5625
+ note(` ${paint.yellow("!")} ${paint.muted(`${refused.path}: ${refused.omitted}`)}`);
5626
+ }
5627
+ for (const handler of runtime.extensions.onTurn) {
5628
+ try {
5629
+ handler({ question: input });
5630
+ } catch {
3546
5631
  }
3547
- for (const handler of runtime.extensions.onTurn) {
3548
- try {
3549
- handler({ question: input });
3550
- } catch {
5632
+ }
5633
+ const abort = new AbortController();
5634
+ state.abort = abort;
5635
+ const ui = {
5636
+ text(chunk) {
5637
+ if (!state.answer || state.answer.block.done) {
5638
+ state.answer = transcript.add({ kind: "assistant", text: "", done: false });
3551
5639
  }
3552
- }
3553
- abort = new AbortController();
3554
- const markdown = new MarkdownStream(renderer.contentWidth);
3555
- let opened = false;
3556
- const ui = {
3557
- text(chunk) {
3558
- const ready = markdown.push(chunk);
3559
- if (!ready) return;
3560
- if (!opened) {
3561
- renderer.print("");
3562
- opened = true;
3563
- }
3564
- renderer.print(ready.replace(/\n$/, ""));
3565
- },
3566
- status(what) {
3567
- startSpinner(what);
3568
- },
3569
- tool(step) {
3570
- for (const handler of runtime.extensions.onTool) {
3571
- try {
3572
- handler(step);
3573
- } catch {
3574
- }
3575
- }
3576
- if (step.status === "running" || step.status === "awaiting") {
3577
- startSpinner(label(step));
3578
- return;
3579
- }
3580
- for (const line of toolBlock(
3581
- step.call.name,
3582
- detail(step),
3583
- step.status,
3584
- { input: verbatim(step.call), output: step.output ?? step.detail },
3585
- renderer.contentWidth
3586
- )) {
3587
- renderer.print(line);
5640
+ state.answer.block.text += chunk;
5641
+ transcript.touch(state.answer);
5642
+ schedule();
5643
+ },
5644
+ status(what) {
5645
+ setBusy(what);
5646
+ },
5647
+ tool(step) {
5648
+ for (const handler of runtime.extensions.onTool) {
5649
+ try {
5650
+ handler(step);
5651
+ } catch {
3588
5652
  }
3589
- },
3590
- async approve(call, preview) {
3591
- stopSpinner();
3592
- for (const line of approvalCard(preview, call, state.mode, renderer.contentWidth)) {
3593
- renderer.print(line);
5653
+ }
5654
+ closeAnswer();
5655
+ const key = stepKey(step);
5656
+ let entry = state.toolBlocks.get(key);
5657
+ if (!entry) {
5658
+ entry = transcript.add({
5659
+ kind: "tool",
5660
+ name: step.call.name,
5661
+ detail: detail(step),
5662
+ status: step.status,
5663
+ input: verbatim(step.call) || void 0,
5664
+ live: []
5665
+ });
5666
+ state.toolBlocks.set(key, entry);
5667
+ if (state.approval) {
5668
+ transcript.toEnd(state.approval.entry);
5669
+ if (viewport.follow) viewport.end();
3594
5670
  }
3595
- const verb = call.name === "run_command" ? "run this command" : "apply this change";
3596
- const answer = await prompt.read(`${verb}? [y/N]`);
3597
- const ok = answer.kind === "line" && /^y(es)?$/i.test(answer.text.trim());
3598
- renderer.print(` ${ok ? paint.green("allowed") : paint.muted("declined")}`);
3599
- return ok;
3600
- },
3601
- error(message) {
3602
- stopSpinner();
3603
- renderer.print(`${paint.red(" labd:")} ${message}`);
3604
- },
3605
- done(info) {
3606
- if (info.context) state.context = info.context;
3607
- if (info.credits) state.credits = info.credits;
3608
- },
3609
- commandOutput(line) {
3610
- renderer.print(paint.dim(` | ${line.replace(/\n/g, "\n | ")}`));
3611
5671
  }
3612
- };
3613
- startSpinner("thinking");
5672
+ entry.block.status = step.status;
5673
+ if (step.status !== "running" && step.status !== "awaiting") {
5674
+ entry.block.output = step.output ?? step.detail;
5675
+ remember(`${label(step)}`.trim());
5676
+ }
5677
+ transcript.touch(entry);
5678
+ if (step.status === "running" || step.status === "awaiting") setBusy(label(step));
5679
+ schedule();
5680
+ },
5681
+ approve(call, preview, helper) {
5682
+ setBusy(null);
5683
+ closeAnswer();
5684
+ const entry = transcript.add({ kind: "approval", call, preview, helper, mode: effectiveMode(), answer: null });
5685
+ viewport.end();
5686
+ return new Promise((resolve2) => {
5687
+ state.approval = { entry, resolve: resolve2 };
5688
+ schedule();
5689
+ });
5690
+ },
5691
+ error(message) {
5692
+ setBusy(null);
5693
+ transcript.add({ kind: "error", text: message });
5694
+ schedule();
5695
+ },
5696
+ done(info2) {
5697
+ if (info2.context) state.context = info2.context;
5698
+ if (info2.credits) state.credits = info2.credits;
5699
+ schedule();
5700
+ },
5701
+ commandOutput(line, key) {
5702
+ const target = key ? state.toolBlocks.get(key) : void 0;
5703
+ if (!target) return;
5704
+ target.block.live.push(...line.replace(/\n$/, "").split("\n"));
5705
+ transcript.touch(target);
5706
+ schedule();
5707
+ }
5708
+ };
5709
+ setBusy("thinking");
5710
+ try {
3614
5711
  const turn = await runTurn(input, {
3615
5712
  session: runtime.session,
3616
5713
  client: runtime.client,
3617
5714
  root: runtime.root,
3618
5715
  mode: state.mode,
5716
+ currentMode: effectiveMode,
3619
5717
  checkpoints: runtime.checkpoints,
3620
5718
  effort: state.effort,
3621
5719
  attachments,
@@ -3623,126 +5721,171 @@ async function runInteractive(runtime) {
3623
5721
  ui,
3624
5722
  signal: abort.signal
3625
5723
  });
3626
- abort = null;
3627
- stopSpinner();
3628
- const tail = markdown.flush();
3629
- if (tail) {
3630
- if (!opened) renderer.print("");
3631
- renderer.print(tail.replace(/\n$/, ""));
3632
- }
5724
+ closeAnswer();
3633
5725
  if (turn.wrote.length && runtime.checkpoints) {
3634
- renderer.print(
3635
- paint.dim(
3636
- ` ${turn.wrote.length} file${turn.wrote.length === 1 ? "" : "s"} changed. /undo puts this answer back.`
3637
- )
3638
- );
5726
+ note(paint.dim(` ${turn.wrote.length} file${turn.wrote.length === 1 ? "" : "s"} changed. /undo puts this answer back.`));
3639
5727
  }
3640
- answered++;
3641
- if (state.context && state.context.percentUsed >= COMPACT_AT) {
3642
- startSpinner("summarising, to make room");
5728
+ if (state.sidebar) refreshProjectFacts();
5729
+ if (!abort.signal.aborted && state.context && state.context.percentUsed >= COMPACT_AT) {
5730
+ setBusy("summarising, to make room");
3643
5731
  const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
3644
- stopSpinner();
3645
- renderer.print(paint.dim(ok ? " summarised the conversation so far." : " (could not summarise; carrying on)"));
5732
+ if (ok) transcript.add({ kind: "compaction" });
5733
+ else note(paint.dim(" (could not summarise; carrying on)"));
3646
5734
  }
5735
+ } catch (err) {
5736
+ transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) });
5737
+ } finally {
5738
+ if (state.abort === abort) state.abort = null;
5739
+ state.heldMode = null;
5740
+ if (state.approval) answerApproval(false);
5741
+ setBusy(null);
3647
5742
  }
3648
- } finally {
3649
- stopSpinner();
3650
- prompt.close();
3651
5743
  }
3652
- if (answered) {
5744
+ function closeAnswer() {
5745
+ const answer = state.answer;
5746
+ if (!answer || answer.block.done) return;
5747
+ answer.block.done = true;
5748
+ transcript.touch(answer);
5749
+ }
5750
+ function remember(step) {
5751
+ state.lastSteps.push(step);
5752
+ if (state.lastSteps.length > 5) state.lastSteps.shift();
5753
+ }
5754
+ function refreshProjectFacts() {
5755
+ try {
5756
+ const out = execFileSync3("git", ["status", "--porcelain"], {
5757
+ cwd: runtime.root,
5758
+ encoding: "utf8",
5759
+ stdio: ["ignore", "pipe", "ignore"],
5760
+ timeout: 2e3
5761
+ });
5762
+ state.dirty = out.split("\n").filter(Boolean).length;
5763
+ } catch {
5764
+ state.dirty = null;
5765
+ }
5766
+ try {
5767
+ const ctx = buildContext(runtime.root).editor;
5768
+ state.conventions = ctx?.instructions?.length ?? 0;
5769
+ state.skills = ctx?.skills?.length ?? 0;
5770
+ } catch {
5771
+ }
5772
+ }
5773
+ screen.on("keys", onKeys);
5774
+ screen.on("resize", schedule);
5775
+ screen.on("end", quit);
5776
+ const onSignal = (code) => () => {
5777
+ screen.leave();
5778
+ process.exit(code);
5779
+ };
5780
+ const onTerm = onSignal(143);
5781
+ const onInt = onSignal(130);
5782
+ const onHup = onSignal(129);
5783
+ process.on("SIGTERM", onTerm);
5784
+ process.on("SIGINT", onInt);
5785
+ process.on("SIGHUP", onHup);
5786
+ screen.enter();
5787
+ if (state.sidebar) refreshProjectFacts();
5788
+ if (runtime.settings.ignoredProjectServerUrl) {
5789
+ note(
5790
+ ` ${paint.yellow("!")} ${paint.muted(
5791
+ `ignoring serverUrl in .labd/settings.json (${runtime.settings.ignoredProjectServerUrl}) - this project isn't trusted; \`labd trust\` if you meant it`
5792
+ )}`
5793
+ );
5794
+ }
5795
+ for (const skipped of runtime.extensions.skipped) note(` ${paint.yellow("!")} ${paint.muted(`extensions: ${skipped.why}`)}`);
5796
+ replay();
5797
+ let faceTimer = null;
5798
+ if (faceFrames.length > 1) {
5799
+ faceTimer = setInterval(() => {
5800
+ state.faceIndex++;
5801
+ if (state.faceIndex >= faceFrames.length - 1 && faceTimer) {
5802
+ clearInterval(faceTimer);
5803
+ faceTimer = null;
5804
+ }
5805
+ if (!transcript.length) schedule();
5806
+ }, FACE_FRAME_MS);
5807
+ }
5808
+ if (opts.pickSession) openSessions();
5809
+ draw();
5810
+ try {
5811
+ await exited;
5812
+ if (serial.running) await Promise.race([serial.running, sleep3(1500)]);
5813
+ } finally {
5814
+ if (faceTimer) clearInterval(faceTimer);
5815
+ if (frameTimer) clearTimeout(frameTimer);
5816
+ setBusy(null);
5817
+ process.off("SIGTERM", onTerm);
5818
+ process.off("SIGINT", onInt);
5819
+ process.off("SIGHUP", onHup);
5820
+ screen.leave();
5821
+ }
5822
+ if (state.asked) {
3653
5823
  process.stderr.write(paint.dim(`
3654
5824
  ${FACE} session saved. Continue it with: labd --resume ${runtime.session.id.slice(0, 8)}
3655
5825
  `));
3656
5826
  await closeConversation(runtime);
3657
5827
  }
5828
+ if (state.farewell) process.stderr.write(`${state.farewell}
5829
+ `);
3658
5830
  return 0;
3659
5831
  }
3660
- function describe(line, runtime) {
3661
- const extras = runtime.extensions.commands.keys();
3662
- const what = classify(line, extras);
3663
- if (what.state === "none") return { state: "idle" };
3664
- if (what.state === "ok") {
3665
- return { state: "ok", hint: what.info ? paint.muted(what.info.what) : null };
3666
- }
3667
- if (what.state === "partial") {
3668
- return { state: "partial", suggestions: matching(line, extras) };
5832
+ function borderColour2(s) {
5833
+ switch (s) {
5834
+ case "ok":
5835
+ return paint.green;
5836
+ case "unknown":
5837
+ return paint.red;
5838
+ case "partial":
5839
+ return paint.cyan;
5840
+ default:
5841
+ return paint.dim;
3669
5842
  }
3670
- const typed = line.trimStart().slice(1).split(/\s+/)[0] ?? "";
3671
- const meant = didYouMean(typed, extras);
3672
- return {
3673
- state: "unknown",
3674
- hint: meant ? `${paint.red("!")} ${paint.muted(`no /${typed}.`)} ${paint.dim("did you mean")} ${paint.accent(`/${meant}`)}${paint.dim("?")}` : `${paint.red("!")} ${paint.muted(`no /${typed}.`)} ${paint.dim("/help lists them all.")}`
3675
- };
3676
5843
  }
3677
- function label(step) {
3678
- return `${step.call.name.replace(/_/g, " ")} ${detail(step)}`;
5844
+ function spread(left, right, width) {
5845
+ const gap = width - visibleWidth(left) - visibleWidth(right);
5846
+ if (gap >= 1) return `${left}${" ".repeat(gap)}${right}`;
5847
+ const room = width - visibleWidth(right) - 1;
5848
+ if (room < 8) return fit(left, width);
5849
+ return `${fit(left, room)} ${right}`;
3679
5850
  }
3680
- function detail(step) {
3681
- const input = step.call.input;
3682
- return String(input.why ?? input.path ?? input.from ?? input.query ?? "");
5851
+ function centre(line, width) {
5852
+ const pad4 = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
5853
+ return `${" ".repeat(pad4)}${line}`;
3683
5854
  }
3684
- function verbatim(call) {
3685
- const i = call.input;
3686
- const str = (k) => typeof i[k] === "string" ? i[k] : "";
3687
- switch (call.name) {
3688
- case "run_command":
3689
- return str("command");
3690
- case "search_workspace": {
3691
- const include = str("include");
3692
- return include ? `${str("query")} in ${include}` : str("query");
3693
- }
3694
- case "move_file":
3695
- return `${str("from")} \u2192 ${str("to")}`;
3696
- case "list_dir":
3697
- return str("path") || "/";
3698
- default:
3699
- return str("path");
3700
- }
5855
+ function overlay(base, left, piece, width) {
5856
+ const before = fit(base, left);
5857
+ const after = sliceVisible(base, left + visibleWidth(piece));
5858
+ return fit(`${before}${ESC6}[0m${piece}${ESC6}[0m${after}`, width);
3701
5859
  }
3702
- async function banner(renderer, runtime, mode) {
3703
- const where = basename4(runtime.root);
3704
- renderer.print("");
3705
- const still = !renderer.isTty || Boolean(process.env.LABD_NO_MOTION);
3706
- if (still) {
3707
- for (const line of faceLines()) renderer.print(line);
3708
- } else {
3709
- renderer.hideCursor();
3710
- for (const frame of decodeFrames()) {
3711
- renderer.draw(frame);
3712
- await sleep2(FACE_FRAME_MS);
5860
+ function sliceVisible(line, from) {
5861
+ let visible = 0;
5862
+ let out = "";
5863
+ let i = 0;
5864
+ while (i < line.length) {
5865
+ if (line[i] === ESC6) {
5866
+ const end = line.indexOf("m", i);
5867
+ if (end === -1) break;
5868
+ out += line.slice(i, end + 1);
5869
+ i = end + 1;
5870
+ continue;
3713
5871
  }
3714
- renderer.clear();
3715
- for (const line of faceLines()) renderer.print(line);
3716
- }
3717
- renderer.print("");
3718
- renderer.print(`${paint.accent(FACE)} ${paint.bold("labd")} ${paint.dim(`in ${where}${runtime.branch ? ` on ${runtime.branch}` : ""}`)}`);
3719
- renderer.print(` ${paint.dim(MODE_CAPTION[mode])}`);
3720
- if (runtime.settings.ignoredProjectServerUrl) {
3721
- renderer.print(
3722
- ` ${paint.yellow("!")} ${paint.muted(
3723
- `ignoring serverUrl in .labd/settings.json (${runtime.settings.ignoredProjectServerUrl}) - this project isn't trusted; \`labd trust\` if you meant it`
3724
- )}`
3725
- );
5872
+ if (visible >= from) out += line[i];
5873
+ visible++;
5874
+ i++;
3726
5875
  }
3727
- for (const skipped of runtime.extensions.skipped) {
3728
- renderer.print(` ${paint.yellow("!")} ${paint.muted(`extensions: ${skipped.why}`)}`);
5876
+ return out;
5877
+ }
5878
+ function summarise2(body2) {
5879
+ const text = typeof body2.text === "string" ? body2.text : "";
5880
+ if (body2.kind === "tool") {
5881
+ const results = Array.isArray(body2.results) ? body2.results.length : 0;
5882
+ return `${results} result(s)`;
3729
5883
  }
3730
- renderer.print("");
5884
+ return text.replace(/\s+/g, " ").slice(0, 60);
3731
5885
  }
3732
- function sleep2(ms) {
5886
+ function sleep3(ms) {
3733
5887
  return new Promise((resolve2) => setTimeout(resolve2, ms));
3734
5888
  }
3735
- function complete(line, runtime) {
3736
- if (line.startsWith("/") && !/\s/.test(line)) {
3737
- const hits2 = matching(line, runtime.extensions.commands.keys());
3738
- return hits2.length === 1 ? `/${hits2[0].name} ` : null;
3739
- }
3740
- const at = line.lastIndexOf("@");
3741
- if (at === -1) return null;
3742
- const partial = line.slice(at + 1);
3743
- const hits = rankPaths(partial, listFiles(runtime.root));
3744
- return hits.length ? `${line.slice(0, at)}@${hits[0]} ` : null;
3745
- }
3746
5889
 
3747
5890
  // packages/cli/src/modes/print.ts
3748
5891
  function toStderr(text) {
@@ -3778,6 +5921,7 @@ async function runPrint(runtime, opts) {
3778
5921
  emit({
3779
5922
  type: "tool",
3780
5923
  name: step.call.name,
5924
+ ...step.helper ? { helper: step.helper } : {},
3781
5925
  status: step.status,
3782
5926
  input: step.call.input,
3783
5927
  detail: step.detail,
@@ -3785,7 +5929,8 @@ async function runPrint(runtime, opts) {
3785
5929
  output: step.output
3786
5930
  });
3787
5931
  } else if (step.status !== "running") {
3788
- toStderr(`${paint.dim(`${step.call.name}: ${step.status}${step.detail ? ` - ${step.detail}` : ""}`)}
5932
+ const who = step.helper ? `${step.helper.label} > ` : "";
5933
+ toStderr(`${paint.dim(`${who}${step.call.name}: ${step.status}${step.detail ? ` - ${step.detail}` : ""}`)}
3789
5934
  `);
3790
5935
  }
3791
5936
  if (step.status === "skipped") declined = true;
@@ -3796,8 +5941,8 @@ async function runPrint(runtime, opts) {
3796
5941
  else toStderr(`${paint.red("labd:")} ${message}
3797
5942
  `);
3798
5943
  },
3799
- done(info) {
3800
- if (opts.json) emit({ type: "done", credits: info.credits, context: info.context });
5944
+ done(info2) {
5945
+ if (opts.json) emit({ type: "done", credits: info2.credits, context: info2.context });
3801
5946
  },
3802
5947
  commandOutput(line) {
3803
5948
  if (opts.json) emit({ type: "command_output", line });
@@ -3854,10 +5999,10 @@ async function runRpc(runtime) {
3854
5999
  process.stdin.setEncoding("utf8");
3855
6000
  process.stdin.on("data", (chunk) => {
3856
6001
  buffer += chunk;
3857
- let at;
3858
- while ((at = buffer.indexOf("\n")) !== -1) {
3859
- const line = buffer.slice(0, at);
3860
- buffer = buffer.slice(at + 1);
6002
+ let at2;
6003
+ while ((at2 = buffer.indexOf("\n")) !== -1) {
6004
+ const line = buffer.slice(0, at2);
6005
+ buffer = buffer.slice(at2 + 1);
3861
6006
  if (line.trim()) void handle(line);
3862
6007
  }
3863
6008
  });
@@ -3944,6 +6089,8 @@ async function runRpc(runtime) {
3944
6089
  tool(step) {
3945
6090
  notify("labd/tool", {
3946
6091
  name: step.call.name,
6092
+ // Which helper made the call, when one did: its spawn_agent call id and name.
6093
+ ...step.helper ? { helper: step.helper } : {},
3947
6094
  status: step.status,
3948
6095
  input: step.call.input,
3949
6096
  detail: step.detail,
@@ -3954,11 +6101,11 @@ async function runRpc(runtime) {
3954
6101
  error(message) {
3955
6102
  notify("labd/error", { message });
3956
6103
  },
3957
- done(info) {
3958
- notify("labd/done", info);
6104
+ done(info2) {
6105
+ notify("labd/done", info2);
3959
6106
  },
3960
- commandOutput(line) {
3961
- notify("labd/command_output", { line });
6107
+ commandOutput(line, key) {
6108
+ notify("labd/command_output", { line, ...key ? { key } : {} });
3962
6109
  },
3963
6110
  /**
3964
6111
  * Ask the host. A host that never answers gets a refusal after a timeout
@@ -3966,7 +6113,7 @@ async function runRpc(runtime) {
3966
6113
  * alternative is writing to someone's files because their integration was
3967
6114
  * asleep.
3968
6115
  */
3969
- approve(call, preview) {
6116
+ approve(call, preview, helper) {
3970
6117
  return new Promise((resolve2) => {
3971
6118
  if (closed) return resolve2(false);
3972
6119
  const id = nextId++;
@@ -3977,7 +6124,7 @@ async function runRpc(runtime) {
3977
6124
  clearTimeout(timer);
3978
6125
  resolve2(approved);
3979
6126
  });
3980
- out({ jsonrpc: "2.0", id, method: "labd/approve", params: { call, preview } });
6127
+ out({ jsonrpc: "2.0", id, method: "labd/approve", params: { call, preview, ...helper ? { helper } : {} } });
3981
6128
  });
3982
6129
  }
3983
6130
  };
@@ -4007,7 +6154,7 @@ async function runRpc(runtime) {
4007
6154
  }
4008
6155
 
4009
6156
  // packages/cli/src/index.ts
4010
- var VERSION = true ? "0.3.1" : "0.0.0-dev";
6157
+ var VERSION = true ? "0.6.0" : "0.0.0-dev";
4011
6158
  async function main(argv) {
4012
6159
  const args = parse(argv);
4013
6160
  if (args.color !== void 0) setColor(args.color);
@@ -4033,11 +6180,16 @@ async function main(argv) {
4033
6180
  default:
4034
6181
  break;
4035
6182
  }
6183
+ const screenCapable = !args.rpc && !args.print && !args.question && !args.plain && process.stdin.isTTY && process.stdout.isTTY;
6184
+ if (args.pickSession && !screenCapable) {
6185
+ throw new UsageError("--resume with no id opens a list, which needs the full-screen session. `labd sessions` prints the ids for `labd --resume <id>`.");
6186
+ }
4036
6187
  const runtime = await createRuntime({
4037
6188
  root,
4038
6189
  overrides: {
4039
6190
  ...args.mode ? { mode: args.mode } : {},
4040
- ...args.effort ? { effort: args.effort } : {}
6191
+ ...args.effort ? { effort: args.effort } : {},
6192
+ ...args.mouse !== void 0 ? { mouse: args.mouse } : {}
4041
6193
  },
4042
6194
  resume: args.resume ? resolveSession(args.resume, root) : args.continue ? latestSession(root) : void 0,
4043
6195
  extensions: { enabled: !args.noExtensions, extra: args.extensions }
@@ -4058,7 +6210,8 @@ async function main(argv) {
4058
6210
  `);
4059
6211
  return 1;
4060
6212
  }
4061
- return runInteractive(runtime);
6213
+ if (args.plain) return runInteractive(runtime);
6214
+ return runScreen(runtime, { mouse: runtime.settings.mouse, sidebar: runtime.settings.sidebar, pickSession: args.pickSession });
4062
6215
  }
4063
6216
  async function login(root) {
4064
6217
  const settings = loadSettings(root, {}, { trusted: isTrusted(stateDir(), root) });
@@ -4093,9 +6246,11 @@ ${paint.red("labd:")} ${result.reason ?? "that did not complete"}.
4093
6246
  }
4094
6247
  async function logout(root) {
4095
6248
  const settings = loadSettings(root, {}, { trusted: isTrusted(stateDir(), root) });
4096
- await new Auth(stateDir(), settings.serverUrl).signOut();
4097
- process.stderr.write(` ${paint.muted("disconnected, and the stored token is gone")}
6249
+ const hadStored = await new Auth(stateDir(), settings.serverUrl).signOut();
6250
+ for (const line of signedOutMessage(hadStored, Boolean(process.env.LABD_TOKEN)).split("\n")) {
6251
+ process.stderr.write(` ${paint.muted(line)}
4098
6252
  `);
6253
+ }
4099
6254
  return 0;
4100
6255
  }
4101
6256
  function listSessions(root) {
@@ -4108,7 +6263,7 @@ function listSessions(root) {
4108
6263
  }
4109
6264
  for (const s of all.slice(0, 25)) {
4110
6265
  process.stdout.write(
4111
- ` ${paint.dim(s.id.slice(0, 8))} ${paint.muted(new Date(s.at).toLocaleString())} ${paint.muted(`${s.messages} nodes`)} ${s.name ?? ""}
6266
+ ` ${paint.dim(s.id.slice(0, 8))} ${paint.muted(new Date(s.at).toLocaleString())} ${paint.muted(`${s.messages} nodes`)} ${sessionTitle(s)}
4112
6267
  `
4113
6268
  );
4114
6269
  }
@@ -4140,11 +6295,13 @@ function parse(argv) {
4140
6295
  print: false,
4141
6296
  json: false,
4142
6297
  rpc: false,
6298
+ pickSession: false,
4143
6299
  continue: false,
4144
6300
  noExtensions: false,
4145
6301
  extensions: [],
4146
6302
  help: false,
4147
- version: false
6303
+ version: false,
6304
+ plain: false
4148
6305
  };
4149
6306
  const words = [];
4150
6307
  for (let i = 0; i < argv.length; i++) {
@@ -4170,10 +6327,14 @@ function parse(argv) {
4170
6327
  out.effort = value;
4171
6328
  break;
4172
6329
  }
6330
+ case "-r":
4173
6331
  case "--resume": {
4174
- const value = next();
4175
- if (!value) throw new UsageError("--resume needs a session id");
4176
- out.resume = value;
6332
+ const value = argv[i + 1];
6333
+ if (!value || value.startsWith("-")) {
6334
+ out.pickSession = true;
6335
+ break;
6336
+ }
6337
+ out.resume = next();
4177
6338
  break;
4178
6339
  }
4179
6340
  case "-c":
@@ -4203,6 +6364,12 @@ function parse(argv) {
4203
6364
  case "--color":
4204
6365
  out.color = true;
4205
6366
  break;
6367
+ case "--plain":
6368
+ out.plain = true;
6369
+ break;
6370
+ case "--no-mouse":
6371
+ out.mouse = false;
6372
+ break;
4206
6373
  case "-h":
4207
6374
  case "--help":
4208
6375
  out.help = true;
@@ -4227,6 +6394,9 @@ function parse(argv) {
4227
6394
  } else {
4228
6395
  out.question = words.join(" ");
4229
6396
  }
6397
+ if ((out.resume || out.pickSession) && out.continue) {
6398
+ throw new UsageError("--resume and --continue both choose a session - pass one of them");
6399
+ }
4230
6400
  return out;
4231
6401
  }
4232
6402
  function applyMode(out, value) {
@@ -4246,7 +6416,7 @@ function usage() {
4246
6416
  ${paint.accent(FACE)} ${paint.bold("labd")} - the Dev Labs assistant, in your terminal
4247
6417
 
4248
6418
  ${paint.bold("USAGE")}
4249
- labd start a session in this project
6419
+ labd start a session in this project (full-screen)
4250
6420
  labd "why is this failing" ask one question and exit
4251
6421
  labd -p "..." | pbcopy print mode, for pipes and scripts
4252
6422
  cat log | labd -p "why?" piped input is added to the question
@@ -4262,12 +6432,15 @@ ${paint.bold("OPTIONS")}
4262
6432
  --json emit newline-delimited JSON events
4263
6433
  --mode <m> ${AGENT_MODES.join(" | ")} (or rpc, for JSON-RPC over stdio)
4264
6434
  --think <low|medium|high>
4265
- --resume <id> continue an earlier session
6435
+ -r, --resume [id] continue an earlier session; with no id, pick one from a list
4266
6436
  -c, --continue continue the most recent session here
4267
6437
  -C, --cwd <dir> run as though started in <dir>
4268
6438
  -e, --extension <path> load an extension file (repeatable)
4269
6439
  --no-extensions load none
4270
6440
  --no-color plain text (also honours NO_COLOR)
6441
+ --no-mouse leave the mouse to the terminal: the session takes it for
6442
+ wheel scrolling, and native text selection needs it released
6443
+ --plain the older print-style session (deprecated; removed in 0.5)
4271
6444
  -h, --help -v, --version
4272
6445
 
4273
6446
  ${paint.bold("MODES")}