pilotswarm-cli 0.1.7 → 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 +519 -113
  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
  }
@@ -2683,6 +3064,7 @@ function recolorWorkerPanes() {
2683
3064
 
2684
3065
  function showCopilotMessage(raw, orchId) {
2685
3066
  const _ph = perfStart("showCopilotMessage");
3067
+ stopChatSpinner(orchId);
2686
3068
 
2687
3069
  appendActivity(`{green-fg}[obs] showCopilotMessage called for ${orchId === activeOrchId ? "ACTIVE" : "background"} session, len=${raw?.length || 0}{/green-fg}`, orchId);
2688
3070
 
@@ -3543,6 +3925,75 @@ const sessionRecoveredTurnResult = new Map(); // orchId → normalized completed
3543
3925
  const sessionObservers = new Map(); // orchId → AbortController
3544
3926
  const sessionLiveStatus = new Map(); // orchId → "idle"|"running"|"waiting"|"input_required"
3545
3927
  const sessionPendingTurns = new Set(); // orchIds with a locally-sent turn awaiting first live status
3928
+
3929
+ // ─── Inline chat spinner ─────────────────────────────────────────
3930
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
3931
+ const sessionSpinnerIndex = new Map(); // orchId → buffer line index where spinner lives
3932
+ let _spinnerFrame = 0;
3933
+ let _spinnerTimer = null;
3934
+
3935
+ function startChatSpinner(orchId) {
3936
+ const buf = sessionChatBuffers.get(orchId);
3937
+ if (!buf) return;
3938
+ // Remove existing spinner if any
3939
+ stopChatSpinner(orchId);
3940
+ const line = `{gray-fg}${SPINNER_FRAMES[0]} Thinking…{/gray-fg}`;
3941
+ buf.push("");
3942
+ buf.push(line);
3943
+ sessionSpinnerIndex.set(orchId, buf.length - 1);
3944
+ if (orchId === activeOrchId) invalidateChat("bottom");
3945
+ // Start global animation timer if not running
3946
+ if (!_spinnerTimer) {
3947
+ _spinnerTimer = setInterval(() => {
3948
+ _spinnerFrame = (_spinnerFrame + 1) % SPINNER_FRAMES.length;
3949
+ let anyActive = false;
3950
+ for (const [sid, idx] of sessionSpinnerIndex) {
3951
+ const b = sessionChatBuffers.get(sid);
3952
+ if (b && idx < b.length) {
3953
+ b[idx] = `{gray-fg}${SPINNER_FRAMES[_spinnerFrame]} Thinking…{/gray-fg}`;
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
+ }
3970
+ }
3971
+ }
3972
+ if (!anyActive && sessionSpinnerIndex.size === 0) {
3973
+ clearInterval(_spinnerTimer);
3974
+ _spinnerTimer = null;
3975
+ }
3976
+ }, 80);
3977
+ }
3978
+ }
3979
+
3980
+ function stopChatSpinner(orchId) {
3981
+ const idx = sessionSpinnerIndex.get(orchId);
3982
+ if (idx == null) return;
3983
+ const buf = sessionChatBuffers.get(orchId);
3984
+ if (buf && idx < buf.length) {
3985
+ // Remove spinner line and the blank line before it
3986
+ const startIdx = (idx > 0 && buf[idx - 1] === "") ? idx - 1 : idx;
3987
+ buf.splice(startIdx, idx - startIdx + 1);
3988
+ }
3989
+ sessionSpinnerIndex.delete(orchId);
3990
+ if (orchId === activeOrchId) invalidateChat();
3991
+ // Stop global timer if no spinners remain
3992
+ if (sessionSpinnerIndex.size === 0 && _spinnerTimer) {
3993
+ clearInterval(_spinnerTimer);
3994
+ _spinnerTimer = null;
3995
+ }
3996
+ }
3546
3997
  const sessionPendingQuestions = new Map(); // orchId → latest input-required question awaiting a user answer
3547
3998
  const sessionLastSeenResponseVersion = new Map(); // orchId → latest KV-backed response version rendered
3548
3999
  const sessionLastSeenCommandVersion = new Map(); // orchId → latest KV-backed command response version rendered
@@ -3756,6 +4207,33 @@ function handleDbRecovered() {
3756
4207
  if (_dbOffline) {
3757
4208
  appendLog(`{green-fg}Database connection restored.{/green-fg}`);
3758
4209
  setStatus("Database connection restored.");
4210
+
4211
+ // The orchestration list poll uses its own management client pool, but
4212
+ // the active session's CMS event stream and reconstructed history may
4213
+ // still be stale after a DB outage. Re-prime the active session view so
4214
+ // chat/activity panes resume updating without requiring a manual switch.
4215
+ const recoveredOrchId = activeOrchId;
4216
+ if (recoveredOrchId) {
4217
+ stopCmsPoller();
4218
+ loadCmsHistory(recoveredOrchId, { force: true })
4219
+ .then(() => {
4220
+ if (recoveredOrchId === activeOrchId) {
4221
+ startCmsPoller(recoveredOrchId);
4222
+ invalidateChat();
4223
+ invalidateActivity();
4224
+ redrawActiveViews();
4225
+ scheduleLightRefresh("dbRecovered", recoveredOrchId);
4226
+ }
4227
+ })
4228
+ .catch(() => {
4229
+ if (recoveredOrchId === activeOrchId) {
4230
+ startCmsPoller(recoveredOrchId);
4231
+ invalidateChat();
4232
+ invalidateActivity();
4233
+ redrawActiveViews();
4234
+ }
4235
+ });
4236
+ }
3759
4237
  }
3760
4238
  _dbOffline = false;
3761
4239
  _dbNextRetryAt = 0;
@@ -4348,7 +4826,7 @@ orchList.key(["n"], async () => {
4348
4826
  items: agentChoices.map(a => ` ${a.name || "(generic)"}${a.description ? ` — ${a.description}` : ""}`),
4349
4827
  keys: true,
4350
4828
  vi: true,
4351
- mouse: true,
4829
+ mouse: false,
4352
4830
  scrollable: true,
4353
4831
  });
4354
4832
 
@@ -4492,7 +4970,7 @@ orchList.key(["S-n"], async () => {
4492
4970
  items,
4493
4971
  keys: true,
4494
4972
  vi: true,
4495
- mouse: true,
4973
+ mouse: false,
4496
4974
  scrollable: true,
4497
4975
  });
4498
4976
  picker.focus();
@@ -4556,7 +5034,7 @@ orchList.key(["t"], async () => {
4556
5034
  },
4557
5035
  keys: true,
4558
5036
  vi: true,
4559
- mouse: true,
5037
+ mouse: false,
4560
5038
  items: [
4561
5039
  " Type a custom title",
4562
5040
  " Ask LLM to summarize",
@@ -5205,6 +5683,7 @@ function startObserver(orchId) {
5205
5683
 
5206
5684
  function renderResponsePayload(response, cs, source) {
5207
5685
  if (!response) return;
5686
+ stopChatSpinner(orchId);
5208
5687
  if (response.type === "completed" && response.content) {
5209
5688
  appendActivity(`{green-fg}[obs] ✓ SHOWING ${source}: version=${response.version} type=completed content=${response.content.slice(0, 80)}{/green-fg}`, orchId);
5210
5689
  renderCompletedContent(response.content);
@@ -5872,7 +6351,11 @@ async function ensureOrchestrationStarted(orchId = activeOrchId) {
5872
6351
  // ─── Input handling ──────────────────────────────────────────────
5873
6352
 
5874
6353
  async function handleInput(text) {
5875
- 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();
5876
6359
  if (!trimmed) {
5877
6360
  inputBar.clearValue();
5878
6361
  focusInput();
@@ -6121,6 +6604,7 @@ async function handleInput(text) {
6121
6604
  }
6122
6605
 
6123
6606
  appendChatRaw(`{white-fg}[${ts()}]{/white-fg} {white-fg}{bold}You:{/bold} ${trimmed}{/white-fg}`, targetOrchId);
6607
+ startChatSpinner(targetOrchId);
6124
6608
  inputBar.clearValue();
6125
6609
  focusInput();
6126
6610
  setSessionPendingTurn(targetOrchId, true);
@@ -6188,6 +6672,7 @@ async function handleInput(text) {
6188
6672
 
6189
6673
  inputBar.on("submit", handleInput);
6190
6674
  inputBar.key(["escape"], () => {
6675
+ if (_fileAttachModal) return; // Modal is handling escape
6191
6676
  inputBar.clearValue();
6192
6677
  // Exit prompt — focus the sessions pane for navigation
6193
6678
  orchList.focus();
@@ -6214,6 +6699,7 @@ function showHelpOverlay() {
6214
6699
  " {yellow-fg}r{/yellow-fg} Force full screen redraw",
6215
6700
  " {yellow-fg}u{/yellow-fg} Dump active session to Markdown file",
6216
6701
  " {yellow-fg}a{/yellow-fg} Show artifact picker (download files)",
6702
+ " {yellow-fg}Ctrl+A{/yellow-fg} Attach local file to prompt",
6217
6703
  "",
6218
6704
  "{bold}Sessions Pane{/bold}",
6219
6705
  " {yellow-fg}j / k{/yellow-fg} Navigate up / down",
@@ -6236,11 +6722,12 @@ function showHelpOverlay() {
6236
6722
  "",
6237
6723
  "{bold}Prompt Editor{/bold}",
6238
6724
  " {yellow-fg}Enter{/yellow-fg} Submit prompt",
6239
- " {yellow-fg}Opt+Enter{/yellow-fg} Insert newline and expand prompt",
6725
+ " {yellow-fg}Ctrl+J{/yellow-fg} Insert newline and expand prompt",
6240
6726
  " {yellow-fg}← / →{/yellow-fg} Move cursor by character",
6727
+ " {yellow-fg}↑ / ↓{/yellow-fg} Move cursor between lines (multiline)",
6241
6728
  " {yellow-fg}Opt+← / →{/yellow-fg} Move cursor by word",
6242
6729
  " {yellow-fg}Backspace{/yellow-fg} Delete backward by character",
6243
- " {yellow-fg}Opt+Backspace{/yellow-fg} Delete backward by word",
6730
+ " {yellow-fg}Ctrl+W{/yellow-fg} Delete backward by word",
6244
6731
  " {yellow-fg}Esc{/yellow-fg} Return to navigation mode",
6245
6732
  "",
6246
6733
  "{bold}Markdown Viewer{/bold}",
@@ -6281,7 +6768,7 @@ function showHelpOverlay() {
6281
6768
  scrollable: true,
6282
6769
  keys: true,
6283
6770
  vi: true,
6284
- mouse: true,
6771
+ mouse: false,
6285
6772
  content: helpContent,
6286
6773
  label: " {bold}Help{/bold} ",
6287
6774
  });
@@ -6381,6 +6868,9 @@ let escPressedAt = 0;
6381
6868
  screen.on("keypress", (ch, key) => {
6382
6869
  if (!key) return;
6383
6870
 
6871
+ // Block all keys when a modal dialog (file attach) is open
6872
+ if (_fileAttachModal) return;
6873
+
6384
6874
  if (startupLandingVisible) {
6385
6875
  startupLandingVisible = false;
6386
6876
  orchList.focus();
@@ -6401,6 +6891,7 @@ screen.on("keypress", (ch, key) => {
6401
6891
  orchList.focus();
6402
6892
  screen.realloc();
6403
6893
  relayoutAll();
6894
+ scheduleLightRefresh("toggleMarkdownView");
6404
6895
  setStatus(mdViewActive ? "Markdown Viewer (v to exit)" : `Log mode: ${({ workers: "Per-Worker", orchestration: "Per-Orchestration", sequence: "Sequence Diagram", nodemap: "Node Map" })[logViewMode]}`);
6405
6896
  return;
6406
6897
  }
@@ -6436,94 +6927,7 @@ screen.on("keypress", (ch, key) => {
6436
6927
 
6437
6928
  // a: open artifact picker for current session — download on selection
6438
6929
  if (ch === "a" && screen.focused !== inputBar && !mdViewActive) {
6439
- const artifacts = sessionArtifacts.get(activeOrchId) || [];
6440
- if (artifacts.length === 0) {
6441
- setStatus("No artifacts for this session");
6442
- return;
6443
- }
6444
-
6445
- const items = artifacts.map((a, i) => {
6446
- const icon = a.downloaded ? "✓" : "↓";
6447
- return ` ${icon} ${a.filename}`;
6448
- });
6449
-
6450
- const picker = blessed.list({
6451
- parent: screen,
6452
- label: " 📎 Artifacts — Enter to download ",
6453
- tags: true,
6454
- left: "center",
6455
- top: "center",
6456
- width: Math.min(60, screen.width - 4),
6457
- height: Math.min(items.length + 2, 16),
6458
- border: { type: "line" },
6459
- style: {
6460
- fg: "white",
6461
- bg: "black",
6462
- border: { fg: "cyan" },
6463
- label: { fg: "cyan" },
6464
- selected: { bg: "blue", fg: "white" },
6465
- },
6466
- keys: true,
6467
- vi: true,
6468
- mouse: true,
6469
- items,
6470
- });
6471
-
6472
- picker.focus();
6473
- screen.render();
6474
-
6475
- const closePicker = () => {
6476
- picker.detach();
6477
- orchList.focus();
6478
- screen.render();
6479
- };
6480
-
6481
- picker.key(["escape", "q", "a"], closePicker);
6482
-
6483
- picker.on("select", async (_el, idx) => {
6484
- const art = artifacts[idx];
6485
- if (!art) return;
6486
-
6487
- if (art.downloaded) {
6488
- // Already downloaded — close picker and open viewer
6489
- closePicker();
6490
- mdViewActive = true;
6491
- refreshMarkdownViewer();
6492
- const files = scanExportFiles();
6493
- const matchIdx = files.findIndex(f => f.localPath === art.localPath);
6494
- if (matchIdx >= 0) {
6495
- mdViewerSelectedIdx = matchIdx;
6496
- mdFileListPane.select(matchIdx);
6497
- refreshMarkdownViewer();
6498
- }
6499
- screen.realloc();
6500
- relayoutAll();
6501
- setStatus("Markdown Viewer (v to exit)");
6502
- screen.render();
6503
- return;
6504
- }
6505
-
6506
- setStatus(`Downloading ${art.filename}...`);
6507
- screen.render();
6508
- const localPath = await downloadArtifact(art.sessionId, art.filename);
6509
- if (localPath) {
6510
- art.downloaded = true;
6511
- art.localPath = localPath;
6512
-
6513
- // Update picker item to show downloaded state
6514
- const updatedItems = artifacts.map((a) => {
6515
- const icon = a.downloaded ? "✓" : "↓";
6516
- return ` ${icon} ${a.filename}`;
6517
- });
6518
- picker.setItems(updatedItems);
6519
- picker.select(idx);
6520
- setStatus(`Downloaded ${art.filename}`);
6521
- } else {
6522
- setStatus("Download failed — check logs");
6523
- }
6524
- screen.render();
6525
- });
6526
-
6930
+ showArtifactPicker();
6527
6931
  return;
6528
6932
  }
6529
6933
 
@@ -6625,7 +7029,6 @@ screen.on("keypress", (ch, key) => {
6625
7029
  // p from any non-input pane → jump to prompt
6626
7030
  if (ch === "p" && screen.focused !== inputBar) {
6627
7031
  focusInput();
6628
- setStatus("Ready — type a message");
6629
7032
  screen.render();
6630
7033
  return;
6631
7034
  }
@@ -6771,6 +7174,9 @@ screen.on("resize", () => {
6771
7174
  // Initial orchestration refresh
6772
7175
  await refreshOrchestrations();
6773
7176
 
7177
+ // Trigger initial right-pane render (orch logs, sequence, etc.)
7178
+ redrawActiveViews();
7179
+
6774
7180
  // ─── Auto-summarize all existing sessions on startup ─────────────
6775
7181
  async function summarizeSession(orchId) {
6776
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.7",
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",