groundcrew-cli 0.15.15 → 0.15.17

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 +396 -280
  2. package/package.json +1 -1
  3. package/src/index.ts +371 -342
package/src/index.ts CHANGED
@@ -565,238 +565,339 @@ 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
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]
586
+
587
+ // Visible width of prompt: "[sessionId] > "
588
+ const padWidth = sessionId.length + 5; // [ + id + ] + space + > + space = len+5
589
+
590
+ // Track how many rows up from cursor to top of rendered area (including separator)
591
+ let lastTermRow = 0;
592
+ let pasteBuffer = "";
593
+ let isPasting = false;
594
+
595
+ const fullText = () => lines.join("\n").trim();
596
+
597
+ const render = () => {
598
+ const buf: string[] = [];
599
+
600
+ // Move to start of input area (includes separator line)
601
+ if (lastTermRow > 0) buf.push(`\x1b[${lastTermRow}A`);
602
+ buf.push("\r\x1b[J"); // col 0 + clear to end of screen
603
+
604
+ // Separator line: ─── sessionId ─────────
605
+ const termW = process.stdout.columns || 80;
606
+ const info = ` ${sessionId} `;
607
+ const dashRight = "─".repeat(Math.max(0, termW - 4 - info.length));
608
+ buf.push(dim("───" + info + dashRight));
609
+
610
+ // Draw each input line (below separator)
611
+ for (let i = 0; i < lines.length; i++) {
612
+ buf.push("\n");
613
+ if (i === 0) {
614
+ buf.push(dim(`[${sessionId}]`) + " " + bold(">") + " " + lines[i]);
615
+ } else {
616
+ buf.push(" ".repeat(padWidth) + lines[i]);
617
+ }
602
618
  }
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
- };
619
619
 
620
- const showGhost = () => {
621
- const line = (rl as any).line as string;
622
- if (!line || !line.startsWith("/") || line.includes(" ")) return;
620
+ // Position cursor at (crow, ccol)
621
+ const lastRow = lines.length - 1;
622
+ const rowsUp = lastRow - crow;
623
+ if (rowsUp > 0) buf.push(`\x1b[${rowsUp}A`);
624
+
625
+ buf.push("\r");
626
+ const col = padWidth + ccol;
627
+ if (col > 0) buf.push(`\x1b[${col}C`);
628
+
629
+ // lastTermRow = rows from cursor back to top of separator
630
+ // separator is 1 row, then crow rows of input below it
631
+ lastTermRow = 1 + crow;
632
+ process.stdout.write(buf.join(""));
633
+ };
634
+
635
+ const finish = (result: string | null) => {
636
+ process.stdin.removeListener("data", onData);
637
+ resolve(result);
638
+ };
639
+
640
+ const submit = () => {
641
+ const text = fullText();
642
+ // Move cursor to end of input for clean output
643
+ const lastRow = lines.length - 1;
644
+ const rowsDown = lastRow - crow;
645
+ const buf: string[] = [];
646
+ if (rowsDown > 0) buf.push(`\x1b[${rowsDown}B`);
647
+ buf.push("\r");
648
+ const endCol = padWidth + lines[lastRow].length;
649
+ if (endCol > 0) buf.push(`\x1b[${endCol}C`);
650
+ buf.push("\n");
651
+ process.stdout.write(buf.join(""));
652
+ lastTermRow = 0;
653
+ finish(text || null);
654
+ };
655
+
656
+ const insertText = (text: string) => {
657
+ const chunks = text.split(/\r?\n/);
658
+ const before = lines[crow].slice(0, ccol);
659
+ const after = lines[crow].slice(ccol);
660
+
661
+ if (chunks.length === 1) {
662
+ lines[crow] = before + chunks[0] + after;
663
+ ccol += chunks[0].length;
664
+ } else {
665
+ lines[crow] = before + chunks[0];
666
+ const middle = chunks.slice(1, -1);
667
+ const last = chunks[chunks.length - 1];
668
+ lines.splice(crow + 1, 0, ...middle, last + after);
669
+ crow += chunks.length - 1;
670
+ ccol = last.length;
671
+ }
672
+ render();
673
+ };
674
+
675
+ const insertNewline = () => {
676
+ const before = lines[crow].slice(0, ccol);
677
+ const after = lines[crow].slice(ccol);
678
+ lines[crow] = before;
679
+ lines.splice(crow + 1, 0, after);
680
+ crow++;
681
+ ccol = 0;
682
+ render();
683
+ };
684
+
685
+ const doBackspace = () => {
686
+ if (ccol > 0) {
687
+ lines[crow] = lines[crow].slice(0, ccol - 1) + lines[crow].slice(ccol);
688
+ ccol--;
689
+ } else if (crow > 0) {
690
+ const prevLen = lines[crow - 1].length;
691
+ lines[crow - 1] += lines[crow];
692
+ lines.splice(crow, 1);
693
+ crow--;
694
+ ccol = prevLen;
695
+ }
696
+ render();
697
+ };
698
+
699
+ const doDelete = () => {
700
+ if (ccol < lines[crow].length) {
701
+ lines[crow] = lines[crow].slice(0, ccol) + lines[crow].slice(ccol + 1);
702
+ } else if (crow < lines.length - 1) {
703
+ lines[crow] += lines[crow + 1];
704
+ lines.splice(crow + 1, 1);
705
+ }
706
+ render();
707
+ };
708
+
709
+ const processKeys = (str: string) => {
710
+ let i = 0;
711
+ while (i < str.length) {
712
+ // Shift+Enter (Kitty: \x1b[13;2u)
713
+ if (str.startsWith("\x1b[13;2u", i)) { insertNewline(); i += 7; continue; }
714
+
715
+ // Alt+Enter (ESC + CR or ESC + LF)
716
+ if (i + 1 < str.length && str[i] === "\x1b" && (str[i + 1] === "\r" || str[i + 1] === "\n")) {
717
+ insertNewline(); i += 2; continue;
718
+ }
623
719
 
624
- const matches = CHAT_COMMANDS.filter((c) => c.cmd.startsWith(line));
625
- if (matches.length === 0) return;
720
+ // Arrow Up
721
+ if (str.startsWith("\x1b[A", i)) {
722
+ if (crow > 0) { crow--; ccol = Math.min(ccol, lines[crow].length); render(); }
723
+ i += 3; continue;
724
+ }
725
+ // Arrow Down
726
+ if (str.startsWith("\x1b[B", i)) {
727
+ if (crow < lines.length - 1) { crow++; ccol = Math.min(ccol, lines[crow].length); render(); }
728
+ i += 3; continue;
729
+ }
730
+ // Arrow Right
731
+ if (str.startsWith("\x1b[C", i)) {
732
+ if (ccol < lines[crow].length) ccol++;
733
+ else if (crow < lines.length - 1) { crow++; ccol = 0; }
734
+ render(); i += 3; continue;
735
+ }
736
+ // Arrow Left
737
+ if (str.startsWith("\x1b[D", i)) {
738
+ if (ccol > 0) ccol--;
739
+ else if (crow > 0) { crow--; ccol = lines[crow].length; }
740
+ render(); i += 3; continue;
741
+ }
626
742
 
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;
743
+ // Delete key (\x1b[3~)
744
+ if (str.startsWith("\x1b[3~", i)) { doDelete(); i += 4; continue; }
631
745
 
632
- const buf: string[] = [];
746
+ // Home (\x1b[H)
747
+ if (str.startsWith("\x1b[H", i)) { ccol = 0; render(); i += 3; continue; }
748
+ // End (\x1b[F)
749
+ if (str.startsWith("\x1b[F", i)) { ccol = lines[crow].length; render(); i += 3; continue; }
633
750
 
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
- }
751
+ // Skip unknown CSI sequences
752
+ if (str[i] === "\x1b" && i + 1 < str.length && str[i + 1] === "[") {
753
+ let j = i + 2;
754
+ while (j < str.length && str.charCodeAt(j) >= 0x30 && str.charCodeAt(j) <= 0x3f) j++;
755
+ if (j < str.length) j++; // skip final byte
756
+ i = j; continue;
757
+ }
758
+ // Skip lone ESC
759
+ if (str[i] === "\x1b") { i++; continue; }
760
+
761
+ // Ctrl+C — clear input or exit
762
+ if (str[i] === "\x03") {
763
+ const hasText = fullText();
764
+ if (hasText || lines.length > 1 || lines[0].length > 0) {
765
+ // Move past current rendering to below last line, start fresh
766
+ const lastRow = lines.length - 1;
767
+ const rowsDown = lastRow - crow;
768
+ if (rowsDown > 0) process.stdout.write(`\x1b[${rowsDown}B`);
769
+ process.stdout.write("\r\n");
770
+ lines.length = 0; lines.push("");
771
+ crow = 0; ccol = 0; lastTermRow = 0;
772
+ render();
773
+ } else {
774
+ process.stdout.write("\r\n");
775
+ finish(null); return;
776
+ }
777
+ i++; continue;
778
+ }
643
779
 
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`);
655
- }
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
- }
780
+ // Ctrl+D delete char or exit on empty
781
+ if (str[i] === "\x04") {
782
+ if (fullText()) { doDelete(); } else { process.stdout.write("\n"); finish(null); return; }
783
+ i++; continue;
784
+ }
665
785
 
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`);
673
- }
674
- }
675
- };
786
+ // Ctrl+A — home
787
+ if (str[i] === "\x01") { ccol = 0; render(); i++; continue; }
788
+ // Ctrl+E end
789
+ if (str[i] === "\x05") { ccol = lines[crow].length; render(); i++; continue; }
790
+ // Ctrl+U clear line before cursor
791
+ if (str[i] === "\x15") {
792
+ lines[crow] = lines[crow].slice(ccol); ccol = 0; render(); i++; continue;
793
+ }
794
+ // Ctrl+K — clear line after cursor
795
+ if (str[i] === "\x0b") {
796
+ lines[crow] = lines[crow].slice(0, ccol); render(); i++; continue;
797
+ }
798
+ // Ctrl+W — delete word before cursor
799
+ if (str[i] === "\x17") {
800
+ const before = lines[crow].slice(0, ccol);
801
+ const stripped = before.replace(/\s+$/, "");
802
+ const sp = stripped.lastIndexOf(" ");
803
+ const newBefore = sp >= 0 ? stripped.slice(0, sp + 1) : "";
804
+ lines[crow] = newBefore + lines[crow].slice(ccol);
805
+ ccol = newBefore.length; render(); i++; continue;
806
+ }
676
807
 
677
- process.stdin.on("keypress", (_ch: string, key: any) => {
678
- if (!key) return;
808
+ // Ctrl+J (LF, 0x0A) newline (cross-terminal)
809
+ if (str[i] === "\n") { insertNewline(); i++; continue; }
810
+
811
+ // Enter (CR, 0x0D) — submit
812
+ if (str[i] === "\r") { submit(); return; }
813
+
814
+ // Backspace (DEL 0x7F or BS 0x08)
815
+ if (str[i] === "\x7f" || str[i] === "\b") { doBackspace(); i++; continue; }
816
+
817
+ // Tab — slash command completion
818
+ if (str[i] === "\t") {
819
+ const currentLine = lines[crow];
820
+ if (lines.length === 1 && currentLine.startsWith("/")) {
821
+ const partial = currentLine.split(" ")[0]; // only match command part
822
+ const matches = CHAT_COMMANDS.filter(c => c.cmd.startsWith(partial));
823
+ if (matches.length === 1) {
824
+ lines[0] = matches[0].cmd + " ";
825
+ ccol = lines[0].length; render();
826
+ } else if (matches.length > 1) {
827
+ // Show matches below, then re-render prompt
828
+ const lastRow = lines.length - 1;
829
+ const rowsDown = lastRow - crow;
830
+ if (rowsDown > 0) process.stdout.write(`\x1b[${rowsDown}B`);
831
+ process.stdout.write("\r\n");
832
+ for (const m of matches) {
833
+ process.stdout.write(` ${cyan(m.cmd.padEnd(14))} ${dim(m.desc)}\n`);
834
+ }
835
+ lastTermRow = 0; render();
836
+ }
837
+ }
838
+ i++; continue;
839
+ }
679
840
 
680
- clearGhost();
681
- if (key.name !== "return" && key.name !== "tab") {
682
- setImmediate(showGhost);
683
- }
684
- });
685
- }
841
+ // Regular printable character
842
+ const code = str.charCodeAt(i);
843
+ if (code >= 32) {
844
+ lines[crow] = lines[crow].slice(0, ccol) + str[i] + lines[crow].slice(ccol);
845
+ ccol++; render();
846
+ }
847
+ i++;
848
+ }
849
+ };
686
850
 
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");
851
+ const onData = (data: Buffer) => {
852
+ let str = data.toString();
690
853
 
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) {
854
+ // Bracketed paste handling
855
+ const ps = str.indexOf("\x1b[200~");
856
+ if (ps !== -1) {
707
857
  isPasting = true;
708
- if (pasteStart > 0) {
709
- originalStdinEmit(event, Buffer.from(str.slice(0, pasteStart)));
710
- }
711
- str = str.slice(pasteStart + 6); // skip \x1b[200~
858
+ const before = str.slice(0, ps);
859
+ if (before) processKeys(before);
860
+ str = str.slice(ps + 6);
712
861
  }
713
-
714
862
  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);
863
+ const pe = str.indexOf("\x1b[201~");
864
+ if (pe !== -1) {
865
+ pasteBuffer += str.slice(0, pe);
719
866
  isPasting = false;
720
-
721
867
  const pasted = pasteBuffer.replace(/[\r\n]+$/, "");
722
868
  pasteBuffer = "";
723
-
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"));
729
- }
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
- }
735
-
736
- if (afterPaste) {
737
- return originalStdinEmit(event, Buffer.from(afterPaste));
738
- }
739
- return false;
869
+ if (pasted) insertText(pasted);
870
+ const after = str.slice(pe + 6);
871
+ if (after) processKeys(after);
740
872
  } else {
741
873
  pasteBuffer += str;
742
- return false;
743
874
  }
875
+ return;
744
876
  }
745
877
 
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
- }
878
+ processKeys(str);
879
+ };
751
880
 
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
- }
881
+ // Start listening
882
+ process.stdin.on("data", onData);
883
+ render();
884
+ });
885
+ }
756
886
 
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
887
 
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
888
 
889
+ async function chat(explicitSession?: string): Promise<void> {
890
+ // Enable bracketed paste + Kitty keyboard protocol
891
+ process.stdout.write("\x1b[?2004h\x1b[>1u");
892
+
893
+ // Use readline ONLY for session picker — then switch to custom multiline editor
789
894
  const rl = readline.createInterface({
790
895
  input: process.stdin,
791
896
  output: process.stdout,
792
- completer: chatCompleter,
793
897
  });
794
898
 
795
- setupInlineSuggestions(rl);
796
-
797
899
  let current: SessionChoice | null = null;
798
900
 
799
- // Resolve initial session
800
901
  if (explicitSession) {
801
902
  const dir = path.join(SESSIONS_DIR, explicitSession);
802
903
  if (!existsSync(dir)) {
@@ -813,6 +914,9 @@ async function chat(explicitSession?: string): Promise<void> {
813
914
  }
814
915
  }
815
916
 
917
+ // Done with readline — close it before switching to raw mode
918
+ rl.close();
919
+
816
920
  const projectName = path.basename(current.cwd);
817
921
  const banner = [
818
922
  " \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 +950,95 @@ async function chat(explicitSession?: string): Promise<void> {
846
950
  console.log(dim(" \u2570" + "\u2500".repeat(W) + "\u256f"));
847
951
  console.log();
848
952
 
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
- });
953
+ // Enable raw mode for custom multiline editor
954
+ process.stdin.setRawMode(true);
955
+ process.stdin.resume();
867
956
 
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."));
957
+ const exitChat = () => {
958
+ process.stdout.write("\x1b[?2004l\x1b[<u");
959
+ process.stdin.setRawMode(false);
960
+ console.log(dim("Bye."));
872
961
  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
- }
962
+ };
888
963
 
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
- }
964
+ // ── Main chat loop ─────────────────────────────────────────────────────────────────
965
+ while (true) {
966
+ const text = await readMultilineInput(current.id);
909
967
 
910
- const trimmed = line.trim();
911
- if (!trimmed) { prompt(); return; }
968
+ if (text === null) exitChat();
969
+ if (!text) continue;
912
970
 
913
- try {
914
- if (trimmed === "/quit" || trimmed === "/exit") {
915
- console.log(dim("Bye."));
916
- rl.close();
917
- return;
918
- }
971
+ const trimmed = text.trim();
919
972
 
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
- }
933
-
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
- }
954
-
955
- if (trimmed === "/status") {
956
- await status(current!.dir);
957
- prompt(); return;
958
- }
973
+ try {
974
+ if (trimmed === "/quit" || trimmed === "/exit") exitChat();
959
975
 
960
- if (trimmed === "/history") {
961
- await history();
962
- prompt(); return;
976
+ if (trimmed === "/sessions") {
977
+ const choices = await listSessionChoices();
978
+ if (choices.length === 0) {
979
+ console.log(dim("No active sessions."));
980
+ } else {
981
+ choices.forEach((s, i) => {
982
+ const marker = s.id === current!.id ? green("*") : " ";
983
+ const pName = path.basename(s.cwd);
984
+ console.log(` ${marker} ${bold(String(i + 1))}. ${cyan(s.id)} ${dim(pName)} | ${s.status} | ${s.minutes}min | ${s.tasks} done`);
985
+ });
963
986
  }
987
+ continue;
988
+ }
964
989
 
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;
990
+ if (trimmed.startsWith("/switch")) {
991
+ const arg = trimmed.slice(7).trim();
992
+ const choices = await listSessionChoices();
993
+ if (choices.length === 0) {
994
+ console.log(red("No active sessions."));
995
+ continue;
973
996
  }
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;
997
+ const idx = parseInt(arg) - 1;
998
+ if (idx >= 0 && idx < choices.length) {
999
+ current = choices[idx];
1000
+ console.log(green(`Switched to ${current.id} (${path.basename(current.cwd)})`));
1001
+ } else {
1002
+ choices.forEach((s, i) => {
1003
+ const marker = s.id === current!.id ? green("*") : " ";
1004
+ console.log(` ${marker} ${bold(String(i + 1))}. ${cyan(s.id)} ${dim(path.basename(s.cwd))}`);
1005
+ });
983
1006
  }
1007
+ continue;
1008
+ }
984
1009
 
985
- if (trimmed === "/queue") {
986
- await listQueueCmd(current!.dir);
987
- prompt(); return;
988
- }
1010
+ if (trimmed === "/status") { await status(current.dir); continue; }
1011
+ if (trimmed === "/history") { await history(); continue; }
989
1012
 
990
- if (trimmed === "/clear") {
991
- await clear(current!.dir);
992
- prompt(); return;
993
- }
1013
+ if (trimmed.startsWith("/feedback ")) {
1014
+ const msg = trimmed.slice(10).trim();
1015
+ if (msg) await feedback(msg, current.dir);
1016
+ else console.log(red("Usage: /feedback <message>"));
1017
+ continue;
1018
+ }
994
1019
 
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
- }
1020
+ if (trimmed.startsWith("/priority ")) {
1021
+ const task = trimmed.slice(10).trim();
1022
+ if (task) await add(task, 9, current.dir);
1023
+ else console.log(red("Usage: /priority <task>"));
1024
+ continue;
1025
+ }
1000
1026
 
1001
- // Default: queue as task
1002
- await add(trimmed, 0, current!.dir);
1027
+ if (trimmed === "/queue") { await listQueueCmd(current.dir); continue; }
1028
+ if (trimmed === "/clear") { await clear(current.dir); continue; }
1003
1029
 
1004
- } catch (err: any) {
1005
- console.error(red(err.message));
1030
+ if (trimmed.startsWith("/")) {
1031
+ console.log(red(`Unknown command: ${trimmed.split(" ")[0]}`));
1032
+ console.log(dim(" Press Tab to see available commands"));
1033
+ continue;
1006
1034
  }
1007
1035
 
1008
- prompt();
1009
- });
1010
- };
1011
-
1012
- prompt();
1036
+ // Default: queue as task
1037
+ await add(trimmed, 0, current.dir);
1038
+ } catch (err: any) {
1039
+ console.error(red(err.message));
1040
+ }
1041
+ }
1013
1042
  }
1014
1043
 
1015
1044
  // ── Main ──────────────────────────────────────────────────────────────────────