groundcrew-cli 0.15.16 → 0.16.0

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 +148 -11
  2. package/package.json +1 -1
  3. package/src/index.ts +124 -16
package/dist/index.js CHANGED
@@ -5,9 +5,42 @@ import fs from "fs/promises";
5
5
  import { existsSync } from "fs";
6
6
  import path from "path";
7
7
  import readline from "readline";
8
- import { execFile } from "child_process";
8
+ import { execFile, execFileSync } from "child_process";
9
9
  import { promisify } from "util";
10
10
  var execFileAsync = promisify(execFile);
11
+ function getGitContext() {
12
+ try {
13
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
14
+ encoding: "utf8",
15
+ timeout: 500,
16
+ stdio: ["pipe", "pipe", "pipe"]
17
+ }).trim();
18
+ let dirty = "";
19
+ try {
20
+ const status2 = execFileSync("git", ["status", "--porcelain", "-uno"], {
21
+ encoding: "utf8",
22
+ timeout: 500,
23
+ stdio: ["pipe", "pipe", "pipe"]
24
+ }).trim();
25
+ if (status2) {
26
+ const hasStaged = status2.split("\n").some((l) => l[0] !== " " && l[0] !== "?");
27
+ const hasUnstaged = status2.split("\n").some((l) => l[1] === "M" || l[1] === "D");
28
+ if (hasStaged) dirty += "+";
29
+ if (hasUnstaged) dirty += "*";
30
+ }
31
+ const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
32
+ encoding: "utf8",
33
+ timeout: 500,
34
+ stdio: ["pipe", "pipe", "pipe"]
35
+ }).trim();
36
+ if (untracked) dirty += "%";
37
+ } catch {
38
+ }
39
+ return { branch, dirty };
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
11
44
  var GROUNDCREW_DIR = ".groundcrew";
12
45
  var SESSIONS_DIR = path.join(GROUNDCREW_DIR, "sessions");
13
46
  var ACTIVE_SESSIONS_FILE = path.join(GROUNDCREW_DIR, "active-sessions.json");
@@ -429,7 +462,7 @@ var CHAT_COMMANDS = [
429
462
  { cmd: "/clear", desc: "Clear pending tasks" },
430
463
  { cmd: "/exit", desc: "Exit chat" }
431
464
  ];
432
- function readMultilineInput(sessionId) {
465
+ function readMultilineInput(sessionId, projectName, gitCtx) {
433
466
  return new Promise((resolve) => {
434
467
  const lines = [""];
435
468
  let crow = 0;
@@ -443,8 +476,18 @@ function readMultilineInput(sessionId) {
443
476
  const buf = [];
444
477
  if (lastTermRow > 0) buf.push(`\x1B[${lastTermRow}A`);
445
478
  buf.push("\r\x1B[J");
479
+ const termW = process.stdout.columns || 80;
480
+ let info = ` ${sessionId} `;
481
+ const ctxParts = [];
482
+ if (projectName) ctxParts.push(projectName);
483
+ if (gitCtx) {
484
+ ctxParts.push(`git:(${gitCtx.branch}${gitCtx.dirty})`);
485
+ }
486
+ if (ctxParts.length) info += ` ${ctxParts.join(" ")} `;
487
+ const dashRight = "\u2500".repeat(Math.max(0, termW - 4 - info.length));
488
+ buf.push(dim("\u2500\u2500\u2500" + info + dashRight));
446
489
  for (let i = 0; i < lines.length; i++) {
447
- if (i > 0) buf.push("\n");
490
+ buf.push("\n");
448
491
  if (i === 0) {
449
492
  buf.push(dim(`[${sessionId}]`) + " " + bold(">") + " " + lines[i]);
450
493
  } else {
@@ -457,7 +500,7 @@ function readMultilineInput(sessionId) {
457
500
  buf.push("\r");
458
501
  const col = padWidth + ccol;
459
502
  if (col > 0) buf.push(`\x1B[${col}C`);
460
- lastTermRow = crow;
503
+ lastTermRow = 1 + crow;
461
504
  process.stdout.write(buf.join(""));
462
505
  };
463
506
  const finish = (result) => {
@@ -595,6 +638,89 @@ function readMultilineInput(sessionId) {
595
638
  continue;
596
639
  }
597
640
  if (str[i] === "\x1B" && i + 1 < str.length && str[i + 1] === "[") {
641
+ const csiMatch = str.slice(i).match(/^\x1b\[(\d+);(\d+)u/);
642
+ if (csiMatch) {
643
+ const codepoint = parseInt(csiMatch[1], 10);
644
+ const modifier = parseInt(csiMatch[2], 10);
645
+ const isCtrl = modifier - 1 & 4;
646
+ const seqLen = csiMatch[0].length;
647
+ if (isCtrl) {
648
+ switch (codepoint) {
649
+ case 99:
650
+ if (fullText() || lines.length > 1 || lines[0].length > 0) {
651
+ const lastRow = lines.length - 1;
652
+ const rowsDown = lastRow - crow;
653
+ if (rowsDown > 0) process.stdout.write(`\x1B[${rowsDown}B`);
654
+ process.stdout.write("\r\n");
655
+ lines.length = 0;
656
+ lines.push("");
657
+ crow = 0;
658
+ ccol = 0;
659
+ lastTermRow = 0;
660
+ render();
661
+ } else {
662
+ process.stdout.write("\r\n");
663
+ finish(null);
664
+ return;
665
+ }
666
+ i += seqLen;
667
+ continue;
668
+ case 100:
669
+ if (fullText()) {
670
+ doDelete();
671
+ } else {
672
+ process.stdout.write("\n");
673
+ finish(null);
674
+ return;
675
+ }
676
+ i += seqLen;
677
+ continue;
678
+ case 97:
679
+ ccol = 0;
680
+ render();
681
+ i += seqLen;
682
+ continue;
683
+ case 101:
684
+ ccol = lines[crow].length;
685
+ render();
686
+ i += seqLen;
687
+ continue;
688
+ case 117:
689
+ lines[crow] = lines[crow].slice(ccol);
690
+ ccol = 0;
691
+ render();
692
+ i += seqLen;
693
+ continue;
694
+ case 107:
695
+ lines[crow] = lines[crow].slice(0, ccol);
696
+ render();
697
+ i += seqLen;
698
+ continue;
699
+ case 119:
700
+ {
701
+ const before = lines[crow].slice(0, ccol);
702
+ const stripped = before.replace(/\s+$/, "");
703
+ const sp = stripped.lastIndexOf(" ");
704
+ const newBefore = sp >= 0 ? stripped.slice(0, sp + 1) : "";
705
+ lines[crow] = newBefore + lines[crow].slice(ccol);
706
+ ccol = newBefore.length;
707
+ render();
708
+ }
709
+ i += seqLen;
710
+ continue;
711
+ case 108:
712
+ process.stdout.write("\x1B[2J\x1B[H");
713
+ lastTermRow = 0;
714
+ render();
715
+ i += seqLen;
716
+ continue;
717
+ default:
718
+ break;
719
+ }
720
+ }
721
+ i += seqLen;
722
+ continue;
723
+ }
598
724
  let j = i + 2;
599
725
  while (j < str.length && str.charCodeAt(j) >= 48 && str.charCodeAt(j) <= 63) j++;
600
726
  if (j < str.length) j++;
@@ -606,11 +732,12 @@ function readMultilineInput(sessionId) {
606
732
  continue;
607
733
  }
608
734
  if (str[i] === "") {
609
- if (fullText()) {
735
+ const hasText = fullText();
736
+ if (hasText || lines.length > 1 || lines[0].length > 0) {
610
737
  const lastRow = lines.length - 1;
611
738
  const rowsDown = lastRow - crow;
612
739
  if (rowsDown > 0) process.stdout.write(`\x1B[${rowsDown}B`);
613
- process.stdout.write("\n");
740
+ process.stdout.write("\r\n");
614
741
  lines.length = 0;
615
742
  lines.push("");
616
743
  crow = 0;
@@ -618,7 +745,7 @@ function readMultilineInput(sessionId) {
618
745
  lastTermRow = 0;
619
746
  render();
620
747
  } else {
621
- process.stdout.write("\n");
748
+ process.stdout.write("\r\n");
622
749
  finish(null);
623
750
  return;
624
751
  }
@@ -672,6 +799,13 @@ function readMultilineInput(sessionId) {
672
799
  i++;
673
800
  continue;
674
801
  }
802
+ if (str[i] === "\f") {
803
+ process.stdout.write("\x1B[2J\x1B[H");
804
+ lastTermRow = 0;
805
+ render();
806
+ i++;
807
+ continue;
808
+ }
675
809
  if (str[i] === "\n") {
676
810
  insertNewline();
677
811
  i++;
@@ -687,8 +821,10 @@ function readMultilineInput(sessionId) {
687
821
  continue;
688
822
  }
689
823
  if (str[i] === " ") {
690
- if (lines.length === 1 && lines[0].startsWith("/")) {
691
- const matches = CHAT_COMMANDS.filter((c) => c.cmd.startsWith(lines[0]));
824
+ const currentLine = lines[crow];
825
+ if (lines.length === 1 && currentLine.startsWith("/")) {
826
+ const partial = currentLine.split(" ")[0];
827
+ const matches = CHAT_COMMANDS.filter((c) => c.cmd.startsWith(partial));
692
828
  if (matches.length === 1) {
693
829
  lines[0] = matches[0].cmd + " ";
694
830
  ccol = lines[0].length;
@@ -697,7 +833,7 @@ function readMultilineInput(sessionId) {
697
833
  const lastRow = lines.length - 1;
698
834
  const rowsDown = lastRow - crow;
699
835
  if (rowsDown > 0) process.stdout.write(`\x1B[${rowsDown}B`);
700
- process.stdout.write("\n");
836
+ process.stdout.write("\r\n");
701
837
  for (const m of matches) {
702
838
  process.stdout.write(` ${cyan(m.cmd.padEnd(14))} ${dim(m.desc)}
703
839
  `);
@@ -812,7 +948,8 @@ async function chat(explicitSession) {
812
948
  process.exit(0);
813
949
  };
814
950
  while (true) {
815
- const text = await readMultilineInput(current.id);
951
+ const gitCtx = getGitContext();
952
+ const text = await readMultilineInput(current.id, projectName, gitCtx);
816
953
  if (text === null) exitChat();
817
954
  if (!text) continue;
818
955
  const trimmed = text.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groundcrew-cli",
3
- "version": "0.15.16",
3
+ "version": "0.16.0",
4
4
  "description": "CLI companion for Groundcrew — queue tasks, send feedback, monitor your Copilot agent from another terminal.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.ts CHANGED
@@ -2,11 +2,40 @@ import fs from "fs/promises";
2
2
  import { existsSync } from "fs";
3
3
  import path from "path";
4
4
  import readline from "readline";
5
- import { execFile } from "child_process";
5
+ import { execFile, execFileSync } from "child_process";
6
6
  import { promisify } from "util";
7
7
 
8
8
  const execFileAsync = promisify(execFile);
9
9
 
10
+ // Git context for separator line (cached, refreshed periodically)
11
+ function getGitContext(): { branch: string; dirty: string } | null {
12
+ try {
13
+ const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
14
+ encoding: "utf8", timeout: 500, stdio: ["pipe", "pipe", "pipe"],
15
+ }).trim();
16
+ // Status indicators matching Copilot CLI: * unstaged, + staged, % untracked
17
+ let dirty = "";
18
+ try {
19
+ const status = execFileSync("git", ["status", "--porcelain", "-uno"], {
20
+ encoding: "utf8", timeout: 500, stdio: ["pipe", "pipe", "pipe"],
21
+ }).trim();
22
+ if (status) {
23
+ const hasStaged = status.split("\n").some(l => l[0] !== " " && l[0] !== "?");
24
+ const hasUnstaged = status.split("\n").some(l => l[1] === "M" || l[1] === "D");
25
+ if (hasStaged) dirty += "+";
26
+ if (hasUnstaged) dirty += "*";
27
+ }
28
+ const untracked = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
29
+ encoding: "utf8", timeout: 500, stdio: ["pipe", "pipe", "pipe"],
30
+ }).trim();
31
+ if (untracked) dirty += "%";
32
+ } catch { /* ignore status errors */ }
33
+ return { branch, dirty };
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
10
39
  // Resolved at startup by resolveRoot() — git-aware project root discovery
11
40
  let GROUNDCREW_DIR = ".groundcrew";
12
41
  let SESSIONS_DIR = path.join(GROUNDCREW_DIR, "sessions");
@@ -578,7 +607,7 @@ const CHAT_COMMANDS: Array<{ cmd: string; desc: string }> = [
578
607
  * Tab: slash command completion
579
608
  * Paste: bracketed paste with multiline support
580
609
  */
581
- function readMultilineInput(sessionId: string): Promise<string | null> {
610
+ function readMultilineInput(sessionId: string, projectName: string, gitCtx: { branch: string; dirty: string } | null): Promise<string | null> {
582
611
  return new Promise((resolve) => {
583
612
  const lines: string[] = [""];
584
613
  let crow = 0; // cursor row in lines[]
@@ -587,7 +616,7 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
587
616
  // Visible width of prompt: "[sessionId] > "
588
617
  const padWidth = sessionId.length + 5; // [ + id + ] + space + > + space = len+5
589
618
 
590
- // Track which terminal row the cursor was on after last render
619
+ // Track how many rows up from cursor to top of rendered area (including separator)
591
620
  let lastTermRow = 0;
592
621
  let pasteBuffer = "";
593
622
  let isPasting = false;
@@ -597,13 +626,25 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
597
626
  const render = () => {
598
627
  const buf: string[] = [];
599
628
 
600
- // Move to start of input area
629
+ // Move to start of input area (includes separator line)
601
630
  if (lastTermRow > 0) buf.push(`\x1b[${lastTermRow}A`);
602
631
  buf.push("\r\x1b[J"); // col 0 + clear to end of screen
603
632
 
604
- // Draw each line
633
+ // Separator line: ─── sessionId projectName git:(branch*) ───
634
+ const termW = process.stdout.columns || 80;
635
+ let info = ` ${sessionId} `;
636
+ const ctxParts: string[] = [];
637
+ if (projectName) ctxParts.push(projectName);
638
+ if (gitCtx) {
639
+ ctxParts.push(`git:(${gitCtx.branch}${gitCtx.dirty})`);
640
+ }
641
+ if (ctxParts.length) info += ` ${ctxParts.join(" ")} `;
642
+ const dashRight = "─".repeat(Math.max(0, termW - 4 - info.length));
643
+ buf.push(dim("───" + info + dashRight));
644
+
645
+ // Draw each input line (below separator)
605
646
  for (let i = 0; i < lines.length; i++) {
606
- if (i > 0) buf.push("\n");
647
+ buf.push("\n");
607
648
  if (i === 0) {
608
649
  buf.push(dim(`[${sessionId}]`) + " " + bold(">") + " " + lines[i]);
609
650
  } else {
@@ -620,7 +661,9 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
620
661
  const col = padWidth + ccol;
621
662
  if (col > 0) buf.push(`\x1b[${col}C`);
622
663
 
623
- lastTermRow = crow;
664
+ // lastTermRow = rows from cursor back to top of separator
665
+ // separator is 1 row, then crow rows of input below it
666
+ lastTermRow = 1 + crow;
624
667
  process.stdout.write(buf.join(""));
625
668
  };
626
669
 
@@ -740,8 +783,63 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
740
783
  // End (\x1b[F)
741
784
  if (str.startsWith("\x1b[F", i)) { ccol = lines[crow].length; render(); i += 3; continue; }
742
785
 
743
- // Skip unknown CSI sequences
786
+ // CSI u (Kitty keyboard protocol) — decode \x1b[{codepoint};{modifier}u
787
+ // modifier bit 3 (value 4) = Ctrl, sent as bits+1 so modifier 5 = Ctrl
744
788
  if (str[i] === "\x1b" && i + 1 < str.length && str[i + 1] === "[") {
789
+ const csiMatch = str.slice(i).match(/^\x1b\[(\d+);(\d+)u/);
790
+ if (csiMatch) {
791
+ const codepoint = parseInt(csiMatch[1], 10);
792
+ const modifier = parseInt(csiMatch[2], 10);
793
+ const isCtrl = (modifier - 1) & 4;
794
+ const seqLen = csiMatch[0].length;
795
+
796
+ if (isCtrl) {
797
+ switch (codepoint) {
798
+ case 99: // Ctrl+C
799
+ if (fullText() || lines.length > 1 || lines[0].length > 0) {
800
+ const lastRow = lines.length - 1;
801
+ const rowsDown = lastRow - crow;
802
+ if (rowsDown > 0) process.stdout.write(`\x1b[${rowsDown}B`);
803
+ process.stdout.write("\r\n");
804
+ lines.length = 0; lines.push("");
805
+ crow = 0; ccol = 0; lastTermRow = 0;
806
+ render();
807
+ } else {
808
+ process.stdout.write("\r\n");
809
+ finish(null); return;
810
+ }
811
+ i += seqLen; continue;
812
+ case 100: // Ctrl+D
813
+ if (fullText()) { doDelete(); } else { process.stdout.write("\n"); finish(null); return; }
814
+ i += seqLen; continue;
815
+ case 97: // Ctrl+A — home
816
+ ccol = 0; render(); i += seqLen; continue;
817
+ case 101: // Ctrl+E — end
818
+ ccol = lines[crow].length; render(); i += seqLen; continue;
819
+ case 117: // Ctrl+U — clear before cursor
820
+ lines[crow] = lines[crow].slice(ccol); ccol = 0; render(); i += seqLen; continue;
821
+ case 107: // Ctrl+K — clear after cursor
822
+ lines[crow] = lines[crow].slice(0, ccol); render(); i += seqLen; continue;
823
+ case 119: // Ctrl+W — delete word before cursor
824
+ { const before = lines[crow].slice(0, ccol);
825
+ const stripped = before.replace(/\s+$/, "");
826
+ const sp = stripped.lastIndexOf(" ");
827
+ const newBefore = sp >= 0 ? stripped.slice(0, sp + 1) : "";
828
+ lines[crow] = newBefore + lines[crow].slice(ccol);
829
+ ccol = newBefore.length; render(); }
830
+ i += seqLen; continue;
831
+ case 108: // Ctrl+L — clear screen
832
+ process.stdout.write("\x1b[2J\x1b[H");
833
+ lastTermRow = 0; render();
834
+ i += seqLen; continue;
835
+ default: break;
836
+ }
837
+ }
838
+ // Unhandled CSI u — skip it
839
+ i += seqLen; continue;
840
+ }
841
+
842
+ // Skip unknown CSI sequences (non-u final byte)
745
843
  let j = i + 2;
746
844
  while (j < str.length && str.charCodeAt(j) >= 0x30 && str.charCodeAt(j) <= 0x3f) j++;
747
845
  if (j < str.length) j++; // skip final byte
@@ -752,17 +850,18 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
752
850
 
753
851
  // Ctrl+C — clear input or exit
754
852
  if (str[i] === "\x03") {
755
- if (fullText()) {
756
- // Move past current rendering, start fresh below
853
+ const hasText = fullText();
854
+ if (hasText || lines.length > 1 || lines[0].length > 0) {
855
+ // Move past current rendering to below last line, start fresh
757
856
  const lastRow = lines.length - 1;
758
857
  const rowsDown = lastRow - crow;
759
858
  if (rowsDown > 0) process.stdout.write(`\x1b[${rowsDown}B`);
760
- process.stdout.write("\n");
859
+ process.stdout.write("\r\n");
761
860
  lines.length = 0; lines.push("");
762
861
  crow = 0; ccol = 0; lastTermRow = 0;
763
862
  render();
764
863
  } else {
765
- process.stdout.write("\n");
864
+ process.stdout.write("\r\n");
766
865
  finish(null); return;
767
866
  }
768
867
  i++; continue;
@@ -795,6 +894,11 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
795
894
  lines[crow] = newBefore + lines[crow].slice(ccol);
796
895
  ccol = newBefore.length; render(); i++; continue;
797
896
  }
897
+ // Ctrl+L — clear screen (legacy byte)
898
+ if (str[i] === "\x0c") {
899
+ process.stdout.write("\x1b[2J\x1b[H");
900
+ lastTermRow = 0; render(); i++; continue;
901
+ }
798
902
 
799
903
  // Ctrl+J (LF, 0x0A) — newline (cross-terminal)
800
904
  if (str[i] === "\n") { insertNewline(); i++; continue; }
@@ -807,8 +911,10 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
807
911
 
808
912
  // Tab — slash command completion
809
913
  if (str[i] === "\t") {
810
- if (lines.length === 1 && lines[0].startsWith("/")) {
811
- const matches = CHAT_COMMANDS.filter(c => c.cmd.startsWith(lines[0]));
914
+ const currentLine = lines[crow];
915
+ if (lines.length === 1 && currentLine.startsWith("/")) {
916
+ const partial = currentLine.split(" ")[0]; // only match command part
917
+ const matches = CHAT_COMMANDS.filter(c => c.cmd.startsWith(partial));
812
918
  if (matches.length === 1) {
813
919
  lines[0] = matches[0].cmd + " ";
814
920
  ccol = lines[0].length; render();
@@ -817,7 +923,7 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
817
923
  const lastRow = lines.length - 1;
818
924
  const rowsDown = lastRow - crow;
819
925
  if (rowsDown > 0) process.stdout.write(`\x1b[${rowsDown}B`);
820
- process.stdout.write("\n");
926
+ process.stdout.write("\r\n");
821
927
  for (const m of matches) {
822
928
  process.stdout.write(` ${cyan(m.cmd.padEnd(14))} ${dim(m.desc)}\n`);
823
929
  }
@@ -952,7 +1058,9 @@ async function chat(explicitSession?: string): Promise<void> {
952
1058
 
953
1059
  // ── Main chat loop ─────────────────────────────────────────────────────────────────
954
1060
  while (true) {
955
- const text = await readMultilineInput(current.id);
1061
+ // Refresh git context each turn (branch may change between prompts)
1062
+ const gitCtx = getGitContext();
1063
+ const text = await readMultilineInput(current.id, projectName, gitCtx);
956
1064
 
957
1065
  if (text === null) exitChat();
958
1066
  if (!text) continue;