groundcrew-cli 0.15.17 → 0.16.1

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 +150 -4
  2. package/package.json +1 -1
  3. package/src/index.ts +122 -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,105 @@ 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 = csiMatch[2] ? parseInt(csiMatch[2], 10) : 1;
645
+ const isCtrl = modifier - 1 & 4;
646
+ const seqLen = csiMatch[0].length;
647
+ if (!isCtrl && modifier <= 1) {
648
+ switch (codepoint) {
649
+ case 9:
650
+ str = str.slice(0, i) + " " + str.slice(i + seqLen);
651
+ continue;
652
+ case 13:
653
+ str = str.slice(0, i) + "\r" + str.slice(i + seqLen);
654
+ continue;
655
+ case 27:
656
+ i += seqLen;
657
+ continue;
658
+ case 127:
659
+ str = str.slice(0, i) + "\x7F" + str.slice(i + seqLen);
660
+ continue;
661
+ }
662
+ }
663
+ if (isCtrl) {
664
+ switch (codepoint) {
665
+ case 99:
666
+ if (fullText() || lines.length > 1 || lines[0].length > 0) {
667
+ const lastRow = lines.length - 1;
668
+ const rowsDown = lastRow - crow;
669
+ if (rowsDown > 0) process.stdout.write(`\x1B[${rowsDown}B`);
670
+ process.stdout.write("\r\n");
671
+ lines.length = 0;
672
+ lines.push("");
673
+ crow = 0;
674
+ ccol = 0;
675
+ lastTermRow = 0;
676
+ render();
677
+ } else {
678
+ process.stdout.write("\r\n");
679
+ finish(null);
680
+ return;
681
+ }
682
+ i += seqLen;
683
+ continue;
684
+ case 100:
685
+ if (fullText()) {
686
+ doDelete();
687
+ } else {
688
+ process.stdout.write("\n");
689
+ finish(null);
690
+ return;
691
+ }
692
+ i += seqLen;
693
+ continue;
694
+ case 97:
695
+ ccol = 0;
696
+ render();
697
+ i += seqLen;
698
+ continue;
699
+ case 101:
700
+ ccol = lines[crow].length;
701
+ render();
702
+ i += seqLen;
703
+ continue;
704
+ case 117:
705
+ lines[crow] = lines[crow].slice(ccol);
706
+ ccol = 0;
707
+ render();
708
+ i += seqLen;
709
+ continue;
710
+ case 107:
711
+ lines[crow] = lines[crow].slice(0, ccol);
712
+ render();
713
+ i += seqLen;
714
+ continue;
715
+ case 119:
716
+ {
717
+ const before = lines[crow].slice(0, ccol);
718
+ const stripped = before.replace(/\s+$/, "");
719
+ const sp = stripped.lastIndexOf(" ");
720
+ const newBefore = sp >= 0 ? stripped.slice(0, sp + 1) : "";
721
+ lines[crow] = newBefore + lines[crow].slice(ccol);
722
+ ccol = newBefore.length;
723
+ render();
724
+ }
725
+ i += seqLen;
726
+ continue;
727
+ case 108:
728
+ process.stdout.write("\x1B[2J\x1B[H");
729
+ lastTermRow = 0;
730
+ render();
731
+ i += seqLen;
732
+ continue;
733
+ default:
734
+ break;
735
+ }
736
+ }
737
+ i += seqLen;
738
+ continue;
739
+ }
602
740
  let j = i + 2;
603
741
  while (j < str.length && str.charCodeAt(j) >= 48 && str.charCodeAt(j) <= 63) j++;
604
742
  if (j < str.length) j++;
@@ -677,6 +815,13 @@ function readMultilineInput(sessionId) {
677
815
  i++;
678
816
  continue;
679
817
  }
818
+ if (str[i] === "\f") {
819
+ process.stdout.write("\x1B[2J\x1B[H");
820
+ lastTermRow = 0;
821
+ render();
822
+ i++;
823
+ continue;
824
+ }
680
825
  if (str[i] === "\n") {
681
826
  insertNewline();
682
827
  i++;
@@ -819,7 +964,8 @@ async function chat(explicitSession) {
819
964
  process.exit(0);
820
965
  };
821
966
  while (true) {
822
- const text = await readMultilineInput(current.id);
967
+ const gitCtx = getGitContext();
968
+ const text = await readMultilineInput(current.id, projectName, gitCtx);
823
969
  if (text === null) exitChat();
824
970
  if (!text) continue;
825
971
  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.1",
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,82 @@ 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
+ // Also handles single-param \x1b[{codepoint}u (unmodified key)
788
+ // modifier bit 3 (value 4) = Ctrl, sent as bits+1 so modifier 5 = Ctrl
752
789
  if (str[i] === "\x1b" && i + 1 < str.length && str[i + 1] === "[") {
790
+ const csiMatch = str.slice(i).match(/^\x1b\[(\d+)(?:;(\d+))?u/);
791
+ if (csiMatch) {
792
+ const codepoint = parseInt(csiMatch[1], 10);
793
+ const modifier = csiMatch[2] ? parseInt(csiMatch[2], 10) : 1;
794
+ const isCtrl = (modifier - 1) & 4;
795
+ const seqLen = csiMatch[0].length;
796
+
797
+ // Handle unmodified functional keys encoded as CSI u
798
+ if (!isCtrl && modifier <= 1) {
799
+ switch (codepoint) {
800
+ case 9: // Tab (unmodified)
801
+ // Delegate to Tab handler by injecting \t
802
+ str = str.slice(0, i) + "\t" + str.slice(i + seqLen);
803
+ continue;
804
+ case 13: // Enter (unmodified)
805
+ str = str.slice(0, i) + "\r" + str.slice(i + seqLen);
806
+ continue;
807
+ case 27: // Escape (unmodified)
808
+ i += seqLen; continue;
809
+ case 127: // Backspace (unmodified)
810
+ str = str.slice(0, i) + "\x7f" + str.slice(i + seqLen);
811
+ continue;
812
+ }
813
+ }
814
+
815
+ if (isCtrl) {
816
+ switch (codepoint) {
817
+ case 99: // Ctrl+C
818
+ if (fullText() || lines.length > 1 || lines[0].length > 0) {
819
+ const lastRow = lines.length - 1;
820
+ const rowsDown = lastRow - crow;
821
+ if (rowsDown > 0) process.stdout.write(`\x1b[${rowsDown}B`);
822
+ process.stdout.write("\r\n");
823
+ lines.length = 0; lines.push("");
824
+ crow = 0; ccol = 0; lastTermRow = 0;
825
+ render();
826
+ } else {
827
+ process.stdout.write("\r\n");
828
+ finish(null); return;
829
+ }
830
+ i += seqLen; continue;
831
+ case 100: // Ctrl+D
832
+ if (fullText()) { doDelete(); } else { process.stdout.write("\n"); finish(null); return; }
833
+ i += seqLen; continue;
834
+ case 97: // Ctrl+A — home
835
+ ccol = 0; render(); i += seqLen; continue;
836
+ case 101: // Ctrl+E — end
837
+ ccol = lines[crow].length; render(); i += seqLen; continue;
838
+ case 117: // Ctrl+U — clear before cursor
839
+ lines[crow] = lines[crow].slice(ccol); ccol = 0; render(); i += seqLen; continue;
840
+ case 107: // Ctrl+K — clear after cursor
841
+ lines[crow] = lines[crow].slice(0, ccol); render(); i += seqLen; continue;
842
+ case 119: // Ctrl+W — delete word before cursor
843
+ { const before = lines[crow].slice(0, ccol);
844
+ const stripped = before.replace(/\s+$/, "");
845
+ const sp = stripped.lastIndexOf(" ");
846
+ const newBefore = sp >= 0 ? stripped.slice(0, sp + 1) : "";
847
+ lines[crow] = newBefore + lines[crow].slice(ccol);
848
+ ccol = newBefore.length; render(); }
849
+ i += seqLen; continue;
850
+ case 108: // Ctrl+L — clear screen
851
+ process.stdout.write("\x1b[2J\x1b[H");
852
+ lastTermRow = 0; render();
853
+ i += seqLen; continue;
854
+ default: break;
855
+ }
856
+ }
857
+ // Unhandled CSI u — skip it
858
+ i += seqLen; continue;
859
+ }
860
+
861
+ // Skip unknown CSI sequences (non-u final byte)
753
862
  let j = i + 2;
754
863
  while (j < str.length && str.charCodeAt(j) >= 0x30 && str.charCodeAt(j) <= 0x3f) j++;
755
864
  if (j < str.length) j++; // skip final byte
@@ -804,6 +913,11 @@ function readMultilineInput(sessionId: string): Promise<string | null> {
804
913
  lines[crow] = newBefore + lines[crow].slice(ccol);
805
914
  ccol = newBefore.length; render(); i++; continue;
806
915
  }
916
+ // Ctrl+L — clear screen (legacy byte)
917
+ if (str[i] === "\x0c") {
918
+ process.stdout.write("\x1b[2J\x1b[H");
919
+ lastTermRow = 0; render(); i++; continue;
920
+ }
807
921
 
808
922
  // Ctrl+J (LF, 0x0A) — newline (cross-terminal)
809
923
  if (str[i] === "\n") { insertNewline(); i++; continue; }
@@ -963,7 +1077,9 @@ async function chat(explicitSession?: string): Promise<void> {
963
1077
 
964
1078
  // ── Main chat loop ─────────────────────────────────────────────────────────────────
965
1079
  while (true) {
966
- const text = await readMultilineInput(current.id);
1080
+ // Refresh git context each turn (branch may change between prompts)
1081
+ const gitCtx = getGitContext();
1082
+ const text = await readMultilineInput(current.id, projectName, gitCtx);
967
1083
 
968
1084
  if (text === null) exitChat();
969
1085
  if (!text) continue;