santree 0.2.12 → 0.2.14
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/commands/dashboard.js +84 -17
- package/dist/commands/helpers/statusline.js +13 -9
- package/dist/commands/linear/auth.js +12 -0
- package/dist/commands/worktree/work.d.ts +1 -0
- package/dist/commands/worktree/work.js +22 -1
- package/dist/lib/dashboard/MultilineTextArea.d.ts +12 -0
- package/dist/lib/dashboard/MultilineTextArea.js +48 -0
- package/dist/lib/dashboard/data.js +1 -1
- package/dist/lib/dashboard/types.d.ts +11 -1
- package/dist/lib/dashboard/types.js +18 -0
- package/package.json +1 -1
- package/prompts/work.njk +6 -0
- package/shell/init.bash.njk +11 -3
- package/shell/init.zsh.njk +11 -3
|
@@ -24,6 +24,7 @@ import DetailPanel from "../lib/dashboard/DetailPanel.js";
|
|
|
24
24
|
import ReviewList from "../lib/dashboard/ReviewList.js";
|
|
25
25
|
import ReviewDetailPanel from "../lib/dashboard/ReviewDetailPanel.js";
|
|
26
26
|
import { CommitOverlay, PrCreateOverlay } from "../lib/dashboard/Overlays.js";
|
|
27
|
+
import { MultilineTextArea } from "../lib/dashboard/MultilineTextArea.js";
|
|
27
28
|
export const description = "Interactive dashboard of your Linear issues";
|
|
28
29
|
const execAsync = promisify(exec);
|
|
29
30
|
const CLAUDE_VERSION = (() => {
|
|
@@ -367,13 +368,27 @@ export default function Dashboard() {
|
|
|
367
368
|
dispatch({ type: "REVIEW_SCROLL_LIST", offset });
|
|
368
369
|
}
|
|
369
370
|
}, [state.reviewSelectedIndex, state.flatReviews, contentHeight, state.reviewListScrollOffset]);
|
|
371
|
+
// ── Mouse tracking pause ─────────────────────────────────────────
|
|
372
|
+
// The MultilineTextArea captures ESC for cancel. With SGR mouse tracking on,
|
|
373
|
+
// every click emits `\x1b[<btn;col;rowM` — Ink reads the leading ESC and fires
|
|
374
|
+
// key.escape, dismissing the overlay. Disable mouse tracking while the
|
|
375
|
+
// context-input overlay is mounted; restore on unmount.
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
if (state.overlay !== "context-input")
|
|
378
|
+
return;
|
|
379
|
+
process.stdout.write("\x1b[?1002l\x1b[?1006l");
|
|
380
|
+
return () => {
|
|
381
|
+
process.stdout.write("\x1b[?1002h\x1b[?1006h");
|
|
382
|
+
};
|
|
383
|
+
}, [state.overlay]);
|
|
370
384
|
// ── Actions ───────────────────────────────────────────────────────
|
|
371
|
-
const launchWorkInTmux = useCallback((di, mode, worktreePath) => {
|
|
385
|
+
const launchWorkInTmux = useCallback((di, mode, worktreePath, contextFile) => {
|
|
372
386
|
const windowName = di.issue.identifier;
|
|
373
387
|
const sessionId = di.worktree?.sessionId;
|
|
374
388
|
const bin = resolveAgentBinary();
|
|
375
389
|
const resumeCmd = sessionId && bin ? `${bin} --resume ${sessionId}` : null;
|
|
376
|
-
const
|
|
390
|
+
const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
|
|
391
|
+
const workCmd = mode === "plan" ? `st worktree work --plan${contextArg}` : `st worktree work${contextArg}`;
|
|
377
392
|
try {
|
|
378
393
|
// Switch to existing window if it exists
|
|
379
394
|
execSync(`tmux select-window -t "${windowName}"`, { stdio: "ignore" });
|
|
@@ -409,10 +424,13 @@ export default function Dashboard() {
|
|
|
409
424
|
// Delayed refresh to pick up session ID created by `st worktree work`
|
|
410
425
|
setTimeout(() => refresh(), 3000);
|
|
411
426
|
}, [refresh]);
|
|
412
|
-
const launchAfterCreation = useCallback((mode, worktreePath, ticketId) => {
|
|
427
|
+
const launchAfterCreation = useCallback((mode, worktreePath, ticketId, contextFile) => {
|
|
413
428
|
if (isInTmux()) {
|
|
414
429
|
const windowName = ticketId;
|
|
415
|
-
const
|
|
430
|
+
const contextArg = contextFile ? ` --context-file "${contextFile}"` : "";
|
|
431
|
+
const workCmd = mode === "plan"
|
|
432
|
+
? `st worktree work --plan${contextArg}`
|
|
433
|
+
: `st worktree work${contextArg}`;
|
|
416
434
|
try {
|
|
417
435
|
execSync(`tmux new-window -n "${windowName}" -c "${worktreePath}"`, { stdio: "ignore" });
|
|
418
436
|
execSync("sleep 0.1", { stdio: "ignore" });
|
|
@@ -431,10 +449,25 @@ export default function Dashboard() {
|
|
|
431
449
|
leaveAltScreen();
|
|
432
450
|
console.log(`SANTREE_CD:${worktreePath}`);
|
|
433
451
|
console.log(`SANTREE_WORK:${mode}`);
|
|
452
|
+
if (contextFile)
|
|
453
|
+
console.log(`SANTREE_WORK_CONTEXT:${contextFile}`);
|
|
434
454
|
exit();
|
|
435
455
|
}
|
|
436
456
|
}, [exit, refresh]);
|
|
437
|
-
const
|
|
457
|
+
const writeContextFile = useCallback((context) => {
|
|
458
|
+
const trimmed = context?.trim();
|
|
459
|
+
if (!trimmed)
|
|
460
|
+
return undefined;
|
|
461
|
+
const filePath = path.join(os.tmpdir(), `santree-context-${Date.now()}.md`);
|
|
462
|
+
try {
|
|
463
|
+
fs.writeFileSync(filePath, trimmed);
|
|
464
|
+
return filePath;
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
return undefined;
|
|
468
|
+
}
|
|
469
|
+
}, []);
|
|
470
|
+
const createAndLaunch = useCallback(async (mode, runSetup, base, contextFile) => {
|
|
438
471
|
const di = stateRef.current.flatIssues[stateRef.current.selectedIndex];
|
|
439
472
|
if (!di)
|
|
440
473
|
return;
|
|
@@ -527,8 +560,10 @@ export default function Dashboard() {
|
|
|
527
560
|
}
|
|
528
561
|
// 4. Done — launch work
|
|
529
562
|
dispatch({ type: "CREATION_DONE" });
|
|
530
|
-
launchAfterCreation(mode, result.path, ticketId);
|
|
563
|
+
launchAfterCreation(mode, result.path, ticketId, contextFile);
|
|
531
564
|
}, [launchAfterCreation]);
|
|
565
|
+
// Holds the context file path through multi-step flows (mode-select → base-select → confirm-setup → create)
|
|
566
|
+
const pendingContextFileRef = useRef(null);
|
|
532
567
|
const proceedAfterBaseSelect = useCallback((mode, base) => {
|
|
533
568
|
const repoRoot = repoRootRef.current;
|
|
534
569
|
if (!repoRoot)
|
|
@@ -537,9 +572,11 @@ export default function Dashboard() {
|
|
|
537
572
|
dispatch({ type: "SETUP_CONFIRM_SHOW", mode });
|
|
538
573
|
return;
|
|
539
574
|
}
|
|
540
|
-
|
|
575
|
+
const contextFile = pendingContextFileRef.current ?? undefined;
|
|
576
|
+
pendingContextFileRef.current = null;
|
|
577
|
+
createAndLaunch(mode, false, base, contextFile);
|
|
541
578
|
}, [createAndLaunch]);
|
|
542
|
-
const doWork = useCallback((mode) => {
|
|
579
|
+
const doWork = useCallback((mode, customContext) => {
|
|
543
580
|
const di = state.flatIssues[state.selectedIndex];
|
|
544
581
|
if (!di)
|
|
545
582
|
return;
|
|
@@ -547,20 +584,24 @@ export default function Dashboard() {
|
|
|
547
584
|
if (!repoRoot)
|
|
548
585
|
return;
|
|
549
586
|
dispatch({ type: "SET_OVERLAY", overlay: null });
|
|
587
|
+
const contextFile = writeContextFile(customContext);
|
|
550
588
|
if (di.worktree) {
|
|
551
589
|
// Worktree exists — launch work
|
|
552
590
|
if (isInTmux()) {
|
|
553
|
-
launchWorkInTmux(di, mode, di.worktree.path);
|
|
591
|
+
launchWorkInTmux(di, mode, di.worktree.path, contextFile);
|
|
554
592
|
}
|
|
555
593
|
else {
|
|
556
594
|
leaveAltScreen();
|
|
557
595
|
console.log(`SANTREE_CD:${di.worktree.path}`);
|
|
558
596
|
console.log(`SANTREE_WORK:${mode}`);
|
|
597
|
+
if (contextFile)
|
|
598
|
+
console.log(`SANTREE_WORK_CONTEXT:${contextFile}`);
|
|
559
599
|
exit();
|
|
560
600
|
}
|
|
561
601
|
}
|
|
562
602
|
else {
|
|
563
|
-
// No worktree —
|
|
603
|
+
// No worktree — stash context for the create flow to pick up
|
|
604
|
+
pendingContextFileRef.current = contextFile ?? null;
|
|
564
605
|
const defaultBranch = getDefaultBranch();
|
|
565
606
|
const baseOptions = [defaultBranch];
|
|
566
607
|
for (const fi of state.flatIssues) {
|
|
@@ -578,7 +619,14 @@ export default function Dashboard() {
|
|
|
578
619
|
// Only default branch available — skip base select
|
|
579
620
|
proceedAfterBaseSelect(mode);
|
|
580
621
|
}
|
|
581
|
-
}, [
|
|
622
|
+
}, [
|
|
623
|
+
state.flatIssues,
|
|
624
|
+
state.selectedIndex,
|
|
625
|
+
exit,
|
|
626
|
+
launchWorkInTmux,
|
|
627
|
+
proceedAfterBaseSelect,
|
|
628
|
+
writeContextFile,
|
|
629
|
+
]);
|
|
582
630
|
// ── Commit flow ──────────────────────────────────────────────────
|
|
583
631
|
const handleStageAll = useCallback(async () => {
|
|
584
632
|
const wtPath = stateRef.current.commitWorktreePath;
|
|
@@ -918,16 +966,21 @@ export default function Dashboard() {
|
|
|
918
966
|
const base = state.baseSelectChosen ?? undefined;
|
|
919
967
|
if (input === "y" && mode) {
|
|
920
968
|
dispatch({ type: "SETUP_CONFIRM_DONE" });
|
|
921
|
-
|
|
969
|
+
const contextFile = pendingContextFileRef.current ?? undefined;
|
|
970
|
+
pendingContextFileRef.current = null;
|
|
971
|
+
createAndLaunch(mode, true, base, contextFile);
|
|
922
972
|
return;
|
|
923
973
|
}
|
|
924
974
|
if (input === "n" && mode) {
|
|
925
975
|
dispatch({ type: "SETUP_CONFIRM_DONE" });
|
|
926
|
-
|
|
976
|
+
const contextFile = pendingContextFileRef.current ?? undefined;
|
|
977
|
+
pendingContextFileRef.current = null;
|
|
978
|
+
createAndLaunch(mode, false, base, contextFile);
|
|
927
979
|
return;
|
|
928
980
|
}
|
|
929
981
|
if (key.escape) {
|
|
930
982
|
dispatch({ type: "SETUP_CONFIRM_DONE" });
|
|
983
|
+
pendingContextFileRef.current = null;
|
|
931
984
|
return;
|
|
932
985
|
}
|
|
933
986
|
return;
|
|
@@ -935,11 +988,11 @@ export default function Dashboard() {
|
|
|
935
988
|
// Mode select overlay
|
|
936
989
|
if (state.overlay === "mode-select") {
|
|
937
990
|
if (input === "p" || input === "1") {
|
|
938
|
-
|
|
991
|
+
dispatch({ type: "CONTEXT_INPUT_SHOW", mode: "plan" });
|
|
939
992
|
return;
|
|
940
993
|
}
|
|
941
994
|
if (input === "i" || input === "2") {
|
|
942
|
-
|
|
995
|
+
dispatch({ type: "CONTEXT_INPUT_SHOW", mode: "implement" });
|
|
943
996
|
return;
|
|
944
997
|
}
|
|
945
998
|
if (key.escape || input === "q") {
|
|
@@ -948,6 +1001,11 @@ export default function Dashboard() {
|
|
|
948
1001
|
}
|
|
949
1002
|
return;
|
|
950
1003
|
}
|
|
1004
|
+
// Context-input overlay — MultilineTextArea owns its own useInput;
|
|
1005
|
+
// outer useInput is disabled for this overlay via isActive below.
|
|
1006
|
+
if (state.overlay === "context-input") {
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
951
1009
|
// Confirm delete overlay
|
|
952
1010
|
if (state.overlay === "confirm-delete") {
|
|
953
1011
|
if (input === "y") {
|
|
@@ -1416,7 +1474,10 @@ export default function Dashboard() {
|
|
|
1416
1474
|
dispatch({ type: "SET_OVERLAY", overlay: "confirm-delete" });
|
|
1417
1475
|
return;
|
|
1418
1476
|
}
|
|
1419
|
-
}, {
|
|
1477
|
+
}, {
|
|
1478
|
+
isActive: state.overlay !== "context-input" &&
|
|
1479
|
+
(state.overlay !== "commit" || state.commitPhase !== "awaiting-message"),
|
|
1480
|
+
});
|
|
1420
1481
|
// ── Render ─────────────────────────────────────────────────────────
|
|
1421
1482
|
if (state.loading) {
|
|
1422
1483
|
return (_jsx(Box, { width: columns, height: rows, flexDirection: "column", children: _jsxs(Box, { justifyContent: "center", alignItems: "center", flexGrow: 1, children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), _jsx(Text, { children: " Loading dashboard..." })] }) }));
|
|
@@ -1426,7 +1487,13 @@ export default function Dashboard() {
|
|
|
1426
1487
|
}
|
|
1427
1488
|
const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
|
|
1428
1489
|
const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
|
|
1429
|
-
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "
|
|
1490
|
+
return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Dashboard" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" ", "claude ", CLAUDE_VERSION] })) : null, _jsx(Text, { dimColor: true, children: state.refreshing ? " refreshing..." : "" }), state.actionMessage && (_jsxs(Text, { color: "yellow", children: [" ", state.actionMessage] }))] }), _jsxs(Box, { children: [_jsxs(Text, { bold: state.activeTab === "issues", color: state.activeTab === "issues" ? "cyan" : undefined, dimColor: state.activeTab !== "issues", children: [state.activeTab === "issues" ? "\u25b8 " : " ", "1 Issues (", state.flatIssues.length, ")"] }), _jsx(Text, { children: " " }), _jsxs(Text, { bold: state.activeTab === "reviews", color: state.activeTab === "reviews" ? "cyan" : undefined, dimColor: state.activeTab !== "reviews", children: [state.activeTab === "reviews" ? "\u25b8 " : " ", "2 Reviews (", state.flatReviews.length, ")"] })] }), state.overlay === "mode-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select mode:" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "p" }), " Plan"] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", bold: true, children: "i" }), " Implement"] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }) })) : state.overlay === "context-input" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", paddingX: 2, children: [_jsxs(Text, { bold: true, color: "cyan", children: ["Extra context for ", state.contextInputMode] }), _jsx(Text, { dimColor: true, children: "Optional \u2014 appended to the prompt before launching Claude" }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => {
|
|
1491
|
+
const mode = state.contextInputMode;
|
|
1492
|
+
const ctx = state.contextInputValue;
|
|
1493
|
+
dispatch({ type: "CONTEXT_INPUT_DONE" });
|
|
1494
|
+
if (mode)
|
|
1495
|
+
doWork(mode, ctx);
|
|
1496
|
+
}, onCancel: () => dispatch({ type: "CONTEXT_INPUT_DONE" }), width: Math.min(columns - 8, 100), height: 10, placeholder: "Type or paste extra context\u2026" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " ", "launch", " ", _jsx(Text, { color: "cyan", bold: true, children: "Enter" }), " ", "newline", " ", _jsx(Text, { color: "cyan", bold: true, children: "ESC" }), " ", "cancel"] })] }) })) : state.overlay === "base-select" ? (_jsx(Box, { flexGrow: 1, justifyContent: "center", alignItems: "center", children: _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 3, paddingY: 1, children: [_jsx(Text, { bold: true, children: "Select base branch:" }), _jsx(Text, { children: " " }), state.baseSelectOptions.map((branch, idx) => {
|
|
1430
1497
|
const selected = idx === state.baseSelectIndex;
|
|
1431
1498
|
const defaultBranch = getDefaultBranch();
|
|
1432
1499
|
const label = branch === defaultBranch ? `${branch} (default)` : branch;
|
|
@@ -132,6 +132,16 @@ function getGitChanges(cwd) {
|
|
|
132
132
|
untracked: countLines(git(cwd, "ls-files --others --exclude-standard")),
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
|
+
// Build a progress bar for context usage
|
|
136
|
+
function formatContextUsage(usedPercentage) {
|
|
137
|
+
const used = Math.round(usedPercentage);
|
|
138
|
+
const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
|
|
139
|
+
const width = 20;
|
|
140
|
+
const filled = Math.round((used * width) / 100);
|
|
141
|
+
const empty = width - filled;
|
|
142
|
+
const bar = "▓".repeat(filled) + "░".repeat(empty);
|
|
143
|
+
return `${color}[${bar}] ${used}%${c.reset}`;
|
|
144
|
+
}
|
|
135
145
|
// Format changes compactly
|
|
136
146
|
function formatChanges(changes) {
|
|
137
147
|
const parts = [];
|
|
@@ -177,9 +187,7 @@ function buildSantreeStatusline(cwd, model, usedPercentage) {
|
|
|
177
187
|
}
|
|
178
188
|
// Context usage %
|
|
179
189
|
if (usedPercentage !== null) {
|
|
180
|
-
|
|
181
|
-
const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
|
|
182
|
-
parts.push(`${color}${used}%${c.reset}`);
|
|
190
|
+
parts.push(formatContextUsage(usedPercentage));
|
|
183
191
|
}
|
|
184
192
|
return parts.join(" | ");
|
|
185
193
|
}
|
|
@@ -202,9 +210,7 @@ function buildGitStatusline(cwd, model, usedPercentage) {
|
|
|
202
210
|
}
|
|
203
211
|
// Context usage %
|
|
204
212
|
if (usedPercentage !== null) {
|
|
205
|
-
|
|
206
|
-
const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
|
|
207
|
-
parts.push(`${color}${used}%${c.reset}`);
|
|
213
|
+
parts.push(formatContextUsage(usedPercentage));
|
|
208
214
|
}
|
|
209
215
|
return parts.join(" | ");
|
|
210
216
|
}
|
|
@@ -220,9 +226,7 @@ function buildPlainStatusline(cwd, model, usedPercentage) {
|
|
|
220
226
|
}
|
|
221
227
|
// Context usage %
|
|
222
228
|
if (usedPercentage !== null) {
|
|
223
|
-
|
|
224
|
-
const color = used >= 80 ? c.red : used >= 60 ? c.yellow : c.green;
|
|
225
|
-
parts.push(`${color}${used}%${c.reset}`);
|
|
229
|
+
parts.push(formatContextUsage(usedPercentage));
|
|
226
230
|
}
|
|
227
231
|
return parts.join(" | ");
|
|
228
232
|
}
|
|
@@ -133,6 +133,18 @@ export default function LinearAuth({ options }) {
|
|
|
133
133
|
setStatus("done");
|
|
134
134
|
return;
|
|
135
135
|
}
|
|
136
|
+
// Token expired and refresh failed — re-authenticate
|
|
137
|
+
setStatus("authenticating");
|
|
138
|
+
const result = await startOAuthFlow();
|
|
139
|
+
if (!result) {
|
|
140
|
+
setError("Authentication failed or timed out. Please try again.");
|
|
141
|
+
setStatus("error");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
setRepoLinearOrg(repoRoot, result.orgSlug);
|
|
145
|
+
setMessage(`Re-authenticated as ${result.orgName} (${result.orgSlug})`);
|
|
146
|
+
setStatus("done");
|
|
147
|
+
return;
|
|
136
148
|
}
|
|
137
149
|
// Check for existing authenticated orgs
|
|
138
150
|
const store = readAuthStore();
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
export declare const description = "Launch Claude to work on current ticket";
|
|
3
3
|
export declare const options: z.ZodObject<{
|
|
4
4
|
plan: z.ZodOptional<z.ZodBoolean>;
|
|
5
|
+
"context-file": z.ZodOptional<z.ZodString>;
|
|
5
6
|
}, z.core.$strip>;
|
|
6
7
|
type Props = {
|
|
7
8
|
options: z.infer<typeof options>;
|
|
@@ -3,12 +3,17 @@ import { useEffect, useState } from "react";
|
|
|
3
3
|
import { Text, Box } from "ink";
|
|
4
4
|
import Spinner from "ink-spinner";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
+
import { readFileSync, unlinkSync } from "fs";
|
|
6
7
|
import { resolveAIContext, renderAIPrompt, launchAgent, cleanupImages, } from "../../lib/ai.js";
|
|
7
8
|
import { randomUUID } from "crypto";
|
|
8
9
|
import { getSessionId, setSessionId } from "../../lib/git.js";
|
|
9
10
|
export const description = "Launch Claude to work on current ticket";
|
|
10
11
|
export const options = z.object({
|
|
11
12
|
plan: z.boolean().optional().describe("Only create implementation plan"),
|
|
13
|
+
"context-file": z
|
|
14
|
+
.string()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Path to a file whose contents are appended to the prompt as extra context"),
|
|
12
17
|
});
|
|
13
18
|
function getModeLabel(mode) {
|
|
14
19
|
return mode === "plan" ? "plan only" : "implement";
|
|
@@ -23,6 +28,7 @@ export default function Work({ options }) {
|
|
|
23
28
|
const [error, setError] = useState(null);
|
|
24
29
|
const [mode] = useState(options.plan ? "plan" : "implement");
|
|
25
30
|
const [aiContext, setAiContext] = useState(null);
|
|
31
|
+
const contextFilePath = options["context-file"];
|
|
26
32
|
useEffect(() => {
|
|
27
33
|
async function init() {
|
|
28
34
|
// Small delay to allow spinner to render
|
|
@@ -46,7 +52,22 @@ export default function Work({ options }) {
|
|
|
46
52
|
if (status !== "ready" || !aiContext)
|
|
47
53
|
return;
|
|
48
54
|
setStatus("launching");
|
|
49
|
-
|
|
55
|
+
let customContext;
|
|
56
|
+
if (contextFilePath) {
|
|
57
|
+
try {
|
|
58
|
+
customContext = readFileSync(contextFilePath, "utf-8").trim() || undefined;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// File missing or unreadable — proceed without extra context
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
unlinkSync(contextFilePath);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Cleanup failure is non-fatal
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const prompt = renderAIPrompt("work", aiContext, { mode, custom_context: customContext });
|
|
50
71
|
// Get or create a session ID for this ticket
|
|
51
72
|
let sessionId;
|
|
52
73
|
let isResume = false;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
interface MultilineTextAreaProps {
|
|
2
|
+
value: string;
|
|
3
|
+
onChange: (value: string) => void;
|
|
4
|
+
onSubmit: () => void;
|
|
5
|
+
onCancel: () => void;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
height?: number;
|
|
9
|
+
focus?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeholder, width, height, focus, }: MultilineTextAreaProps): import("react/jsx-runtime").JSX.Element;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeholder, width, height = 6, focus = true, }) {
|
|
4
|
+
useInput((input, key) => {
|
|
5
|
+
// Ctrl+D submits
|
|
6
|
+
if (key.ctrl && input === "d") {
|
|
7
|
+
onSubmit();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
// ESC cancels. Parent disables SGR mouse tracking while this overlay is
|
|
11
|
+
// mounted so clicks can no longer masquerade as ESC.
|
|
12
|
+
if (key.escape) {
|
|
13
|
+
onCancel();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if (key.backspace || key.delete) {
|
|
17
|
+
onChange(value.slice(0, -1));
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// Swallow navigation keys — this is an append-only text area.
|
|
21
|
+
if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.tab)
|
|
22
|
+
return;
|
|
23
|
+
// Enter inserts a newline. MUST run before the meta/ctrl swallow below so
|
|
24
|
+
// Option+Enter and Ctrl+Enter also insert newlines. When Ink delivers a
|
|
25
|
+
// paste as one chunk, `input` may carry embedded content alongside the
|
|
26
|
+
// \r — normalize and append the whole thing instead of dropping it.
|
|
27
|
+
if (key.return) {
|
|
28
|
+
const chunk = input ? input.replace(/\r\n?/g, "\n") : "\n";
|
|
29
|
+
onChange(value + chunk);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
// Swallow remaining modifier combos.
|
|
33
|
+
if (key.ctrl || key.meta)
|
|
34
|
+
return;
|
|
35
|
+
if (!input)
|
|
36
|
+
return;
|
|
37
|
+
// Pasted content may embed \r or \r\n — normalize to \n.
|
|
38
|
+
const normalized = input.replace(/\r\n?/g, "\n");
|
|
39
|
+
onChange(value + normalized);
|
|
40
|
+
}, { isActive: focus });
|
|
41
|
+
const lines = value.length === 0 ? [""] : value.split("\n");
|
|
42
|
+
const visibleLines = lines.slice(Math.max(0, lines.length - height));
|
|
43
|
+
const isEmpty = value.length === 0;
|
|
44
|
+
return (_jsx(Box, { flexDirection: "column", width: width, borderStyle: "round", borderColor: "cyan", paddingX: 1, minHeight: height + 2, children: isEmpty && placeholder ? (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { color: "cyan", children: "\u2588" }), _jsx(Text, { dimColor: true, children: placeholder })] })) : (visibleLines.map((line, i) => {
|
|
45
|
+
const isLast = i === visibleLines.length - 1;
|
|
46
|
+
return (_jsxs(Box, { minHeight: 1, children: [_jsx(Text, { children: line }), isLast && focus ? _jsx(Text, { color: "cyan", children: "\u2588" }) : null] }, i));
|
|
47
|
+
})) }));
|
|
48
|
+
}
|
|
@@ -8,7 +8,7 @@ export async function loadDashboardData(repoRoot) {
|
|
|
8
8
|
Promise.resolve(listWorktrees()),
|
|
9
9
|
]);
|
|
10
10
|
if (!issues)
|
|
11
|
-
throw new Error("Failed to
|
|
11
|
+
throw new Error("Failed to authenticate with Linear. Run: santree linear auth");
|
|
12
12
|
// Build worktree map: ticketId -> worktree info
|
|
13
13
|
const wtMap = new Map();
|
|
14
14
|
for (const wt of worktrees) {
|
|
@@ -58,7 +58,7 @@ export interface EnrichedReviewPR {
|
|
|
58
58
|
worktree: WorktreeInfo | null;
|
|
59
59
|
}
|
|
60
60
|
export type DashboardTab = "issues" | "reviews";
|
|
61
|
-
export type ActionOverlay = "mode-select" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
|
|
61
|
+
export type ActionOverlay = "mode-select" | "context-input" | "base-select" | "confirm-delete" | "confirm-setup" | "commit" | "pr-create" | null;
|
|
62
62
|
export type CommitPhase = "idle" | "confirm-stage" | "awaiting-message" | "committing" | "pushing" | "done" | "error";
|
|
63
63
|
export type PrCreatePhase = "idle" | "choose-mode" | "pushing" | "filling" | "review" | "creating" | "done" | "error";
|
|
64
64
|
export interface DashboardState {
|
|
@@ -100,6 +100,8 @@ export interface DashboardState {
|
|
|
100
100
|
baseSelectOptions: string[];
|
|
101
101
|
baseSelectIndex: number;
|
|
102
102
|
baseSelectChosen: string | null;
|
|
103
|
+
contextInputValue: string;
|
|
104
|
+
contextInputMode: "plan" | "implement" | null;
|
|
103
105
|
}
|
|
104
106
|
export type DashboardAction = {
|
|
105
107
|
type: "SET_DATA";
|
|
@@ -215,6 +217,14 @@ export type DashboardAction = {
|
|
|
215
217
|
} | {
|
|
216
218
|
type: "REVIEW_SCROLL_DETAIL";
|
|
217
219
|
offset: number;
|
|
220
|
+
} | {
|
|
221
|
+
type: "CONTEXT_INPUT_SHOW";
|
|
222
|
+
mode: "plan" | "implement";
|
|
223
|
+
} | {
|
|
224
|
+
type: "CONTEXT_INPUT_CHANGE";
|
|
225
|
+
value: string;
|
|
226
|
+
} | {
|
|
227
|
+
type: "CONTEXT_INPUT_DONE";
|
|
218
228
|
};
|
|
219
229
|
export declare const initialState: DashboardState;
|
|
220
230
|
export declare function reducer(state: DashboardState, action: DashboardAction): DashboardState;
|
|
@@ -38,6 +38,8 @@ export const initialState = {
|
|
|
38
38
|
baseSelectOptions: [],
|
|
39
39
|
baseSelectIndex: 0,
|
|
40
40
|
baseSelectChosen: null,
|
|
41
|
+
contextInputValue: "",
|
|
42
|
+
contextInputMode: null,
|
|
41
43
|
};
|
|
42
44
|
export function reducer(state, action) {
|
|
43
45
|
switch (action.type) {
|
|
@@ -242,6 +244,22 @@ export function reducer(state, action) {
|
|
|
242
244
|
return { ...state, reviewListScrollOffset: action.offset };
|
|
243
245
|
case "REVIEW_SCROLL_DETAIL":
|
|
244
246
|
return { ...state, reviewDetailScrollOffset: action.offset };
|
|
247
|
+
case "CONTEXT_INPUT_SHOW":
|
|
248
|
+
return {
|
|
249
|
+
...state,
|
|
250
|
+
overlay: "context-input",
|
|
251
|
+
contextInputMode: action.mode,
|
|
252
|
+
contextInputValue: "",
|
|
253
|
+
};
|
|
254
|
+
case "CONTEXT_INPUT_CHANGE":
|
|
255
|
+
return { ...state, contextInputValue: action.value };
|
|
256
|
+
case "CONTEXT_INPUT_DONE":
|
|
257
|
+
return {
|
|
258
|
+
...state,
|
|
259
|
+
overlay: null,
|
|
260
|
+
contextInputMode: null,
|
|
261
|
+
contextInputValue: "",
|
|
262
|
+
};
|
|
245
263
|
default:
|
|
246
264
|
return state;
|
|
247
265
|
}
|
package/package.json
CHANGED
package/prompts/work.njk
CHANGED
|
@@ -6,6 +6,12 @@ If a Linear MCP server is available, use it to fetch the ticket description, com
|
|
|
6
6
|
Otherwise, proceed based on branch name context.
|
|
7
7
|
{% endif %}
|
|
8
8
|
|
|
9
|
+
{% if custom_context %}
|
|
10
|
+
## Additional context from the user
|
|
11
|
+
|
|
12
|
+
{{ custom_context }}
|
|
13
|
+
|
|
14
|
+
{% endif %}
|
|
9
15
|
Review the codebase to understand the relevant areas and existing patterns.
|
|
10
16
|
{% if mode == "plan" %}
|
|
11
17
|
Create a detailed implementation plan with:
|
package/shell/init.bash.njk
CHANGED
|
@@ -51,7 +51,7 @@ function santree() {
|
|
|
51
51
|
local exit_code=$?
|
|
52
52
|
|
|
53
53
|
if [[ "$output" == *SANTREE_CD:* ]]; then
|
|
54
|
-
echo "$output" | grep -v "SANTREE_CD:" | grep -v "SANTREE_WORK:"
|
|
54
|
+
echo "$output" | grep -v "SANTREE_CD:" | grep -v "SANTREE_WORK:" | grep -v "SANTREE_WORK_CONTEXT:"
|
|
55
55
|
|
|
56
56
|
local target_dir=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "SANTREE_CD:" | sed 's/.*SANTREE_CD://')
|
|
57
57
|
|
|
@@ -60,8 +60,16 @@ function santree() {
|
|
|
60
60
|
fi
|
|
61
61
|
|
|
62
62
|
if [[ "$output" == *SANTREE_WORK:* ]]; then
|
|
63
|
-
local work_mode=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "SANTREE_WORK:" | sed 's/.*SANTREE_WORK://')
|
|
64
|
-
|
|
63
|
+
local work_mode=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "^SANTREE_WORK:" | sed 's/.*SANTREE_WORK://')
|
|
64
|
+
local context_file=""
|
|
65
|
+
if [[ "$output" == *SANTREE_WORK_CONTEXT:* ]]; then
|
|
66
|
+
context_file=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "SANTREE_WORK_CONTEXT:" | sed 's/.*SANTREE_WORK_CONTEXT://')
|
|
67
|
+
fi
|
|
68
|
+
if [[ -n "$context_file" ]]; then
|
|
69
|
+
[[ "$work_mode" == "plan" ]] && command santree worktree work --plan --context-file "$context_file" || command santree worktree work --context-file "$context_file"
|
|
70
|
+
else
|
|
71
|
+
[[ "$work_mode" == "plan" ]] && command santree worktree work --plan || command santree worktree work
|
|
72
|
+
fi
|
|
65
73
|
fi
|
|
66
74
|
else
|
|
67
75
|
echo "$output"
|
package/shell/init.zsh.njk
CHANGED
|
@@ -51,7 +51,7 @@ function santree() {
|
|
|
51
51
|
local exit_code=$?
|
|
52
52
|
|
|
53
53
|
if [[ "$output" == *SANTREE_CD:* ]]; then
|
|
54
|
-
echo "$output" | grep -v "SANTREE_CD:" | grep -v "SANTREE_WORK:"
|
|
54
|
+
echo "$output" | grep -v "SANTREE_CD:" | grep -v "SANTREE_WORK:" | grep -v "SANTREE_WORK_CONTEXT:"
|
|
55
55
|
|
|
56
56
|
local target_dir=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "SANTREE_CD:" | sed 's/.*SANTREE_CD://')
|
|
57
57
|
|
|
@@ -60,8 +60,16 @@ function santree() {
|
|
|
60
60
|
fi
|
|
61
61
|
|
|
62
62
|
if [[ "$output" == *SANTREE_WORK:* ]]; then
|
|
63
|
-
local work_mode=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "SANTREE_WORK:" | sed 's/.*SANTREE_WORK://')
|
|
64
|
-
|
|
63
|
+
local work_mode=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "^SANTREE_WORK:" | sed 's/.*SANTREE_WORK://')
|
|
64
|
+
local context_file=""
|
|
65
|
+
if [[ "$output" == *SANTREE_WORK_CONTEXT:* ]]; then
|
|
66
|
+
context_file=$(echo "$output" | sed 's/\x1b\[[0-9;]*m//g' | grep "SANTREE_WORK_CONTEXT:" | sed 's/.*SANTREE_WORK_CONTEXT://')
|
|
67
|
+
fi
|
|
68
|
+
if [[ -n "$context_file" ]]; then
|
|
69
|
+
[[ "$work_mode" == "plan" ]] && command santree worktree work --plan --context-file "$context_file" || command santree worktree work --context-file "$context_file"
|
|
70
|
+
else
|
|
71
|
+
[[ "$work_mode" == "plan" ]] && command santree worktree work --plan || command santree worktree work
|
|
72
|
+
fi
|
|
65
73
|
fi
|
|
66
74
|
else
|
|
67
75
|
echo "$output"
|