groundcrew-cli 0.16.3 → 0.16.5

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 CHANGED
@@ -41,6 +41,38 @@ function getGitContext() {
41
41
  return null;
42
42
  }
43
43
  }
44
+ function pasteFromClipboard(sessionDir) {
45
+ try {
46
+ const text = execFileSync("pbpaste", [], { encoding: "utf8", timeout: 1e3, stdio: ["pipe", "pipe", "pipe"] }).replace(/\r\n/g, "\n");
47
+ if (text) return { type: "text", text };
48
+ } catch {
49
+ }
50
+ if (process.platform !== "darwin") return null;
51
+ try {
52
+ const check = execFileSync("osascript", [
53
+ "-e",
54
+ 'try\nthe clipboard as \xABclass PNGf\xBB\nreturn "image"\non error\nreturn "none"\nend try'
55
+ ], { encoding: "utf8", timeout: 2e3, stdio: ["pipe", "pipe", "pipe"] }).trim();
56
+ if (check !== "image") return null;
57
+ const attachDir = path.join(sessionDir, "attachments");
58
+ try {
59
+ execFileSync("mkdir", ["-p", attachDir]);
60
+ } catch {
61
+ }
62
+ const fname = `clipboard-${Date.now()}.png`;
63
+ const fpath = path.join(attachDir, fname);
64
+ execFileSync("osascript", ["-e", `
65
+ set theFile to POSIX file "${fpath}"
66
+ set imageData to the clipboard as \xABclass PNGf\xBB
67
+ set fp to open for access theFile with write permission
68
+ set eof fp to 0
69
+ write imageData to fp
70
+ close access fp`], { timeout: 3e3, stdio: ["pipe", "pipe", "pipe"] });
71
+ return { type: "image", path: fpath };
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
44
76
  var GROUNDCREW_DIR = ".groundcrew";
45
77
  var SESSIONS_DIR = path.join(GROUNDCREW_DIR, "sessions");
46
78
  var ACTIVE_SESSIONS_FILE = path.join(GROUNDCREW_DIR, "active-sessions.json");
@@ -462,7 +494,7 @@ var CHAT_COMMANDS = [
462
494
  { cmd: "/clear", desc: "Clear pending tasks" },
463
495
  { cmd: "/exit", desc: "Exit chat" }
464
496
  ];
465
- function readMultilineInput(sessionId, projectName, gitCtx) {
497
+ function readMultilineInput(sessionId, projectName, gitCtx, sessionDir) {
466
498
  return new Promise((resolve) => {
467
499
  const lines = [""];
468
500
  let crow = 0;
@@ -507,12 +539,23 @@ function readMultilineInput(sessionId, projectName, gitCtx) {
507
539
  }
508
540
  }
509
541
  const lastRow = lines.length - 1;
510
- const rowsUp = lastRow - crow + suggestionRows;
511
- if (rowsUp > 0) buf.push(`\x1B[${rowsUp}A`);
542
+ const termRowsForLine = (i) => {
543
+ const lineLen = (i === 0 ? padWidth : padWidth) + lines[i].length;
544
+ return lineLen === 0 ? 1 : Math.max(1, Math.ceil(lineLen / termW));
545
+ };
546
+ let rowsBelowCursor = suggestionRows;
547
+ for (let i = lastRow; i > crow; i--) rowsBelowCursor += termRowsForLine(i);
548
+ const cursorLineTermRows = termRowsForLine(crow);
549
+ const cursorRowWithinLine = Math.floor((padWidth + ccol) / termW);
550
+ rowsBelowCursor += cursorLineTermRows - 1 - cursorRowWithinLine;
551
+ if (rowsBelowCursor > 0) buf.push(`\x1B[${rowsBelowCursor}A`);
512
552
  buf.push("\r");
513
- const col = padWidth + ccol;
553
+ const col = (padWidth + ccol) % termW;
514
554
  if (col > 0) buf.push(`\x1B[${col}C`);
515
- lastTermRow = 1 + crow;
555
+ let rowsAbove = 1;
556
+ for (let i = 0; i < crow; i++) rowsAbove += termRowsForLine(i);
557
+ rowsAbove += cursorRowWithinLine;
558
+ lastTermRow = rowsAbove;
516
559
  process.stdout.write(buf.join(""));
517
560
  };
518
561
  const finish = (result) => {
@@ -741,6 +784,15 @@ function readMultilineInput(sessionId, projectName, gitCtx) {
741
784
  render();
742
785
  i += seqLen;
743
786
  continue;
787
+ case 118:
788
+ {
789
+ const clip = pasteFromClipboard(sessionDir);
790
+ if (clip?.type === "text") insertText(clip.text);
791
+ else if (clip?.type === "image") insertText(`[\u{1F4F7} ${clip.path}]`);
792
+ render();
793
+ }
794
+ i += seqLen;
795
+ continue;
744
796
  default:
745
797
  break;
746
798
  }
@@ -827,6 +879,14 @@ function readMultilineInput(sessionId, projectName, gitCtx) {
827
879
  i++;
828
880
  continue;
829
881
  }
882
+ if (str[i] === "") {
883
+ const clip = pasteFromClipboard(sessionDir);
884
+ if (clip?.type === "text") insertText(clip.text);
885
+ else if (clip?.type === "image") insertText(`[\u{1F4F7} ${clip.path}]`);
886
+ render();
887
+ i++;
888
+ continue;
889
+ }
830
890
  if (str[i] === "\n") {
831
891
  insertNewline();
832
892
  i++;
@@ -970,7 +1030,7 @@ async function chat(explicitSession) {
970
1030
  };
971
1031
  while (true) {
972
1032
  const gitCtx = getGitContext();
973
- const text = await readMultilineInput(current.id, projectName, gitCtx);
1033
+ const text = await readMultilineInput(current.id, projectName, gitCtx, current.dir);
974
1034
  if (text === null) exitChat();
975
1035
  if (!text) continue;
976
1036
  const trimmed = text.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "groundcrew-cli",
3
- "version": "0.16.3",
3
+ "version": "0.16.5",
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
@@ -36,6 +36,38 @@ function getGitContext(): { branch: string; dirty: string } | null {
36
36
  }
37
37
  }
38
38
 
39
+ // Clipboard paste with image support (macOS)
40
+ function pasteFromClipboard(sessionDir: string): { type: "text"; text: string } | { type: "image"; path: string } | null {
41
+ // Try text first (fast)
42
+ try {
43
+ const text = execFileSync("pbpaste", [], { encoding: "utf8", timeout: 1000, stdio: ["pipe", "pipe", "pipe"] }).replace(/\r\n/g, "\n");
44
+ if (text) return { type: "text", text };
45
+ } catch { /* no text */ }
46
+
47
+ // Check for image in clipboard
48
+ if (process.platform !== "darwin") return null;
49
+ try {
50
+ const check = execFileSync("osascript", ["-e",
51
+ 'try\nthe clipboard as «class PNGf»\nreturn "image"\non error\nreturn "none"\nend try',
52
+ ], { encoding: "utf8", timeout: 2000, stdio: ["pipe", "pipe", "pipe"] }).trim();
53
+ if (check !== "image") return null;
54
+
55
+ // Save image to session attachments dir
56
+ const attachDir = path.join(sessionDir, "attachments");
57
+ try { execFileSync("mkdir", ["-p", attachDir]); } catch { /* exists */ }
58
+ const fname = `clipboard-${Date.now()}.png`;
59
+ const fpath = path.join(attachDir, fname);
60
+ execFileSync("osascript", ["-e", `
61
+ set theFile to POSIX file "${fpath}"
62
+ set imageData to the clipboard as «class PNGf»
63
+ set fp to open for access theFile with write permission
64
+ set eof fp to 0
65
+ write imageData to fp
66
+ close access fp`], { timeout: 3000, stdio: ["pipe", "pipe", "pipe"] });
67
+ return { type: "image", path: fpath };
68
+ } catch { return null; }
69
+ }
70
+
39
71
  // Resolved at startup by resolveRoot() — git-aware project root discovery
40
72
  let GROUNDCREW_DIR = ".groundcrew";
41
73
  let SESSIONS_DIR = path.join(GROUNDCREW_DIR, "sessions");
@@ -607,7 +639,7 @@ const CHAT_COMMANDS: Array<{ cmd: string; desc: string }> = [
607
639
  * Tab: slash command completion
608
640
  * Paste: bracketed paste with multiline support
609
641
  */
610
- function readMultilineInput(sessionId: string, projectName: string, gitCtx: { branch: string; dirty: string } | null): Promise<string | null> {
642
+ function readMultilineInput(sessionId: string, projectName: string, gitCtx: { branch: string; dirty: string } | null, sessionDir: string): Promise<string | null> {
611
643
  return new Promise((resolve) => {
612
644
  const lines: string[] = [""];
613
645
  let crow = 0; // cursor row in lines[]
@@ -667,17 +699,33 @@ function readMultilineInput(sessionId: string, projectName: string, gitCtx: { br
667
699
 
668
700
  // Position cursor at (crow, ccol)
669
701
  const lastRow = lines.length - 1;
670
- // Position cursor at (crow, ccol) — move up past remaining input lines + suggestions
671
- const rowsUp = (lastRow - crow) + suggestionRows;
672
- if (rowsUp > 0) buf.push(`\x1b[${rowsUp}A`);
702
+
703
+ // Calculate actual terminal rows each line occupies (for wrapped lines)
704
+ const termRowsForLine = (i: number): number => {
705
+ const lineLen = (i === 0 ? padWidth : padWidth) + lines[i].length;
706
+ return lineLen === 0 ? 1 : Math.max(1, Math.ceil(lineLen / termW));
707
+ };
708
+
709
+ // Move up from the end of the last drawn line to the cursor position
710
+ // Count terminal rows below cursor line (remaining input lines + suggestions)
711
+ let rowsBelowCursor = suggestionRows;
712
+ for (let i = lastRow; i > crow; i--) rowsBelowCursor += termRowsForLine(i);
713
+ // Add any extra wrapped rows on the cursor line itself (below the cursor's row within wraps)
714
+ const cursorLineTermRows = termRowsForLine(crow);
715
+ const cursorRowWithinLine = Math.floor((padWidth + ccol) / termW);
716
+ rowsBelowCursor += (cursorLineTermRows - 1 - cursorRowWithinLine);
717
+
718
+ if (rowsBelowCursor > 0) buf.push(`\x1b[${rowsBelowCursor}A`);
673
719
 
674
720
  buf.push("\r");
675
- const col = padWidth + ccol;
721
+ const col = (padWidth + ccol) % termW;
676
722
  if (col > 0) buf.push(`\x1b[${col}C`);
677
723
 
678
- // lastTermRow = rows above cursor (separator + input lines above crow)
679
- // Suggestion rows below cursor are cleared by \x1b[J on next render
680
- lastTermRow = 1 + crow;
724
+ // lastTermRow = terminal rows above cursor (separator + wrapped input lines above crow + cursor's wrapped rows above)
725
+ let rowsAbove = 1; // separator line
726
+ for (let i = 0; i < crow; i++) rowsAbove += termRowsForLine(i);
727
+ rowsAbove += cursorRowWithinLine;
728
+ lastTermRow = rowsAbove;
681
729
  process.stdout.write(buf.join(""));
682
730
  };
683
731
 
@@ -868,6 +916,12 @@ function readMultilineInput(sessionId: string, projectName: string, gitCtx: { br
868
916
  process.stdout.write("\x1b[2J\x1b[H");
869
917
  lastTermRow = 0; render();
870
918
  i += seqLen; continue;
919
+ case 118: // Ctrl+V — paste from clipboard
920
+ { const clip = pasteFromClipboard(sessionDir);
921
+ if (clip?.type === "text") insertText(clip.text);
922
+ else if (clip?.type === "image") insertText(`[📷 ${clip.path}]`);
923
+ render(); }
924
+ i += seqLen; continue;
871
925
  default: break;
872
926
  }
873
927
  }
@@ -930,6 +984,13 @@ function readMultilineInput(sessionId: string, projectName: string, gitCtx: { br
930
984
  process.stdout.write("\x1b[2J\x1b[H");
931
985
  lastTermRow = 0; render(); i++; continue;
932
986
  }
987
+ // Ctrl+V — paste from clipboard (legacy byte)
988
+ if (str[i] === "\x16") {
989
+ const clip = pasteFromClipboard(sessionDir);
990
+ if (clip?.type === "text") insertText(clip.text);
991
+ else if (clip?.type === "image") insertText(`[📷 ${clip.path}]`);
992
+ render(); i++; continue;
993
+ }
933
994
 
934
995
  // Ctrl+J (LF, 0x0A) — newline (cross-terminal)
935
996
  if (str[i] === "\n") { insertNewline(); i++; continue; }
@@ -1091,7 +1152,7 @@ async function chat(explicitSession?: string): Promise<void> {
1091
1152
  while (true) {
1092
1153
  // Refresh git context each turn (branch may change between prompts)
1093
1154
  const gitCtx = getGitContext();
1094
- const text = await readMultilineInput(current.id, projectName, gitCtx);
1155
+ const text = await readMultilineInput(current.id, projectName, gitCtx, current.dir);
1095
1156
 
1096
1157
  if (text === null) exitChat();
1097
1158
  if (!text) continue;