groundcrew-cli 0.15.15 → 0.15.16

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 (3) hide show
  1. package/dist/index.js +389 -280
  2. package/package.json +1 -1
  3. package/src/index.ts +358 -340
package/src/index.ts CHANGED
@@ -565,238 +565,328 @@ const CHAT_COMMANDS: Array<{ cmd: string; desc: string }> = [
565
565
  { cmd: "/exit", desc: "Exit chat" },
566
566
  ];
567
567
 
568
- function chatCompleter(line: string): [string[], string] {
569
- if (!line.startsWith("/")) return [[], line];
570
- const matches = CHAT_COMMANDS.filter((c) => c.cmd.startsWith(line));
571
-
572
- if (matches.length === 1) {
573
- return [[matches[0].cmd + " "], line];
574
- }
575
-
576
- if (matches.length > 1) {
577
- const display = matches.map((c) => `${c.cmd.padEnd(14)} ${c.desc}`);
578
- console.log();
579
- display.forEach((d) => console.log(` ${d}`));
580
- return [matches.map((c) => c.cmd), line];
581
- }
582
-
583
- return [[], line];
584
- }
585
-
586
568
  /**
587
- * Show inline ghost + multi-line dropdown as user types / commands.
588
- * Best match completion shown inline (dimmed) after cursor.
589
- * All matches (max 5) shown as dropdown lines below the prompt.
590
- * Uses only relative cursor movement — no absolute column needed.
569
+ * Custom multiline editor replaces readline for chat input.
570
+ * No `\` line endings, no `...` prefix, clean aligned continuation.
571
+ * Uses ANSI escape sequences for in-place rendering.
572
+ *
573
+ * Submit: Enter
574
+ * Newline: Shift+Enter (Kitty), Alt+Enter, Ctrl+J
575
+ * Clear: Ctrl+C (clears input, or exits if empty)
576
+ * Navigation: Arrow keys, Home/End, Ctrl+A/E
577
+ * Editing: Backspace, Delete, Ctrl+U/K/W/D
578
+ * Tab: slash command completion
579
+ * Paste: bracketed paste with multiline support
591
580
  */
592
- function setupInlineSuggestions(rl: readline.Interface): void {
593
- let dropdownLines = 0; // extra lines rendered below prompt
594
- let ghostLen = 0; // length of inline ghost text on prompt line
595
-
596
- const clearGhost = () => {
597
- const buf: string[] = [];
598
- // Clear dropdown lines below prompt
599
- if (dropdownLines > 0) {
600
- for (let i = 0; i < dropdownLines; i++) {
601
- buf.push("\x1b[B\x1b[2K"); // down + clear entire line
602
- }
603
- buf.push(`\x1b[${dropdownLines}A`); // back up to prompt line
604
- dropdownLines = 0;
605
- }
606
- // Clear inline ghost only if one is showing — save cursor, jump to
607
- // ghost start (far right), erase it, then restore cursor position.
608
- // This avoids nuking typed text when cursor is mid-line (left arrow).
609
- if (ghostLen > 0) {
610
- buf.push("\x1b[s"); // save cursor position
611
- buf.push("\x1b[999C"); // jump to far right of line
612
- buf.push(`\x1b[${ghostLen}D`); // back up to ghost start
613
- buf.push("\x1b[K"); // clear from ghost start to EOL
614
- buf.push("\x1b[u"); // restore cursor position
615
- ghostLen = 0;
616
- }
617
- if (buf.length) process.stdout.write(buf.join(""));
618
- };
581
+ function readMultilineInput(sessionId: string): Promise<string | null> {
582
+ return new Promise((resolve) => {
583
+ const lines: string[] = [""];
584
+ let crow = 0; // cursor row in lines[]
585
+ let ccol = 0; // cursor col in lines[crow]
619
586
 
620
- const showGhost = () => {
621
- const line = (rl as any).line as string;
622
- if (!line || !line.startsWith("/") || line.includes(" ")) return;
587
+ // Visible width of prompt: "[sessionId] > "
588
+ const padWidth = sessionId.length + 5; // [ + id + ] + space + > + space = len+5
623
589
 
624
- const matches = CHAT_COMMANDS.filter((c) => c.cmd.startsWith(line));
625
- if (matches.length === 0) return;
590
+ // Track which terminal row the cursor was on after last render
591
+ let lastTermRow = 0;
592
+ let pasteBuffer = "";
593
+ let isPasting = false;
626
594
 
627
- const shown = matches.slice(0, 5);
628
- const best = shown[0];
629
- const remainder = best.cmd.slice(line.length);
630
- if (!remainder && shown.length === 1) return;
595
+ const fullText = () => lines.join("\n").trim();
631
596
 
632
- const buf: string[] = [];
597
+ const render = () => {
598
+ const buf: string[] = [];
633
599
 
634
- // Inline ghost: cursor is already at end of typed text (readline did that)
635
- // Just clear rest of line and write dimmed remainder
636
- buf.push("\x1b[K");
637
- if (remainder) {
638
- buf.push(`\x1b[2m${remainder}\x1b[0m`);
639
- ghostLen = remainder.length;
640
- // Move cursor back to end of typed text
641
- buf.push(`\x1b[${remainder.length}D`);
642
- }
600
+ // Move to start of input area
601
+ if (lastTermRow > 0) buf.push(`\x1b[${lastTermRow}A`);
602
+ buf.push("\r\x1b[J"); // col 0 + clear to end of screen
643
603
 
644
- // Dropdown: show all matches below prompt
645
- if (shown.length > 1 || (shown.length === 1 && remainder)) {
646
- const count = shown.length;
647
- // Make room by writing newlines (handles terminal bottom scroll)
648
- for (let i = 0; i < count; i++) buf.push("\n");
649
- buf.push(`\x1b[${count}A`); // back to prompt line
650
-
651
- // Write each dropdown line: cyan command + dim description
652
- for (let i = 0; i < count; i++) {
653
- buf.push(`\x1b[B\r\x1b[2K`); // down + col 1 + clear
654
- buf.push(` \x1b[36m${shown[i].cmd.padEnd(14)}\x1b[0m\x1b[2m${shown[i].desc}\x1b[0m`);
604
+ // Draw each line
605
+ for (let i = 0; i < lines.length; i++) {
606
+ if (i > 0) buf.push("\n");
607
+ if (i === 0) {
608
+ buf.push(dim(`[${sessionId}]`) + " " + bold(">") + " " + lines[i]);
609
+ } else {
610
+ buf.push(" ".repeat(padWidth) + lines[i]);
611
+ }
655
612
  }
656
- dropdownLines = count;
657
-
658
- // Back to prompt line and restore horizontal position
659
- // Move up to prompt line
660
- buf.push(`\x1b[${count}A`);
661
- // Move to column 1, then rewrite prompt+line to position cursor correctly
662
- buf.push(`\r`);
663
- // Let readline handle cursor positioning
664
- }
665
613
 
666
- process.stdout.write(buf.join(""));
667
- // After dropdown, force readline to redraw prompt line to fix cursor position
668
- if (dropdownLines > 0) {
669
- (rl as any)._refreshLine();
670
- // Re-draw inline ghost since _refreshLine cleared it
671
- if (remainder) {
672
- process.stdout.write(`\x1b[K\x1b[2m${remainder}\x1b[0m\x1b[${remainder.length}D`);
614
+ // Position cursor at (crow, ccol)
615
+ const lastRow = lines.length - 1;
616
+ const rowsUp = lastRow - crow;
617
+ if (rowsUp > 0) buf.push(`\x1b[${rowsUp}A`);
618
+
619
+ buf.push("\r");
620
+ const col = padWidth + ccol;
621
+ if (col > 0) buf.push(`\x1b[${col}C`);
622
+
623
+ lastTermRow = crow;
624
+ process.stdout.write(buf.join(""));
625
+ };
626
+
627
+ const finish = (result: string | null) => {
628
+ process.stdin.removeListener("data", onData);
629
+ resolve(result);
630
+ };
631
+
632
+ const submit = () => {
633
+ const text = fullText();
634
+ // Move cursor to end of input for clean output
635
+ const lastRow = lines.length - 1;
636
+ const rowsDown = lastRow - crow;
637
+ const buf: string[] = [];
638
+ if (rowsDown > 0) buf.push(`\x1b[${rowsDown}B`);
639
+ buf.push("\r");
640
+ const endCol = padWidth + lines[lastRow].length;
641
+ if (endCol > 0) buf.push(`\x1b[${endCol}C`);
642
+ buf.push("\n");
643
+ process.stdout.write(buf.join(""));
644
+ lastTermRow = 0;
645
+ finish(text || null);
646
+ };
647
+
648
+ const insertText = (text: string) => {
649
+ const chunks = text.split(/\r?\n/);
650
+ const before = lines[crow].slice(0, ccol);
651
+ const after = lines[crow].slice(ccol);
652
+
653
+ if (chunks.length === 1) {
654
+ lines[crow] = before + chunks[0] + after;
655
+ ccol += chunks[0].length;
656
+ } else {
657
+ lines[crow] = before + chunks[0];
658
+ const middle = chunks.slice(1, -1);
659
+ const last = chunks[chunks.length - 1];
660
+ lines.splice(crow + 1, 0, ...middle, last + after);
661
+ crow += chunks.length - 1;
662
+ ccol = last.length;
673
663
  }
674
- }
675
- };
664
+ render();
665
+ };
666
+
667
+ const insertNewline = () => {
668
+ const before = lines[crow].slice(0, ccol);
669
+ const after = lines[crow].slice(ccol);
670
+ lines[crow] = before;
671
+ lines.splice(crow + 1, 0, after);
672
+ crow++;
673
+ ccol = 0;
674
+ render();
675
+ };
676
+
677
+ const doBackspace = () => {
678
+ if (ccol > 0) {
679
+ lines[crow] = lines[crow].slice(0, ccol - 1) + lines[crow].slice(ccol);
680
+ ccol--;
681
+ } else if (crow > 0) {
682
+ const prevLen = lines[crow - 1].length;
683
+ lines[crow - 1] += lines[crow];
684
+ lines.splice(crow, 1);
685
+ crow--;
686
+ ccol = prevLen;
687
+ }
688
+ render();
689
+ };
690
+
691
+ const doDelete = () => {
692
+ if (ccol < lines[crow].length) {
693
+ lines[crow] = lines[crow].slice(0, ccol) + lines[crow].slice(ccol + 1);
694
+ } else if (crow < lines.length - 1) {
695
+ lines[crow] += lines[crow + 1];
696
+ lines.splice(crow + 1, 1);
697
+ }
698
+ render();
699
+ };
700
+
701
+ const processKeys = (str: string) => {
702
+ let i = 0;
703
+ while (i < str.length) {
704
+ // Shift+Enter (Kitty: \x1b[13;2u)
705
+ if (str.startsWith("\x1b[13;2u", i)) { insertNewline(); i += 7; continue; }
706
+
707
+ // Alt+Enter (ESC + CR or ESC + LF)
708
+ if (i + 1 < str.length && str[i] === "\x1b" && (str[i + 1] === "\r" || str[i + 1] === "\n")) {
709
+ insertNewline(); i += 2; continue;
710
+ }
676
711
 
677
- process.stdin.on("keypress", (_ch: string, key: any) => {
678
- if (!key) return;
712
+ // Arrow Up
713
+ if (str.startsWith("\x1b[A", i)) {
714
+ if (crow > 0) { crow--; ccol = Math.min(ccol, lines[crow].length); render(); }
715
+ i += 3; continue;
716
+ }
717
+ // Arrow Down
718
+ if (str.startsWith("\x1b[B", i)) {
719
+ if (crow < lines.length - 1) { crow++; ccol = Math.min(ccol, lines[crow].length); render(); }
720
+ i += 3; continue;
721
+ }
722
+ // Arrow Right
723
+ if (str.startsWith("\x1b[C", i)) {
724
+ if (ccol < lines[crow].length) ccol++;
725
+ else if (crow < lines.length - 1) { crow++; ccol = 0; }
726
+ render(); i += 3; continue;
727
+ }
728
+ // Arrow Left
729
+ if (str.startsWith("\x1b[D", i)) {
730
+ if (ccol > 0) ccol--;
731
+ else if (crow > 0) { crow--; ccol = lines[crow].length; }
732
+ render(); i += 3; continue;
733
+ }
679
734
 
680
- clearGhost();
681
- if (key.name !== "return" && key.name !== "tab") {
682
- setImmediate(showGhost);
683
- }
684
- });
685
- }
735
+ // Delete key (\x1b[3~)
736
+ if (str.startsWith("\x1b[3~", i)) { doDelete(); i += 4; continue; }
686
737
 
687
- async function chat(explicitSession?: string): Promise<void> {
688
- // Enable bracketed paste mode + Kitty keyboard protocol (Shift+Enter detection)
689
- process.stdout.write("\x1b[?2004h\x1b[>1u");
738
+ // Home (\x1b[H)
739
+ if (str.startsWith("\x1b[H", i)) { ccol = 0; render(); i += 3; continue; }
740
+ // End (\x1b[F)
741
+ if (str.startsWith("\x1b[F", i)) { ccol = lines[crow].length; render(); i += 3; continue; }
690
742
 
691
- // Intercept raw stdin for:
692
- // 1. Bracketed paste (\x1b[200~ ... \x1b[201~) buffer paste, submit as single task
693
- // 2. Shift+Enter (\x1b[13;2u via Kitty protocol) — line continuation
694
- // 3. Alt+Enter (\x1b\r universal fallback) line continuation
695
- const originalStdinEmit = process.stdin.emit.bind(process.stdin);
696
- let pasteBuffer = "";
697
- let isPasting = false;
698
-
699
- process.stdin.emit = function (event: string, ...args: any[]) {
700
- if (event === "data") {
701
- const data = args[0] as Buffer | string;
702
- let str = typeof data === "string" ? data : data.toString();
703
-
704
- // --- Bracketed paste handling ---
705
- const pasteStart = str.indexOf("\x1b[200~");
706
- if (pasteStart !== -1) {
707
- isPasting = true;
708
- if (pasteStart > 0) {
709
- originalStdinEmit(event, Buffer.from(str.slice(0, pasteStart)));
743
+ // Skip unknown CSI sequences
744
+ if (str[i] === "\x1b" && i + 1 < str.length && str[i + 1] === "[") {
745
+ let j = i + 2;
746
+ while (j < str.length && str.charCodeAt(j) >= 0x30 && str.charCodeAt(j) <= 0x3f) j++;
747
+ if (j < str.length) j++; // skip final byte
748
+ i = j; continue;
749
+ }
750
+ // Skip lone ESC
751
+ if (str[i] === "\x1b") { i++; continue; }
752
+
753
+ // Ctrl+C clear input or exit
754
+ if (str[i] === "\x03") {
755
+ if (fullText()) {
756
+ // Move past current rendering, start fresh below
757
+ const lastRow = lines.length - 1;
758
+ const rowsDown = lastRow - crow;
759
+ if (rowsDown > 0) process.stdout.write(`\x1b[${rowsDown}B`);
760
+ process.stdout.write("\n");
761
+ lines.length = 0; lines.push("");
762
+ crow = 0; ccol = 0; lastTermRow = 0;
763
+ render();
764
+ } else {
765
+ process.stdout.write("\n");
766
+ finish(null); return;
767
+ }
768
+ i++; continue;
710
769
  }
711
- str = str.slice(pasteStart + 6); // skip \x1b[200~
712
- }
713
770
 
714
- if (isPasting) {
715
- const pasteEnd = str.indexOf("\x1b[201~");
716
- if (pasteEnd !== -1) {
717
- pasteBuffer += str.slice(0, pasteEnd);
718
- const afterPaste = str.slice(pasteEnd + 6);
719
- isPasting = false;
771
+ // Ctrl+D — delete char or exit on empty
772
+ if (str[i] === "\x04") {
773
+ if (fullText()) { doDelete(); } else { process.stdout.write("\n"); finish(null); return; }
774
+ i++; continue;
775
+ }
720
776
 
721
- const pasted = pasteBuffer.replace(/[\r\n]+$/, "");
722
- pasteBuffer = "";
777
+ // Ctrl+A home
778
+ if (str[i] === "\x01") { ccol = 0; render(); i++; continue; }
779
+ // Ctrl+E — end
780
+ if (str[i] === "\x05") { ccol = lines[crow].length; render(); i++; continue; }
781
+ // Ctrl+U — clear line before cursor
782
+ if (str[i] === "\x15") {
783
+ lines[crow] = lines[crow].slice(ccol); ccol = 0; render(); i++; continue;
784
+ }
785
+ // Ctrl+K — clear line after cursor
786
+ if (str[i] === "\x0b") {
787
+ lines[crow] = lines[crow].slice(0, ccol); render(); i++; continue;
788
+ }
789
+ // Ctrl+W — delete word before cursor
790
+ if (str[i] === "\x17") {
791
+ const before = lines[crow].slice(0, ccol);
792
+ const stripped = before.replace(/\s+$/, "");
793
+ const sp = stripped.lastIndexOf(" ");
794
+ const newBefore = sp >= 0 ? stripped.slice(0, sp + 1) : "";
795
+ lines[crow] = newBefore + lines[crow].slice(ccol);
796
+ ccol = newBefore.length; render(); i++; continue;
797
+ }
723
798
 
724
- if (pasted.includes("\n") || pasted.includes("\r")) {
725
- // Multi-line paste: use backslash continuation for each internal line
726
- const lines = pasted.split(/\r?\n/);
727
- for (let i = 0; i < lines.length - 1; i++) {
728
- originalStdinEmit(event, Buffer.from(lines[i] + "\\\r"));
799
+ // Ctrl+J (LF, 0x0A) newline (cross-terminal)
800
+ if (str[i] === "\n") { insertNewline(); i++; continue; }
801
+
802
+ // Enter (CR, 0x0D) submit
803
+ if (str[i] === "\r") { submit(); return; }
804
+
805
+ // Backspace (DEL 0x7F or BS 0x08)
806
+ if (str[i] === "\x7f" || str[i] === "\b") { doBackspace(); i++; continue; }
807
+
808
+ // Tab — slash command completion
809
+ if (str[i] === "\t") {
810
+ if (lines.length === 1 && lines[0].startsWith("/")) {
811
+ const matches = CHAT_COMMANDS.filter(c => c.cmd.startsWith(lines[0]));
812
+ if (matches.length === 1) {
813
+ lines[0] = matches[0].cmd + " ";
814
+ ccol = lines[0].length; render();
815
+ } else if (matches.length > 1) {
816
+ // Show matches below, then re-render prompt
817
+ const lastRow = lines.length - 1;
818
+ const rowsDown = lastRow - crow;
819
+ if (rowsDown > 0) process.stdout.write(`\x1b[${rowsDown}B`);
820
+ process.stdout.write("\n");
821
+ for (const m of matches) {
822
+ process.stdout.write(` ${cyan(m.cmd.padEnd(14))} ${dim(m.desc)}\n`);
823
+ }
824
+ lastTermRow = 0; render();
729
825
  }
730
- // Last line: insert without auto-submit
731
- originalStdinEmit(event, Buffer.from(lines[lines.length - 1]));
732
- } else {
733
- originalStdinEmit(event, Buffer.from(pasted));
734
826
  }
827
+ i++; continue;
828
+ }
735
829
 
736
- if (afterPaste) {
737
- return originalStdinEmit(event, Buffer.from(afterPaste));
738
- }
739
- return false;
830
+ // Regular printable character
831
+ const code = str.charCodeAt(i);
832
+ if (code >= 32) {
833
+ lines[crow] = lines[crow].slice(0, ccol) + str[i] + lines[crow].slice(ccol);
834
+ ccol++; render();
835
+ }
836
+ i++;
837
+ }
838
+ };
839
+
840
+ const onData = (data: Buffer) => {
841
+ let str = data.toString();
842
+
843
+ // Bracketed paste handling
844
+ const ps = str.indexOf("\x1b[200~");
845
+ if (ps !== -1) {
846
+ isPasting = true;
847
+ const before = str.slice(0, ps);
848
+ if (before) processKeys(before);
849
+ str = str.slice(ps + 6);
850
+ }
851
+ if (isPasting) {
852
+ const pe = str.indexOf("\x1b[201~");
853
+ if (pe !== -1) {
854
+ pasteBuffer += str.slice(0, pe);
855
+ isPasting = false;
856
+ const pasted = pasteBuffer.replace(/[\r\n]+$/, "");
857
+ pasteBuffer = "";
858
+ if (pasted) insertText(pasted);
859
+ const after = str.slice(pe + 6);
860
+ if (after) processKeys(after);
740
861
  } else {
741
862
  pasteBuffer += str;
742
- return false;
743
863
  }
864
+ return;
744
865
  }
745
866
 
746
- // --- Shift+Enter (Kitty protocol: \x1b[13;2u) ---
747
- if (str.includes("\x1b[13;2u")) {
748
- const replaced = str.replace(/\x1b\[13;2u/g, "\\\r");
749
- return originalStdinEmit(event, Buffer.from(replaced));
750
- }
867
+ processKeys(str);
868
+ };
751
869
 
752
- // --- Alt+Enter (ESC + CR/LF) — universal fallback for newline ---
753
- if (str === "\x1b\r" || str === "\x1b\n") {
754
- return originalStdinEmit(event, Buffer.from("\\\r"));
755
- }
870
+ // Start listening
871
+ process.stdin.on("data", onData);
872
+ render();
873
+ });
874
+ }
756
875
 
757
- // --- Ctrl+J (LF, 0x0A) — cross-terminal newline ---
758
- // In raw mode: Enter sends \r (CR), Ctrl+J sends \n (LF).
759
- // This is the only reliable way to distinguish "newline" from "submit"
760
- // across ALL terminal emulators (Terminal.app, iTerm2, Kitty, etc.)
761
- if (str === "\n") {
762
- return originalStdinEmit(event, Buffer.from("\\\r"));
763
- }
764
876
 
765
- // --- Backspace at start of continuation → rejoin previous line ---
766
- // \x7f = DEL (backspace on most terminals), \b = BS (some terminals)
767
- if ((str === "\x7f" || str === "\b") && continuationBuffer.length > 0) {
768
- const currentLine = (rl as any).line as string;
769
- const cursor = (rl as any).cursor as number;
770
- if (cursor === 0 && currentLine.length === 0) {
771
- const prevLine = continuationBuffer.pop()!;
772
- // Inject the previous line's text back into readline
773
- (rl as any).line = prevLine;
774
- (rl as any).cursor = prevLine.length;
775
- // Update the prompt (may no longer be continuation)
776
- const isCont = continuationBuffer.length > 0;
777
- const prefix = isCont
778
- ? `${dim(`[${current!.id}]`)} ${dim("...")} `
779
- : `${dim(`[${current!.id}]`)} ${bold(">")} `;
780
- rl.setPrompt(prefix);
781
- (rl as any)._refreshLine();
782
- return false;
783
- }
784
- }
785
- }
786
- return originalStdinEmit(event, ...args);
787
- } as any;
788
877
 
878
+ async function chat(explicitSession?: string): Promise<void> {
879
+ // Enable bracketed paste + Kitty keyboard protocol
880
+ process.stdout.write("\x1b[?2004h\x1b[>1u");
881
+
882
+ // Use readline ONLY for session picker — then switch to custom multiline editor
789
883
  const rl = readline.createInterface({
790
884
  input: process.stdin,
791
885
  output: process.stdout,
792
- completer: chatCompleter,
793
886
  });
794
887
 
795
- setupInlineSuggestions(rl);
796
-
797
888
  let current: SessionChoice | null = null;
798
889
 
799
- // Resolve initial session
800
890
  if (explicitSession) {
801
891
  const dir = path.join(SESSIONS_DIR, explicitSession);
802
892
  if (!existsSync(dir)) {
@@ -813,6 +903,9 @@ async function chat(explicitSession?: string): Promise<void> {
813
903
  }
814
904
  }
815
905
 
906
+ // Done with readline — close it before switching to raw mode
907
+ rl.close();
908
+
816
909
  const projectName = path.basename(current.cwd);
817
910
  const banner = [
818
911
  " \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588 ",
@@ -846,170 +939,95 @@ async function chat(explicitSession?: string): Promise<void> {
846
939
  console.log(dim(" \u2570" + "\u2500".repeat(W) + "\u256f"));
847
940
  console.log();
848
941
 
849
- let continuationBuffer: string[] = [];
850
-
851
- // Ctrl+C: clear current input (like Claude Code / Copilot CLI)
852
- // If line has text → clear it and re-prompt
853
- // If line is empty → exit
854
- rl.on("SIGINT", () => {
855
- const line = (rl as any).line as string;
856
- if (line || continuationBuffer.length > 0) {
857
- continuationBuffer = [];
858
- // Clear the current line visually and re-prompt
859
- process.stdout.write("\n");
860
- prompt();
861
- } else {
862
- process.stdout.write("\x1b[?2004l\x1b[<u");
863
- console.log(dim("\nBye."));
864
- process.exit(0);
865
- }
866
- });
942
+ // Enable raw mode for custom multiline editor
943
+ process.stdin.setRawMode(true);
944
+ process.stdin.resume();
867
945
 
868
- // Handle stream close (pipe EOF, etc.)
869
- rl.on("close", () => {
870
- process.stdout.write("\x1b[?2004l\x1b[<u"); // disable bracketed paste + Kitty
871
- console.log(dim("\nBye."));
946
+ const exitChat = () => {
947
+ process.stdout.write("\x1b[?2004l\x1b[<u");
948
+ process.stdin.setRawMode(false);
949
+ console.log(dim("Bye."));
872
950
  process.exit(0);
873
- });
874
-
875
- const prompt = () => {
876
- const isContinuation = continuationBuffer.length > 0;
877
- const prefix = isContinuation
878
- ? `${dim(`[${current!.id}]`)} ${dim("...")} `
879
- : `${dim(`[${current!.id}]`)} ${bold(">")} `;
880
-
881
- rl.setPrompt(prefix);
882
- rl.question(prefix, async (line) => {
883
- // Line continuation with backslash
884
- if (line.endsWith("\\")) {
885
- continuationBuffer.push(line.slice(0, -1));
886
- prompt(); return;
887
- }
888
-
889
- // If we were in continuation mode, join and process
890
- if (continuationBuffer.length > 0) {
891
- continuationBuffer.push(line);
892
- const fullText = continuationBuffer.join("\n").trim();
893
- continuationBuffer = [];
894
- if (fullText) {
895
- try {
896
- if (fullText.startsWith("/")) {
897
- // Process as command — use first line
898
- // (multiline commands don't make sense, treat as task)
899
- await add(fullText, 0, current!.dir);
900
- } else {
901
- await add(fullText, 0, current!.dir);
902
- }
903
- } catch (err: any) {
904
- console.error(red(err.message));
905
- }
906
- }
907
- prompt(); return;
908
- }
909
-
910
- const trimmed = line.trim();
911
- if (!trimmed) { prompt(); return; }
951
+ };
912
952
 
913
- try {
914
- if (trimmed === "/quit" || trimmed === "/exit") {
915
- console.log(dim("Bye."));
916
- rl.close();
917
- return;
918
- }
953
+ // ── Main chat loop ─────────────────────────────────────────────────────────────────
954
+ while (true) {
955
+ const text = await readMultilineInput(current.id);
919
956
 
920
- if (trimmed === "/sessions") {
921
- const choices = await listSessionChoices();
922
- if (choices.length === 0) {
923
- console.log(dim("No active sessions."));
924
- } else {
925
- choices.forEach((s, i) => {
926
- const marker = s.id === current!.id ? green("*") : " ";
927
- const pName = path.basename(s.cwd);
928
- console.log(` ${marker} ${bold(String(i + 1))}. ${cyan(s.id)} ${dim(pName)} | ${s.status} | ${s.minutes}min | ${s.tasks} done`);
929
- });
930
- }
931
- prompt(); return;
932
- }
957
+ if (text === null) exitChat();
958
+ if (!text) continue;
933
959
 
934
- if (trimmed.startsWith("/switch")) {
935
- const arg = trimmed.slice(7).trim();
936
- const choices = await listSessionChoices();
937
- if (choices.length === 0) {
938
- console.log(red("No active sessions."));
939
- prompt(); return;
940
- }
941
- const idx = parseInt(arg) - 1;
942
- if (idx >= 0 && idx < choices.length) {
943
- current = choices[idx];
944
- console.log(green(`Switched to ${current.id} (${path.basename(current.cwd)})`));
945
- } else {
946
- // Show picker
947
- choices.forEach((s, i) => {
948
- const marker = s.id === current!.id ? green("*") : " ";
949
- console.log(` ${marker} ${bold(String(i + 1))}. ${cyan(s.id)} ${dim(path.basename(s.cwd))}`);
950
- });
951
- }
952
- prompt(); return;
953
- }
960
+ const trimmed = text.trim();
954
961
 
955
- if (trimmed === "/status") {
956
- await status(current!.dir);
957
- prompt(); return;
958
- }
962
+ try {
963
+ if (trimmed === "/quit" || trimmed === "/exit") exitChat();
959
964
 
960
- if (trimmed === "/history") {
961
- await history();
962
- prompt(); return;
965
+ if (trimmed === "/sessions") {
966
+ const choices = await listSessionChoices();
967
+ if (choices.length === 0) {
968
+ console.log(dim("No active sessions."));
969
+ } else {
970
+ choices.forEach((s, i) => {
971
+ const marker = s.id === current!.id ? green("*") : " ";
972
+ const pName = path.basename(s.cwd);
973
+ console.log(` ${marker} ${bold(String(i + 1))}. ${cyan(s.id)} ${dim(pName)} | ${s.status} | ${s.minutes}min | ${s.tasks} done`);
974
+ });
963
975
  }
976
+ continue;
977
+ }
964
978
 
965
- if (trimmed.startsWith("/feedback ")) {
966
- const msg = trimmed.slice(10).trim();
967
- if (msg) {
968
- await feedback(msg, current!.dir);
969
- } else {
970
- console.log(red("Usage: /feedback <message>"));
971
- }
972
- prompt(); return;
979
+ if (trimmed.startsWith("/switch")) {
980
+ const arg = trimmed.slice(7).trim();
981
+ const choices = await listSessionChoices();
982
+ if (choices.length === 0) {
983
+ console.log(red("No active sessions."));
984
+ continue;
973
985
  }
974
-
975
- if (trimmed.startsWith("/priority ")) {
976
- const task = trimmed.slice(10).trim();
977
- if (task) {
978
- await add(task, 9, current!.dir);
979
- } else {
980
- console.log(red("Usage: /priority <task>"));
981
- }
982
- prompt(); return;
986
+ const idx = parseInt(arg) - 1;
987
+ if (idx >= 0 && idx < choices.length) {
988
+ current = choices[idx];
989
+ console.log(green(`Switched to ${current.id} (${path.basename(current.cwd)})`));
990
+ } else {
991
+ choices.forEach((s, i) => {
992
+ const marker = s.id === current!.id ? green("*") : " ";
993
+ console.log(` ${marker} ${bold(String(i + 1))}. ${cyan(s.id)} ${dim(path.basename(s.cwd))}`);
994
+ });
983
995
  }
996
+ continue;
997
+ }
984
998
 
985
- if (trimmed === "/queue") {
986
- await listQueueCmd(current!.dir);
987
- prompt(); return;
988
- }
999
+ if (trimmed === "/status") { await status(current.dir); continue; }
1000
+ if (trimmed === "/history") { await history(); continue; }
989
1001
 
990
- if (trimmed === "/clear") {
991
- await clear(current!.dir);
992
- prompt(); return;
993
- }
1002
+ if (trimmed.startsWith("/feedback ")) {
1003
+ const msg = trimmed.slice(10).trim();
1004
+ if (msg) await feedback(msg, current.dir);
1005
+ else console.log(red("Usage: /feedback <message>"));
1006
+ continue;
1007
+ }
994
1008
 
995
- if (trimmed.startsWith("/")) {
996
- console.log(red(`Unknown command: ${trimmed.split(" ")[0]}`));
997
- console.log(dim(" Press Tab to see available commands"));
998
- prompt(); return;
999
- }
1009
+ if (trimmed.startsWith("/priority ")) {
1010
+ const task = trimmed.slice(10).trim();
1011
+ if (task) await add(task, 9, current.dir);
1012
+ else console.log(red("Usage: /priority <task>"));
1013
+ continue;
1014
+ }
1000
1015
 
1001
- // Default: queue as task
1002
- await add(trimmed, 0, current!.dir);
1016
+ if (trimmed === "/queue") { await listQueueCmd(current.dir); continue; }
1017
+ if (trimmed === "/clear") { await clear(current.dir); continue; }
1003
1018
 
1004
- } catch (err: any) {
1005
- console.error(red(err.message));
1019
+ if (trimmed.startsWith("/")) {
1020
+ console.log(red(`Unknown command: ${trimmed.split(" ")[0]}`));
1021
+ console.log(dim(" Press Tab to see available commands"));
1022
+ continue;
1006
1023
  }
1007
1024
 
1008
- prompt();
1009
- });
1010
- };
1011
-
1012
- prompt();
1025
+ // Default: queue as task
1026
+ await add(trimmed, 0, current.dir);
1027
+ } catch (err: any) {
1028
+ console.error(red(err.message));
1029
+ }
1030
+ }
1013
1031
  }
1014
1032
 
1015
1033
  // ── Main ──────────────────────────────────────────────────────────────────────