@thedevlabs-io/labd 0.3.1 → 0.5.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
  }
@@ -454,8 +454,8 @@ function danglingToolCallAt(messages) {
454
454
  return null;
455
455
  }
456
456
  function sendableMessages(messages) {
457
- const at = danglingToolCallAt(messages);
458
- return at === null ? messages : messages.slice(0, at);
457
+ const at2 = danglingToolCallAt(messages);
458
+ return at2 === null ? messages : messages.slice(0, at2);
459
459
  }
460
460
  function forExtraction(messages) {
461
461
  return messages.map((m) => {
@@ -678,6 +678,11 @@ function attachOne(root, path, budget) {
678
678
  import { appendFileSync, mkdirSync, readFileSync as readFileSync2, readdirSync as readdirSync2, statSync as statSync3, writeFileSync } from "node:fs";
679
679
  import { createHash, randomUUID } from "node:crypto";
680
680
  import { basename as basename2, join as join2 } from "node:path";
681
+ function sessionTitle(summary, width = 60) {
682
+ const text = (summary.name ?? summary.preview ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim();
683
+ if (!text) return "(no questions yet)";
684
+ return text.length > width ? `${text.slice(0, width - 1)}\u2026` : text;
685
+ }
681
686
  function bucketFor(stateDir2, root) {
682
687
  const bucket = join2(stateDir2, "sessions", createHash("sha256").update(root).digest("hex").slice(0, 16));
683
688
  mkdirSync(bucket, { recursive: true });
@@ -749,6 +754,7 @@ var Session = class _Session {
749
754
  id: session.id,
750
755
  file: join2(bucket, f),
751
756
  name: session.name,
757
+ preview: session.firstQuestion(),
752
758
  at: statSync3(join2(bucket, f)).mtimeMs,
753
759
  messages: session.nodes.size
754
760
  };
@@ -763,6 +769,14 @@ var Session = class _Session {
763
769
  size() {
764
770
  return this.nodes.size;
765
771
  }
772
+ /** The earliest question in the file, on any branch. */
773
+ firstQuestion() {
774
+ let first;
775
+ for (const node of this.nodes.values()) {
776
+ if (node.body.kind === "user" && (!first || node.at < first.at)) first = node;
777
+ }
778
+ return first?.body.kind === "user" ? first.body.text : void 0;
779
+ }
766
780
  rename(name) {
767
781
  this.name = name;
768
782
  this.append({ t: "meta", name });
@@ -802,15 +816,15 @@ var Session = class _Session {
802
816
  /** Root-to-head, which is the conversation as the model will see it. */
803
817
  path(from = this.head) {
804
818
  const out = [];
805
- let at = from;
819
+ let at2 = from;
806
820
  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);
821
+ while (at2) {
822
+ if (seen.has(at2)) break;
823
+ seen.add(at2);
824
+ const node = this.nodes.get(at2);
811
825
  if (!node) break;
812
826
  out.push(node);
813
- at = node.parentId;
827
+ at2 = node.parentId;
814
828
  }
815
829
  return out.reverse();
816
830
  }
@@ -2147,21 +2161,21 @@ function wrapCommand(command, width) {
2147
2161
  for (let i = 0; i < command.length; i += width) out.push(command.slice(i, i + width));
2148
2162
  return out;
2149
2163
  }
2150
- function statusLine(info) {
2164
+ function statusLine(info2) {
2151
2165
  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`));
2166
+ if (info2.contextPercent !== null && info2.contextPercent !== void 0) {
2167
+ const crowded = info2.contextPercent >= 75;
2168
+ bits.push((crowded ? paint.yellow : paint.muted)(`${info2.contextPercent}% full`));
2155
2169
  }
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`));
2170
+ bits.push(paint.muted(info2.project));
2171
+ if (info2.branch) bits.push(paint.muted(info2.branch));
2172
+ if (info2.creditsPercent !== null && info2.creditsPercent !== void 0) {
2173
+ bits.push(paint.muted(`${info2.creditsPercent}% left`));
2160
2174
  }
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");
2175
+ if (info2.effort) bits.push(paint.muted(`think ${info2.effort}`));
2176
+ const mode = info2.mode === "agent" ? paint.red(paint.bold("agent")) : info2.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
2163
2177
  bits.push(mode);
2164
- const left = info.busy ? `${paint.accent(FACE)} ${paint.dim(info.busy)}` : paint.dim(FACE);
2178
+ const left = info2.busy ? `${paint.accent(FACE)} ${paint.dim(info2.busy)}` : paint.dim(FACE);
2165
2179
  return `${left} ${paint.dim("|")} ${bits.join(paint.dim(" | "))}`;
2166
2180
  }
2167
2181
  function spinnerFrame(tick) {
@@ -2198,10 +2212,10 @@ function body(opts, inner) {
2198
2212
  function withCursor(view, room, hidden) {
2199
2213
  const text = view.text.slice(0, room);
2200
2214
  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);
2215
+ const at2 = Math.min(view.column, room - 1 < 0 ? 0 : room);
2216
+ const before = text.slice(0, at2);
2217
+ const under = text.slice(at2, at2 + 1) || " ";
2218
+ const after = text.slice(at2 + 1);
2205
2219
  return `${before}${paint.invert(under)}${after}`;
2206
2220
  }
2207
2221
  function suggestionLines(all, inner) {
@@ -2250,27 +2264,27 @@ function boundaries(text) {
2250
2264
  for (const { index, segment } of segmenter.segment(text)) out.push(index + segment.length);
2251
2265
  return out;
2252
2266
  }
2253
- function prevBoundary(text, at) {
2267
+ function prevBoundary(text, at2) {
2254
2268
  const marks = boundaries(text);
2255
2269
  let last = 0;
2256
2270
  for (const mark of marks) {
2257
- if (mark >= at) return last;
2271
+ if (mark >= at2) return last;
2258
2272
  last = mark;
2259
2273
  }
2260
2274
  return last;
2261
2275
  }
2262
- function nextBoundary(text, at) {
2263
- for (const mark of boundaries(text)) if (mark > at) return mark;
2276
+ function nextBoundary(text, at2) {
2277
+ for (const mark of boundaries(text)) if (mark > at2) return mark;
2264
2278
  return text.length;
2265
2279
  }
2266
- function wordStart(text, at) {
2267
- let i = at;
2280
+ function wordStart(text, at2) {
2281
+ let i = at2;
2268
2282
  while (i > 0 && /\s/.test(text[i - 1])) i--;
2269
2283
  while (i > 0 && !/\s/.test(text[i - 1])) i--;
2270
2284
  return i;
2271
2285
  }
2272
- function wordEnd(text, at) {
2273
- let i = at;
2286
+ function wordEnd(text, at2) {
2287
+ let i = at2;
2274
2288
  while (i < text.length && /\s/.test(text[i])) i++;
2275
2289
  while (i < text.length && !/\s/.test(text[i])) i++;
2276
2290
  return i;
@@ -2330,6 +2344,7 @@ var LineEditor = class {
2330
2344
  if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) {
2331
2345
  return this.insert(key.sequence);
2332
2346
  }
2347
+ if (key.paste !== void 0) return this.insert(key.paste);
2333
2348
  return "unhandled";
2334
2349
  }
2335
2350
  moveTo(next) {
@@ -2398,51 +2413,696 @@ var History = class {
2398
2413
  }
2399
2414
  };
2400
2415
 
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)];
2416
+ // packages/tui/src/editor.ts
2417
+ function sanitizeMultiline(text) {
2418
+ return text.replace(/\r\n?/g, "\n").replace(/\t/g, " ").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
2419
+ }
2420
+ var TextEditor = class {
2421
+ lines = [""];
2422
+ row = 0;
2423
+ col = 0;
2424
+ /** First line shown in the last `view`, kept so the window scrolls rather than jumps. */
2425
+ top = 0;
2426
+ get text() {
2427
+ return this.lines.join("\n");
2428
+ }
2429
+ get cursor() {
2430
+ return { row: this.row, col: this.col };
2431
+ }
2432
+ get lineCount() {
2433
+ return this.lines.length;
2434
+ }
2435
+ get isEmpty() {
2436
+ return this.lines.length === 1 && this.lines[0] === "";
2437
+ }
2438
+ /** The line the caret is on. Used to work out whether a `/` or `@` is being typed. */
2439
+ get currentLine() {
2440
+ return this.lines[this.row];
2441
+ }
2442
+ /** Replace everything - from history, or a completion. The caret lands at the end. */
2443
+ setText(text) {
2444
+ this.lines = sanitizeMultiline(text).split("\n");
2445
+ this.row = this.lines.length - 1;
2446
+ this.col = this.lines[this.row].length;
2447
+ }
2448
+ /** Replace just the caret's line, keeping the rest. For a completion of `/cmd` or `@path`. */
2449
+ setCurrentLine(text) {
2450
+ this.lines[this.row] = sanitizeMultiline(text).replace(/\n/g, " ");
2451
+ this.col = this.lines[this.row].length;
2452
+ }
2453
+ reset() {
2454
+ this.lines = [""];
2455
+ this.row = 0;
2456
+ this.col = 0;
2457
+ this.top = 0;
2458
+ }
2459
+ /** Insert at the caret. Newlines in `text` become new lines. */
2460
+ insert(text) {
2461
+ const clean = sanitizeMultiline(text);
2462
+ if (!clean) return "same";
2463
+ const parts = clean.split("\n");
2464
+ const line = this.lines[this.row];
2465
+ const before = line.slice(0, this.col);
2466
+ const after = line.slice(this.col);
2467
+ if (parts.length === 1) {
2468
+ this.lines[this.row] = before + parts[0] + after;
2469
+ this.col += parts[0].length;
2470
+ return "text";
2471
+ }
2472
+ const inserted = [before + parts[0], ...parts.slice(1, -1), parts[parts.length - 1] + after];
2473
+ this.lines.splice(this.row, 1, ...inserted);
2474
+ this.row += parts.length - 1;
2475
+ this.col = parts[parts.length - 1].length;
2476
+ return "text";
2477
+ }
2478
+ newline() {
2479
+ return this.insert("\n");
2480
+ }
2481
+ /**
2482
+ * Apply one key. `unhandled` for a plain Enter (the caller sends), for Up on
2483
+ * the first line and Down on the last (the caller walks history), and for
2484
+ * anything else the editor does not claim.
2485
+ */
2486
+ apply(key) {
2487
+ const { name, ctrl, meta, shift } = key;
2488
+ const line = this.lines[this.row];
2489
+ if (key.paste !== void 0) return this.insert(key.paste);
2490
+ if (name === "return") {
2491
+ if (shift || ctrl || meta) return this.newline();
2492
+ if (/(^|[^\\])\\$/.test(line)) {
2493
+ this.lines[this.row] = line.slice(0, -1);
2494
+ this.col = Math.min(this.col, this.lines[this.row].length);
2495
+ return this.newline();
2496
+ }
2497
+ return "unhandled";
2498
+ }
2499
+ if (ctrl && name === "j") return this.newline();
2500
+ if (name === "up" && !ctrl && !meta) {
2501
+ if (this.row === 0) return "unhandled";
2502
+ this.row--;
2503
+ this.col = Math.min(this.col, this.lines[this.row].length);
2504
+ return "cursor";
2505
+ }
2506
+ if (name === "down" && !ctrl && !meta) {
2507
+ if (this.row === this.lines.length - 1) return "unhandled";
2508
+ this.row++;
2509
+ this.col = Math.min(this.col, this.lines[this.row].length);
2510
+ return "cursor";
2511
+ }
2512
+ if (name === "left" && !ctrl && !meta) {
2513
+ if (this.col === 0) {
2514
+ if (this.row === 0) return "same";
2515
+ this.row--;
2516
+ this.col = this.lines[this.row].length;
2517
+ return "cursor";
2518
+ }
2519
+ return this.moveTo(prevBoundary(line, this.col));
2520
+ }
2521
+ if (name === "right" && !ctrl && !meta) {
2522
+ if (this.col >= line.length) {
2523
+ if (this.row === this.lines.length - 1) return "same";
2524
+ this.row++;
2525
+ this.col = 0;
2526
+ return "cursor";
2527
+ }
2528
+ return this.moveTo(nextBoundary(line, this.col));
2529
+ }
2530
+ if (name === "home" || ctrl && name === "a") return this.moveTo(0);
2531
+ if (name === "end" || ctrl && name === "e") return this.moveTo(line.length);
2532
+ if ((meta || ctrl) && (name === "left" || name === "b")) return this.moveTo(wordStart(line, this.col));
2533
+ if ((meta || ctrl) && (name === "right" || name === "f")) return this.moveTo(wordEnd(line, this.col));
2534
+ if (name === "backspace" && (meta || ctrl)) {
2535
+ if (this.col === 0) return this.joinUp();
2536
+ return this.cut(wordStart(line, this.col), this.col);
2537
+ }
2538
+ if (ctrl && name === "w") {
2539
+ if (this.col === 0) return this.joinUp();
2540
+ return this.cut(wordStart(line, this.col), this.col);
2541
+ }
2542
+ if (name === "backspace") {
2543
+ if (this.col === 0) return this.joinUp();
2544
+ return this.cut(prevBoundary(line, this.col), this.col);
2545
+ }
2546
+ if (name === "delete") {
2547
+ if (this.col >= line.length) return this.joinDown();
2548
+ return this.cut(this.col, nextBoundary(line, this.col));
2549
+ }
2550
+ if (ctrl && name === "u") return this.cut(0, this.col);
2551
+ if (ctrl && name === "k") {
2552
+ if (this.col >= line.length) return this.joinDown();
2553
+ return this.cut(this.col, line.length);
2554
+ }
2555
+ if (!ctrl && !meta && key.sequence && PRINTABLE.test(key.sequence)) return this.insert(key.sequence);
2556
+ return "unhandled";
2557
+ }
2558
+ /**
2559
+ * The lines to draw and where the caret is among them.
2560
+ *
2561
+ * At most `maxLines` rows, chosen so the caret's row is always one of them;
2562
+ * each row scrolls horizontally on its own so a long line never wraps into the
2563
+ * row below and the caret never leaves the box.
2564
+ */
2565
+ view(width, maxLines) {
2566
+ const rows = Math.max(1, maxLines);
2567
+ if (this.row < this.top) this.top = this.row;
2568
+ if (this.row >= this.top + rows) this.top = this.row - rows + 1;
2569
+ this.top = Math.max(0, Math.min(this.top, this.lines.length - rows));
2570
+ const shown = this.lines.slice(this.top, this.top + rows);
2571
+ let cursorCol = this.col;
2572
+ const lines = shown.map((line, i) => {
2573
+ if (width <= 0) return "";
2574
+ if (line.length <= width) return line;
2575
+ if (this.top + i !== this.row) return line.slice(0, width);
2576
+ const start = Math.max(0, this.col - width + 1);
2577
+ cursorCol = this.col - start;
2578
+ return line.slice(start, start + width);
2579
+ });
2580
+ return { lines, cursor: { row: this.row - this.top, col: cursorCol } };
2581
+ }
2582
+ moveTo(next) {
2583
+ if (next === this.col) return "same";
2584
+ this.col = next;
2585
+ return "cursor";
2586
+ }
2587
+ cut(from, to) {
2588
+ if (from >= to) return "same";
2589
+ const line = this.lines[this.row];
2590
+ this.lines[this.row] = line.slice(0, from) + line.slice(to);
2591
+ this.col = from;
2592
+ return "text";
2593
+ }
2594
+ joinUp() {
2595
+ if (this.row === 0) return "same";
2596
+ const above = this.lines[this.row - 1];
2597
+ this.lines.splice(this.row - 1, 2, above + this.lines[this.row]);
2598
+ this.row--;
2599
+ this.col = above.length;
2600
+ return "text";
2601
+ }
2602
+ joinDown() {
2603
+ if (this.row >= this.lines.length - 1) return "same";
2604
+ this.lines.splice(this.row, 2, this.lines[this.row] + this.lines[this.row + 1]);
2605
+ return "text";
2606
+ }
2607
+ };
2608
+
2609
+ // packages/tui/src/keys.ts
2610
+ var ESC3 = "\x1B";
2611
+ var PASTE_START = `${ESC3}[200~`;
2612
+ var PASTE_END = `${ESC3}[201~`;
2613
+ var CSI_FINAL = {
2614
+ A: "up",
2615
+ B: "down",
2616
+ C: "right",
2617
+ D: "left",
2618
+ H: "home",
2619
+ F: "end",
2620
+ Z: "tab"
2621
+ };
2622
+ var TILDE = {
2623
+ "1": "home",
2624
+ "2": "insert",
2625
+ "3": "delete",
2626
+ "4": "end",
2627
+ "5": "pageup",
2628
+ "6": "pagedown",
2629
+ "7": "home",
2630
+ "8": "end"
2631
+ };
2632
+ function modifiers(param) {
2633
+ const n = (Number(param) || 1) - 1;
2634
+ return {
2635
+ ...n & 1 ? { shift: true } : {},
2636
+ ...n & 2 ? { meta: true } : {},
2637
+ ...n & 4 ? { ctrl: true } : {}
2638
+ };
2415
2639
  }
2416
- function faceLines() {
2417
- return FACE_ART.map((row) => paint.accent(row));
2640
+ function coded(code, mods, sequence) {
2641
+ switch (code) {
2642
+ case 13:
2643
+ return { name: "return", ...mods, sequence };
2644
+ case 9:
2645
+ return { name: "tab", ...mods, sequence };
2646
+ case 27:
2647
+ return { name: "escape", ...mods, sequence };
2648
+ case 127:
2649
+ case 8:
2650
+ return { name: "backspace", ...mods, sequence };
2651
+ default:
2652
+ if (code >= 32) {
2653
+ const ch = String.fromCodePoint(code);
2654
+ return { name: ch.toLowerCase(), ...mods, sequence: mods.ctrl || mods.meta ? sequence : ch };
2655
+ }
2656
+ return null;
2657
+ }
2418
2658
  }
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
- );
2659
+ function isPrefix(text, whole) {
2660
+ return text.length <= whole.length && whole.startsWith(text);
2661
+ }
2662
+ function partialMarker(buf, marker) {
2663
+ for (let n = Math.min(marker.length - 1, buf.length); n > 0; n--) {
2664
+ if (isPrefix(buf.slice(buf.length - n), marker)) return n;
2437
2665
  }
2438
- frames.push(faceLines());
2439
- return frames;
2666
+ return 0;
2667
+ }
2668
+ var KeyDecoder = class {
2669
+ /** Bytes that could still turn out to be the start of something. */
2670
+ pending = "";
2671
+ /** Inside a bracketed paste: the text so far. Null outside one. */
2672
+ pasting = null;
2673
+ /**
2674
+ * Decode a chunk. Sequences split across two reads are held until the next
2675
+ * one; a lone ESC at the END of a chunk is reported as Escape at once, because
2676
+ * a terminal sends a whole sequence in one write and a person pressing the key
2677
+ * should not have to press another to be heard.
2678
+ */
2679
+ feed(chunk) {
2680
+ const out = [];
2681
+ let buf = this.pending + chunk;
2682
+ this.pending = "";
2683
+ let i = 0;
2684
+ while (i < buf.length) {
2685
+ if (this.pasting !== null) {
2686
+ const end = buf.indexOf(PASTE_END, i);
2687
+ if (end === -1) {
2688
+ const hold = partialMarker(buf, PASTE_END);
2689
+ this.pasting += buf.slice(i, buf.length - hold);
2690
+ this.pending = buf.slice(buf.length - hold);
2691
+ return out;
2692
+ }
2693
+ this.pasting += buf.slice(i, end);
2694
+ out.push({ name: "paste", paste: this.pasting.replace(/\r\n?/g, "\n") });
2695
+ this.pasting = null;
2696
+ i = end + PASTE_END.length;
2697
+ continue;
2698
+ }
2699
+ const ch = buf[i];
2700
+ if (ch !== ESC3) {
2701
+ const code2 = ch.charCodeAt(0);
2702
+ if (code2 === 13) out.push({ name: "return", sequence: ch });
2703
+ else if (code2 === 10) out.push({ name: "j", ctrl: true, sequence: ch });
2704
+ else if (code2 === 9) out.push({ name: "tab", sequence: ch });
2705
+ else if (code2 === 127 || code2 === 8) out.push({ name: "backspace", sequence: ch });
2706
+ else if (code2 < 32) {
2707
+ if (code2 > 0) out.push({ name: String.fromCharCode(96 + code2), ctrl: true, sequence: ch });
2708
+ } else {
2709
+ let j = i + 1;
2710
+ while (j < buf.length) {
2711
+ const c = buf.charCodeAt(j);
2712
+ if (c < 32 || c === 127 || buf[j] === ESC3) break;
2713
+ j++;
2714
+ }
2715
+ out.push({ sequence: buf.slice(i, j) });
2716
+ i = j;
2717
+ continue;
2718
+ }
2719
+ i++;
2720
+ continue;
2721
+ }
2722
+ if (i === buf.length - 1) {
2723
+ out.push({ name: "escape", sequence: ESC3 });
2724
+ i++;
2725
+ continue;
2726
+ }
2727
+ const next = buf[i + 1];
2728
+ if (next === "[") {
2729
+ let j = i + 2;
2730
+ while (j < buf.length && !(buf.charCodeAt(j) >= 64 && buf.charCodeAt(j) <= 126)) j++;
2731
+ if (j >= buf.length) {
2732
+ this.pending = buf.slice(i);
2733
+ return out;
2734
+ }
2735
+ const seq = buf.slice(i, j + 1);
2736
+ const body2 = buf.slice(i + 2, j);
2737
+ const final = buf[j];
2738
+ i = j + 1;
2739
+ if (seq === PASTE_START) {
2740
+ this.pasting = "";
2741
+ continue;
2742
+ }
2743
+ const key = decodeCsi(body2, final, seq);
2744
+ if (key) out.push(key);
2745
+ continue;
2746
+ }
2747
+ if (next === "O") {
2748
+ const final = buf[i + 2];
2749
+ if (final === void 0) {
2750
+ this.pending = buf.slice(i);
2751
+ return out;
2752
+ }
2753
+ const name = CSI_FINAL[final];
2754
+ if (name) out.push({ name, sequence: buf.slice(i, i + 3) });
2755
+ i += 3;
2756
+ continue;
2757
+ }
2758
+ if (next === "\x7F" || next === "\b") {
2759
+ out.push({ name: "backspace", meta: true, sequence: buf.slice(i, i + 2) });
2760
+ i += 2;
2761
+ continue;
2762
+ }
2763
+ if (next === ESC3) {
2764
+ out.push({ name: "escape", sequence: ESC3 });
2765
+ i++;
2766
+ continue;
2767
+ }
2768
+ const code = next.charCodeAt(0);
2769
+ if (code >= 32 && code !== 127) {
2770
+ out.push({
2771
+ name: next.toLowerCase(),
2772
+ meta: true,
2773
+ ...next !== next.toLowerCase() ? { shift: true } : {},
2774
+ sequence: buf.slice(i, i + 2)
2775
+ });
2776
+ i += 2;
2777
+ continue;
2778
+ }
2779
+ out.push({ name: "escape", sequence: ESC3 });
2780
+ i++;
2781
+ }
2782
+ return out;
2783
+ }
2784
+ /** Anything still held back - the tail of a paste, an escape that never finished. */
2785
+ flush() {
2786
+ const out = [];
2787
+ if (this.pasting !== null) {
2788
+ out.push({ name: "paste", paste: this.pasting + this.pending });
2789
+ this.pasting = null;
2790
+ } else if (this.pending === ESC3) {
2791
+ out.push({ name: "escape", sequence: ESC3 });
2792
+ }
2793
+ this.pending = "";
2794
+ return out;
2795
+ }
2796
+ };
2797
+ function decodeCsi(body2, final, sequence) {
2798
+ if (body2.startsWith("<") && (final === "M" || final === "m")) {
2799
+ const [b, x, y] = body2.slice(1).split(";").map(Number);
2800
+ if (final === "m") return null;
2801
+ if (b & 32) return null;
2802
+ const button = b & 3;
2803
+ if (b & 64) {
2804
+ return { name: "mouse", sequence, mouse: { kind: button === 0 ? "wheel-up" : "wheel-down", button: b, x, y } };
2805
+ }
2806
+ return { name: "mouse", sequence, mouse: { kind: "click", button, x, y } };
2807
+ }
2808
+ const params = body2.split(";");
2809
+ if (final === "~") {
2810
+ if (params[0] === "27") return coded(Number(params[2]), modifiers(params[1]), sequence);
2811
+ if (params[0] === "200" || params[0] === "201") return null;
2812
+ const name2 = TILDE[params[0]];
2813
+ return name2 ? { name: name2, ...modifiers(params[1]), sequence } : null;
2814
+ }
2815
+ if (final === "u") return coded(Number(params[0]), modifiers(params[1]), sequence);
2816
+ const name = CSI_FINAL[final];
2817
+ if (!name) return null;
2818
+ const mods = final === "Z" ? { shift: true } : modifiers(params[1]);
2819
+ return { name, ...mods, sequence };
2820
+ }
2821
+
2822
+ // packages/tui/src/layout.ts
2823
+ var MAX_COMPOSER_ROWS = 8;
2824
+ var SIDEBAR_MIN_COLUMNS = 120;
2825
+ var SIDEBAR_WIDTH = 32;
2826
+ function layout(opts) {
2827
+ const rows = Math.max(4, opts.rows);
2828
+ const columns = Math.max(20, opts.columns);
2829
+ const wanted = Math.min(MAX_COMPOSER_ROWS, Math.max(1, opts.editorLines) + 2);
2830
+ const composerHeight = Math.max(3, Math.min(wanted, rows - 3));
2831
+ const header = { top: 0, left: 0, width: columns, height: 1 };
2832
+ const footer = { top: rows - 1, left: 0, width: columns, height: 1 };
2833
+ const composer2 = { top: rows - 1 - composerHeight, left: 0, width: columns, height: composerHeight };
2834
+ const transcriptHeight = Math.max(0, composer2.top - 1);
2835
+ const withSidebar = Boolean(opts.sidebar) && columns >= SIDEBAR_MIN_COLUMNS;
2836
+ const mainWidth = withSidebar ? columns - SIDEBAR_WIDTH - 1 : columns;
2837
+ const out = {
2838
+ header,
2839
+ transcript: { top: 1, left: 0, width: mainWidth, height: transcriptHeight },
2840
+ composer: { ...composer2, width: mainWidth },
2841
+ footer
2842
+ };
2843
+ if (withSidebar) {
2844
+ out.sidebar = { top: 1, left: mainWidth + 1, width: SIDEBAR_WIDTH, height: composer2.top + composerHeight - 1 };
2845
+ }
2846
+ return out;
2847
+ }
2848
+
2849
+ // packages/tui/src/popup.ts
2850
+ var MAX_POPUP_ROWS = 8;
2851
+ var Popup = class {
2852
+ constructor(kind, items) {
2853
+ this.kind = kind;
2854
+ this.items = items;
2855
+ }
2856
+ selected = 0;
2857
+ get current() {
2858
+ return this.items[this.selected] ?? null;
2859
+ }
2860
+ move(delta) {
2861
+ if (!this.items.length) return;
2862
+ this.selected = (this.selected + delta + this.items.length) % this.items.length;
2863
+ }
2864
+ /** Rows to draw, at most `MAX_POPUP_ROWS`, keeping the selection in view. */
2865
+ render(width, max = MAX_POPUP_ROWS) {
2866
+ if (!this.items.length) return [];
2867
+ const rows = Math.max(1, max);
2868
+ const start = Math.max(0, Math.min(this.selected - rows + 1, this.items.length - rows));
2869
+ const shown = this.items.slice(start, start + rows);
2870
+ const column = Math.min(Math.max(...shown.map((s) => s.label.length)), Math.max(10, Math.floor(width / 2)));
2871
+ return shown.map((item, i) => {
2872
+ const isSelected = start + i === this.selected;
2873
+ const mark = isSelected ? paint.accent("\u25B8") : " ";
2874
+ const label2 = item.label.padEnd(column);
2875
+ const room = width - column - 6;
2876
+ const what = item.what ? paint.muted(truncate(item.what, Math.max(0, room))) : "";
2877
+ const line = ` ${mark} ${isSelected ? paint.bold(paint.accent(label2)) : paint.accent(label2)} ${what}`;
2878
+ return pad2(truncate(line, width), width);
2879
+ });
2880
+ }
2881
+ };
2882
+ function pad2(text, width) {
2883
+ const short = width - visibleWidth(text);
2884
+ return short > 0 ? `${text}${" ".repeat(short)}` : text;
2885
+ }
2886
+
2887
+ // packages/tui/src/dialog.ts
2888
+ function pick(title, items, onSelect) {
2889
+ return { kind: "pick", title, items, query: "", selected: 0, onSelect };
2890
+ }
2891
+ function info(title, lines) {
2892
+ return { kind: "info", title, lines, scroll: 0 };
2893
+ }
2894
+ function filterItems(items, query) {
2895
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
2896
+ if (!words.length) return items;
2897
+ return items.filter((item) => {
2898
+ const hay = `${item.label} ${item.hint ?? ""}`.toLowerCase();
2899
+ return words.every((w) => hay.includes(w));
2900
+ });
2901
+ }
2902
+ function dialogKey(dialog, key) {
2903
+ if (key.name === "escape" || key.ctrl && (key.name === "c" || key.name === "g")) return "close";
2904
+ if (dialog.kind === "info") {
2905
+ if (key.name === "up" || key.name === "k") dialog.scroll = Math.max(0, dialog.scroll - 1);
2906
+ else if (key.name === "down" || key.name === "j") dialog.scroll++;
2907
+ else if (key.name === "pageup") dialog.scroll = Math.max(0, dialog.scroll - 10);
2908
+ else if (key.name === "pagedown") dialog.scroll += 10;
2909
+ else if (key.name === "home") dialog.scroll = 0;
2910
+ else if (key.name === "return" || key.name === "q") return "close";
2911
+ else if (key.mouse?.kind === "wheel-up") dialog.scroll = Math.max(0, dialog.scroll - 3);
2912
+ else if (key.mouse?.kind === "wheel-down") dialog.scroll += 3;
2913
+ else return "same";
2914
+ return "changed";
2915
+ }
2916
+ const shown = filterItems(dialog.items, dialog.query);
2917
+ if (key.name === "up" || key.mouse?.kind === "wheel-up") {
2918
+ dialog.selected = shown.length ? (dialog.selected - 1 + shown.length) % shown.length : 0;
2919
+ return "changed";
2920
+ }
2921
+ if (key.name === "down" || key.mouse?.kind === "wheel-down" || key.name === "tab") {
2922
+ dialog.selected = shown.length ? (dialog.selected + 1) % shown.length : 0;
2923
+ return "changed";
2924
+ }
2925
+ if (key.name === "return") {
2926
+ const item = shown[dialog.selected];
2927
+ if (!item) return "same";
2928
+ dialog.onSelect(item);
2929
+ return "select";
2930
+ }
2931
+ if (key.name === "backspace") {
2932
+ if (!dialog.query) return "same";
2933
+ dialog.query = dialog.query.slice(0, -1);
2934
+ dialog.selected = 0;
2935
+ return "changed";
2936
+ }
2937
+ if (!key.ctrl && !key.meta && key.sequence && /^[^\u0000-\u001f\u007f]+$/.test(key.sequence)) {
2938
+ dialog.query += key.sequence;
2939
+ dialog.selected = 0;
2940
+ return "changed";
2941
+ }
2942
+ return "same";
2943
+ }
2944
+ var BOX = { tl: "\u256D", tr: "\u256E", bl: "\u2570", br: "\u256F", h: "\u2500", v: "\u2502" };
2945
+ function renderDialog(dialog, rows, columns) {
2946
+ const width = Math.max(24, Math.min(columns - 4, 84));
2947
+ const inner = width - 4;
2948
+ const maxBody = Math.max(3, rows - 6);
2949
+ const body2 = [];
2950
+ if (dialog.kind === "pick") {
2951
+ const shown = filterItems(dialog.items, dialog.query);
2952
+ const listRows = Math.max(1, Math.min(shown.length, maxBody - 2));
2953
+ const start = Math.max(0, Math.min(dialog.selected - listRows + 1, shown.length - listRows));
2954
+ body2.push(`${paint.accent("search")} ${dialog.query}${paint.invert(" ")}`);
2955
+ body2.push("");
2956
+ if (!shown.length) body2.push(paint.muted("nothing matches"));
2957
+ for (const [i, item] of shown.slice(start, start + listRows).entries()) {
2958
+ const isSelected = start + i === dialog.selected;
2959
+ const mark = isSelected ? paint.accent("\u25B8") : " ";
2960
+ const label2 = isSelected ? paint.bold(item.label) : item.label;
2961
+ body2.push(truncate(`${mark} ${label2}${item.hint ? ` ${paint.muted(item.hint)}` : ""}`, inner));
2962
+ }
2963
+ if (shown.length > listRows) body2.push(paint.dim(`\u2026 ${shown.length - listRows} more; type to narrow`));
2964
+ } else {
2965
+ const visible = Math.min(dialog.lines.length, maxBody);
2966
+ dialog.scroll = Math.max(0, Math.min(dialog.scroll, dialog.lines.length - visible));
2967
+ for (const line of dialog.lines.slice(dialog.scroll, dialog.scroll + visible)) body2.push(truncate(line, inner));
2968
+ if (dialog.lines.length > visible) {
2969
+ body2.push(paint.dim(`\u2026 ${dialog.scroll + visible} of ${dialog.lines.length} lines \xB7 \u2191\u2193 to scroll`));
2970
+ }
2971
+ }
2972
+ const title = ` ${dialog.title} `;
2973
+ const rule = BOX.h.repeat(Math.max(0, width - 2 - title.length - 1));
2974
+ const lines = [
2975
+ paint.dim(`${BOX.tl}${BOX.h}`) + paint.bold(title) + paint.dim(`${rule}${BOX.tr}`),
2976
+ ...body2.map((line) => `${paint.dim(BOX.v)} ${pad3(truncate(line, inner), inner)} ${paint.dim(BOX.v)}`),
2977
+ paint.dim(`${BOX.bl}${BOX.h.repeat(width - 2)}${BOX.br}`)
2978
+ ];
2979
+ const top = Math.max(0, Math.floor((rows - lines.length) / 2));
2980
+ const left = Math.max(0, Math.floor((columns - width) / 2));
2981
+ return { top, left, lines };
2982
+ }
2983
+ function pad3(text, width) {
2984
+ const short = width - visibleWidth(text);
2985
+ return short > 0 ? `${text}${" ".repeat(short)}` : text;
2986
+ }
2987
+
2988
+ // packages/tui/src/screen.ts
2989
+ import { EventEmitter } from "node:events";
2990
+ var ESC4 = "\x1B";
2991
+ var ALT_SCREEN_ON = `${ESC4}[?1049h`;
2992
+ var ALT_SCREEN_OFF = `${ESC4}[?1049l`;
2993
+ var CURSOR_HIDE = `${ESC4}[?25l`;
2994
+ var CURSOR_SHOW = `${ESC4}[?25h`;
2995
+ var PASTE_ON = `${ESC4}[?2004h`;
2996
+ var PASTE_OFF = `${ESC4}[?2004l`;
2997
+ var MOUSE_ON = `${ESC4}[?1000h${ESC4}[?1006h`;
2998
+ var MOUSE_OFF = `${ESC4}[?1006l${ESC4}[?1000l`;
2999
+ var CLEAR = `${ESC4}[2J`;
3000
+ var RESET = `${ESC4}[0m`;
3001
+ var at = (row, col) => `${ESC4}[${row + 1};${col + 1}H`;
3002
+ var Screen = class extends EventEmitter {
3003
+ constructor(out = process.stdout, input = process.stdin, opts = {}) {
3004
+ super();
3005
+ this.out = out;
3006
+ this.input = input;
3007
+ this.opts = opts;
3008
+ }
3009
+ last = null;
3010
+ lastSize = "";
3011
+ entered = false;
3012
+ decoder = new KeyDecoder();
3013
+ get rows() {
3014
+ const n = this.opts.rows ?? this.out.rows;
3015
+ return typeof n === "number" && n > 0 ? n : 24;
3016
+ }
3017
+ get columns() {
3018
+ const n = this.opts.columns ?? this.out.columns;
3019
+ return typeof n === "number" && n > 0 ? n : 80;
3020
+ }
3021
+ /** Take the terminal: alternate screen, raw keys, bracketed paste, the mouse if asked. */
3022
+ enter() {
3023
+ if (this.entered) return;
3024
+ this.entered = true;
3025
+ this.out.write(`${ALT_SCREEN_ON}${CURSOR_HIDE}${CLEAR}${PASTE_ON}${this.opts.mouse ? MOUSE_ON : ""}`);
3026
+ try {
3027
+ this.input.setRawMode?.(true);
3028
+ } catch {
3029
+ }
3030
+ this.input.setEncoding?.("utf8");
3031
+ this.input.on("data", this.onData);
3032
+ this.input.on("end", this.onEnd);
3033
+ this.input.resume();
3034
+ this.out.on?.("resize", this.onResize);
3035
+ process.once("exit", this.leave);
3036
+ }
3037
+ /** Give the terminal back, in the reverse order. Safe to call more than once. */
3038
+ leave = () => {
3039
+ if (!this.entered) return;
3040
+ this.entered = false;
3041
+ process.off("exit", this.leave);
3042
+ this.out.off?.("resize", this.onResize);
3043
+ this.input.off("data", this.onData);
3044
+ this.input.off("end", this.onEnd);
3045
+ try {
3046
+ this.input.pause();
3047
+ this.input.setRawMode?.(false);
3048
+ } catch {
3049
+ }
3050
+ this.out.write(`${this.opts.mouse ? MOUSE_OFF : ""}${PASTE_OFF}${RESET}${CURSOR_SHOW}${ALT_SCREEN_OFF}`);
3051
+ this.last = null;
3052
+ };
3053
+ /**
3054
+ * Draw a whole frame, writing only the rows that changed.
3055
+ *
3056
+ * `frame` is read as `rows` lines; missing ones are blank, extra ones are
3057
+ * dropped. A size change since the last paint clears and redraws everything,
3058
+ * because a shorter line leaves the old one's tail where a diff cannot see it.
3059
+ */
3060
+ paint(frame, cursor = null) {
3061
+ if (!this.entered) return;
3062
+ const rows = this.rows;
3063
+ const columns = this.columns;
3064
+ const size = `${rows}x${columns}`;
3065
+ let out = CURSOR_HIDE;
3066
+ if (!this.last || this.lastSize !== size) {
3067
+ out += CLEAR;
3068
+ this.last = new Array(rows).fill(null);
3069
+ this.lastSize = size;
3070
+ }
3071
+ for (let r = 0; r < rows; r++) {
3072
+ const line = fit(frame[r] ?? "", columns);
3073
+ if (this.last[r] === line) continue;
3074
+ this.last[r] = line;
3075
+ out += `${at(r, 0)}${line}${RESET}`;
3076
+ }
3077
+ if (cursor) out += `${at(Math.min(cursor.row, rows - 1), Math.min(cursor.col, columns - 1))}${CURSOR_SHOW}`;
3078
+ this.out.write(out);
3079
+ }
3080
+ /** Forget the last frame, so the next paint redraws every row. For ctrl-l. */
3081
+ invalidate() {
3082
+ this.last = null;
3083
+ }
3084
+ onData = (chunk) => {
3085
+ const keys = this.decoder.feed(typeof chunk === "string" ? chunk : chunk.toString("utf8"));
3086
+ if (keys.length) this.emit("keys", keys);
3087
+ };
3088
+ onEnd = () => {
3089
+ this.emit("end");
3090
+ };
3091
+ onResize = () => {
3092
+ this.last = null;
3093
+ this.emit("resize");
3094
+ };
3095
+ };
3096
+ function fit(line, width) {
3097
+ const cut = truncate(line, width);
3098
+ const short = width - visibleWidth(cut);
3099
+ return short > 0 ? `${cut}${" ".repeat(short)}` : cut;
2440
3100
  }
2441
3101
 
2442
3102
  // packages/tui/src/markdown.ts
2443
3103
  function renderMarkdown(text, opts) {
2444
3104
  const lines = text.replace(/\r\n/g, "\n").split("\n");
2445
- const pad2 = opts.indent ?? "";
3105
+ const pad4 = opts.indent ?? "";
2446
3106
  const out = [];
2447
3107
  let inFence = false;
2448
3108
  let fenceLang = "";
@@ -2452,7 +3112,7 @@ function renderMarkdown(text, opts) {
2452
3112
  if (!inFence) {
2453
3113
  inFence = true;
2454
3114
  fenceLang = fence[1] ?? "";
2455
- out.push(paint.dim(`${pad2}${fenceLang || "code"}`));
3115
+ out.push(paint.dim(`${pad4}${fenceLang || "code"}`));
2456
3116
  } else {
2457
3117
  inFence = false;
2458
3118
  fenceLang = "";
@@ -2461,36 +3121,36 @@ function renderMarkdown(text, opts) {
2461
3121
  continue;
2462
3122
  }
2463
3123
  if (inFence) {
2464
- out.push(`${paint.dim(`${pad2}|`)} ${line}`);
3124
+ out.push(`${paint.dim(`${pad4}|`)} ${line}`);
2465
3125
  continue;
2466
3126
  }
2467
- out.push(...prose(line, opts.width, pad2));
3127
+ out.push(...prose(line, opts.width, pad4));
2468
3128
  }
2469
3129
  return out.join("\n");
2470
3130
  }
2471
- function prose(line, width, pad2) {
3131
+ function prose(line, width, pad4) {
2472
3132
  const heading = /^(#{1,6})\s+(.*)$/.exec(line);
2473
- if (heading) return [`${pad2}${paint.bold(paint.accent(heading[2]))}`];
3133
+ if (heading) return [`${pad4}${paint.bold(paint.accent(heading[2]))}`];
2474
3134
  if (/^\s*([-*_])\s*\1\s*\1[\s-*_]*$/.test(line)) {
2475
- return [`${pad2}${paint.dim("-".repeat(Math.min(width, 40)))}`];
3135
+ return [`${pad4}${paint.dim("-".repeat(Math.min(width, 40)))}`];
2476
3136
  }
2477
3137
  const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
2478
3138
  if (bullet) {
2479
- const indent2 = `${pad2}${bullet[1]}${paint.accent("-")} `;
2480
- return wrap2(inline(bullet[2]), width, indent2, `${pad2}${bullet[1]} `);
3139
+ const indent2 = `${pad4}${bullet[1]}${paint.accent("-")} `;
3140
+ return wrap2(inline(bullet[2]), width, indent2, `${pad4}${bullet[1]} `);
2481
3141
  }
2482
3142
  const numbered = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
2483
3143
  if (numbered) {
2484
- const indent2 = `${pad2}${numbered[1]}${paint.accent(`${numbered[2]}.`)} `;
2485
- return wrap2(inline(numbered[3]), width, indent2, `${pad2}${numbered[1]} `);
3144
+ const indent2 = `${pad4}${numbered[1]}${paint.accent(`${numbered[2]}.`)} `;
3145
+ return wrap2(inline(numbered[3]), width, indent2, `${pad4}${numbered[1]} `);
2486
3146
  }
2487
3147
  const quote = /^\s*>\s?(.*)$/.exec(line);
2488
3148
  if (quote) {
2489
- const gutter = `${pad2}${paint.dim("|")} `;
3149
+ const gutter = `${pad4}${paint.dim("|")} `;
2490
3150
  return wrap2(paint.muted(inline(quote[1])), width, gutter, gutter);
2491
3151
  }
2492
3152
  if (!line.trim()) return [""];
2493
- return wrap2(inline(line), width, pad2, pad2);
3153
+ return wrap2(inline(line), width, pad4, pad4);
2494
3154
  }
2495
3155
  var HOLD = "\0";
2496
3156
  function inline(text) {
@@ -2522,55 +3182,301 @@ function wrap2(text, width, first, rest) {
2522
3182
  return out;
2523
3183
  }
2524
3184
 
2525
- // packages/tui/src/prompt.ts
2526
- import { emitKeypressEvents } from "node:readline";
2527
- var ESC3 = "\x1B";
2528
- var PASTE_ON = `${ESC3}[?2004h`;
2529
- var PASTE_OFF = `${ESC3}[?2004l`;
2530
- var PASTE_START = `${ESC3}[200~`;
2531
- var PASTE_END = `${ESC3}[201~`;
2532
- var Prompt = class {
2533
- constructor(renderer, hooks, input = process.stdin) {
2534
- this.renderer = renderer;
2535
- this.hooks = hooks;
2536
- this.input = input;
2537
- emitKeypressEvents(this.input);
2538
- if (this.input.isTTY) this.input.setRawMode(true);
2539
- this.input.on("keypress", this.onKey);
2540
- this.input.on("end", this.onEnd);
2541
- this.input.resume();
2542
- if (this.renderer.isTty) process.stdout.write(PASTE_ON);
2543
- this.renderer.hideCursor();
2544
- process.stdout.on("resize", this.onResize);
2545
- process.once("exit", this.restore);
3185
+ // packages/tui/src/transcript.ts
3186
+ var MAX_PROSE_WIDTH = 100;
3187
+ var LIVE_LINES = 8;
3188
+ var Transcript = class {
3189
+ entries = [];
3190
+ nextId = 1;
3191
+ cache = /* @__PURE__ */ new Map();
3192
+ /** For a streaming answer: the rendered COMPLETE part, so only the tail is re-rendered per chunk. */
3193
+ prose = /* @__PURE__ */ new Map();
3194
+ get length() {
3195
+ return this.entries.length;
3196
+ }
3197
+ get blocks() {
3198
+ return this.entries;
3199
+ }
3200
+ /** Typed by kind rather than by the literal passed, so `done: false` stays a boolean the caller can flip. */
3201
+ add(block) {
3202
+ const entry = { id: this.nextId++, version: 0, block };
3203
+ this.entries.push(entry);
3204
+ return entry;
3205
+ }
3206
+ /** Say a block changed, so its cached lines are dropped on the next render. */
3207
+ touch(entry) {
3208
+ entry.version++;
3209
+ }
3210
+ /** Forget everything shown. The session file is untouched; this is the view. */
3211
+ clear() {
3212
+ this.entries = [];
3213
+ this.cache.clear();
3214
+ this.prose.clear();
3215
+ }
3216
+ /** Every line, top to bottom, with one blank line between blocks. */
3217
+ render(width) {
3218
+ const out = [];
3219
+ const live = /* @__PURE__ */ new Set();
3220
+ for (const entry of this.entries) {
3221
+ live.add(entry.id);
3222
+ const lines = this.lines(entry, width);
3223
+ if (out.length && lines.length) out.push("");
3224
+ out.push(...lines);
3225
+ }
3226
+ for (const id of this.cache.keys()) if (!live.has(id)) this.cache.delete(id);
3227
+ for (const id of this.prose.keys()) if (!live.has(id)) this.prose.delete(id);
3228
+ return out;
3229
+ }
3230
+ lines(entry, width) {
3231
+ const hit = this.cache.get(entry.id);
3232
+ if (hit && hit.version === entry.version && hit.width === width) return hit.lines;
3233
+ const lines = entry.block.kind === "assistant" ? this.assistant(entry, width) : renderBlock(entry.block, width);
3234
+ this.cache.set(entry.id, { version: entry.version, width, lines });
3235
+ return lines;
2546
3236
  }
2547
- editor = new LineEditor();
2548
- history = new History();
2549
- resolve = null;
2550
- busyLabel = null;
2551
- label = null;
2552
- pasting = false;
2553
- /** stdin has closed. Every later `read` resolves `exit` at once. */
2554
- ended = false;
2555
- closed = false;
2556
3237
  /**
2557
- * Wait for a line.
3238
+ * An answer, streaming or finished.
2558
3239
  *
2559
- * `label` replaces the placeholder and turns the border yellow - used for the
2560
- * approval question, which is a different kind of asking and should not look
2561
- * like the ordinary prompt.
3240
+ * Complete markdown blocks are rendered; the unfinished tail - a line still
3241
+ * arriving, a fence not yet closed - is shown raw and dimmed, so the reader
3242
+ * sees the words land without a heading flickering between plain and bold.
2562
3243
  */
2563
- read(label2) {
2564
- if (this.ended) return Promise.resolve({ kind: "exit" });
2565
- this.label = label2 ?? null;
2566
- this.busyLabel = null;
2567
- this.editor.reset();
2568
- this.draw();
2569
- return new Promise((resolve2) => {
2570
- this.resolve = resolve2;
2571
- });
2572
- }
2573
- /** Put the box in its working state, or take it out of one. */
3244
+ assistant(entry, width) {
3245
+ const inner = proseWidth(width);
3246
+ const { text, done } = entry.block;
3247
+ if (done) return text.trim() ? renderMarkdown(text.replace(/\s+$/, ""), { width: inner, indent: " " }).split("\n") : [];
3248
+ const { complete: complete2, tail } = splitStreaming(text);
3249
+ let head = this.prose.get(entry.id);
3250
+ if (!head || head.length !== complete2.length || head.width !== width) {
3251
+ head = {
3252
+ length: complete2.length,
3253
+ width,
3254
+ lines: complete2 ? renderMarkdown(complete2, { width: inner, indent: " " }).split("\n") : []
3255
+ };
3256
+ this.prose.set(entry.id, head);
3257
+ }
3258
+ const raw = tail ? tail.split("\n").flatMap((line) => wrapPlain(line, inner - 2)).map((line) => ` ${paint.dim(line)}`) : [];
3259
+ return [...head.lines, ...raw];
3260
+ }
3261
+ };
3262
+ function splitStreaming(text) {
3263
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
3264
+ let tail = lines.pop() ?? "";
3265
+ let open = -1;
3266
+ lines.forEach((line, i) => {
3267
+ if (/^\s*```/.test(line)) open = open === -1 ? i : -1;
3268
+ });
3269
+ let complete2 = lines;
3270
+ if (open !== -1) {
3271
+ tail = [...lines.slice(open), tail].join("\n");
3272
+ complete2 = lines.slice(0, open);
3273
+ }
3274
+ return { complete: complete2.join("\n"), tail };
3275
+ }
3276
+ function proseWidth(width) {
3277
+ return Math.max(20, Math.min(width - 2, MAX_PROSE_WIDTH));
3278
+ }
3279
+ function renderBlock(block, width) {
3280
+ const inner = proseWidth(width);
3281
+ switch (block.kind) {
3282
+ case "user":
3283
+ return block.text.split("\n").flatMap(
3284
+ (line, i) => wrapPlain(line, inner - 2).map((piece, j) => i === 0 && j === 0 ? `${paint.accent(">")} ${piece}` : ` ${piece}`)
3285
+ );
3286
+ case "assistant":
3287
+ return [];
3288
+ case "tool": {
3289
+ const out = toolBlock(block.name, block.detail, block.status, { input: block.input, output: block.output }, inner);
3290
+ if (block.live.length && !block.output) {
3291
+ const shown = block.live.slice(-LIVE_LINES);
3292
+ if (block.live.length > LIVE_LINES) out.push(` ${paint.dim(`\u2026 ${block.live.length - LIVE_LINES} more lines`)}`);
3293
+ for (const line of shown) out.push(` ${paint.dim(line)}`);
3294
+ }
3295
+ return out;
3296
+ }
3297
+ case "approval": {
3298
+ const out = approvalCard(block.preview, block.call, block.mode, inner);
3299
+ out.push(
3300
+ 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")}`
3301
+ );
3302
+ return out;
3303
+ }
3304
+ case "error":
3305
+ return block.text.split("\n").map((line, i) => i === 0 ? `${paint.red(" labd:")} ${line}` : ` ${line}`);
3306
+ case "note":
3307
+ return block.text.replace(/\n$/, "").split("\n");
3308
+ case "compaction": {
3309
+ const label2 = " summarised the conversation so far ";
3310
+ const room = Math.max(0, inner - label2.length);
3311
+ return [paint.dim(` ${"\u2500".repeat(Math.floor(room / 2))}${label2}${"\u2500".repeat(Math.ceil(room / 2))}`)];
3312
+ }
3313
+ }
3314
+ }
3315
+ function wrapPlain(text, width) {
3316
+ const limit = Math.max(10, width);
3317
+ if (visibleWidth(text) <= limit) return [text];
3318
+ const out = [];
3319
+ let line = "";
3320
+ for (const word of text.split(" ")) {
3321
+ if (line && line.length + 1 + word.length > limit) {
3322
+ out.push(line);
3323
+ line = word;
3324
+ } else {
3325
+ line = line ? `${line} ${word}` : word;
3326
+ }
3327
+ while (line.length > limit) {
3328
+ out.push(line.slice(0, limit));
3329
+ line = line.slice(limit);
3330
+ }
3331
+ }
3332
+ if (line || !out.length) out.push(line);
3333
+ return out;
3334
+ }
3335
+ var Viewport = class {
3336
+ constructor(height) {
3337
+ this.height = height;
3338
+ }
3339
+ top = 0;
3340
+ follow = true;
3341
+ total = 0;
3342
+ /** The line count when the reader stopped following, so `newLines` can be counted from it. */
3343
+ anchor = 0;
3344
+ /** Lines that arrived since the reader scrolled away. Zero while following. */
3345
+ get newLines() {
3346
+ return this.follow ? 0 : Math.max(0, this.total - this.anchor);
3347
+ }
3348
+ /** Tell the viewport how many lines there are now, and how tall it is. */
3349
+ update(total, height = this.height) {
3350
+ this.height = height;
3351
+ this.total = total;
3352
+ if (this.follow) this.top = Math.max(0, total - height);
3353
+ else this.top = Math.max(0, Math.min(this.top, total - height));
3354
+ }
3355
+ scroll(delta) {
3356
+ const max = Math.max(0, this.total - this.height);
3357
+ const next = Math.max(0, Math.min(max, this.top + delta));
3358
+ if (next === this.top && this.follow) return;
3359
+ this.top = next;
3360
+ if (next >= max) this.refollow();
3361
+ else this.unfollow();
3362
+ }
3363
+ pageUp() {
3364
+ this.scroll(-Math.max(1, this.height - 1));
3365
+ }
3366
+ pageDown() {
3367
+ this.scroll(Math.max(1, this.height - 1));
3368
+ }
3369
+ home() {
3370
+ this.top = 0;
3371
+ if (this.total > this.height) this.unfollow();
3372
+ }
3373
+ end() {
3374
+ this.refollow();
3375
+ this.top = Math.max(0, this.total - this.height);
3376
+ }
3377
+ /** The slice of `lines` on screen. */
3378
+ slice(lines) {
3379
+ return lines.slice(this.top, this.top + this.height);
3380
+ }
3381
+ unfollow() {
3382
+ if (this.follow) this.anchor = this.total;
3383
+ this.follow = false;
3384
+ }
3385
+ refollow() {
3386
+ this.follow = true;
3387
+ }
3388
+ };
3389
+
3390
+ // packages/tui/src/face.ts
3391
+ var FACE_ART = [
3392
+ " \u250F\u2501 \u2501\u2513 ",
3393
+ " \u2503 \u2584\u2584\u2584\u2584\u2584 \u2584\u2584\u2584\u2584\u2584 \u2503 ",
3394
+ " \u2503 \u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588 \u2503 ",
3395
+ " \u2503 \u2580\u2580\u2580\u2580\u2580 \u2580\u2580\u2580\u2580\u2580 \u2503 ",
3396
+ " \u2503 \u2503 ",
3397
+ " \u2503 \u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584\u2584 \u2503 ",
3398
+ " \u2517\u2501 \u2501\u251B "
3399
+ ];
3400
+ var NOISE = "$#@%&*+=-:.<>/\\|_01x?";
3401
+ var FACE_FRAME_MS = 34;
3402
+ function noiseChar() {
3403
+ return NOISE[Math.floor(Math.random() * NOISE.length)];
3404
+ }
3405
+ function faceLines() {
3406
+ return FACE_ART.map((row) => paint.accent(row));
3407
+ }
3408
+ function decodeFrames() {
3409
+ const cells = FACE_ART.map((row) => [...row]);
3410
+ const lockAt = cells.map(
3411
+ (row, y) => row.map((_, x) => Math.round(x * 0.55 + y * 0.9 + Math.random() * 7))
3412
+ );
3413
+ const last = Math.max(...lockAt.flat());
3414
+ const frames = [];
3415
+ for (let f = 1; f <= last + 1; f++) {
3416
+ frames.push(
3417
+ cells.map(
3418
+ (row, y) => paint.accent(
3419
+ row.map((char, x) => {
3420
+ if (char === " ") return " ";
3421
+ return f >= lockAt[y][x] ? char : noiseChar();
3422
+ }).join("")
3423
+ )
3424
+ )
3425
+ );
3426
+ }
3427
+ frames.push(faceLines());
3428
+ return frames;
3429
+ }
3430
+
3431
+ // packages/tui/src/prompt.ts
3432
+ import { emitKeypressEvents } from "node:readline";
3433
+ var ESC5 = "\x1B";
3434
+ var PASTE_ON2 = `${ESC5}[?2004h`;
3435
+ var PASTE_OFF2 = `${ESC5}[?2004l`;
3436
+ var PASTE_START2 = `${ESC5}[200~`;
3437
+ var PASTE_END2 = `${ESC5}[201~`;
3438
+ var Prompt = class {
3439
+ constructor(renderer, hooks, input = process.stdin) {
3440
+ this.renderer = renderer;
3441
+ this.hooks = hooks;
3442
+ this.input = input;
3443
+ emitKeypressEvents(this.input);
3444
+ if (this.input.isTTY) this.input.setRawMode(true);
3445
+ this.input.on("keypress", this.onKey);
3446
+ this.input.on("end", this.onEnd);
3447
+ this.input.resume();
3448
+ if (this.renderer.isTty) process.stdout.write(PASTE_ON2);
3449
+ this.renderer.hideCursor();
3450
+ process.stdout.on("resize", this.onResize);
3451
+ process.once("exit", this.restore);
3452
+ }
3453
+ editor = new LineEditor();
3454
+ history = new History();
3455
+ resolve = null;
3456
+ busyLabel = null;
3457
+ label = null;
3458
+ pasting = false;
3459
+ /** stdin has closed. Every later `read` resolves `exit` at once. */
3460
+ ended = false;
3461
+ closed = false;
3462
+ /**
3463
+ * Wait for a line.
3464
+ *
3465
+ * `label` replaces the placeholder and turns the border yellow - used for the
3466
+ * approval question, which is a different kind of asking and should not look
3467
+ * like the ordinary prompt.
3468
+ */
3469
+ read(label2) {
3470
+ if (this.ended) return Promise.resolve({ kind: "exit" });
3471
+ this.label = label2 ?? null;
3472
+ this.busyLabel = null;
3473
+ this.editor.reset();
3474
+ this.draw();
3475
+ return new Promise((resolve2) => {
3476
+ this.resolve = resolve2;
3477
+ });
3478
+ }
3479
+ /** Put the box in its working state, or take it out of one. */
2574
3480
  busy(what) {
2575
3481
  this.busyLabel = what;
2576
3482
  this.draw();
@@ -2613,7 +3519,7 @@ var Prompt = class {
2613
3519
  if (this.input.isTTY) this.input.setRawMode(false);
2614
3520
  } catch {
2615
3521
  }
2616
- if (this.renderer.isTty) process.stdout.write(PASTE_OFF);
3522
+ if (this.renderer.isTty) process.stdout.write(PASTE_OFF2);
2617
3523
  this.renderer.showCursor();
2618
3524
  };
2619
3525
  onResize = () => {
@@ -2626,11 +3532,11 @@ var Prompt = class {
2626
3532
  };
2627
3533
  onKey = (str, key = {}) => {
2628
3534
  const sequence = key.sequence ?? str ?? "";
2629
- if (sequence.includes(PASTE_START)) {
3535
+ if (sequence.includes(PASTE_START2)) {
2630
3536
  this.pasting = true;
2631
3537
  return;
2632
3538
  }
2633
- if (sequence.includes(PASTE_END)) {
3539
+ if (sequence.includes(PASTE_END2)) {
2634
3540
  this.pasting = false;
2635
3541
  this.draw();
2636
3542
  return;
@@ -2776,6 +3682,7 @@ import { chmodSync, existsSync, mkdirSync as mkdirSync4, readFileSync as readFil
2776
3682
  import { hostname } from "node:os";
2777
3683
  import { join as join5 } from "node:path";
2778
3684
  var POLL_INTERVAL_MS = 2500;
3685
+ var REVOKE_TIMEOUT_MS = 3e3;
2779
3686
  var Auth = class {
2780
3687
  constructor(stateDir2, baseUrl) {
2781
3688
  this.baseUrl = baseUrl;
@@ -2802,18 +3709,21 @@ var Auth = class {
2802
3709
  isReady() {
2803
3710
  return Boolean(this.token());
2804
3711
  }
3712
+ /** True when there was a stored token to sign out; `LABD_TOKEN` is never stored, so never revoked. */
2805
3713
  async signOut() {
2806
3714
  const token = this.read().token;
2807
3715
  if (token) {
2808
3716
  await fetch(`${this.baseUrl}/v1/auth/device/revoke`, {
2809
3717
  method: "POST",
2810
- headers: { Authorization: `Bearer ${token}` }
3718
+ headers: { Authorization: `Bearer ${token}` },
3719
+ signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
2811
3720
  }).catch(() => void 0);
2812
3721
  }
2813
3722
  try {
2814
3723
  rmSync3(this.file, { force: true });
2815
3724
  } catch {
2816
3725
  }
3726
+ return Boolean(token);
2817
3727
  }
2818
3728
  /**
2819
3729
  * Pair this machine with the learner's account.
@@ -2894,6 +3804,17 @@ function sleep(ms, signal) {
2894
3804
  }, { once: true });
2895
3805
  });
2896
3806
  }
3807
+ function signedOutMessage(hadStored, envToken) {
3808
+ const lines = [];
3809
+ if (hadStored) lines.push("signed out, and the stored token is gone. `labd login` pairs this machine again.");
3810
+ else if (!envToken) lines.push("this machine was not signed in.");
3811
+ if (envToken) {
3812
+ lines.push(
3813
+ 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."
3814
+ );
3815
+ }
3816
+ return lines.join("\n");
3817
+ }
2897
3818
 
2898
3819
  // packages/cli/src/config.ts
2899
3820
  import { existsSync as existsSync2, mkdirSync as mkdirSync5, readFileSync as readFileSync6 } from "node:fs";
@@ -2904,7 +3825,9 @@ var DEFAULTS = {
2904
3825
  modeChosen: false,
2905
3826
  serverUrl: "https://agent.thedevlabs.io",
2906
3827
  commandTimeoutSeconds: 120,
2907
- checkpoints: true
3828
+ checkpoints: true,
3829
+ mouse: true,
3830
+ sidebar: false
2908
3831
  };
2909
3832
  function unattendedMode(settings) {
2910
3833
  return settings.modeChosen ? settings.mode : "ask";
@@ -2940,7 +3863,11 @@ function loadSettings(root, overrides = {}, opts = {}) {
2940
3863
  commandTimeoutSeconds: clampTimeout(
2941
3864
  overrides.commandTimeoutSeconds ?? asNumber(project.commandTimeoutSeconds) ?? asNumber(global.commandTimeoutSeconds) ?? DEFAULTS.commandTimeoutSeconds
2942
3865
  ),
2943
- checkpoints: overrides.checkpoints ?? asBoolean(project.checkpoints) ?? asBoolean(global.checkpoints) ?? DEFAULTS.checkpoints
3866
+ checkpoints: overrides.checkpoints ?? asBoolean(project.checkpoints) ?? asBoolean(global.checkpoints) ?? DEFAULTS.checkpoints,
3867
+ // Screen preferences. Harmless from either file: neither changes what labd
3868
+ // may do, only how the session is drawn.
3869
+ mouse: overrides.mouse ?? asBoolean(project.mouse) ?? asBoolean(global.mouse) ?? DEFAULTS.mouse,
3870
+ sidebar: overrides.sidebar ?? asBoolean(project.sidebar) ?? asBoolean(global.sidebar) ?? DEFAULTS.sidebar
2944
3871
  };
2945
3872
  }
2946
3873
  function read(path) {
@@ -2977,10 +3904,14 @@ var BUILTIN_COMMANDS = [
2977
3904
  { name: "resume", args: "<id>", what: "continue from a point in /tree" },
2978
3905
  { name: "sessions", what: "earlier sessions in this project" },
2979
3906
  { name: "session", what: "where this one is stored" },
2980
- { name: "new", what: "how to start a fresh session" },
3907
+ { name: "rename", args: "<name>", what: "name this session, so /sessions and --resume show it" },
3908
+ { name: "new", what: "start a fresh session; this one stays saved" },
2981
3909
  { name: "usage", what: "what's left of this month's allowance" },
2982
3910
  { name: "trust", what: "let this project load its own extensions" },
3911
+ { name: "logout", what: "sign this machine out and leave" },
2983
3912
  { name: "reload", what: "reload extensions and the file list" },
3913
+ { name: "sidebar", what: "show or hide the side panel (full-screen session only)" },
3914
+ { name: "clear", what: "clear the screen; the session itself is kept" },
2984
3915
  { name: "quit", what: "leave" }
2985
3916
  ];
2986
3917
  function classify(line, extra = []) {
@@ -3167,9 +4098,12 @@ function branchOf(root) {
3167
4098
  if (abbrev === null) return void 0;
3168
4099
  return describeBranch(abbrev, abbrev === "HEAD" ? git("rev-parse", "--short", "HEAD") : null);
3169
4100
  }
4101
+ var closedAt = /* @__PURE__ */ new WeakMap();
3170
4102
  async function closeConversation(runtime) {
3171
4103
  const messages = runtime.session.wire();
3172
4104
  if (!messages.some((m) => m.role === "user")) return;
4105
+ if (closedAt.get(runtime.session) === messages.length) return;
4106
+ closedAt.set(runtime.session, messages.length);
3173
4107
  try {
3174
4108
  await runtime.client.close({
3175
4109
  protocol: PROTOCOL_VERSION,
@@ -3188,6 +4122,9 @@ async function closeConversation(runtime) {
3188
4122
  // packages/cli/src/modes/interactive.ts
3189
4123
  import { basename as basename4 } from "node:path";
3190
4124
 
4125
+ // packages/cli/src/commands.ts
4126
+ import { dirname as dirname3 } from "node:path";
4127
+
3191
4128
  // packages/cli/src/files.ts
3192
4129
  import { readdirSync as readdirSync6 } from "node:fs";
3193
4130
  import { join as join8, relative as relative4 } from "node:path";
@@ -3227,7 +4164,6 @@ function walk2(root, dir, out, budget) {
3227
4164
  }
3228
4165
 
3229
4166
  // packages/cli/src/commands.ts
3230
- import { dirname as dirname3 } from "node:path";
3231
4167
  async function handleCommand(input, ctx) {
3232
4168
  const [rawName = "", ...rest] = input.replace(/^\//, "").split(/\s+/);
3233
4169
  const name = rawName === "?" || rawName === "" ? "help" : rawName.toLowerCase();
@@ -3328,9 +4264,9 @@ async function handleCommand(input, ctx) {
3328
4264
  for (const s of all) {
3329
4265
  const when = new Date(s.at).toLocaleString();
3330
4266
  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 ?? ""}`);
4267
+ print(` ${mark} ${paint.dim(s.id.slice(0, 8))} ${paint.muted(`${when} ${s.messages} nodes`)} ${sessionTitle(s)}`);
3332
4268
  }
3333
- print(` ${paint.dim("labd --resume <id> opens one.")}`);
4269
+ print(` ${paint.dim("labd --resume <id> opens one; labd --resume on its own lists them to pick from.")}`);
3334
4270
  return {};
3335
4271
  }
3336
4272
  case "undo": {
@@ -3427,6 +4363,27 @@ async function handleCommand(input, ctx) {
3427
4363
  for (const skipped of next.skipped) print(` ${paint.yellow("!")} ${paint.muted(skipped.why)}`);
3428
4364
  return {};
3429
4365
  }
4366
+ case "sidebar":
4367
+ case "clear":
4368
+ print(` ${paint.muted(`/${name} works in the full-screen session; this is the plain one (--plain).`)}`);
4369
+ return {};
4370
+ case "rename": {
4371
+ const name2 = sessionName(args);
4372
+ if (!name2) {
4373
+ const current = sessionName(runtime.session.displayName ?? "");
4374
+ print(` ${current ? paint.bold(current) : paint.muted("this session has no name yet")} ${paint.dim("- /rename <name> sets one")}`);
4375
+ return {};
4376
+ }
4377
+ runtime.session.rename(name2);
4378
+ print(` ${paint.muted("named")} ${paint.bold(name2)}`);
4379
+ return {};
4380
+ }
4381
+ case "logout": {
4382
+ if (ctx.asked) await closeConversation(runtime);
4383
+ const hadStored = await runtime.auth.signOut();
4384
+ const farewell = signedOutMessage(hadStored, Boolean(process.env.LABD_TOKEN)).split("\n").map((line) => ` ${paint.muted(line)}`).join("\n");
4385
+ return { exit: true, farewell };
4386
+ }
3430
4387
  case "session":
3431
4388
  print(` ${paint.muted(runtime.session.file)}`);
3432
4389
  print(` ${paint.muted(`${runtime.session.size()} nodes, head ${runtime.session.headId?.slice(0, 8) ?? "none"}`)}`);
@@ -3466,10 +4423,77 @@ function help(runtime) {
3466
4423
  ` ${paint.dim("@path tags a file into the question. ctrl-c stops an answer.")}`
3467
4424
  ].join("\n");
3468
4425
  }
4426
+ var MAX_SESSION_NAME = 80;
4427
+ function sessionName(raw) {
4428
+ return raw.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").replace(/\s+/g, " ").trim().slice(0, MAX_SESSION_NAME);
4429
+ }
3469
4430
  function indent(text) {
3470
4431
  return text.split("\n").map((line) => ` ${line}`).join("\n");
3471
4432
  }
3472
4433
 
4434
+ // packages/cli/src/modes/shared.ts
4435
+ function describe(line, runtime) {
4436
+ const extras = runtime.extensions.commands.keys();
4437
+ const what = classify(line, extras);
4438
+ if (what.state === "none") return { state: "idle" };
4439
+ if (what.state === "ok") {
4440
+ return { state: "ok", hint: what.info ? paint.muted(what.info.what) : null };
4441
+ }
4442
+ if (what.state === "partial") {
4443
+ return { state: "partial", suggestions: matching(line, extras) };
4444
+ }
4445
+ const typed = line.trimStart().slice(1).split(/\s+/)[0] ?? "";
4446
+ const meant = didYouMean(typed, extras);
4447
+ return {
4448
+ state: "unknown",
4449
+ 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.")}`
4450
+ };
4451
+ }
4452
+ function complete(line, runtime) {
4453
+ if (line.startsWith("/") && !/\s/.test(line)) {
4454
+ const hits2 = matching(line, runtime.extensions.commands.keys());
4455
+ return hits2.length === 1 ? `/${hits2[0].name} ` : null;
4456
+ }
4457
+ const at2 = line.lastIndexOf("@");
4458
+ if (at2 === -1) return null;
4459
+ const partial = line.slice(at2 + 1);
4460
+ const hits = rankPaths(partial, listFiles(runtime.root));
4461
+ return hits.length ? `${line.slice(0, at2)}@${hits[0]} ` : null;
4462
+ }
4463
+ function fileTag(line) {
4464
+ const at2 = line.lastIndexOf("@");
4465
+ if (at2 === -1) return null;
4466
+ if (at2 > 0 && !/\s/.test(line[at2 - 1])) return null;
4467
+ const partial = line.slice(at2 + 1);
4468
+ if (/\s/.test(partial)) return null;
4469
+ return { at: at2, partial };
4470
+ }
4471
+ function label(step) {
4472
+ return `${step.call.name.replace(/_/g, " ")} ${detail(step)}`;
4473
+ }
4474
+ function detail(step) {
4475
+ const input = step.call.input;
4476
+ return String(input.why ?? input.path ?? input.from ?? input.query ?? "");
4477
+ }
4478
+ function verbatim(call) {
4479
+ const i = call.input;
4480
+ const str = (k) => typeof i[k] === "string" ? i[k] : "";
4481
+ switch (call.name) {
4482
+ case "run_command":
4483
+ return str("command");
4484
+ case "search_workspace": {
4485
+ const include = str("include");
4486
+ return include ? `${str("query")} in ${include}` : str("query");
4487
+ }
4488
+ case "move_file":
4489
+ return `${str("from")} \u2192 ${str("to")}`;
4490
+ case "list_dir":
4491
+ return str("path") || "/";
4492
+ default:
4493
+ return str("path");
4494
+ }
4495
+ }
4496
+
3473
4497
  // packages/cli/src/modes/interactive.ts
3474
4498
  async function runInteractive(runtime) {
3475
4499
  const renderer = new Renderer();
@@ -3532,8 +4556,11 @@ async function runInteractive(runtime) {
3532
4556
  runtime,
3533
4557
  mode: state.mode,
3534
4558
  effort: state.effort,
4559
+ asked: answered > 0,
3535
4560
  print: (text) => renderer.print(text)
3536
4561
  });
4562
+ if (outcome.farewell) process.stderr.write(`${outcome.farewell}
4563
+ `);
3537
4564
  if (outcome.exit) break;
3538
4565
  if (outcome.mode) state.mode = outcome.mode;
3539
4566
  if (outcome.effort !== void 0) state.effort = outcome.effort;
@@ -3602,9 +4629,9 @@ async function runInteractive(runtime) {
3602
4629
  stopSpinner();
3603
4630
  renderer.print(`${paint.red(" labd:")} ${message}`);
3604
4631
  },
3605
- done(info) {
3606
- if (info.context) state.context = info.context;
3607
- if (info.credits) state.credits = info.credits;
4632
+ done(info2) {
4633
+ if (info2.context) state.context = info2.context;
4634
+ if (info2.credits) state.credits = info2.credits;
3608
4635
  },
3609
4636
  commandOutput(line) {
3610
4637
  renderer.print(paint.dim(` | ${line.replace(/\n/g, "\n | ")}`));
@@ -3657,48 +4684,6 @@ ${FACE} session saved. Continue it with: labd --resume ${runtime.session.id.slic
3657
4684
  }
3658
4685
  return 0;
3659
4686
  }
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) };
3669
- }
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
- }
3677
- function label(step) {
3678
- return `${step.call.name.replace(/_/g, " ")} ${detail(step)}`;
3679
- }
3680
- function detail(step) {
3681
- const input = step.call.input;
3682
- return String(input.why ?? input.path ?? input.from ?? input.query ?? "");
3683
- }
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
- }
3701
- }
3702
4687
  async function banner(renderer, runtime, mode) {
3703
4688
  const where = basename4(runtime.root);
3704
4689
  renderer.print("");
@@ -3732,16 +4717,890 @@ async function banner(renderer, runtime, mode) {
3732
4717
  function sleep2(ms) {
3733
4718
  return new Promise((resolve2) => setTimeout(resolve2, ms));
3734
4719
  }
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;
4720
+
4721
+ // packages/cli/src/modes/screen.ts
4722
+ import { execFileSync as execFileSync3 } from "node:child_process";
4723
+ import { basename as basename5, dirname as dirname4 } from "node:path";
4724
+
4725
+ // packages/cli/src/modes/serial.ts
4726
+ var Serial = class {
4727
+ constructor(opts) {
4728
+ this.opts = opts;
3739
4729
  }
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;
4730
+ pending = [];
4731
+ current = null;
4732
+ get busy() {
4733
+ return this.current !== null;
4734
+ }
4735
+ /** What is waiting, oldest first. */
4736
+ get queued() {
4737
+ return this.pending;
4738
+ }
4739
+ /** Where the running work can be awaited, for a clean exit. */
4740
+ get running() {
4741
+ return this.current;
4742
+ }
4743
+ /** Send now if nothing is running and nothing holds the line, else wait behind it. */
4744
+ submit(text) {
4745
+ if (this.current || this.opts.held() || this.pending.length) {
4746
+ this.pending.push(text);
4747
+ this.opts.changed();
4748
+ return "queued";
4749
+ }
4750
+ this.start(() => this.opts.send(text));
4751
+ return "sent";
4752
+ }
4753
+ /**
4754
+ * Run something that is not a typed line - picking a session from a list, say
4755
+ * - under the same rule. Refused rather than queued when busy: the choice was
4756
+ * made against what was on screen, and by the time it ran that would be gone.
4757
+ */
4758
+ run(work) {
4759
+ if (this.current) return false;
4760
+ this.start(work);
4761
+ return true;
4762
+ }
4763
+ /** Start the next line, if there is one and nothing holds it. Call when a hold lifts. */
4764
+ drain() {
4765
+ if (this.current || this.opts.held()) return;
4766
+ const next = this.pending.shift();
4767
+ if (next === void 0) return;
4768
+ this.start(() => this.opts.send(next));
4769
+ }
4770
+ /**
4771
+ * Stop waiting for what is running, without stopping it. For a command that
4772
+ * never finishes - an extension's, say - which would otherwise hold the line
4773
+ * for good. Whatever it does later lands late; nothing waits on it.
4774
+ */
4775
+ release() {
4776
+ if (!this.current) return;
4777
+ this.current = null;
4778
+ this.opts.changed();
4779
+ this.drain();
4780
+ }
4781
+ /** Empty the line and hand back what was in it, so a stop does not lose it. */
4782
+ take() {
4783
+ const out = this.pending;
4784
+ this.pending = [];
4785
+ if (out.length) this.opts.changed();
4786
+ return out;
4787
+ }
4788
+ start(work) {
4789
+ const done = (async () => {
4790
+ try {
4791
+ await work();
4792
+ } catch (err) {
4793
+ this.opts.failed(err);
4794
+ }
4795
+ })();
4796
+ this.current = done;
4797
+ this.opts.changed();
4798
+ void done.then(() => {
4799
+ if (this.current === done) this.current = null;
4800
+ this.opts.changed();
4801
+ this.drain();
4802
+ });
4803
+ }
4804
+ };
4805
+
4806
+ // packages/cli/src/modes/screen.ts
4807
+ var FRAME_MS = 33;
4808
+ var QUIT_WINDOW_MS = 2e3;
4809
+ var WHEEL_LINES = 3;
4810
+ var MAX_QUEUED_SHOWN = 3;
4811
+ var ESC6 = "\x1B";
4812
+ async function runScreen(runtime, opts) {
4813
+ const screen = new Screen(process.stdout, process.stdin, { mouse: opts.mouse });
4814
+ const transcript = new Transcript();
4815
+ const viewport = new Viewport(10);
4816
+ const editor = new TextEditor();
4817
+ const history = new History();
4818
+ const state = {
4819
+ mode: runtime.settings.mode,
4820
+ effort: runtime.settings.effort,
4821
+ context: null,
4822
+ credits: null,
4823
+ sidebar: opts.sidebar,
4824
+ tick: 0,
4825
+ /** What labd is doing right now, for the footer. Null when idle. */
4826
+ busy: null,
4827
+ abort: null,
4828
+ popup: null,
4829
+ /** The line the popup was dismissed on, so Esc is not undone by the next frame. */
4830
+ popupDismissed: null,
4831
+ dialog: null,
4832
+ approval: null,
4833
+ /** The assistant block a streaming answer is going into. */
4834
+ answer: null,
4835
+ toolBlocks: /* @__PURE__ */ new Map(),
4836
+ lastSteps: [],
4837
+ ctrlCAt: 0,
4838
+ /** A one-line message in the footer, and when it expires. */
4839
+ notice: null,
4840
+ /** Printed to stderr once the screen is given back. */
4841
+ farewell: null,
4842
+ /** A question was asked in the CURRENT session, so leaving should say so and close it. */
4843
+ asked: false,
4844
+ quitting: false,
4845
+ faceIndex: 0,
4846
+ dirty: null,
4847
+ conventions: 0,
4848
+ skills: 0
4849
+ };
4850
+ const serial = new Serial({
4851
+ send: (text) => text.startsWith("/") || text === "?" ? command(text) : ask(text),
4852
+ held: () => state.dialog !== null || state.quitting,
4853
+ failed: (err) => transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) }),
4854
+ changed: () => schedule()
4855
+ });
4856
+ const faceFrames = process.env.LABD_NO_MOTION ? [faceLines()] : decodeFrames();
4857
+ let frameTimer = null;
4858
+ const schedule = () => {
4859
+ if (frameTimer) return;
4860
+ frameTimer = setTimeout(() => {
4861
+ frameTimer = null;
4862
+ draw();
4863
+ }, FRAME_MS);
4864
+ };
4865
+ let spinner = null;
4866
+ const setBusy = (what) => {
4867
+ state.busy = what;
4868
+ if (what && !spinner) {
4869
+ spinner = setInterval(() => {
4870
+ state.tick++;
4871
+ schedule();
4872
+ }, 400);
4873
+ }
4874
+ if (!what && spinner) {
4875
+ clearInterval(spinner);
4876
+ spinner = null;
4877
+ }
4878
+ schedule();
4879
+ };
4880
+ let resolveExit = null;
4881
+ const exited = new Promise((resolve2) => {
4882
+ resolveExit = resolve2;
4883
+ });
4884
+ const quit = () => {
4885
+ if (state.quitting) return;
4886
+ state.quitting = true;
4887
+ state.abort?.abort();
4888
+ resolveExit?.();
4889
+ };
4890
+ const note = (text) => {
4891
+ transcript.add({ kind: "note", text });
4892
+ schedule();
4893
+ };
4894
+ const notice = (text, ms = QUIT_WINDOW_MS) => {
4895
+ state.notice = { text, until: Date.now() + ms };
4896
+ schedule();
4897
+ setTimeout(schedule, ms + 10);
4898
+ };
4899
+ function draw() {
4900
+ if (state.quitting) return;
4901
+ const rows = screen.rows;
4902
+ const columns = screen.columns;
4903
+ const L = layout({ rows, columns, editorLines: editor.lineCount, sidebar: state.sidebar });
4904
+ const frame = [];
4905
+ let cursor = null;
4906
+ frame.push(header(columns));
4907
+ const width = L.transcript.width;
4908
+ const lines = transcript.length ? transcript.render(width) : emptyState(width, L.transcript.height);
4909
+ viewport.update(lines.length, L.transcript.height);
4910
+ const visible = viewport.slice(lines);
4911
+ while (visible.length < L.transcript.height) visible.push("");
4912
+ if (!viewport.follow && viewport.newLines > 0 && visible.length) {
4913
+ const n = viewport.newLines;
4914
+ const tag = paint.accent(`\u2193 ${n} new line${n === 1 ? "" : "s"} \xB7 end to follow`);
4915
+ visible[visible.length - 1] = fit(" ".repeat(Math.max(0, width - visibleWidth(tag) - 1)) + tag, width);
4916
+ }
4917
+ const overlays = [];
4918
+ if (serial.queued.length && !state.approval) overlays.push(queuedLines(width));
4919
+ if (state.popup && !state.dialog) overlays.push(state.popup.render(width));
4920
+ for (const rowsOut of overlays) {
4921
+ const start = visible.length - rowsOut.length;
4922
+ rowsOut.forEach((line, i) => {
4923
+ if (start + i >= 0) visible[start + i] = line;
4924
+ });
4925
+ }
4926
+ const body2 = [...visible.map((l) => fit(l, width)), ...composer2(L.composer.width, L.composer.height)];
4927
+ if (L.sidebar) {
4928
+ const side = sidebar(L.sidebar.width);
4929
+ body2.forEach((line, i) => {
4930
+ body2[i] = `${line}${paint.dim("\u2502")}${fit(side[i] ?? "", L.sidebar.width)}`;
4931
+ });
4932
+ }
4933
+ frame.push(...body2);
4934
+ frame.push(footer(columns));
4935
+ if (!state.dialog && !state.approval && !state.quitting) {
4936
+ const view = editor.view(L.composer.width - 6, L.composer.height - 2);
4937
+ cursor = { row: L.composer.top + 1 + view.cursor.row, col: 4 + view.cursor.col };
4938
+ }
4939
+ if (state.dialog) {
4940
+ const placed = renderDialog(state.dialog, rows, columns);
4941
+ placed.lines.forEach((line, i) => {
4942
+ const row = placed.top + i;
4943
+ if (row < frame.length) frame[row] = overlay(frame[row] ?? "", placed.left, line, columns);
4944
+ });
4945
+ }
4946
+ screen.paint(frame, cursor);
4947
+ }
4948
+ function header(width) {
4949
+ const session = runtime.session.id.slice(0, 8);
4950
+ const left = [
4951
+ `${paint.accent(FACE)} ${paint.bold("labd")}`,
4952
+ `${paint.muted(basename5(runtime.root))}${runtime.branch ? ` ${paint.muted(runtime.branch)}` : ""}`,
4953
+ paint.dim(`session ${session}`)
4954
+ ].join(paint.dim(" \xB7 "));
4955
+ const mode = state.mode === "agent" ? paint.red(paint.bold("agent")) : state.mode === "auto" ? paint.accent("auto") : paint.muted("ask");
4956
+ const right = `${mode}${state.effort ? paint.dim(` \xB7 think ${state.effort}`) : ""} `;
4957
+ return spread(` ${left}`, right, width);
4958
+ }
4959
+ function footer(width) {
4960
+ const d = paint.dim;
4961
+ let left;
4962
+ if (state.notice && state.notice.until > Date.now()) left = paint.yellow(state.notice.text);
4963
+ else if (state.dialog) left = d("\u2191\u2193 move \xB7 enter choose \xB7 type to search \xB7 esc close");
4964
+ else if (state.approval) left = `${paint.yellow("y")} ${d("allow \xB7")} ${paint.yellow("n")} ${d("decline \xB7 ctrl+c stop")}`;
4965
+ else if (state.busy) left = `${paint.accent(state.busy)}${spinnerFrame(state.tick)} ${d("enter queues \xB7 ctrl+c stop \xB7 pgup/pgdn scroll")}`;
4966
+ else if (state.popup) left = d("\u2191\u2193 move \xB7 tab/enter accept \xB7 esc close");
4967
+ else {
4968
+ const described = describe(editor.currentLine, runtime);
4969
+ left = described.hint ? described.hint : d("enter send \xB7 shift+enter newline \xB7 / commands \xB7 @ files \xB7 pgup/pgdn scroll \xB7 ctrl+c stop \xB7 ctrl+d quit");
4970
+ }
4971
+ const bits = [];
4972
+ if (state.context) {
4973
+ const crowded = state.context.percentUsed >= 75;
4974
+ bits.push((crowded ? paint.yellow : paint.muted)(`context ${state.context.percentUsed}%`));
4975
+ }
4976
+ if (state.credits) bits.push(paint.muted(`credits ${state.credits.percentLeft}%`));
4977
+ return spread(` ${left}`, `${bits.join(paint.dim(" \xB7 "))} `, width);
4978
+ }
4979
+ function composer2(width, height) {
4980
+ const inner = width - 4;
4981
+ const textRows = Math.max(1, height - 2);
4982
+ const colour = state.approval ? paint.yellow : state.busy ? paint.accent : borderColour2(describe(editor.currentLine, runtime).state);
4983
+ const out = [];
4984
+ out.push(colour(`\u256D${"\u2500".repeat(Math.max(0, width - 2))}\u256E`));
4985
+ const view = editor.view(inner - 2, textRows);
4986
+ const lines = [...view.lines];
4987
+ while (lines.length < textRows) lines.push("");
4988
+ lines.forEach((line, i) => {
4989
+ const first = i === 0 && editor.cursor.row - view.cursor.row === 0;
4990
+ const sigil = first ? state.busy ? paint.dim(">") : paint.accent(">") : " ";
4991
+ let text = line;
4992
+ if (editor.isEmpty && i === 0) {
4993
+ 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");
4994
+ }
4995
+ out.push(`${colour("\u2502")} ${sigil} ${fit(text, inner - 2)} ${colour("\u2502")}`);
4996
+ });
4997
+ out.push(colour(`\u2570${"\u2500".repeat(Math.max(0, width - 2))}\u256F`));
4998
+ return out;
4999
+ }
5000
+ function emptyState(width, height) {
5001
+ const face = faceFrames[Math.min(state.faceIndex, faceFrames.length - 1)];
5002
+ const hint = paint.dim("ask labd something - / for commands - @ to tag a file");
5003
+ const caption = wrapPlain(MODE_CAPTION[state.mode], Math.max(20, width - 4)).map((l) => paint.dim(l));
5004
+ const body2 = [...face, "", hint, "", ...caption];
5005
+ const top = Math.max(0, Math.floor((height - body2.length) / 2));
5006
+ return [...Array(top).fill(""), ...body2.map((line) => centre(line, width))];
5007
+ }
5008
+ function sidebar(width) {
5009
+ const row = (k, v) => `${paint.muted(k.padEnd(12))}${v}`;
5010
+ const lines = [
5011
+ ` ${paint.bold("project")}`,
5012
+ ` ${row("name", basename5(runtime.root))}`,
5013
+ ` ${row("branch", runtime.branch ?? paint.dim("none"))}`,
5014
+ ` ${row("changed", state.dirty === null ? paint.dim("-") : `${state.dirty} file${state.dirty === 1 ? "" : "s"}`)}`,
5015
+ ` ${row("conventions", String(state.conventions))}`,
5016
+ ` ${row("skills", String(state.skills))}`,
5017
+ "",
5018
+ ` ${paint.bold("session")}`,
5019
+ ` ${row("mode", state.mode)}`,
5020
+ ` ${row("think", state.effort ?? paint.dim("default"))}`,
5021
+ ` ${row("context", state.context ? `${state.context.percentUsed}%` : paint.dim("-"))}`,
5022
+ ` ${row("credits", state.credits ? `${state.credits.percentLeft}% left` : paint.dim("-"))}`,
5023
+ "",
5024
+ ` ${paint.bold("recent steps")}`,
5025
+ ...state.lastSteps.length ? state.lastSteps.map((s) => ` ${paint.muted(s)}`) : [` ${paint.dim("none yet")}`]
5026
+ ];
5027
+ return lines.map((l) => fit(l, width));
5028
+ }
5029
+ function onKeys(keys) {
5030
+ for (const key of keys) onKey(key);
5031
+ }
5032
+ function onKey(key) {
5033
+ if (key.ctrl && key.name === "c") return interrupt();
5034
+ if (state.dialog) {
5035
+ const r = dialogKey(state.dialog, key);
5036
+ if (r === "close" || r === "select") closeDialog();
5037
+ return schedule();
5038
+ }
5039
+ if (key.mouse) {
5040
+ if (key.mouse.kind === "wheel-up") viewport.scroll(-WHEEL_LINES);
5041
+ else if (key.mouse.kind === "wheel-down") viewport.scroll(WHEEL_LINES);
5042
+ return schedule();
5043
+ }
5044
+ if (key.name === "pageup") return viewport.pageUp(), schedule();
5045
+ if (key.name === "pagedown") return viewport.pageDown(), schedule();
5046
+ if (key.name === "home" && editor.isEmpty) return viewport.home(), schedule();
5047
+ if (key.name === "end" && editor.isEmpty) return viewport.end(), schedule();
5048
+ if (state.approval) {
5049
+ if (key.sequence === "y" || key.sequence === "Y") return answerApproval(true);
5050
+ if (key.sequence === "n" || key.sequence === "N" || key.name === "escape") return answerApproval(false);
5051
+ return;
5052
+ }
5053
+ if (key.ctrl && key.name === "d") {
5054
+ if (editor.isEmpty) quit();
5055
+ return;
5056
+ }
5057
+ if (key.ctrl && key.name === "l") return screen.invalidate(), schedule();
5058
+ if (key.ctrl && key.name === "n") return void serial.submit("/new");
5059
+ if (key.ctrl && key.name === "o") return void serial.submit("/sessions");
5060
+ if (key.name === "escape") {
5061
+ if (state.popup) {
5062
+ state.popup = null;
5063
+ state.popupDismissed = editor.currentLine;
5064
+ } else if (!viewport.follow) viewport.end();
5065
+ return schedule();
5066
+ }
5067
+ if (state.popup) {
5068
+ if (key.name === "up") return state.popup.move(-1), schedule();
5069
+ if (key.name === "down") return state.popup.move(1), schedule();
5070
+ if (key.name === "tab" || key.name === "return" && !key.shift && !key.ctrl) return acceptPopup();
5071
+ }
5072
+ if (key.name === "tab") {
5073
+ const completed = complete(editor.currentLine, runtime);
5074
+ if (completed !== null) editor.setCurrentLine(completed);
5075
+ refreshPopup();
5076
+ return schedule();
5077
+ }
5078
+ const outcome = editor.apply(key);
5079
+ if (outcome === "unhandled") {
5080
+ if (key.name === "return") return submit();
5081
+ if (key.name === "up" || key.name === "down") {
5082
+ const line = key.name === "up" ? history.up(editor.text) : history.down();
5083
+ if (line !== null) {
5084
+ editor.setText(line);
5085
+ refreshPopup();
5086
+ schedule();
5087
+ }
5088
+ }
5089
+ return;
5090
+ }
5091
+ if (outcome === "text") refreshPopup();
5092
+ if (outcome !== "same") schedule();
5093
+ }
5094
+ function interrupt() {
5095
+ if (state.approval) {
5096
+ answerApproval(false);
5097
+ }
5098
+ if (state.abort) {
5099
+ state.abort.abort();
5100
+ state.abort = null;
5101
+ note(paint.dim(" stopped."));
5102
+ unqueue();
5103
+ return;
5104
+ }
5105
+ if (serial.busy && serial.queued.length) {
5106
+ unqueue();
5107
+ notice("the queue is back in the box; ctrl+c again stops waiting for the command", 2500);
5108
+ return;
5109
+ }
5110
+ if (serial.busy) {
5111
+ serial.release();
5112
+ setBusy(null);
5113
+ note(paint.dim(" stopped waiting. The command may still finish in the background."));
5114
+ return;
5115
+ }
5116
+ if (state.dialog) {
5117
+ closeDialog();
5118
+ return schedule();
5119
+ }
5120
+ if (!editor.isEmpty) {
5121
+ editor.reset();
5122
+ state.popup = null;
5123
+ return schedule();
5124
+ }
5125
+ const now = Date.now();
5126
+ if (now - state.ctrlCAt < QUIT_WINDOW_MS) return quit();
5127
+ state.ctrlCAt = now;
5128
+ notice("press ctrl+c again to quit");
5129
+ }
5130
+ function answerApproval(ok) {
5131
+ const approval = state.approval;
5132
+ if (!approval) return;
5133
+ state.approval = null;
5134
+ approval.entry.block.answer = ok ? "allowed" : "declined";
5135
+ transcript.touch(approval.entry);
5136
+ approval.resolve(ok);
5137
+ schedule();
5138
+ }
5139
+ function refreshPopup() {
5140
+ const line = editor.currentLine;
5141
+ if (state.popupDismissed !== null && state.popupDismissed === line) return;
5142
+ state.popupDismissed = null;
5143
+ if (line.startsWith("/") && !/\s/.test(line)) {
5144
+ if (classify(line, runtime.extensions.commands.keys()).state !== "partial") {
5145
+ state.popup = null;
5146
+ return;
5147
+ }
5148
+ const items = matching(line, runtime.extensions.commands.keys()).map((c) => ({
5149
+ label: `/${c.name}`,
5150
+ what: c.what,
5151
+ value: `/${c.name} `
5152
+ }));
5153
+ state.popup = items.length ? new Popup("command", items) : null;
5154
+ return;
5155
+ }
5156
+ const tag = fileTag(line);
5157
+ if (tag) {
5158
+ const hits = rankPaths(tag.partial, listFiles(runtime.root), 8);
5159
+ state.popup = hits.length ? new Popup(
5160
+ "file",
5161
+ hits.map((path) => ({ label: path, value: `${line.slice(0, tag.at)}@${path} ` }))
5162
+ ) : null;
5163
+ return;
5164
+ }
5165
+ state.popup = null;
5166
+ }
5167
+ function acceptPopup() {
5168
+ const item = state.popup?.current;
5169
+ state.popup = null;
5170
+ if (item) editor.setCurrentLine(item.value);
5171
+ schedule();
5172
+ }
5173
+ function submit() {
5174
+ const text = editor.text.trim();
5175
+ if (!text) return;
5176
+ history.add(text);
5177
+ editor.reset();
5178
+ state.popup = null;
5179
+ viewport.end();
5180
+ serial.submit(text);
5181
+ schedule();
5182
+ }
5183
+ async function command(text) {
5184
+ setBusy(`running ${text.split(/\s+/)[0]}`);
5185
+ try {
5186
+ await runCommand2(text);
5187
+ } finally {
5188
+ setBusy(null);
5189
+ }
5190
+ }
5191
+ function closeDialog() {
5192
+ state.dialog = null;
5193
+ serial.drain();
5194
+ }
5195
+ function unqueue() {
5196
+ const queued = serial.take();
5197
+ if (!queued.length) return;
5198
+ editor.setText([...queued, editor.text].filter(Boolean).join("\n"));
5199
+ state.popup = null;
5200
+ }
5201
+ function queuedLines(width) {
5202
+ const shown = serial.queued.slice(0, MAX_QUEUED_SHOWN);
5203
+ const lines = shown.map((text) => fit(` ${paint.dim("queued")} ${paint.muted(text.split("\n")[0] ?? "")}`, width));
5204
+ const more = serial.queued.length - shown.length;
5205
+ if (more > 0) lines.push(paint.dim(` and ${more} more`));
5206
+ return lines;
5207
+ }
5208
+ async function runCommand2(input) {
5209
+ transcript.add({ kind: "user", text: input });
5210
+ const [rawName = ""] = input.replace(/^\//, "").split(/\s+/);
5211
+ const name = rawName === "?" || rawName === "" ? "help" : rawName.toLowerCase();
5212
+ switch (name) {
5213
+ case "help":
5214
+ state.dialog = info("help", helpLines());
5215
+ return schedule();
5216
+ case "sessions":
5217
+ return openSessions();
5218
+ case "tree":
5219
+ return openTree();
5220
+ case "sidebar":
5221
+ state.sidebar = !state.sidebar;
5222
+ if (state.sidebar) refreshProjectFacts();
5223
+ note(paint.muted(state.sidebar ? " side panel on. It needs 120 columns to show." : " side panel off."));
5224
+ return;
5225
+ case "clear":
5226
+ transcript.clear();
5227
+ state.toolBlocks.clear();
5228
+ state.answer = null;
5229
+ return schedule();
5230
+ case "new":
5231
+ return newSession();
5232
+ default:
5233
+ break;
5234
+ }
5235
+ const outcome = await handleCommand(input, {
5236
+ runtime,
5237
+ mode: state.mode,
5238
+ effort: state.effort,
5239
+ asked: state.asked,
5240
+ print: (text) => note(text)
5241
+ });
5242
+ if (outcome.farewell) state.farewell = outcome.farewell;
5243
+ if (outcome.exit) return quit();
5244
+ if (outcome.mode) state.mode = outcome.mode;
5245
+ if (outcome.effort !== void 0) state.effort = outcome.effort;
5246
+ schedule();
5247
+ }
5248
+ function helpLines() {
5249
+ const rows = BUILTIN_COMMANDS.map((c) => [`/${c.name}${c.args ? ` ${c.args}` : ""}`, c.what]);
5250
+ for (const [cmd, command2] of runtime.extensions.commands) rows.push([`/${cmd}`, `${command2.describe} ${paint.dim("(extension)")}`]);
5251
+ const width = Math.max(...rows.map(([n]) => n.length));
5252
+ const keys = [
5253
+ ["enter", "send"],
5254
+ ["shift+enter, ctrl+j, or a trailing \\", "new line"],
5255
+ ["/ and @", "commands and files, with a list"],
5256
+ ["tab", "complete"],
5257
+ ["pgup / pgdn, wheel", "scroll the transcript"],
5258
+ ["home / end", "top / bottom (with an empty box)"],
5259
+ ["esc", "close a list or dialog; jump back to the bottom"],
5260
+ ["ctrl+c", "stop the answer; clear the box; twice to quit"],
5261
+ ["ctrl+d", "quit"],
5262
+ ["ctrl+n / ctrl+o", "new session / earlier sessions"],
5263
+ ["ctrl+l", "redraw"]
5264
+ ];
5265
+ const kwidth = Math.max(...keys.map(([k]) => k.length));
5266
+ return [
5267
+ paint.bold("commands"),
5268
+ ...rows.map(([n, w]) => `${paint.accent(n.padEnd(width))} ${paint.muted(w)}`),
5269
+ "",
5270
+ paint.bold("keys"),
5271
+ ...keys.map(([k, w]) => `${paint.accent(k.padEnd(kwidth))} ${paint.muted(w)}`),
5272
+ "",
5273
+ paint.dim("@path tags a file into the question. --plain runs the older print-style session.")
5274
+ ];
5275
+ }
5276
+ function openSessions() {
5277
+ const all = Session.list(dirname4(runtime.session.file));
5278
+ if (!all.length) return note(paint.muted(" no earlier sessions in this project"));
5279
+ state.dialog = pick(
5280
+ "sessions - enter resumes one",
5281
+ all.map((s) => ({
5282
+ id: s.file,
5283
+ label: `${s.id === runtime.session.id ? "> " : " "}${sessionTitle(s)}`,
5284
+ hint: `${s.id.slice(0, 8)} ${new Date(s.at).toLocaleString()} ${s.messages} nodes`
5285
+ })),
5286
+ (item) => {
5287
+ if (!serial.run(() => switchSession(item.id))) notice("labd is still working - pick again when it is done", 2500);
5288
+ }
5289
+ );
5290
+ schedule();
5291
+ }
5292
+ function openTree() {
5293
+ const nodes = runtime.session.all();
5294
+ if (!nodes.length) return note(paint.muted(" nothing in this session yet"));
5295
+ const head = runtime.session.headId;
5296
+ state.dialog = pick(
5297
+ "this session - enter continues from a point",
5298
+ nodes.map((n) => ({
5299
+ id: n.id,
5300
+ label: `${n.id === head ? "> " : " "}${n.id.slice(0, 8)} ${n.body.kind.padEnd(9)}`,
5301
+ hint: summarise2(n.body)
5302
+ })),
5303
+ (item) => {
5304
+ if (!serial.run(() => runCommand2(`/resume ${item.id.slice(0, 8)}`))) notice("labd is still working - pick again when it is done", 2500);
5305
+ }
5306
+ );
5307
+ schedule();
5308
+ }
5309
+ async function switchSession(file) {
5310
+ const current = runtime.session;
5311
+ if (Session.list(dirname4(current.file)).find((s) => s.file === file)?.id === current.id) {
5312
+ return note(paint.muted(" that is this session."));
5313
+ }
5314
+ if (state.asked) await closeConversation(runtime);
5315
+ runtime.session = Session.open(file);
5316
+ state.asked = false;
5317
+ transcript.clear();
5318
+ state.toolBlocks.clear();
5319
+ state.answer = null;
5320
+ replay();
5321
+ note(paint.muted(` resumed ${runtime.session.id.slice(0, 8)}.`));
5322
+ }
5323
+ async function newSession() {
5324
+ if (state.asked) await closeConversation(runtime);
5325
+ runtime.session = Session.create(bucketFor(runtime.stateDir, runtime.root));
5326
+ state.asked = false;
5327
+ transcript.clear();
5328
+ state.toolBlocks.clear();
5329
+ state.answer = null;
5330
+ note(paint.muted(` new session ${runtime.session.id.slice(0, 8)}. The previous one is saved; /sessions lists it.`));
5331
+ }
5332
+ function replay() {
5333
+ for (const m of runtime.session.wire()) {
5334
+ if (m.role === "user" && m.text) transcript.add({ kind: "user", text: m.text });
5335
+ else if (m.role === "assistant" && m.text) transcript.add({ kind: "assistant", text: m.text, done: true });
5336
+ }
5337
+ }
5338
+ async function ask(input) {
5339
+ transcript.add({ kind: "user", text: input });
5340
+ state.asked = true;
5341
+ state.answer = null;
5342
+ const tags = taggedPaths(input);
5343
+ const attachments = tags.length ? attachTagged(runtime.root, tags) : void 0;
5344
+ for (const refused of attachments?.filter((a) => a.omitted) ?? []) {
5345
+ note(` ${paint.yellow("!")} ${paint.muted(`${refused.path}: ${refused.omitted}`)}`);
5346
+ }
5347
+ for (const handler of runtime.extensions.onTurn) {
5348
+ try {
5349
+ handler({ question: input });
5350
+ } catch {
5351
+ }
5352
+ }
5353
+ const abort = new AbortController();
5354
+ state.abort = abort;
5355
+ const ui = {
5356
+ text(chunk) {
5357
+ if (!state.answer || state.answer.block.done) {
5358
+ state.answer = transcript.add({ kind: "assistant", text: "", done: false });
5359
+ }
5360
+ state.answer.block.text += chunk;
5361
+ transcript.touch(state.answer);
5362
+ schedule();
5363
+ },
5364
+ status(what) {
5365
+ setBusy(what);
5366
+ },
5367
+ tool(step) {
5368
+ for (const handler of runtime.extensions.onTool) {
5369
+ try {
5370
+ handler(step);
5371
+ } catch {
5372
+ }
5373
+ }
5374
+ closeAnswer();
5375
+ let entry = state.toolBlocks.get(step.call.id);
5376
+ if (!entry) {
5377
+ entry = transcript.add({
5378
+ kind: "tool",
5379
+ name: step.call.name,
5380
+ detail: detail(step),
5381
+ status: step.status,
5382
+ input: verbatim(step.call) || void 0,
5383
+ live: []
5384
+ });
5385
+ state.toolBlocks.set(step.call.id, entry);
5386
+ }
5387
+ entry.block.status = step.status;
5388
+ if (step.status !== "running" && step.status !== "awaiting") {
5389
+ entry.block.output = step.output ?? step.detail;
5390
+ remember(`${label(step)}`.trim());
5391
+ }
5392
+ transcript.touch(entry);
5393
+ if (step.status === "running" || step.status === "awaiting") setBusy(label(step));
5394
+ schedule();
5395
+ },
5396
+ approve(call, preview) {
5397
+ setBusy(null);
5398
+ closeAnswer();
5399
+ const entry = transcript.add({ kind: "approval", call, preview, mode: state.mode, answer: null });
5400
+ viewport.end();
5401
+ return new Promise((resolve2) => {
5402
+ state.approval = { entry, resolve: resolve2 };
5403
+ schedule();
5404
+ });
5405
+ },
5406
+ error(message) {
5407
+ setBusy(null);
5408
+ transcript.add({ kind: "error", text: message });
5409
+ schedule();
5410
+ },
5411
+ done(info2) {
5412
+ if (info2.context) state.context = info2.context;
5413
+ if (info2.credits) state.credits = info2.credits;
5414
+ schedule();
5415
+ },
5416
+ commandOutput(line) {
5417
+ let target;
5418
+ for (const entry of state.toolBlocks.values()) {
5419
+ if (entry.block.name === "run_command" && entry.block.status === "running") target = entry;
5420
+ }
5421
+ if (!target) return;
5422
+ target.block.live.push(...line.replace(/\n$/, "").split("\n"));
5423
+ transcript.touch(target);
5424
+ schedule();
5425
+ }
5426
+ };
5427
+ setBusy("thinking");
5428
+ try {
5429
+ const turn = await runTurn(input, {
5430
+ session: runtime.session,
5431
+ client: runtime.client,
5432
+ root: runtime.root,
5433
+ mode: state.mode,
5434
+ checkpoints: runtime.checkpoints,
5435
+ effort: state.effort,
5436
+ attachments,
5437
+ commandTimeoutMs: runtime.settings.commandTimeoutSeconds * 1e3,
5438
+ ui,
5439
+ signal: abort.signal
5440
+ });
5441
+ closeAnswer();
5442
+ if (turn.wrote.length && runtime.checkpoints) {
5443
+ note(paint.dim(` ${turn.wrote.length} file${turn.wrote.length === 1 ? "" : "s"} changed. /undo puts this answer back.`));
5444
+ }
5445
+ if (state.sidebar) refreshProjectFacts();
5446
+ if (!abort.signal.aborted && state.context && state.context.percentUsed >= COMPACT_AT) {
5447
+ setBusy("summarising, to make room");
5448
+ const ok = await compact({ session: runtime.session, client: runtime.client, root: runtime.root });
5449
+ if (ok) transcript.add({ kind: "compaction" });
5450
+ else note(paint.dim(" (could not summarise; carrying on)"));
5451
+ }
5452
+ } catch (err) {
5453
+ transcript.add({ kind: "error", text: err instanceof Error ? err.message : String(err) });
5454
+ } finally {
5455
+ if (state.abort === abort) state.abort = null;
5456
+ if (state.approval) answerApproval(false);
5457
+ setBusy(null);
5458
+ }
5459
+ }
5460
+ function closeAnswer() {
5461
+ const answer = state.answer;
5462
+ if (!answer || answer.block.done) return;
5463
+ answer.block.done = true;
5464
+ transcript.touch(answer);
5465
+ }
5466
+ function remember(step) {
5467
+ state.lastSteps.push(step);
5468
+ if (state.lastSteps.length > 5) state.lastSteps.shift();
5469
+ }
5470
+ function refreshProjectFacts() {
5471
+ try {
5472
+ const out = execFileSync3("git", ["status", "--porcelain"], {
5473
+ cwd: runtime.root,
5474
+ encoding: "utf8",
5475
+ stdio: ["ignore", "pipe", "ignore"],
5476
+ timeout: 2e3
5477
+ });
5478
+ state.dirty = out.split("\n").filter(Boolean).length;
5479
+ } catch {
5480
+ state.dirty = null;
5481
+ }
5482
+ try {
5483
+ const ctx = buildContext(runtime.root).editor;
5484
+ state.conventions = ctx?.instructions?.length ?? 0;
5485
+ state.skills = ctx?.skills?.length ?? 0;
5486
+ } catch {
5487
+ }
5488
+ }
5489
+ screen.on("keys", onKeys);
5490
+ screen.on("resize", schedule);
5491
+ screen.on("end", quit);
5492
+ const onSignal = (code) => () => {
5493
+ screen.leave();
5494
+ process.exit(code);
5495
+ };
5496
+ const onTerm = onSignal(143);
5497
+ const onInt = onSignal(130);
5498
+ const onHup = onSignal(129);
5499
+ process.on("SIGTERM", onTerm);
5500
+ process.on("SIGINT", onInt);
5501
+ process.on("SIGHUP", onHup);
5502
+ screen.enter();
5503
+ if (state.sidebar) refreshProjectFacts();
5504
+ if (runtime.settings.ignoredProjectServerUrl) {
5505
+ note(
5506
+ ` ${paint.yellow("!")} ${paint.muted(
5507
+ `ignoring serverUrl in .labd/settings.json (${runtime.settings.ignoredProjectServerUrl}) - this project isn't trusted; \`labd trust\` if you meant it`
5508
+ )}`
5509
+ );
5510
+ }
5511
+ for (const skipped of runtime.extensions.skipped) note(` ${paint.yellow("!")} ${paint.muted(`extensions: ${skipped.why}`)}`);
5512
+ replay();
5513
+ let faceTimer = null;
5514
+ if (faceFrames.length > 1) {
5515
+ faceTimer = setInterval(() => {
5516
+ state.faceIndex++;
5517
+ if (state.faceIndex >= faceFrames.length - 1 && faceTimer) {
5518
+ clearInterval(faceTimer);
5519
+ faceTimer = null;
5520
+ }
5521
+ if (!transcript.length) schedule();
5522
+ }, FACE_FRAME_MS);
5523
+ }
5524
+ if (opts.pickSession) openSessions();
5525
+ draw();
5526
+ try {
5527
+ await exited;
5528
+ if (serial.running) await Promise.race([serial.running, sleep3(1500)]);
5529
+ } finally {
5530
+ if (faceTimer) clearInterval(faceTimer);
5531
+ if (frameTimer) clearTimeout(frameTimer);
5532
+ setBusy(null);
5533
+ process.off("SIGTERM", onTerm);
5534
+ process.off("SIGINT", onInt);
5535
+ process.off("SIGHUP", onHup);
5536
+ screen.leave();
5537
+ }
5538
+ if (state.asked) {
5539
+ process.stderr.write(paint.dim(`
5540
+ ${FACE} session saved. Continue it with: labd --resume ${runtime.session.id.slice(0, 8)}
5541
+ `));
5542
+ await closeConversation(runtime);
5543
+ }
5544
+ if (state.farewell) process.stderr.write(`${state.farewell}
5545
+ `);
5546
+ return 0;
5547
+ }
5548
+ function borderColour2(s) {
5549
+ switch (s) {
5550
+ case "ok":
5551
+ return paint.green;
5552
+ case "unknown":
5553
+ return paint.red;
5554
+ case "partial":
5555
+ return paint.cyan;
5556
+ default:
5557
+ return paint.dim;
5558
+ }
5559
+ }
5560
+ function spread(left, right, width) {
5561
+ const gap = width - visibleWidth(left) - visibleWidth(right);
5562
+ if (gap >= 1) return `${left}${" ".repeat(gap)}${right}`;
5563
+ const room = width - visibleWidth(right) - 1;
5564
+ if (room < 8) return fit(left, width);
5565
+ return `${fit(left, room)} ${right}`;
5566
+ }
5567
+ function centre(line, width) {
5568
+ const pad4 = Math.max(0, Math.floor((width - visibleWidth(line)) / 2));
5569
+ return `${" ".repeat(pad4)}${line}`;
5570
+ }
5571
+ function overlay(base, left, piece, width) {
5572
+ const before = fit(base, left);
5573
+ const after = sliceVisible(base, left + visibleWidth(piece));
5574
+ return fit(`${before}${ESC6}[0m${piece}${ESC6}[0m${after}`, width);
5575
+ }
5576
+ function sliceVisible(line, from) {
5577
+ let visible = 0;
5578
+ let out = "";
5579
+ let i = 0;
5580
+ while (i < line.length) {
5581
+ if (line[i] === ESC6) {
5582
+ const end = line.indexOf("m", i);
5583
+ if (end === -1) break;
5584
+ out += line.slice(i, end + 1);
5585
+ i = end + 1;
5586
+ continue;
5587
+ }
5588
+ if (visible >= from) out += line[i];
5589
+ visible++;
5590
+ i++;
5591
+ }
5592
+ return out;
5593
+ }
5594
+ function summarise2(body2) {
5595
+ const text = typeof body2.text === "string" ? body2.text : "";
5596
+ if (body2.kind === "tool") {
5597
+ const results = Array.isArray(body2.results) ? body2.results.length : 0;
5598
+ return `${results} result(s)`;
5599
+ }
5600
+ return text.replace(/\s+/g, " ").slice(0, 60);
5601
+ }
5602
+ function sleep3(ms) {
5603
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
3745
5604
  }
3746
5605
 
3747
5606
  // packages/cli/src/modes/print.ts
@@ -3796,8 +5655,8 @@ async function runPrint(runtime, opts) {
3796
5655
  else toStderr(`${paint.red("labd:")} ${message}
3797
5656
  `);
3798
5657
  },
3799
- done(info) {
3800
- if (opts.json) emit({ type: "done", credits: info.credits, context: info.context });
5658
+ done(info2) {
5659
+ if (opts.json) emit({ type: "done", credits: info2.credits, context: info2.context });
3801
5660
  },
3802
5661
  commandOutput(line) {
3803
5662
  if (opts.json) emit({ type: "command_output", line });
@@ -3854,10 +5713,10 @@ async function runRpc(runtime) {
3854
5713
  process.stdin.setEncoding("utf8");
3855
5714
  process.stdin.on("data", (chunk) => {
3856
5715
  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);
5716
+ let at2;
5717
+ while ((at2 = buffer.indexOf("\n")) !== -1) {
5718
+ const line = buffer.slice(0, at2);
5719
+ buffer = buffer.slice(at2 + 1);
3861
5720
  if (line.trim()) void handle(line);
3862
5721
  }
3863
5722
  });
@@ -3954,8 +5813,8 @@ async function runRpc(runtime) {
3954
5813
  error(message) {
3955
5814
  notify("labd/error", { message });
3956
5815
  },
3957
- done(info) {
3958
- notify("labd/done", info);
5816
+ done(info2) {
5817
+ notify("labd/done", info2);
3959
5818
  },
3960
5819
  commandOutput(line) {
3961
5820
  notify("labd/command_output", { line });
@@ -4007,7 +5866,7 @@ async function runRpc(runtime) {
4007
5866
  }
4008
5867
 
4009
5868
  // packages/cli/src/index.ts
4010
- var VERSION = true ? "0.3.1" : "0.0.0-dev";
5869
+ var VERSION = true ? "0.5.0" : "0.0.0-dev";
4011
5870
  async function main(argv) {
4012
5871
  const args = parse(argv);
4013
5872
  if (args.color !== void 0) setColor(args.color);
@@ -4033,11 +5892,16 @@ async function main(argv) {
4033
5892
  default:
4034
5893
  break;
4035
5894
  }
5895
+ const screenCapable = !args.rpc && !args.print && !args.question && !args.plain && process.stdin.isTTY && process.stdout.isTTY;
5896
+ if (args.pickSession && !screenCapable) {
5897
+ 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>`.");
5898
+ }
4036
5899
  const runtime = await createRuntime({
4037
5900
  root,
4038
5901
  overrides: {
4039
5902
  ...args.mode ? { mode: args.mode } : {},
4040
- ...args.effort ? { effort: args.effort } : {}
5903
+ ...args.effort ? { effort: args.effort } : {},
5904
+ ...args.mouse !== void 0 ? { mouse: args.mouse } : {}
4041
5905
  },
4042
5906
  resume: args.resume ? resolveSession(args.resume, root) : args.continue ? latestSession(root) : void 0,
4043
5907
  extensions: { enabled: !args.noExtensions, extra: args.extensions }
@@ -4058,7 +5922,8 @@ async function main(argv) {
4058
5922
  `);
4059
5923
  return 1;
4060
5924
  }
4061
- return runInteractive(runtime);
5925
+ if (args.plain) return runInteractive(runtime);
5926
+ return runScreen(runtime, { mouse: runtime.settings.mouse, sidebar: runtime.settings.sidebar, pickSession: args.pickSession });
4062
5927
  }
4063
5928
  async function login(root) {
4064
5929
  const settings = loadSettings(root, {}, { trusted: isTrusted(stateDir(), root) });
@@ -4093,9 +5958,11 @@ ${paint.red("labd:")} ${result.reason ?? "that did not complete"}.
4093
5958
  }
4094
5959
  async function logout(root) {
4095
5960
  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")}
5961
+ const hadStored = await new Auth(stateDir(), settings.serverUrl).signOut();
5962
+ for (const line of signedOutMessage(hadStored, Boolean(process.env.LABD_TOKEN)).split("\n")) {
5963
+ process.stderr.write(` ${paint.muted(line)}
4098
5964
  `);
5965
+ }
4099
5966
  return 0;
4100
5967
  }
4101
5968
  function listSessions(root) {
@@ -4108,7 +5975,7 @@ function listSessions(root) {
4108
5975
  }
4109
5976
  for (const s of all.slice(0, 25)) {
4110
5977
  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 ?? ""}
5978
+ ` ${paint.dim(s.id.slice(0, 8))} ${paint.muted(new Date(s.at).toLocaleString())} ${paint.muted(`${s.messages} nodes`)} ${sessionTitle(s)}
4112
5979
  `
4113
5980
  );
4114
5981
  }
@@ -4140,11 +6007,13 @@ function parse(argv) {
4140
6007
  print: false,
4141
6008
  json: false,
4142
6009
  rpc: false,
6010
+ pickSession: false,
4143
6011
  continue: false,
4144
6012
  noExtensions: false,
4145
6013
  extensions: [],
4146
6014
  help: false,
4147
- version: false
6015
+ version: false,
6016
+ plain: false
4148
6017
  };
4149
6018
  const words = [];
4150
6019
  for (let i = 0; i < argv.length; i++) {
@@ -4170,10 +6039,14 @@ function parse(argv) {
4170
6039
  out.effort = value;
4171
6040
  break;
4172
6041
  }
6042
+ case "-r":
4173
6043
  case "--resume": {
4174
- const value = next();
4175
- if (!value) throw new UsageError("--resume needs a session id");
4176
- out.resume = value;
6044
+ const value = argv[i + 1];
6045
+ if (!value || value.startsWith("-")) {
6046
+ out.pickSession = true;
6047
+ break;
6048
+ }
6049
+ out.resume = next();
4177
6050
  break;
4178
6051
  }
4179
6052
  case "-c":
@@ -4203,6 +6076,12 @@ function parse(argv) {
4203
6076
  case "--color":
4204
6077
  out.color = true;
4205
6078
  break;
6079
+ case "--plain":
6080
+ out.plain = true;
6081
+ break;
6082
+ case "--no-mouse":
6083
+ out.mouse = false;
6084
+ break;
4206
6085
  case "-h":
4207
6086
  case "--help":
4208
6087
  out.help = true;
@@ -4227,6 +6106,9 @@ function parse(argv) {
4227
6106
  } else {
4228
6107
  out.question = words.join(" ");
4229
6108
  }
6109
+ if ((out.resume || out.pickSession) && out.continue) {
6110
+ throw new UsageError("--resume and --continue both choose a session - pass one of them");
6111
+ }
4230
6112
  return out;
4231
6113
  }
4232
6114
  function applyMode(out, value) {
@@ -4246,7 +6128,7 @@ function usage() {
4246
6128
  ${paint.accent(FACE)} ${paint.bold("labd")} - the Dev Labs assistant, in your terminal
4247
6129
 
4248
6130
  ${paint.bold("USAGE")}
4249
- labd start a session in this project
6131
+ labd start a session in this project (full-screen)
4250
6132
  labd "why is this failing" ask one question and exit
4251
6133
  labd -p "..." | pbcopy print mode, for pipes and scripts
4252
6134
  cat log | labd -p "why?" piped input is added to the question
@@ -4262,12 +6144,15 @@ ${paint.bold("OPTIONS")}
4262
6144
  --json emit newline-delimited JSON events
4263
6145
  --mode <m> ${AGENT_MODES.join(" | ")} (or rpc, for JSON-RPC over stdio)
4264
6146
  --think <low|medium|high>
4265
- --resume <id> continue an earlier session
6147
+ -r, --resume [id] continue an earlier session; with no id, pick one from a list
4266
6148
  -c, --continue continue the most recent session here
4267
6149
  -C, --cwd <dir> run as though started in <dir>
4268
6150
  -e, --extension <path> load an extension file (repeatable)
4269
6151
  --no-extensions load none
4270
6152
  --no-color plain text (also honours NO_COLOR)
6153
+ --no-mouse leave the mouse to the terminal: the session takes it for
6154
+ wheel scrolling, and native text selection needs it released
6155
+ --plain the older print-style session (deprecated; removed in 0.5)
4271
6156
  -h, --help -v, --version
4272
6157
 
4273
6158
  ${paint.bold("MODES")}