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 +19 -2
- package/dist/commands/dashboard.js +19 -35
- package/dist/commands/doctor.js +68 -9
- package/dist/commands/helpers/english-tutor/index.d.ts +1 -0
- package/dist/commands/helpers/english-tutor/index.js +1 -0
- package/dist/commands/helpers/english-tutor/install.d.ts +8 -0
- package/dist/commands/helpers/english-tutor/install.js +24 -0
- package/dist/commands/helpers/english-tutor/prompt.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/prompt.js +16 -0
- package/dist/commands/helpers/english-tutor/session-start.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/session-start.js +34 -0
- package/dist/commands/helpers/english-tutor/uninstall.d.ts +2 -0
- package/dist/commands/helpers/english-tutor/uninstall.js +15 -0
- package/dist/lib/ai.js +3 -3
- package/dist/lib/dashboard/MultilineTextArea.js +10 -10
- package/dist/lib/dashboard/Overlays.js +1 -1
- package/dist/lib/dashboard/types.d.ts +0 -5
- package/dist/lib/dashboard/types.js +0 -7
- package/dist/lib/english-tutor.d.ts +13 -0
- package/dist/lib/english-tutor.js +125 -0
- package/dist/lib/multiplexer/index.js +5 -12
- package/dist/lib/trackers/index.d.ts +1 -1
- package/dist/lib/trackers/index.js +1 -1
- package/package.json +1 -1
- package/prompts/english-tutor-prompt.njk +15 -0
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** | `
|
|
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
|
-
//
|
|
633
|
-
//
|
|
634
|
-
//
|
|
635
|
-
//
|
|
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 =
|
|
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.
|
|
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
|
-
//
|
|
1401
|
-
//
|
|
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:
|
|
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: " " }),
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
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;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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.
|
|
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
|
|
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: "
|
|
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
|
|
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,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,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,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` (
|
|
135
|
-
//
|
|
136
|
-
//
|
|
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
|
|
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, ``);
|
|
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+
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
@@ -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.
|