groundcrew-cli 0.15.17 → 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 +134 -4
  2. package/package.json +1 -1
  3. package/src/index.ts +103 -6
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;
@@ -444,7 +477,13 @@ function readMultilineInput(sessionId) {
444
477
  if (lastTermRow > 0) buf.push(`\x1B[${lastTermRow}A`);
445
478
  buf.push("\r\x1B[J");
446
479
  const termW = process.stdout.columns || 80;
447
- const info = ` ${sessionId} `;
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(" ")} `;
448
487
  const dashRight = "\u2500".repeat(Math.max(0, termW - 4 - info.length));
449
488
  buf.push(dim("\u2500\u2500\u2500" + info + dashRight));
450
489
  for (let i = 0; i < lines.length; i++) {
@@ -599,6 +638,89 @@ function readMultilineInput(sessionId) {
599
638
  continue;
600
639
  }
601
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
+ }
602
724
  let j = i + 2;
603
725
  while (j < str.length && str.charCodeAt(j) >= 48 && str.charCodeAt(j) <= 63) j++;
604
726
  if (j < str.length) j++;
@@ -677,6 +799,13 @@ function readMultilineInput(sessionId) {
677
799
  i++;
678
800
  continue;
679
801
  }
802
+ if (str[i] === "\f") {
803
+ process.stdout.write("\x1B[2J\x1B[H");
804
+ lastTermRow = 0;
805
+ render();
806
+ i++;
807
+ continue;
808
+ }
680
809
  if (str[i] === "\n") {
681
810
  insertNewline();
682
811
  i++;
@@ -819,7 +948,8 @@ async function chat(explicitSession) {
819
948
  process.exit(0);
820
949
  };
821
950
  while (true) {
822
- const text = await readMultilineInput(current.id);
951
+ const gitCtx = getGitContext();
952
+ const text = await readMultilineInput(current.id, projectName, gitCtx);
823
953
  if (text === null) exitChat();
824
954
  if (!text) continue;
825
955
  const trimmed = text.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groundcrew-cli",
3
- "version": "0.15.17",
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[]
@@ -601,9 +630,15 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
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
- // Separator line: ─── sessionId ─────────
633
+ // Separator line: ─── sessionId projectName git:(branch*) ───
605
634
  const termW = process.stdout.columns || 80;
606
- const info = ` ${sessionId} `;
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(" ")} `;
607
642
  const dashRight = "─".repeat(Math.max(0, termW - 4 - info.length));
608
643
  buf.push(dim("───" + info + dashRight));
609
644
 
@@ -748,8 +783,63 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
748
783
  // End (\x1b[F)
749
784
  if (str.startsWith("\x1b[F", i)) { ccol = lines[crow].length; render(); i += 3; continue; }
750
785
 
751
- // 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
752
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)
753
843
  let j = i + 2;
754
844
  while (j < str.length && str.charCodeAt(j) >= 0x30 && str.charCodeAt(j) <= 0x3f) j++;
755
845
  if (j < str.length) j++; // skip final byte
@@ -804,6 +894,11 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
804
894
  lines[crow] = newBefore + lines[crow].slice(ccol);
805
895
  ccol = newBefore.length; render(); i++; continue;
806
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
+ }
807
902
 
808
903
  // Ctrl+J (LF, 0x0A) — newline (cross-terminal)
809
904
  if (str[i] === "\n") { insertNewline(); i++; continue; }
@@ -963,7 +1058,9 @@ async function chat(explicitSession?: string): Promise<void> {
963
1058
 
964
1059
  // ── Main chat loop ─────────────────────────────────────────────────────────────────
965
1060
  while (true) {
966
- 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);
967
1064
 
968
1065
  if (text === null) exitChat();
969
1066
  if (!text) continue;