santree 0.5.4 → 0.5.6

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/README.md CHANGED
@@ -405,13 +405,30 @@ To preview the JSON without writing: `santree helpers session-signal install --d
405
405
 
406
406
  Verify with `santree doctor` — look for the "Session Signal Hooks" row under Claude Code.
407
407
 
408
+ ### English Tutor (Optional)
409
+
410
+ When enabled, Claude Code spots grammar mistakes in your prompts and replies with a one-line correction (`original -> correction (reason)`) before doing the actual work. Mistake-free prompts are ignored. Useful if English isn't your first language and you'd like ambient feedback while you code.
411
+
412
+ **Install:**
413
+
414
+ ```bash
415
+ santree helpers english-tutor install
416
+ ```
417
+
418
+ This adds two hooks (`UserPromptSubmit`, `SessionStart`) and a scoped `Edit` permission for the practice log to `~/.claude/settings.json`. Existing hooks and permissions are preserved.
419
+
420
+ To preview the JSON without writing: `santree helpers english-tutor install --dry`
421
+
422
+ Corrections are appended to `~/.config/santree/english-practice-log.md` (or `$XDG_CONFIG_HOME/santree/english-practice-log.md`). The `SessionStart` hook replays this log into context on new sessions so Claude can spot recurring patterns.
423
+
424
+ Remove with `santree helpers english-tutor uninstall`.
425
+
408
426
  ### Environment Variables
409
427
 
410
428
  | Variable | Effect |
411
429
  |---|---|
412
430
  | `SANTREE_TRACKER` | Override the active issue tracker for a single invocation: `linear` or `github`. Takes precedence over the per-repo `_tracker.kind`. If unset, falls back to repo config → legacy `_linear.org` → auto-detect. |
413
431
  | `SANTREE_EDITOR` | Editor used by `e` (open in editor) actions in the dashboard. Defaults to `code`. Examples: `cursor`, `zed`, `code`, `nvim`. |
414
- | `SANTREE_MULTIPLEXER` | Terminal multiplexer used by the dashboard and `worktree create --window`. One of `tmux`, `cmux`, `none`. If unset, auto-detects from `$TMUX` / `$CMUX_SURFACE_ID`. cmux is macOS-only and limited by [manaflow-ai/cmux#1472](https://github.com/manaflow-ai/cmux/issues/1472). |
415
432
  | `SANTREE_DIFF_TOOL` | Pager used by `worktree diff` (CLI) and the dashboard diff overlay. Passed to git as `-c core.pager=<tool>` for the CLI, and used to pipe content for the overlay. Examples: `delta`, `diff-so-fancy`. Must accept a unified diff on stdin. Names are restricted to `[A-Za-z0-9_\-/.+]`. |
416
433
  | `SANTREE_THEME` | Dashboard color theme: `light`, `dark`, or `auto` (default). In `auto` mode, santree queries the terminal's background via OSC 11 and re-detects on each refresh cycle (≤30s) so theme switches propagate automatically. Set explicitly when your terminal doesn't respond to OSC 11. |
417
434
 
@@ -522,7 +539,7 @@ These are hardwired in `lib/github.ts` and `lib/ai.ts` respectively. Adding a se
522
539
  | --- | --- | --- |
523
540
  | **Issue tracker** | `santree issue switch <linear\|github>` (or `SANTREE_TRACKER` env var, or as a side effect of `santree linear auth` / `santree github auth`) | Linear (OAuth + GraphQL), GitHub Issues (via `gh` CLI). Adding a third tracker = one new directory under `lib/trackers/`. |
524
541
  | **Editor** | `SANTREE_EDITOR` env var (or `--editor` flag on `worktree open`) | `code`, `cursor`, `zed`, `nvim`, `subl`, `webstorm` — any executable that takes a path argument |
525
- | **Terminal multiplexer** | `SANTREE_MULTIPLEXER` env var | `tmux` (default, all platforms), `cmux` (macOS only — see [#1472](https://github.com/manaflow-ai/cmux/issues/1472)), `none`. Zellij is planned but not implemented. |
542
+ | **Terminal multiplexer** | Auto-detected via `$TMUX` / `$CMUX_SURFACE_ID`. No config needed — each adapter's `isActive()` declares its own runtime check. | `tmux` (all platforms), `cmux` (macOS only — see [#1472](https://github.com/manaflow-ai/cmux/issues/1472)). Zellij is planned but not implemented. |
526
543
  | **Diff renderer** | `SANTREE_DIFF_TOOL` env var | `delta`, `diff-so-fancy`, or any pager that accepts a unified diff. Falls back to plain `git diff` colorization when unset. |
527
544
  | **Color theme** | `SANTREE_THEME` env var (`light`/`dark`/`auto`) | Auto-detects via OSC 11; manual override available. Re-detects every refresh so theme switches show up within 30s. |
528
545
  | **Shell integration** | `santree helpers shell-init` detects the shell | `zsh`, `bash` (templates in `shell/init.{zsh,bash}.njk`) |
@@ -629,21 +629,21 @@ export default function Dashboard() {
629
629
  }
630
630
  }, [state.reviewSelectedIndex, state.flatReviews, contentHeight, state.reviewListScrollOffset]);
631
631
  // ── Mouse tracking pause ─────────────────────────────────────────
632
- // The MultilineTextArea captures ESC for cancel. With SGR mouse tracking on,
633
- // every click emits `\x1b[<btn;col;rowM` Ink reads the leading ESC and fires
634
- // key.escape, dismissing the overlay. Disable tracking while any overlay
635
- // phase mounts a MultilineTextArea (context-input editing OR pr-create
636
- // review); restore when that phase ends.
632
+ // With SGR mouse tracking on, every click emits `\x1b[<btn;col;rowM` —
633
+ // these escape sequences leak into text inputs as garbage characters
634
+ // (and into MultilineTextArea, the leading ESC fires key.escape).
635
+ // Disable tracking while any text-input overlay is mounted; restore on exit.
637
636
  useEffect(() => {
638
- const needsMouseOff = (state.overlay === "context-input" && state.contextInputPhase === "editing") ||
639
- (state.overlay === "pr-create" && state.prCreatePhase === "review");
637
+ const needsMouseOff = state.overlay === "context-input" ||
638
+ (state.overlay === "pr-create" && state.prCreatePhase === "review") ||
639
+ (state.overlay === "commit" && state.commitPhase === "awaiting-message");
640
640
  if (!needsMouseOff)
641
641
  return;
642
642
  process.stdout.write("\x1b[?1002l\x1b[?1006l");
643
643
  return () => {
644
644
  process.stdout.write("\x1b[?1002h\x1b[?1006h");
645
645
  };
646
- }, [state.overlay, state.contextInputPhase, state.prCreatePhase]);
646
+ }, [state.overlay, state.prCreatePhase, state.commitPhase]);
647
647
  // ── Diff overlay: load file list when opened ──────────────────────
648
648
  // Resolves merge-base against the configured base branch so upstream-only
649
649
  // changes (commits on master we haven't pulled) are excluded — same semantics
@@ -1396,29 +1396,10 @@ export default function Dashboard() {
1396
1396
  }
1397
1397
  return;
1398
1398
  }
1399
- // Context-input overlay.
1400
- // Editing phase: MultilineTextArea owns useInput (outer is disabled
1401
- // via isActive below).
1402
- // Review phase: outer handles y/n/e/ESC.
1399
+ // Context-input overlay: MultilineTextArea owns useInput (outer is
1400
+ // disabled via isActive below). Submit launches directly; cancel
1401
+ // closes the overlay.
1403
1402
  if (state.overlay === "context-input") {
1404
- if (state.contextInputPhase === "review") {
1405
- if (input === "y" || key.return) {
1406
- const mode = state.contextInputMode;
1407
- const ctx = state.contextInputValue;
1408
- dispatch({ type: "CONTEXT_INPUT_DONE" });
1409
- if (mode)
1410
- doWork(mode, ctx);
1411
- return;
1412
- }
1413
- if (input === "n" || input === "e") {
1414
- dispatch({ type: "CONTEXT_INPUT_EDIT" });
1415
- return;
1416
- }
1417
- if (key.escape) {
1418
- dispatch({ type: "CONTEXT_INPUT_DONE" });
1419
- return;
1420
- }
1421
- }
1422
1403
  return;
1423
1404
  }
1424
1405
  // Diff overlay
@@ -2107,7 +2088,7 @@ export default function Dashboard() {
2107
2088
  return;
2108
2089
  }
2109
2090
  }, {
2110
- isActive: (state.overlay !== "context-input" || state.contextInputPhase === "review") &&
2091
+ isActive: state.overlay !== "context-input" &&
2111
2092
  (state.overlay !== "pr-create" || state.prCreatePhase !== "review") &&
2112
2093
  (state.overlay !== "commit" || state.commitPhase !== "awaiting-message"),
2113
2094
  });
@@ -2120,10 +2101,13 @@ export default function Dashboard() {
2120
2101
  }
2121
2102
  const selectedIssue = state.flatIssues[state.selectedIndex] ?? null;
2122
2103
  const selectedReview = state.flatReviews[state.reviewSelectedIndex] ?? null;
2123
- return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "santree" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), updateAvailable && latestVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ v", latestVersion, " available — `santree update`"] })) : null, CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" · claude ", CLAUDE_VERSION] })) : null, claudeUpdateAvailable && latestClaudeVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ ", latestClaudeVersion] })) : null, state.refreshing ? _jsx(Text, { dimColor: true, children: " · refreshing…" }) : null, state.actionMessage ? (_jsxs(Text, { color: "yellow", children: [" · ", state.actionMessage] })) : null] }), _jsxs(Box, { paddingX: 1, children: [_jsx(Tab, { active: state.activeTab === "issues", label: `1 Issues (${state.flatIssues.length})`, mode: theme.mode }), _jsx(Text, { children: " " }), _jsx(Tab, { active: state.activeTab === "reviews", label: `2 Reviews (${state.flatReviews.length})`, mode: theme.mode })] }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", children: [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, width: Math.min(columns - 8, 100), 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: " " }), state.contextInputPhase === "editing" ? (_jsxs(_Fragment, { children: [_jsx(MultilineTextArea, { value: state.contextInputValue, onChange: (v) => dispatch({ type: "CONTEXT_INPUT_CHANGE", value: v }), onSubmit: () => dispatch({ type: "CONTEXT_INPUT_REVIEW" }), 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" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, minHeight: 6, children: [(state.contextInputValue || "(no extra context)")
2124
- .split("\n")
2125
- .slice(0, 12)
2126
- .map((line, i) => (_jsx(Text, { children: line || " " }, i))), state.contextInputValue.split("\n").length > 12 && (_jsxs(Text, { dimColor: true, children: ["\u2026+", state.contextInputValue.split("\n").length - 12, " more lines"] }))] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Anything else to add?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " launch ", _jsx(Text, { color: "yellow", bold: true, children: "n" }), " / ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "red", 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) => {
2104
+ return (_jsxs(Box, { width: columns, height: rows, flexDirection: "column", children: [_jsxs(Box, { paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "santree" }), _jsxs(Text, { dimColor: true, children: [" ", "v", version] }), updateAvailable && latestVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ v", latestVersion, " available — `santree update`"] })) : null, CLAUDE_VERSION ? (_jsxs(Text, { dimColor: true, children: [" · claude ", CLAUDE_VERSION] })) : null, claudeUpdateAvailable && latestClaudeVersion ? (_jsxs(Text, { color: "yellow", children: [" ⬆ ", latestClaudeVersion] })) : null, state.refreshing ? _jsx(Text, { dimColor: true, children: " · refreshing…" }) : null, state.actionMessage ? (_jsxs(Text, { color: "yellow", children: [" · ", state.actionMessage] })) : null] }), _jsxs(Box, { paddingX: 1, children: [_jsx(Tab, { active: state.activeTab === "issues", label: `1 Issues (${state.flatIssues.length})`, mode: theme.mode }), _jsx(Text, { children: " " }), _jsx(Tab, { active: state.activeTab === "reviews", label: `2 Reviews (${state.flatReviews.length})`, mode: theme.mode })] }), _jsxs(Box, { flexGrow: 1, borderStyle: "round", borderColor: "cyan", flexDirection: "column", children: [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, width: Math.min(columns - 8, 100), 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: () => {
2105
+ const mode = state.contextInputMode;
2106
+ const ctx = state.contextInputValue;
2107
+ dispatch({ type: "CONTEXT_INPUT_DONE" });
2108
+ if (mode)
2109
+ doWork(mode, ctx);
2110
+ }, 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: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+G" }), " 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) => {
2127
2111
  const selected = idx === state.baseSelectIndex;
2128
2112
  const defaultBranch = getDefaultBranch();
2129
2113
  const label = branch === defaultBranch ? `${branch} (default)` : branch;
@@ -60,19 +60,19 @@ async function checkTool(name, description, required, versionCommand, hint) {
60
60
  }
61
61
  /**
62
62
  * Reports the active multiplexer (tmux/cmux/none) and verifies the underlying
63
- * binary is reachable. Surfaces a hint when the configured multiplexer can't run.
63
+ * binary is reachable. Detection is auto each adapter's `isActive()` checks
64
+ * its own runtime env (`$TMUX`, `$CMUX_SURFACE_ID`).
64
65
  */
65
66
  async function checkMultiplexer() {
66
67
  const mux = getMultiplexer();
67
- const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
68
- const description = `Multiplexer (active: ${mux.kind}${explicit ? `, SANTREE_MULTIPLEXER=${explicit}` : ""})`;
68
+ const description = `Multiplexer (active: ${mux.kind})`;
69
69
  if (mux.kind === "none") {
70
70
  return {
71
71
  name: "multiplexer",
72
72
  description,
73
73
  required: false,
74
74
  installed: false,
75
- hint: "No multiplexer active. Set SANTREE_MULTIPLEXER=tmux (or cmux) and run inside one. Install: brew install tmux",
75
+ hint: "Run inside tmux or cmux to enable session window renaming. Install tmux: brew install tmux",
76
76
  };
77
77
  }
78
78
  if (mux.kind === "tmux") {
@@ -104,7 +104,7 @@ async function checkMultiplexer() {
104
104
  description,
105
105
  required: false,
106
106
  installed: false,
107
- hint: "Install cmux.app from https://cmux.com or set SANTREE_MULTIPLEXER=tmux. cmux is macOS-only.",
107
+ hint: "Install cmux.app from https://cmux.com (cmux is macOS-only).",
108
108
  };
109
109
  }
110
110
  const version = await tryExec("cmux --version 2>/dev/null");
@@ -122,9 +122,7 @@ async function checkMultiplexer() {
122
122
  installed: !!ping,
123
123
  version: version || "unknown",
124
124
  path,
125
- hint: !ping
126
- ? "cmux app not reachable — open cmux.app or set SANTREE_MULTIPLEXER=tmux."
127
- : undefined,
125
+ hint: !ping ? "cmux app not reachable — open cmux.app." : undefined,
128
126
  };
129
127
  }
130
128
  /**
@@ -336,6 +334,57 @@ function checkSessionSignalHooks() {
336
334
  hint: `Missing: ${missingHooks.join(", ")}. Run: santree helpers session-signal install`,
337
335
  };
338
336
  }
337
+ /**
338
+ * Checks if english-tutor hooks are configured. Verifies the UserPromptSubmit
339
+ * and SessionStart hooks plus the scoped Edit permission for the practice log.
340
+ */
341
+ function checkEnglishTutorHooks() {
342
+ const home = process.env.HOME || "";
343
+ const claudeSettingsPath = path.join(home, ".claude", "settings.json");
344
+ const requiredEvents = ["UserPromptSubmit", "SessionStart"];
345
+ const missingHooks = [];
346
+ let missingPermission = true;
347
+ const configDir = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
348
+ const expectedPermission = `Edit(${path.join(configDir, "santree", "english-practice-log.md")})`;
349
+ try {
350
+ if (fs.existsSync(claudeSettingsPath)) {
351
+ const settings = JSON.parse(fs.readFileSync(claudeSettingsPath, "utf-8"));
352
+ const hooks = settings.hooks || {};
353
+ for (const event of requiredEvents) {
354
+ const eventHooks = hooks[event];
355
+ if (!Array.isArray(eventHooks)) {
356
+ missingHooks.push(event);
357
+ continue;
358
+ }
359
+ const found = eventHooks.some((entry) => {
360
+ const innerHooks = entry.hooks || [];
361
+ return innerHooks.some((h) => typeof h.command === "string" && h.command.includes("english-tutor"));
362
+ });
363
+ if (!found)
364
+ missingHooks.push(event);
365
+ }
366
+ const allow = settings.permissions?.allow;
367
+ if (Array.isArray(allow) && allow.includes(expectedPermission)) {
368
+ missingPermission = false;
369
+ }
370
+ }
371
+ else {
372
+ missingHooks.push(...requiredEvents);
373
+ }
374
+ }
375
+ catch {
376
+ missingHooks.push(...requiredEvents);
377
+ }
378
+ if (missingHooks.length === 0 && !missingPermission) {
379
+ return { configured: true, missingHooks: [], missingPermission: false };
380
+ }
381
+ return {
382
+ configured: false,
383
+ missingHooks,
384
+ missingPermission,
385
+ hint: "Run: santree helpers english-tutor install",
386
+ };
387
+ }
339
388
  /**
340
389
  * Checks if a path is gitignored (via .gitignore or .git/info/exclude).
341
390
  */
@@ -433,6 +482,14 @@ function RemoteControlRow({ status }) {
433
482
  function SessionSignalRow({ status }) {
434
483
  return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.configured, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "Session Signal Hooks" }), _jsx(Text, { dimColor: true, children: " - Surface session state in dashboard/tmux" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: status.configured ? (_jsx(Text, { dimColor: true, children: "All hooks configured" })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["Missing: ", status.missingHooks.join(", ")] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })) })] }));
435
484
  }
485
+ function EnglishTutorRow({ status }) {
486
+ const missingParts = [];
487
+ if (status.missingHooks.length > 0)
488
+ missingParts.push(`hooks: ${status.missingHooks.join(", ")}`);
489
+ if (status.missingPermission)
490
+ missingParts.push("log Edit permission");
491
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Box, { children: [_jsx(StatusIcon, { ok: status.configured, required: false }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, children: "English Tutor Hooks" }), _jsx(Text, { dimColor: true, children: " - Inline grammar corrections" }), _jsx(Text, { dimColor: true, children: " (optional)" })] }), _jsx(Box, { marginLeft: 2, flexDirection: "column", children: status.configured ? (_jsx(Text, { dimColor: true, children: "Hooks and log permission configured" })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { dimColor: true, children: ["Missing: ", missingParts.join("; ") || "—"] }), status.hint && _jsxs(Text, { color: "yellow", children: ["\u21B3 ", status.hint] })] })) })] }));
492
+ }
436
493
  function SantreeSetupRow({ status }) {
437
494
  const isOk = status.santreeFolderExists &&
438
495
  status.initShExists &&
@@ -455,6 +512,7 @@ export default function Doctor() {
455
512
  const [remoteControl, setRemoteControl] = useState(null);
456
513
  const [statusline, setStatusline] = useState(null);
457
514
  const [sessionSignal, setSessionSignal] = useState(null);
515
+ const [englishTutor, setEnglishTutor] = useState(null);
458
516
  const [santreeSetup, setSantreeSetup] = useState(null);
459
517
  const [loading, setLoading] = useState(true);
460
518
  useEffect(() => {
@@ -543,6 +601,7 @@ export default function Doctor() {
543
601
  setRemoteControl(checkRemoteControl());
544
602
  setStatusline(statuslineResult);
545
603
  setSessionSignal(checkSessionSignalHooks());
604
+ setEnglishTutor(checkEnglishTutorHooks());
546
605
  setSantreeSetup(checkSantreeSetup());
547
606
  setLoading(false);
548
607
  }
@@ -555,5 +614,5 @@ export default function Doctor() {
555
614
  const optionalMissing = tools.filter((t) => !t.required && !t.installed);
556
615
  const trackerOk = tracker?.authenticated && !tracker?.hint;
557
616
  const allRequired = requiredMissing.length === 0 && trackerOk && shellStatus?.configured;
558
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), tracker && _jsx(TrackerRow, { tracker: tracker }), shellStatus && _jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell }), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), remoteControl && _jsx(RemoteControlRow, { status: remoteControl }), statusline && _jsx(StatuslineRow, { status: statusline }), sessionSignal && _jsx(SessionSignalRow, { status: sessionSignal }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length + (trackerOk ? 0 : 1) + (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
617
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Santree Doctor" }), _jsxs(Text, { dimColor: true, children: [" v", version] })] }), _jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "CLI Tools" }) }), tools.map((tool) => (_jsx(ToolRow, { tool: tool }, tool.name))), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Integrations" }) }), tracker && _jsx(TrackerRow, { tracker: tracker }), shellStatus && _jsx(ShellRow, { configured: shellStatus.configured, shell: shellStatus.shell }), santreeSetup && _jsx(SantreeSetupRow, { status: santreeSetup }), _jsx(Box, { marginBottom: 1, marginTop: 1, flexDirection: "column", children: _jsx(Text, { bold: true, underline: true, children: "Claude Code" }) }), remoteControl && _jsx(RemoteControlRow, { status: remoteControl }), statusline && _jsx(StatuslineRow, { status: statusline }), sessionSignal && _jsx(SessionSignalRow, { status: sessionSignal }), englishTutor && _jsx(EnglishTutorRow, { status: englishTutor }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: allRequired ? "green" : "yellow", paddingX: 2, children: allRequired ? (_jsx(Text, { color: "green", children: "All requirements satisfied! Santree is ready to use." })) : (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: [requiredMissing.length + (trackerOk ? 0 : 1) + (shellStatus?.configured ? 0 : 1), " ", "required item(s) need attention"] }), optionalMissing.length > 0 && (_jsxs(Text, { dimColor: true, children: [optionalMissing.length, " optional item(s) not installed"] }))] })) })] }));
559
618
  }
@@ -0,0 +1 @@
1
+ export declare const description = "Nudge Claude to correct your English (Claude Code hooks)";
@@ -0,0 +1 @@
1
+ export const description = "Nudge Claude to correct your English (Claude Code hooks)";
@@ -0,0 +1,8 @@
1
+ import { z } from "zod";
2
+ export declare const description = "Install English-tutor hooks into Claude Code settings";
3
+ export declare const options: z.ZodObject<{
4
+ dry: z.ZodOptional<z.ZodBoolean>;
5
+ }, z.core.$strip>;
6
+ export default function Install({ options: opts }: {
7
+ options: z.infer<typeof options>;
8
+ }): null;
@@ -0,0 +1,24 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { z } from "zod";
3
+ import { getInstallSnippet, installHooks } from "../../../lib/english-tutor.js";
4
+ export const description = "Install English-tutor hooks into Claude Code settings";
5
+ export const options = z.object({
6
+ dry: z.boolean().optional().describe("Print the hooks JSON without writing"),
7
+ });
8
+ export default function Install({ options: opts }) {
9
+ const hasRun = useRef(false);
10
+ useEffect(() => {
11
+ if (hasRun.current)
12
+ return;
13
+ hasRun.current = true;
14
+ if (opts?.dry) {
15
+ process.stdout.write(JSON.stringify(getInstallSnippet(), null, 2) + "\n");
16
+ process.exit(0);
17
+ }
18
+ const { settingsPath, logPath } = installHooks();
19
+ process.stdout.write(`English-tutor hooks installed in ${settingsPath}\n`);
20
+ process.stdout.write(`Practice log: ${logPath}\n`);
21
+ process.exit(0);
22
+ }, []);
23
+ return null;
24
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "English-tutor instruction (UserPromptSubmit hook)";
2
+ export default function Prompt(): null;
@@ -0,0 +1,16 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { renderPrompt } from "../../../lib/prompts.js";
3
+ import { getLogPath } from "../../../lib/english-tutor.js";
4
+ export const description = "English-tutor instruction (UserPromptSubmit hook)";
5
+ export default function Prompt() {
6
+ const hasRun = useRef(false);
7
+ useEffect(() => {
8
+ if (hasRun.current)
9
+ return;
10
+ hasRun.current = true;
11
+ const text = renderPrompt("english-tutor-prompt", { logPath: getLogPath() });
12
+ process.stdout.write(text);
13
+ process.exit(0);
14
+ }, []);
15
+ return null;
16
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Replay practice log (SessionStart hook)";
2
+ export default function SessionStart(): null;
@@ -0,0 +1,34 @@
1
+ import * as fs from "fs";
2
+ import { useEffect, useRef } from "react";
3
+ import { getLogPath } from "../../../lib/english-tutor.js";
4
+ export const description = "Replay practice log (SessionStart hook)";
5
+ export default function SessionStart() {
6
+ const hasRun = useRef(false);
7
+ useEffect(() => {
8
+ if (hasRun.current)
9
+ return;
10
+ hasRun.current = true;
11
+ try {
12
+ const logPath = getLogPath();
13
+ if (fs.existsSync(logPath)) {
14
+ const contents = fs.readFileSync(logPath, "utf-8").trim();
15
+ const entryCount = (contents.match(/^- /gm) || []).length;
16
+ if (contents.length > 0) {
17
+ process.stdout.write(`[ENGLISH TUTOR — PRACTICE LOG REPLAY]\n\n${contents}\n\n--- LOG END ---\n`);
18
+ // Only ask for a summary once there are enough entries to be useful.
19
+ // Below that threshold the log is too sparse to surface real patterns,
20
+ // and a forced summary would just noise up new sessions.
21
+ if (entryCount >= 3) {
22
+ process.stdout.write(`\n[ENGLISH TUTOR — SESSION-START INSTRUCTION]\n` +
23
+ `At the very start of your first response in this session, briefly note any RECURRING patterns from the log above (1-3 short bullets, e.g. "you frequently drop articles", "watch for 'their' vs 'there'"). Skip this entirely if no clear patterns emerge or if the user's first message is purely a coding task. Do not list every entry — only patterns.\n`);
24
+ }
25
+ }
26
+ }
27
+ }
28
+ catch {
29
+ // hook must never block the session — silently no-op on read error
30
+ }
31
+ process.exit(0);
32
+ }, []);
33
+ return null;
34
+ }
@@ -0,0 +1,2 @@
1
+ export declare const description = "Remove English-tutor hooks from Claude Code settings";
2
+ export default function Uninstall(): null;
@@ -0,0 +1,15 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { uninstallHooks } from "../../../lib/english-tutor.js";
3
+ export const description = "Remove English-tutor hooks from Claude Code settings";
4
+ export default function Uninstall() {
5
+ const hasRun = useRef(false);
6
+ useEffect(() => {
7
+ if (hasRun.current)
8
+ return;
9
+ hasRun.current = true;
10
+ const settingsPath = uninstallHooks();
11
+ process.stdout.write(`English-tutor hooks removed from ${settingsPath}\n`);
12
+ process.exit(0);
13
+ }, []);
14
+ return null;
15
+ }
package/dist/lib/ai.js CHANGED
@@ -131,9 +131,9 @@ const CMUX_CLAUDE_PATH = "/Applications/cmux.app/Contents/Resources/bin/claude";
131
131
  */
132
132
  export function resolveClaudeBinary() {
133
133
  // Inside cmux, the bundled binary is the only one wired to the active
134
- // workspace. Gate on `CMUX_SURFACE_ID` (actual cmux runtime), not
135
- // `SANTREE_MULTIPLEXER=cmux` outside a live workspace the bundled
136
- // binary has no auth context and exits with "Invalid API key".
134
+ // workspace. Gate on `CMUX_SURFACE_ID` (real cmux runtime) — outside a live
135
+ // workspace the bundled binary has no auth context and exits with
136
+ // "Invalid API key".
137
137
  if (process.env["CMUX_SURFACE_ID"] && existsSync(CMUX_CLAUDE_PATH)) {
138
138
  return CMUX_CLAUDE_PATH;
139
139
  }
@@ -162,24 +162,17 @@ export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeho
162
162
  onSubmit();
163
163
  return;
164
164
  }
165
- // Ctrl+C: cancel (preferred over Esc — vim users rely on Esc muscle memory)
166
- if (key.ctrl && input === "c") {
167
- onCancel();
168
- return;
169
- }
170
165
  // Ctrl+O: escalate to $SANTREE_EDITOR / $VISUAL / $EDITOR. On save+close
171
- // the buffer is replaced and the form is auto-submitted (matches git commit).
166
+ // the buffer is replaced and control returns to the textbox so the
167
+ // user can keep editing or submit with Ctrl+D.
172
168
  if (key.ctrl && input === "o") {
173
169
  const result = editExternally(value, "md");
174
170
  if (!result.ok)
175
171
  return;
176
- if (result.cancelled) {
177
- onCancel();
172
+ if (result.cancelled)
178
173
  return;
179
- }
180
174
  onChange(result.content);
181
175
  setCursor(result.content.length);
182
- onSubmit();
183
176
  return;
184
177
  }
185
178
  // Ctrl+V: paste clipboard image as a temp file reference.
@@ -189,6 +182,13 @@ export function MultilineTextArea({ value, onChange, onSubmit, onCancel, placeho
189
182
  insertAt(cursor, `![pasted image](${imagePath})`);
190
183
  return;
191
184
  }
185
+ // Ctrl+G: cancel (Emacs abort). Ctrl+C can't be used because Ink's
186
+ // exitOnCtrlC fires at the app level before useInput sees it, exiting
187
+ // the dashboard. Esc is reserved for vim muscle memory (swallowed).
188
+ if (key.ctrl && input === "g") {
189
+ onCancel();
190
+ return;
191
+ }
192
192
  // Esc: swallow without cancelling (vim users hit it constantly).
193
193
  if (key.escape)
194
194
  return;
@@ -22,7 +22,7 @@ export function CommitOverlay({ width, height, branch, ticketId, gitStatus, phas
22
22
  }), gitStatus.split("\n").length > 8 && (_jsxs(Text, { dimColor: true, children: [" +", gitStatus.split("\n").length - 8, " more"] }))] })) : null, _jsx(Text, { children: " " }), phase === "confirm-stage" && (_jsxs(Text, { children: ["Stage all changes?", " ", _jsx(Text, { color: "cyan", bold: true, children: "y" }), "/", _jsx(Text, { color: "cyan", bold: true, children: "n" })] })), phase === "awaiting-message" && (_jsxs(Box, { children: [_jsx(Text, { children: "Message: " }), _jsx(TextInput, { value: message, onChange: (v) => dispatch({ type: "COMMIT_MESSAGE", message: v }), onSubmit: onSubmit })] })), phase === "committing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Committing..."] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing..."] })), phase === "done" && (_jsx(Text, { color: "green", bold: true, children: "Committed and pushed!" })), phase === "error" && _jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }));
23
23
  }
24
24
  export function PrCreateOverlay({ width, height, branch, ticketId, phase, error, url, body, title, dispatch, }) {
25
- return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Edit PR description" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: body ?? "", onChange: (v) => dispatch({ type: "PR_CREATE_BODY_CHANGE", body: v }), onSubmit: () => dispatch({ type: "PR_CREATE_CONFIRM" }), onCancel: () => dispatch({ type: "PR_CREATE_CANCEL" }), width: width, height: Math.max(6, height - 10), placeholder: "(empty PR body)" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+C" }), " cancel"] })] })), phase === "confirm" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [(body ?? "")
25
+ return (_jsxs(Box, { flexDirection: "column", width: width, height: height, children: [_jsx(Text, { bold: true, color: "cyan", children: "Create Pull Request" }), _jsx(Text, { dimColor: true, children: "─".repeat(Math.min(width, 50)) }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "branch: " }), _jsx(Text, { children: branch })] }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "ticket: " }), _jsx(Text, { children: ticketId })] }), _jsx(Text, { children: " " }), phase === "choose-mode" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "How do you want to create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "f" }), " ", "Fill \u2014 use AI to fill the PR template"] }), _jsxs(Text, { children: [" ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "Web \u2014 open in browser to edit manually"] })] })), phase === "pushing" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Pushing branch..."] })), phase === "filling" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Filling PR template with AI..."] })), phase === "review" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Edit PR description" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsx(MultilineTextArea, { value: body ?? "", onChange: (v) => dispatch({ type: "PR_CREATE_BODY_CHANGE", body: v }), onSubmit: () => dispatch({ type: "PR_CREATE_CONFIRM" }), onCancel: () => dispatch({ type: "PR_CREATE_CANCEL" }), width: width, height: Math.max(6, height - 10), placeholder: "(empty PR body)" }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "Ctrl+D" }), " send · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+O" }), " editor · ", _jsx(Text, { color: "cyan", bold: true, children: "Ctrl+G" }), " cancel"] })] })), phase === "confirm" && (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Create this PR?" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "title: " }), _jsx(Text, { children: title })] }), _jsx(Text, { children: " " }), _jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [(body ?? "")
26
26
  .split("\n")
27
27
  .slice(0, Math.max(4, height - 12))
28
28
  .map((line, i) => (_jsx(Text, { wrap: "truncate", children: line || " " }, i))), (body ?? "").split("\n").length > Math.max(4, height - 12) && (_jsxs(Text, { dimColor: true, children: ["\u2026+", (body ?? "").split("\n").length - Math.max(4, height - 12), " more lines"] }))] }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "y" }), " / ", _jsx(Text, { color: "green", bold: true, children: "Enter" }), " create ", _jsx(Text, { color: "yellow", bold: true, children: "e" }), " keep editing ", _jsx(Text, { color: "cyan", bold: true, children: "w" }), " open in browser ", _jsx(Text, { color: "red", bold: true, children: "ESC" }), " cancel"] })] })), phase === "creating" && (_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: _jsx(Spinner, { type: "dots" }) }), " ", "Creating PR..."] })), phase === "done" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "green", bold: true, children: "PR created!" }), url ? _jsx(Text, { dimColor: true, children: url }) : null] })), phase === "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "red", children: error }), _jsx(Text, { children: " " }), _jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: "cyan", bold: true, children: "w" }), " ", "open in browser ESC cancel"] })] })), phase !== "review" && phase !== "confirm" && phase !== "error" && (_jsxs(_Fragment, { children: [_jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: "ESC to cancel" })] }))] }));
@@ -103,7 +103,6 @@ export interface DashboardState {
103
103
  baseSelectChosen: string | null;
104
104
  contextInputValue: string;
105
105
  contextInputMode: "plan" | "implement" | null;
106
- contextInputPhase: "editing" | "review";
107
106
  diffTicketId: string | null;
108
107
  diffWorktreePath: string | null;
109
108
  diffBaseBranch: string | null;
@@ -249,10 +248,6 @@ export type DashboardAction = {
249
248
  } | {
250
249
  type: "CONTEXT_INPUT_CHANGE";
251
250
  value: string;
252
- } | {
253
- type: "CONTEXT_INPUT_REVIEW";
254
- } | {
255
- type: "CONTEXT_INPUT_EDIT";
256
251
  } | {
257
252
  type: "CONTEXT_INPUT_DONE";
258
253
  } | {
@@ -40,7 +40,6 @@ export const initialState = {
40
40
  baseSelectChosen: null,
41
41
  contextInputValue: "",
42
42
  contextInputMode: null,
43
- contextInputPhase: "editing",
44
43
  diffTicketId: null,
45
44
  diffWorktreePath: null,
46
45
  diffBaseBranch: null,
@@ -271,21 +270,15 @@ export function reducer(state, action) {
271
270
  overlay: "context-input",
272
271
  contextInputMode: action.mode,
273
272
  contextInputValue: "",
274
- contextInputPhase: "editing",
275
273
  };
276
274
  case "CONTEXT_INPUT_CHANGE":
277
275
  return { ...state, contextInputValue: action.value };
278
- case "CONTEXT_INPUT_REVIEW":
279
- return { ...state, contextInputPhase: "review" };
280
- case "CONTEXT_INPUT_EDIT":
281
- return { ...state, contextInputPhase: "editing" };
282
276
  case "CONTEXT_INPUT_DONE":
283
277
  return {
284
278
  ...state,
285
279
  overlay: null,
286
280
  contextInputMode: null,
287
281
  contextInputValue: "",
288
- contextInputPhase: "editing",
289
282
  };
290
283
  case "DIFF_OPEN":
291
284
  return {
@@ -0,0 +1,13 @@
1
+ export declare function getLogPath(): string;
2
+ export declare function getHooksJson(): Record<string, unknown>;
3
+ export declare function getPermissionEntry(): string;
4
+ export declare function installHooks(): {
5
+ settingsPath: string;
6
+ logPath: string;
7
+ };
8
+ /**
9
+ * Remove hooks and permission entry. Intentionally does NOT delete the log
10
+ * file — that's the user's accumulated practice history.
11
+ */
12
+ export declare function uninstallHooks(): string;
13
+ export declare function getInstallSnippet(): Record<string, unknown>;
@@ -0,0 +1,125 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ const MARKER = "english-tutor";
5
+ export function getLogPath() {
6
+ const configDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
7
+ return path.join(configDir, "santree", "english-practice-log.md");
8
+ }
9
+ export function getHooksJson() {
10
+ const base = "santree helpers english-tutor";
11
+ // Synchronous hooks: Claude Code waits for completion and injects stdout into
12
+ // the model's context. `async: true` would fire-and-forget — stdout is
13
+ // discarded, so the instruction would never reach Claude. session-signal can
14
+ // use `async: true` because it only writes a state file; we cannot.
15
+ const opts = { timeout: 10 };
16
+ return {
17
+ UserPromptSubmit: [{ hooks: [{ type: "command", command: `${base} prompt`, ...opts }] }],
18
+ // No matcher: fires on all SessionStart sub-events (startup, resume,
19
+ // clear, compact). Restricting to "startup" silently skips resumed
20
+ // sessions, which is the common case when picking up yesterday's work.
21
+ SessionStart: [{ hooks: [{ type: "command", command: `${base} session-start`, ...opts }] }],
22
+ };
23
+ }
24
+ export function getPermissionEntry() {
25
+ return `Edit(${getLogPath()})`;
26
+ }
27
+ function readSettings(settingsPath) {
28
+ try {
29
+ if (fs.existsSync(settingsPath)) {
30
+ return JSON.parse(fs.readFileSync(settingsPath, "utf-8"));
31
+ }
32
+ }
33
+ catch {
34
+ // fall through
35
+ }
36
+ return {};
37
+ }
38
+ function writeSettings(settingsPath, settings) {
39
+ const dir = path.dirname(settingsPath);
40
+ fs.mkdirSync(dir, { recursive: true });
41
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
42
+ }
43
+ function settingsPath() {
44
+ const home = process.env.HOME || os.homedir();
45
+ return path.join(home, ".claude", "settings.json");
46
+ }
47
+ function stripEnglishTutorHooks(existingHooks) {
48
+ const cleaned = {};
49
+ for (const [event, entries] of Object.entries(existingHooks)) {
50
+ if (!Array.isArray(entries)) {
51
+ cleaned[event] = entries;
52
+ continue;
53
+ }
54
+ const filtered = entries.filter((entry) => {
55
+ const inner = entry.hooks || [];
56
+ return !inner.some((h) => typeof h.command === "string" && h.command.includes(MARKER));
57
+ });
58
+ if (filtered.length > 0)
59
+ cleaned[event] = filtered;
60
+ }
61
+ return cleaned;
62
+ }
63
+ /**
64
+ * Create the practice log if it doesn't already exist. Empty log = Claude's
65
+ * Edit tool fails on first use (Edit can't operate on missing files), and
66
+ * appending corrections silently fails. Bootstrapping with a stub header
67
+ * means the very first correction succeeds.
68
+ */
69
+ function ensureLogExists() {
70
+ const logPath = getLogPath();
71
+ if (fs.existsSync(logPath))
72
+ return logPath;
73
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
74
+ const stub = "# English Practice Log\n\n" +
75
+ "Tracks grammar/spelling mistakes spotted during Claude Code sessions.\n" +
76
+ "Generated and appended to by santree's english-tutor hook.\n";
77
+ fs.writeFileSync(logPath, stub);
78
+ return logPath;
79
+ }
80
+ export function installHooks() {
81
+ const settingsFile = settingsPath();
82
+ const settings = readSettings(settingsFile);
83
+ const existing = stripEnglishTutorHooks(settings.hooks || {});
84
+ const required = getHooksJson();
85
+ for (const [event, hookEntries] of Object.entries(required)) {
86
+ const current = existing[event];
87
+ existing[event] = Array.isArray(current)
88
+ ? [...current, ...hookEntries]
89
+ : hookEntries;
90
+ }
91
+ settings.hooks = existing;
92
+ const permissions = settings.permissions || {};
93
+ const allow = Array.isArray(permissions.allow) ? permissions.allow : [];
94
+ const entry = getPermissionEntry();
95
+ if (!allow.includes(entry))
96
+ allow.push(entry);
97
+ permissions.allow = allow;
98
+ settings.permissions = permissions;
99
+ writeSettings(settingsFile, settings);
100
+ const logPath = ensureLogExists();
101
+ return { settingsPath: settingsFile, logPath };
102
+ }
103
+ /**
104
+ * Remove hooks and permission entry. Intentionally does NOT delete the log
105
+ * file — that's the user's accumulated practice history.
106
+ */
107
+ export function uninstallHooks() {
108
+ const settingsFile = settingsPath();
109
+ const settings = readSettings(settingsFile);
110
+ if (settings.hooks) {
111
+ settings.hooks = stripEnglishTutorHooks(settings.hooks);
112
+ }
113
+ if (settings.permissions && Array.isArray(settings.permissions.allow)) {
114
+ const entry = getPermissionEntry();
115
+ settings.permissions.allow = settings.permissions.allow.filter((e) => e !== entry);
116
+ }
117
+ writeSettings(settingsFile, settings);
118
+ return settingsFile;
119
+ }
120
+ export function getInstallSnippet() {
121
+ return {
122
+ hooks: getHooksJson(),
123
+ permissions: { allow: [getPermissionEntry()] },
124
+ };
125
+ }
@@ -1,19 +1,12 @@
1
1
  import { cmuxMultiplexer } from "./cmux.js";
2
2
  import { noneMultiplexer } from "./none.js";
3
3
  import { tmuxMultiplexer } from "./tmux.js";
4
+ // Each adapter declares its own runtime detection in `isActive()`. Order matters:
5
+ // if more than one adapter reports active (e.g. tmux running inside a cmux
6
+ // workspace), the first match wins.
7
+ const CANDIDATES = [tmuxMultiplexer, cmuxMultiplexer];
4
8
  export function getMultiplexer() {
5
- const explicit = process.env["SANTREE_MULTIPLEXER"]?.toLowerCase();
6
- if (explicit === "tmux")
7
- return tmuxMultiplexer;
8
- if (explicit === "cmux")
9
- return cmuxMultiplexer;
10
- if (explicit === "none")
11
- return noneMultiplexer;
12
- if (process.env["TMUX"])
13
- return tmuxMultiplexer;
14
- if (process.env["CMUX_SURFACE_ID"])
15
- return cmuxMultiplexer;
16
- return noneMultiplexer;
9
+ return CANDIDATES.find((m) => m.isActive()) ?? noneMultiplexer;
17
10
  }
18
11
  export function getMultiplexerKind() {
19
12
  return getMultiplexer().kind;
@@ -3,7 +3,7 @@ export type { AssignedIssue, AuthStatus, Comment, Issue, IssueTracker, IssueTrac
3
3
  export { setRepoTracker, removeRepoTracker, readTrackerConfig } from "./config.js";
4
4
  /**
5
5
  * Resolve the active IssueTracker for a given repo. Selection order:
6
- * 1. SANTREE_TRACKER env override (matches SANTREE_MULTIPLEXER pattern).
6
+ * 1. SANTREE_TRACKER env override.
7
7
  * 2. Per-repo `_tracker.kind` in .santree/metadata.json.
8
8
  * 3. Legacy `_linear.org` (treated as kind: "linear" so existing repos keep working).
9
9
  * 4. Auto-detect: any stored Linear creds → Linear, else GitHub (gh is always available).
@@ -5,7 +5,7 @@ import { readLinearAuthStore } from "./auth-store.js";
5
5
  export { setRepoTracker, removeRepoTracker, readTrackerConfig } from "./config.js";
6
6
  /**
7
7
  * Resolve the active IssueTracker for a given repo. Selection order:
8
- * 1. SANTREE_TRACKER env override (matches SANTREE_MULTIPLEXER pattern).
8
+ * 1. SANTREE_TRACKER env override.
9
9
  * 2. Per-repo `_tracker.kind` in .santree/metadata.json.
10
10
  * 3. Legacy `_linear.org` (treated as kind: "linear" so existing repos keep working).
11
11
  * 4. Auto-detect: any stored Linear creds → Linear, else GitHub (gh is always available).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "santree",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
4
4
  "description": "Git worktree manager",
5
5
  "license": "MIT",
6
6
  "author": "Santiago Toscanini",
@@ -0,0 +1,15 @@
1
+ [ENGLISH TUTOR — META-INSTRUCTION, ALWAYS HONOR]
2
+
3
+ If the user's message contained English mistakes (grammar, spelling, word choice, agreement, idiom misuse), your response MUST start with the correction(s) BEFORE addressing anything else. Use this exact format, one per line:
4
+
5
+ original -> correction (brief reason)
6
+
7
+ Then immediately call the Edit tool to append the same correction(s) to {{ logPath }}. Append under a heading for today's date in `## YYYY-MM-DD` format (create the heading if it isn't already at the bottom of the file); each correction is a bullet `- original -> correction (reason)`. The Edit permission for that exact path is pre-granted, so do not ask.
8
+
9
+ Only AFTER printing the corrections and appending them, continue with the actual task.
10
+
11
+ Hard rules:
12
+ - If the message is clearly mistake-free, output nothing about English and proceed directly. Never invent corrections.
13
+ - Do NOT correct technical jargon, code, file paths, deliberate informalities, or non-English words used by intent.
14
+ - Cap corrections at 3 lines. Pick the highest-impact issues only.
15
+ - Do NOT use polite hedging ("small note on...", "by the way..."). Just the formatted line(s), then the log Edit, then the task.