santree 0.2.13 → 0.2.15

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.
@@ -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 workCmd = mode === "plan" ? "st worktree work --plan" : "st worktree work";
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 workCmd = mode === "plan" ? "st worktree work --plan" : "st worktree work";
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 createAndLaunch = useCallback(async (mode, runSetup, base) => {
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
- createAndLaunch(mode, false, base);
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 — collect possible base branches
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
- }, [state.flatIssues, state.selectedIndex, exit, launchWorkInTmux, proceedAfterBaseSelect]);
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
- createAndLaunch(mode, true, base);
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
- createAndLaunch(mode, false, base);
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
- doWork("plan");
991
+ dispatch({ type: "CONTEXT_INPUT_SHOW", mode: "plan" });
939
992
  return;
940
993
  }
941
994
  if (input === "i" || input === "2") {
942
- doWork("implement");
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
- }, { isActive: state.overlay !== "commit" || state.commitPhase !== "awaiting-message" });
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 === "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) => {
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
- const used = Math.round(usedPercentage);
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
- const used = Math.round(usedPercentage);
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
- const used = Math.round(usedPercentage);
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
  }
@@ -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
+ contextFile: 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
+ contextFile: 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.contextFile;
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
- const prompt = renderAIPrompt("work", aiContext, { mode });
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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
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:
@@ -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
- [[ "$work_mode" == "plan" ]] && command santree worktree work --plan || command santree worktree work
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"
@@ -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
- [[ "$work_mode" == "plan" ]] && command santree worktree work --plan || command santree worktree work
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"