pilotswarm-cli 0.1.8 → 0.1.9

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.
Files changed (2) hide show
  1. package/cli/tui.js +436 -114
  2. package/package.json +2 -2
package/cli/tui.js CHANGED
@@ -401,6 +401,285 @@ async function downloadArtifact(sessionId, filename) {
401
401
  }
402
402
  }
403
403
 
404
+ /**
405
+ * Gather all artifacts for the active session and its descendants.
406
+ */
407
+ function gatherAllArtifacts(orchId) {
408
+ const all = [];
409
+ const visited = new Set();
410
+ const queue = [orchId];
411
+ while (queue.length) {
412
+ const id = queue.shift();
413
+ if (visited.has(id)) continue;
414
+ visited.add(id);
415
+ const arts = sessionArtifacts.get(id);
416
+ if (arts) all.push(...arts);
417
+ const children = orchChildrenOf.get(id) || [];
418
+ for (const child of children) queue.push(child);
419
+ }
420
+ return all;
421
+ }
422
+
423
+ /** Currently open artifact picker (so we can dismiss/reopen cleanly). */
424
+ let _artifactPicker = null;
425
+
426
+ function showArtifactPicker() {
427
+ // Close existing picker if open
428
+ if (_artifactPicker) {
429
+ _artifactPicker.detach();
430
+ _artifactPicker = null;
431
+ }
432
+
433
+ const artifacts = gatherAllArtifacts(activeOrchId);
434
+ if (artifacts.length === 0) {
435
+ setStatus("No artifacts for this session");
436
+ return;
437
+ }
438
+
439
+ const hasMultiple = artifacts.length > 1;
440
+ const buildItems = () => {
441
+ const items = artifacts.map(a => {
442
+ const icon = a.downloaded ? " ✓" : " ↓";
443
+ const sid = shortId(a.sessionId);
444
+ return `${icon} ${sid}/${a.filename}`;
445
+ });
446
+ if (hasMultiple) items.push(" ⬇ Download All");
447
+ return items;
448
+ };
449
+
450
+ const picker = blessed.list({
451
+ parent: screen,
452
+ label: " 📎 Artifacts — Enter download · Esc close ",
453
+ tags: true,
454
+ left: "center",
455
+ top: "center",
456
+ width: Math.min(65, screen.width - 4),
457
+ height: Math.min(artifacts.length + (hasMultiple ? 3 : 2), 18),
458
+ border: { type: "line" },
459
+ style: {
460
+ fg: "white",
461
+ bg: "black",
462
+ border: { fg: "cyan" },
463
+ label: { fg: "cyan" },
464
+ selected: { bg: "blue", fg: "white" },
465
+ },
466
+ keys: true,
467
+ vi: true,
468
+ mouse: false,
469
+ items: buildItems(),
470
+ });
471
+
472
+ _artifactPicker = picker;
473
+ picker.focus();
474
+ screen.render();
475
+
476
+ const closePicker = () => {
477
+ picker.detach();
478
+ _artifactPicker = null;
479
+ orchList.focus();
480
+ screen.render();
481
+ };
482
+
483
+ picker.key(["escape", "q"], closePicker);
484
+ // 'a' while picker is open → close (toggle behavior)
485
+ picker.key(["a"], closePicker);
486
+
487
+ picker.on("select", async (_el, idx) => {
488
+ // "Download All" option
489
+ if (hasMultiple && idx === artifacts.length) {
490
+ const pending = artifacts.filter(a => !a.downloaded);
491
+ if (pending.length === 0) {
492
+ setStatus("All artifacts already downloaded");
493
+ screen.render();
494
+ return;
495
+ }
496
+ setStatus(`Downloading ${pending.length} artifacts...`);
497
+ screen.render();
498
+ let ok = 0;
499
+ for (const art of pending) {
500
+ const localPath = await downloadArtifact(art.sessionId, art.filename);
501
+ if (localPath) { art.downloaded = true; art.localPath = localPath; ok++; }
502
+ }
503
+ picker.setItems(buildItems());
504
+ picker.select(idx);
505
+ setStatus(`Downloaded ${ok}/${pending.length} artifacts`);
506
+ screen.render();
507
+ return;
508
+ }
509
+
510
+ const art = artifacts[idx];
511
+ if (!art) return;
512
+
513
+ if (art.downloaded) {
514
+ // Already downloaded — open viewer at that file
515
+ closePicker();
516
+ mdViewActive = true;
517
+ refreshMarkdownViewer();
518
+ const files = scanExportFiles();
519
+ const matchIdx = files.findIndex(f => f.localPath === art.localPath);
520
+ if (matchIdx >= 0) {
521
+ mdViewerSelectedIdx = matchIdx;
522
+ mdFileListPane.select(matchIdx);
523
+ refreshMarkdownViewer();
524
+ }
525
+ screen.realloc();
526
+ relayoutAll();
527
+ setStatus("Markdown Viewer (v to exit)");
528
+ screen.render();
529
+ return;
530
+ }
531
+
532
+ setStatus(`Downloading ${art.filename}...`);
533
+ screen.render();
534
+ const localPath = await downloadArtifact(art.sessionId, art.filename);
535
+ if (localPath) {
536
+ art.downloaded = true;
537
+ art.localPath = localPath;
538
+ picker.setItems(buildItems());
539
+ picker.select(idx);
540
+ setStatus(`Downloaded ${art.filename}`);
541
+ } else {
542
+ setStatus("Download failed — check logs");
543
+ }
544
+ screen.render();
545
+ });
546
+ }
547
+
548
+ // ─── File attachment for prompt ──────────────────────────────────
549
+ // Tracks file attachments embedded in the prompt. When the prompt is sent,
550
+ // file contents are prepended to the message text.
551
+ const _promptAttachments = []; // [{ path, displayName, sessionId }]
552
+
553
+ let _fileAttachModal = false;
554
+
555
+ function showFileAttachPrompt() {
556
+ _fileAttachModal = true;
557
+
558
+ // Save the current inputBar value so we can restore it after
559
+ const savedInput = inputBar.getValue() || "";
560
+ const savedCursor = inputCursorIndex;
561
+
562
+ // Modal overlay to block all other input
563
+ const overlay = blessed.box({
564
+ parent: screen,
565
+ left: 0, top: 0, width: "100%", height: "100%",
566
+ style: { bg: "black", transparent: true },
567
+ });
568
+
569
+ const pathInput = blessed.textbox({
570
+ parent: overlay,
571
+ label: " 📄 Attach file — paste path, Enter to attach, Esc to cancel ",
572
+ tags: true,
573
+ left: "center",
574
+ top: "center",
575
+ width: Math.min(70, screen.width - 4),
576
+ height: 3,
577
+ border: { type: "line" },
578
+ style: {
579
+ fg: "white",
580
+ bg: "black",
581
+ border: { fg: "yellow" },
582
+ label: { fg: "yellow" },
583
+ },
584
+ inputOnFocus: true,
585
+ });
586
+
587
+ pathInput.focus();
588
+ screen.render();
589
+
590
+ const closeModal = () => {
591
+ _fileAttachModal = false;
592
+ overlay.detach();
593
+ // Restore input bar state and refocus
594
+ setInputValue(savedInput, savedCursor);
595
+ focusInput();
596
+ screen.render();
597
+ };
598
+
599
+ pathInput.on("submit", async (filePath) => {
600
+ if (!filePath || !filePath.trim()) {
601
+ closeModal();
602
+ return;
603
+ }
604
+
605
+ const resolved = filePath.trim().replace(/^~/, os.homedir());
606
+ if (!fs.existsSync(resolved)) {
607
+ setStatus(`File not found: ${filePath.trim()}`);
608
+ closeModal();
609
+ return;
610
+ }
611
+
612
+ const displayName = path.basename(resolved);
613
+ const content = fs.readFileSync(resolved, "utf-8");
614
+
615
+ // Upload to artifact store under the active session
616
+ const orchId = activeOrchId;
617
+ const sessionId = orchId ? (orchId.startsWith("session-") ? orchId.slice(8) : orchId) : "local";
618
+ try {
619
+ const store = getTuiArtifactStore();
620
+ await store.uploadArtifact(sessionId, displayName, content);
621
+
622
+ // Register in artifact registry so it shows up in 'v' viewer and 'a' picker
623
+ const artKey = `${sessionId}/${displayName}`;
624
+ if (!_registeredArtifacts.has(artKey)) {
625
+ _registeredArtifacts.add(artKey);
626
+ if (!sessionArtifacts.has(orchId)) sessionArtifacts.set(orchId, []);
627
+ const localDir = path.join(EXPORTS_DIR, sessionId.slice(0, 8));
628
+ fs.mkdirSync(localDir, { recursive: true });
629
+ const localPath = path.join(localDir, displayName);
630
+ fs.writeFileSync(localPath, content, "utf-8");
631
+ sessionArtifacts.get(orchId).push({
632
+ sessionId, filename: displayName, downloaded: true, localPath,
633
+ });
634
+ }
635
+
636
+ // Show a snipped preview in chat (first 3 lines)
637
+ const lines = content.split("\n");
638
+ const preview = lines.slice(0, 3).map(l => ` ${l.slice(0, 80)}`).join("\n");
639
+ const suffix = lines.length > 3 ? `\n {gray-fg}… (${lines.length - 3} more lines){/gray-fg}` : "";
640
+ appendChatRaw(
641
+ `{yellow-fg}📄 Attached: ${displayName} (${(content.length / 1024).toFixed(1)}KB){/yellow-fg}\n${preview}${suffix}`,
642
+ orchId,
643
+ );
644
+ } catch (err) {
645
+ appendChatRaw(`{red-fg}Upload failed: ${err.message}{/red-fg}`, orchId);
646
+ }
647
+
648
+ _promptAttachments.push({ path: resolved, displayName, sessionId });
649
+
650
+ const token = ` 📎 ${displayName} `;
651
+ closeModal();
652
+ insertInputText(token);
653
+ setStatus(`Attached: ${displayName}`);
654
+ screen.render();
655
+ });
656
+
657
+ pathInput.on("cancel", closeModal);
658
+ pathInput.key(["escape"], closeModal);
659
+ }
660
+
661
+ /**
662
+ * Process prompt text before sending: expand attachment tokens into file content.
663
+ * Returns the final prompt string with artifact:// references.
664
+ */
665
+ function expandAttachments(promptText) {
666
+ if (_promptAttachments.length === 0) return promptText;
667
+
668
+ const attachmentRefs = [];
669
+ for (const att of _promptAttachments) {
670
+ attachmentRefs.push(
671
+ `[Attached file: ${att.displayName} — artifact://${att.sessionId}/${att.displayName}]`
672
+ );
673
+ }
674
+
675
+ // Clear attachments after expanding
676
+ _promptAttachments.length = 0;
677
+
678
+ // Remove 📎 filename tokens from the prompt text
679
+ const cleaned = promptText.replace(/\s*📎\s*[^\s]+\s*/g, " ").trim();
680
+ return attachmentRefs.join("\n") + (cleaned ? "\n\n" + cleaned : "");
681
+ }
682
+
404
683
  function ts() {
405
684
  return formatDisplayTime(Date.now());
406
685
  }
@@ -423,7 +702,7 @@ const screen = blessed.screen({
423
702
  title: BASE_TUI_TITLE,
424
703
  fullUnicode: true,
425
704
  forceUnicode: true,
426
- mouse: true,
705
+ mouse: false,
427
706
  });
428
707
  process.stderr.write = _origStderr;
429
708
  applyWindowTitle(BASE_TUI_TITLE);
@@ -561,7 +840,15 @@ const MAX_PROMPT_EDITOR_ROWS = 8;
561
840
  let promptValueCache = "";
562
841
 
563
842
  function promptLineCount(text) {
564
- return Math.max(1, String(text || "").split("\n").length);
843
+ const str = String(text || "");
844
+ if (!str) return 1;
845
+ // Count visual lines: each \n-delimited line may wrap across multiple visual rows
846
+ const width = Math.max(1, screen.width - 2); // input bar inner width (full width minus borders)
847
+ let visualLines = 0;
848
+ for (const line of str.split("\n")) {
849
+ visualLines += Math.max(1, Math.ceil((line.length + 1) / width));
850
+ }
851
+ return Math.max(1, visualLines);
565
852
  }
566
853
 
567
854
  function promptEditorRows() {
@@ -572,7 +859,7 @@ function inputBarHeight() {
572
859
  return promptEditorRows() + 2; // border + content rows
573
860
  }
574
861
 
575
- function bodyH() { return screen.height - inputBarHeight(); } // total body (minus input bar)
862
+ function bodyH() { return screen.height - inputBarHeight() - 1; } // total body (minus input bar + status line)
576
863
  function sessH() { return Math.max(5, Math.floor(bodyH() * 0.25)); }
577
864
  function chatH() { return bodyH() - sessH(); }
578
865
  function activityH() { return Math.max(6, Math.floor(bodyH() * 0.28)); } // sticky Activity pane height
@@ -630,7 +917,7 @@ const orchList = blessed.list({
630
917
  scrollbar: { style: { bg: "yellow" } },
631
918
  keys: false,
632
919
  vi: false,
633
- mouse: true,
920
+ mouse: false,
634
921
  interactive: true,
635
922
  });
636
923
 
@@ -704,7 +991,7 @@ const chatBox = blessed.log({
704
991
  scrollbar: { style: { bg: "cyan" } },
705
992
  keys: true,
706
993
  vi: true,
707
- mouse: true,
994
+ mouse: false,
708
995
  });
709
996
 
710
997
  // ─── Clickable URLs in chat ──────────────────────────────────────
@@ -801,7 +1088,7 @@ const orchLogPane = blessed.log({
801
1088
  scrollbar: { style: { bg: "cyan" } },
802
1089
  keys: true,
803
1090
  vi: true,
804
- mouse: true,
1091
+ mouse: false,
805
1092
  hidden: true,
806
1093
  });
807
1094
 
@@ -856,7 +1143,7 @@ const nodeMapPane = blessed.box({
856
1143
  scrollbar: { style: { bg: "yellow" } },
857
1144
  keys: true,
858
1145
  vi: true,
859
- mouse: true,
1146
+ mouse: false,
860
1147
  hidden: true,
861
1148
  });
862
1149
 
@@ -887,7 +1174,7 @@ const mdFileListPane = blessed.list({
887
1174
  },
888
1175
  keys: false,
889
1176
  vi: false,
890
- mouse: true,
1177
+ mouse: false,
891
1178
  interactive: true,
892
1179
  hidden: true,
893
1180
  });
@@ -912,7 +1199,7 @@ const mdPreviewPane = blessed.box({
912
1199
  scrollbar: { style: { bg: "green" } },
913
1200
  keys: true,
914
1201
  vi: true,
915
- mouse: true,
1202
+ mouse: false,
916
1203
  hidden: true,
917
1204
  });
918
1205
 
@@ -1069,7 +1356,7 @@ const activityPane = blessed.log({
1069
1356
  scrollbar: { style: { bg: "gray" } },
1070
1357
  keys: true,
1071
1358
  vi: true,
1072
- mouse: true,
1359
+ mouse: false,
1073
1360
  });
1074
1361
 
1075
1362
  // Per-session activity buffers
@@ -1320,7 +1607,7 @@ const seqPane = blessed.log({
1320
1607
  scrollbar: { style: { bg: "magenta" } },
1321
1608
  keys: true,
1322
1609
  vi: true,
1323
- mouse: true,
1610
+ mouse: false,
1324
1611
  hidden: true,
1325
1612
  });
1326
1613
 
@@ -1848,7 +2135,7 @@ function switchLogMode() {
1848
2135
  // Reset focus to sessions list when panes change
1849
2136
  orchList.focus();
1850
2137
  // Force full repaint on next tick (same as pressing 'r')
1851
- setTimeout(() => { screen.realloc(); screen.render(); }, 0);
2138
+ scheduleLightRefresh("switchLogMode");
1852
2139
  }
1853
2140
 
1854
2141
  /**
@@ -1975,7 +2262,7 @@ function getOrCreateWorkerPane(podName) {
1975
2262
  scrollbar: { style: { bg: color } },
1976
2263
  keys: true,
1977
2264
  vi: true,
1978
- mouse: true,
2265
+ mouse: false,
1979
2266
  });
1980
2267
 
1981
2268
  workerPanes.set(podName, pane);
@@ -2025,7 +2312,7 @@ function relayoutAll() {
2025
2312
  if (typeof statusBar !== "undefined" && statusBar) {
2026
2313
  statusBar.left = 1;
2027
2314
  statusBar.width = lW - 2;
2028
- statusBar.bottom = iH - 1;
2315
+ statusBar.bottom = iH;
2029
2316
  }
2030
2317
  if (typeof inputBar !== "undefined" && inputBar) {
2031
2318
  inputBar.left = 0;
@@ -2152,7 +2439,7 @@ const inputBar = blessed.textarea({
2152
2439
  },
2153
2440
  inputOnFocus: true,
2154
2441
  keys: true,
2155
- mouse: true,
2442
+ mouse: false,
2156
2443
  });
2157
2444
  registerFocusRing(inputBar, "green");
2158
2445
  inputBar.on("focus", () => {
@@ -2252,6 +2539,36 @@ function moveCursorRight() {
2252
2539
  screen.render();
2253
2540
  }
2254
2541
 
2542
+ function moveCursorUp() {
2543
+ const value = String(inputBar.getValue() || "");
2544
+ // Find the start of the current line and the line above
2545
+ const before = value.slice(0, inputCursorIndex);
2546
+ const currentLineStart = before.lastIndexOf("\n") + 1;
2547
+ if (currentLineStart === 0) return; // already on first line
2548
+ const colInLine = inputCursorIndex - currentLineStart;
2549
+ const prevLineEnd = currentLineStart - 1; // the \n before current line
2550
+ const prevLineStart = value.lastIndexOf("\n", prevLineEnd - 1) + 1;
2551
+ const prevLineLen = prevLineEnd - prevLineStart;
2552
+ inputCursorIndex = clampInputCursor(prevLineStart + Math.min(colInLine, prevLineLen), value);
2553
+ inputBar._updateCursor();
2554
+ screen.render();
2555
+ }
2556
+
2557
+ function moveCursorDown() {
2558
+ const value = String(inputBar.getValue() || "");
2559
+ // Find the end of the current line and the line below
2560
+ const currentLineStart = value.lastIndexOf("\n", inputCursorIndex - 1) + 1;
2561
+ const currentLineEnd = value.indexOf("\n", inputCursorIndex);
2562
+ if (currentLineEnd === -1) return; // already on last line
2563
+ const colInLine = inputCursorIndex - currentLineStart;
2564
+ const nextLineStart = currentLineEnd + 1;
2565
+ const nextLineEnd = value.indexOf("\n", nextLineStart);
2566
+ const nextLineLen = (nextLineEnd === -1 ? value.length : nextLineEnd) - nextLineStart;
2567
+ inputCursorIndex = clampInputCursor(nextLineStart + Math.min(colInLine, nextLineLen), value);
2568
+ inputBar._updateCursor();
2569
+ screen.render();
2570
+ }
2571
+
2255
2572
  function getPreviousWordBoundary(value, fromIndex) {
2256
2573
  let index = clampInputCursor(fromIndex, value);
2257
2574
  while (index > 0 && /\s/.test(value[index - 1])) index -= 1;
@@ -2328,6 +2645,28 @@ inputBar._listener = function promptInputListener(ch, key) {
2328
2645
  || key.sequence === "\x1bf";
2329
2646
 
2330
2647
  if (key.name === "escape") {
2648
+ // Alt+Enter (ESC + CR) → insert newline
2649
+ if (key.sequence === "\x1b\r" || key.sequence === "\x1b\n") {
2650
+ insertInputText("\n");
2651
+ screen.render();
2652
+ return;
2653
+ }
2654
+ // Alt+Backspace (ESC + DEL) → delete word backward
2655
+ if (key.sequence === "\x1b\x7f") {
2656
+ deleteInputWordBackward();
2657
+ screen.render();
2658
+ return;
2659
+ }
2660
+ // Alt+Left (ESC + b) → word left
2661
+ if (key.sequence === "\x1bb") {
2662
+ moveCursorWordLeft();
2663
+ return;
2664
+ }
2665
+ // Alt+Right (ESC + f) → word right
2666
+ if (key.sequence === "\x1bf") {
2667
+ moveCursorWordRight();
2668
+ return;
2669
+ }
2331
2670
  inputBar._done(null, null);
2332
2671
  return;
2333
2672
  }
@@ -2340,6 +2679,12 @@ inputBar._listener = function promptInputListener(ch, key) {
2340
2679
  inputBar._done(null, value);
2341
2680
  return;
2342
2681
  }
2682
+ // Ctrl+J — insert newline (Unix standard line feed)
2683
+ if (key.ctrl && key.name === "j") {
2684
+ insertInputText("\n");
2685
+ screen.render();
2686
+ return;
2687
+ }
2343
2688
  if (isWordLeft) {
2344
2689
  moveCursorWordLeft();
2345
2690
  return;
@@ -2356,11 +2701,25 @@ inputBar._listener = function promptInputListener(ch, key) {
2356
2701
  moveCursorRight();
2357
2702
  return;
2358
2703
  }
2704
+ if (key.name === "up") {
2705
+ moveCursorUp();
2706
+ return;
2707
+ }
2708
+ if (key.name === "down") {
2709
+ moveCursorDown();
2710
+ return;
2711
+ }
2359
2712
  if (isMetaBackspace) {
2360
2713
  deleteInputWordBackward();
2361
2714
  screen.render();
2362
2715
  return;
2363
2716
  }
2717
+ // Ctrl+W — delete word backward (Unix standard)
2718
+ if (key.ctrl && key.name === "w") {
2719
+ deleteInputWordBackward();
2720
+ screen.render();
2721
+ return;
2722
+ }
2364
2723
  if (key.name === "backspace") {
2365
2724
  deleteInputBackward();
2366
2725
  screen.render();
@@ -2371,6 +2730,28 @@ inputBar._listener = function promptInputListener(ch, key) {
2371
2730
  screen.render();
2372
2731
  return;
2373
2732
  }
2733
+ // Ctrl+A — attach file
2734
+ if (key.ctrl && key.name === "a") {
2735
+ // Set modal flag FIRST so subsequent keypresses in this listener are blocked
2736
+ _fileAttachModal = true;
2737
+ // Cancel inputBar's readInput cleanly — _done(null,null) triggers cancel
2738
+ // which we handle with a no-op when _fileAttachModal is set
2739
+ inputBar._done(null, null);
2740
+ setImmediate(() => showFileAttachPrompt());
2741
+ return;
2742
+ }
2743
+ if (key.ctrl && key.name === "e") {
2744
+ inputCursorIndex = clampInputCursor(value.length);
2745
+ inputBar._updateCursor();
2746
+ screen.render();
2747
+ return;
2748
+ }
2749
+ // Ctrl+U — kill line (clear input)
2750
+ if (key.ctrl && key.name === "u") {
2751
+ setInputValue("", 0);
2752
+ screen.render();
2753
+ return;
2754
+ }
2374
2755
  if (ch === "/" && value === "") {
2375
2756
  insertInputText(ch);
2376
2757
  setImmediate(() => {
@@ -2616,8 +2997,8 @@ function setNavigationStatusForPane(kind) {
2616
2997
  nodemap: "{yellow-fg}j/k scroll node map · g/G top/bottom · m cycle log mode · p prompt · ? help · Esc then q quit{/yellow-fg}",
2617
2998
  markdownList: "{yellow-fg}j/k choose file · Enter preview · d delete file · v exit viewer · ? help · Esc then q quit{/yellow-fg}",
2618
2999
  markdownPreview: "{yellow-fg}j/k scroll preview · g/G top/bottom · Ctrl+D/U page · o open · y copy path · v exit viewer · ? help{/yellow-fg}",
2619
- prompt: "{yellow-fg}Type a message · Opt+Enter newline · Opt+←/→ word move · Opt+Backspace word delete · Esc for navigation mode{/yellow-fg}",
2620
- answer: "{yellow-fg}Type your answer · Opt+Enter newline · Opt+←/→ word move · Opt+Backspace word delete · Esc for navigation mode{/yellow-fg}",
3000
+ prompt: "{yellow-fg}Type a message · ^J newline · ^W word del · ^A attach file · Esc navigation mode{/yellow-fg}",
3001
+ answer: "{yellow-fg}Type your answer · ^J newline · ^W word del · ^A attach file · Esc navigation mode{/yellow-fg}",
2621
3002
  };
2622
3003
  setStatus(hints[kind] || hints.chat);
2623
3004
  }
@@ -3570,7 +3951,22 @@ function startChatSpinner(orchId) {
3570
3951
  const b = sessionChatBuffers.get(sid);
3571
3952
  if (b && idx < b.length) {
3572
3953
  b[idx] = `{gray-fg}${SPINNER_FRAMES[_spinnerFrame]} Thinking…{/gray-fg}`;
3573
- if (sid === activeOrchId) { invalidateChat(); anyActive = true; }
3954
+ if (sid === activeOrchId) {
3955
+ anyActive = true;
3956
+ // Only repaint if the spinner line is visible in the
3957
+ // viewport — avoids fighting j/k and PgUp/PgDn scroll.
3958
+ const scrollTop = chatBox.childBase || 0;
3959
+ const visibleRows = chatBox.height - 2; // minus border
3960
+ if (idx >= scrollTop && idx < scrollTop + visibleRows) {
3961
+ // Update chatBox content in-place (no setContent
3962
+ // which resets scroll position). Re-join the full
3963
+ // buffer but restore exact scroll afterward.
3964
+ const prevBase = chatBox.childBase || 0;
3965
+ chatBox.setContent(b.map(styleUrls).join("\n"));
3966
+ chatBox.childBase = prevBase;
3967
+ _screenDirty = true;
3968
+ }
3969
+ }
3574
3970
  }
3575
3971
  }
3576
3972
  if (!anyActive && sessionSpinnerIndex.size === 0) {
@@ -4430,7 +4826,7 @@ orchList.key(["n"], async () => {
4430
4826
  items: agentChoices.map(a => ` ${a.name || "(generic)"}${a.description ? ` — ${a.description}` : ""}`),
4431
4827
  keys: true,
4432
4828
  vi: true,
4433
- mouse: true,
4829
+ mouse: false,
4434
4830
  scrollable: true,
4435
4831
  });
4436
4832
 
@@ -4574,7 +4970,7 @@ orchList.key(["S-n"], async () => {
4574
4970
  items,
4575
4971
  keys: true,
4576
4972
  vi: true,
4577
- mouse: true,
4973
+ mouse: false,
4578
4974
  scrollable: true,
4579
4975
  });
4580
4976
  picker.focus();
@@ -4638,7 +5034,7 @@ orchList.key(["t"], async () => {
4638
5034
  },
4639
5035
  keys: true,
4640
5036
  vi: true,
4641
- mouse: true,
5037
+ mouse: false,
4642
5038
  items: [
4643
5039
  " Type a custom title",
4644
5040
  " Ask LLM to summarize",
@@ -5955,7 +6351,11 @@ async function ensureOrchestrationStarted(orchId = activeOrchId) {
5955
6351
  // ─── Input handling ──────────────────────────────────────────────
5956
6352
 
5957
6353
  async function handleInput(text) {
5958
- const trimmed = (text || "").trim();
6354
+ // If file attach modal triggered cancellation, ignore this call
6355
+ if (_fileAttachModal) return;
6356
+ // Expand file attachments before trimming — replaces 📎tokens with file content
6357
+ const expanded = expandAttachments(text || "");
6358
+ const trimmed = expanded.trim();
5959
6359
  if (!trimmed) {
5960
6360
  inputBar.clearValue();
5961
6361
  focusInput();
@@ -6272,6 +6672,7 @@ async function handleInput(text) {
6272
6672
 
6273
6673
  inputBar.on("submit", handleInput);
6274
6674
  inputBar.key(["escape"], () => {
6675
+ if (_fileAttachModal) return; // Modal is handling escape
6275
6676
  inputBar.clearValue();
6276
6677
  // Exit prompt — focus the sessions pane for navigation
6277
6678
  orchList.focus();
@@ -6298,6 +6699,7 @@ function showHelpOverlay() {
6298
6699
  " {yellow-fg}r{/yellow-fg} Force full screen redraw",
6299
6700
  " {yellow-fg}u{/yellow-fg} Dump active session to Markdown file",
6300
6701
  " {yellow-fg}a{/yellow-fg} Show artifact picker (download files)",
6702
+ " {yellow-fg}Ctrl+A{/yellow-fg} Attach local file to prompt",
6301
6703
  "",
6302
6704
  "{bold}Sessions Pane{/bold}",
6303
6705
  " {yellow-fg}j / k{/yellow-fg} Navigate up / down",
@@ -6320,11 +6722,12 @@ function showHelpOverlay() {
6320
6722
  "",
6321
6723
  "{bold}Prompt Editor{/bold}",
6322
6724
  " {yellow-fg}Enter{/yellow-fg} Submit prompt",
6323
- " {yellow-fg}Opt+Enter{/yellow-fg} Insert newline and expand prompt",
6725
+ " {yellow-fg}Ctrl+J{/yellow-fg} Insert newline and expand prompt",
6324
6726
  " {yellow-fg}← / →{/yellow-fg} Move cursor by character",
6727
+ " {yellow-fg}↑ / ↓{/yellow-fg} Move cursor between lines (multiline)",
6325
6728
  " {yellow-fg}Opt+← / →{/yellow-fg} Move cursor by word",
6326
6729
  " {yellow-fg}Backspace{/yellow-fg} Delete backward by character",
6327
- " {yellow-fg}Opt+Backspace{/yellow-fg} Delete backward by word",
6730
+ " {yellow-fg}Ctrl+W{/yellow-fg} Delete backward by word",
6328
6731
  " {yellow-fg}Esc{/yellow-fg} Return to navigation mode",
6329
6732
  "",
6330
6733
  "{bold}Markdown Viewer{/bold}",
@@ -6365,7 +6768,7 @@ function showHelpOverlay() {
6365
6768
  scrollable: true,
6366
6769
  keys: true,
6367
6770
  vi: true,
6368
- mouse: true,
6771
+ mouse: false,
6369
6772
  content: helpContent,
6370
6773
  label: " {bold}Help{/bold} ",
6371
6774
  });
@@ -6465,6 +6868,9 @@ let escPressedAt = 0;
6465
6868
  screen.on("keypress", (ch, key) => {
6466
6869
  if (!key) return;
6467
6870
 
6871
+ // Block all keys when a modal dialog (file attach) is open
6872
+ if (_fileAttachModal) return;
6873
+
6468
6874
  if (startupLandingVisible) {
6469
6875
  startupLandingVisible = false;
6470
6876
  orchList.focus();
@@ -6485,6 +6891,7 @@ screen.on("keypress", (ch, key) => {
6485
6891
  orchList.focus();
6486
6892
  screen.realloc();
6487
6893
  relayoutAll();
6894
+ scheduleLightRefresh("toggleMarkdownView");
6488
6895
  setStatus(mdViewActive ? "Markdown Viewer (v to exit)" : `Log mode: ${({ workers: "Per-Worker", orchestration: "Per-Orchestration", sequence: "Sequence Diagram", nodemap: "Node Map" })[logViewMode]}`);
6489
6896
  return;
6490
6897
  }
@@ -6520,94 +6927,7 @@ screen.on("keypress", (ch, key) => {
6520
6927
 
6521
6928
  // a: open artifact picker for current session — download on selection
6522
6929
  if (ch === "a" && screen.focused !== inputBar && !mdViewActive) {
6523
- const artifacts = sessionArtifacts.get(activeOrchId) || [];
6524
- if (artifacts.length === 0) {
6525
- setStatus("No artifacts for this session");
6526
- return;
6527
- }
6528
-
6529
- const items = artifacts.map((a, i) => {
6530
- const icon = a.downloaded ? "✓" : "↓";
6531
- return ` ${icon} ${a.filename}`;
6532
- });
6533
-
6534
- const picker = blessed.list({
6535
- parent: screen,
6536
- label: " 📎 Artifacts — Enter to download ",
6537
- tags: true,
6538
- left: "center",
6539
- top: "center",
6540
- width: Math.min(60, screen.width - 4),
6541
- height: Math.min(items.length + 2, 16),
6542
- border: { type: "line" },
6543
- style: {
6544
- fg: "white",
6545
- bg: "black",
6546
- border: { fg: "cyan" },
6547
- label: { fg: "cyan" },
6548
- selected: { bg: "blue", fg: "white" },
6549
- },
6550
- keys: true,
6551
- vi: true,
6552
- mouse: true,
6553
- items,
6554
- });
6555
-
6556
- picker.focus();
6557
- screen.render();
6558
-
6559
- const closePicker = () => {
6560
- picker.detach();
6561
- orchList.focus();
6562
- screen.render();
6563
- };
6564
-
6565
- picker.key(["escape", "q", "a"], closePicker);
6566
-
6567
- picker.on("select", async (_el, idx) => {
6568
- const art = artifacts[idx];
6569
- if (!art) return;
6570
-
6571
- if (art.downloaded) {
6572
- // Already downloaded — close picker and open viewer
6573
- closePicker();
6574
- mdViewActive = true;
6575
- refreshMarkdownViewer();
6576
- const files = scanExportFiles();
6577
- const matchIdx = files.findIndex(f => f.localPath === art.localPath);
6578
- if (matchIdx >= 0) {
6579
- mdViewerSelectedIdx = matchIdx;
6580
- mdFileListPane.select(matchIdx);
6581
- refreshMarkdownViewer();
6582
- }
6583
- screen.realloc();
6584
- relayoutAll();
6585
- setStatus("Markdown Viewer (v to exit)");
6586
- screen.render();
6587
- return;
6588
- }
6589
-
6590
- setStatus(`Downloading ${art.filename}...`);
6591
- screen.render();
6592
- const localPath = await downloadArtifact(art.sessionId, art.filename);
6593
- if (localPath) {
6594
- art.downloaded = true;
6595
- art.localPath = localPath;
6596
-
6597
- // Update picker item to show downloaded state
6598
- const updatedItems = artifacts.map((a) => {
6599
- const icon = a.downloaded ? "✓" : "↓";
6600
- return ` ${icon} ${a.filename}`;
6601
- });
6602
- picker.setItems(updatedItems);
6603
- picker.select(idx);
6604
- setStatus(`Downloaded ${art.filename}`);
6605
- } else {
6606
- setStatus("Download failed — check logs");
6607
- }
6608
- screen.render();
6609
- });
6610
-
6930
+ showArtifactPicker();
6611
6931
  return;
6612
6932
  }
6613
6933
 
@@ -6709,7 +7029,6 @@ screen.on("keypress", (ch, key) => {
6709
7029
  // p from any non-input pane → jump to prompt
6710
7030
  if (ch === "p" && screen.focused !== inputBar) {
6711
7031
  focusInput();
6712
- setStatus("Ready — type a message");
6713
7032
  screen.render();
6714
7033
  return;
6715
7034
  }
@@ -6855,6 +7174,9 @@ screen.on("resize", () => {
6855
7174
  // Initial orchestration refresh
6856
7175
  await refreshOrchestrations();
6857
7176
 
7177
+ // Trigger initial right-pane render (orch logs, sequence, etc.)
7178
+ redrawActiveViews();
7179
+
6858
7180
  // ─── Auto-summarize all existing sessions on startup ─────────────
6859
7181
  async function summarizeSession(orchId) {
6860
7182
  if (sessionSummarized.has(orchId)) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pilotswarm-cli",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Terminal UI for pilotswarm — interactive durable agent orchestration.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "url": "https://github.com/affandar/PilotSwarm/issues"
31
31
  },
32
32
  "dependencies": {
33
- "pilotswarm-sdk": "^0.1.4",
33
+ "pilotswarm-sdk": "^0.1.9",
34
34
  "@bradygaster/squad-cli": "^0.8.25",
35
35
  "chalk": "^5.6.2",
36
36
  "marked": "^15.0.12",