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.
- package/dist/index.js +389 -280
- package/package.json +1 -1
- 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
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
621
|
-
const
|
|
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
|
-
|
|
625
|
-
|
|
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
|
|
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
|
|
597
|
+
const render = () => {
|
|
598
|
+
const buf: string[] = [];
|
|
633
599
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
(
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
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
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
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
|
-
|
|
688
|
-
|
|
689
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
722
|
-
|
|
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
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
747
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
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
|
-
|
|
869
|
-
|
|
870
|
-
process.
|
|
871
|
-
console.log(dim("
|
|
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
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
rl.close();
|
|
917
|
-
return;
|
|
918
|
-
}
|
|
953
|
+
// ── Main chat loop ─────────────────────────────────────────────────────────────────
|
|
954
|
+
while (true) {
|
|
955
|
+
const text = await readMultilineInput(current.id);
|
|
919
956
|
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
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
|
-
|
|
956
|
-
|
|
957
|
-
prompt(); return;
|
|
958
|
-
}
|
|
962
|
+
try {
|
|
963
|
+
if (trimmed === "/quit" || trimmed === "/exit") exitChat();
|
|
959
964
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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 (
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
prompt(); return;
|
|
988
|
-
}
|
|
999
|
+
if (trimmed === "/status") { await status(current.dir); continue; }
|
|
1000
|
+
if (trimmed === "/history") { await history(); continue; }
|
|
989
1001
|
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
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
|
-
|
|
1002
|
-
|
|
1016
|
+
if (trimmed === "/queue") { await listQueueCmd(current.dir); continue; }
|
|
1017
|
+
if (trimmed === "/clear") { await clear(current.dir); continue; }
|
|
1003
1018
|
|
|
1004
|
-
|
|
1005
|
-
console.
|
|
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
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
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 ──────────────────────────────────────────────────────────────────────
|