s-app-scoder-agent 0.9.10 → 0.9.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.scoderrc.example.json +2 -0
- package/README.md +1 -1
- package/dist/agent/interaction.js +15 -8
- package/dist/agent/prompt-context.js +5 -0
- package/dist/agent.js +232 -3
- package/dist/cli/repl.js +6 -0
- package/dist/config/config.js +23 -6
- package/dist/context/prompt.js +10 -4
- package/dist/llm/llm.js +3 -1
- package/dist/llm/provider-anthropic.js +5 -3
- package/dist/llm/retry.js +4 -0
- package/dist/mcp/manager.js +2 -2
- package/dist/security/permissions.js +32 -12
- package/dist/tools/read-file.js +4 -2
- package/dist/tools/run-command.js +42 -24
- package/dist/tools/search.js +7 -0
- package/dist/tui/app.js +16 -3
- package/dist/tui/charset.js +26 -0
- package/dist/tui/chrome.js +9 -6
- package/dist/tui/hooks/use-keybinds.js +24 -1
- package/dist/tui/hooks/use-scroll.js +12 -3
- package/dist/tui/markdown/diagram.js +85 -6
- package/dist/tui/markdown/graph.js +1021 -0
- package/dist/tui/selectors.js +9 -3
- package/dist/tui/state.js +13 -0
- package/dist/workspace/file-index.js +6 -5
- package/package.json +1 -1
|
@@ -116,23 +116,34 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox, r
|
|
|
116
116
|
cwd: workdir,
|
|
117
117
|
stdio: ["ignore", "pipe", "pipe"],
|
|
118
118
|
env: buildChildEnv(workdir), // G3: allowlist — không kế thừa full process.env
|
|
119
|
+
// Own process group so killTree(-pid) can reap grandchildren (see killTree).
|
|
120
|
+
detached: process.platform !== "win32",
|
|
119
121
|
});
|
|
122
|
+
let exited = false;
|
|
120
123
|
let stdout = "";
|
|
121
124
|
let stderr = "";
|
|
122
125
|
let killedReason = null;
|
|
123
|
-
|
|
124
|
-
|
|
126
|
+
// Linux/macOS: sh fork command con (sleep, make, npm…) rồi chết trước —
|
|
127
|
+
// kill mỗi `child` để chúng mồ côi, còn giữ PIPE => 'close' không bao giờ
|
|
128
|
+
// tới, tool treo, run không finish (fail CI serve-abort reproducibly).
|
|
129
|
+
// detached:true cho child một process group riêng -> kill cả nhóm qua -pid.
|
|
130
|
+
const killTree = (sig) => {
|
|
125
131
|
try {
|
|
126
|
-
|
|
132
|
+
if (process.platform !== "win32" && child.pid)
|
|
133
|
+
process.kill(-child.pid, sig);
|
|
134
|
+
else
|
|
135
|
+
child.kill(sig);
|
|
127
136
|
}
|
|
128
|
-
catch { /* already dead */ }
|
|
129
|
-
|
|
137
|
+
catch { /* already dead / group gone */ }
|
|
138
|
+
};
|
|
139
|
+
const killChild = (reason) => {
|
|
140
|
+
killedReason = reason;
|
|
141
|
+
killTree("SIGTERM");
|
|
142
|
+
// SIGKILL escalation — sh chết nhưng GRANDCHILD ignore TERM thì group
|
|
143
|
+
// vẫn sống; exitCode của bản thân sh không phản ánh điều đó, dùng `exited`.
|
|
130
144
|
setTimeout(() => {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
child.kill("SIGKILL");
|
|
134
|
-
}
|
|
135
|
-
catch { /* already dead */ }
|
|
145
|
+
if (!exited)
|
|
146
|
+
killTree("SIGKILL");
|
|
136
147
|
}, 5_000).unref();
|
|
137
148
|
};
|
|
138
149
|
const timer = setTimeout(() => killChild("timeout"), timeoutMs);
|
|
@@ -156,34 +167,41 @@ export async function toolRunCommand(workdir, args, signal, onOutput, sandbox, r
|
|
|
156
167
|
child.on("error", (err) => {
|
|
157
168
|
finish({ ok: false, output: clamp(`${echoLine}\n(spawn error) ${err.message}`) });
|
|
158
169
|
});
|
|
159
|
-
|
|
170
|
+
const buildResult = (code) => {
|
|
160
171
|
if (killedReason === "timeout") {
|
|
161
|
-
|
|
172
|
+
return {
|
|
162
173
|
ok: false,
|
|
163
174
|
output: clamp(`${echoLine}\n(command timed out after ${Math.round(timeoutMs / 1000)}s and was killed)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
164
|
-
}
|
|
165
|
-
return;
|
|
175
|
+
};
|
|
166
176
|
}
|
|
167
177
|
if (killedReason === "aborted") {
|
|
168
|
-
|
|
178
|
+
return {
|
|
169
179
|
ok: false,
|
|
170
180
|
output: clamp(`${echoLine}\n(interrupted by user)\n--- stdout ---\n${redactSecrets(stdout)}\n--- stderr ---\n${redactSecrets(stderr)}`),
|
|
171
|
-
}
|
|
172
|
-
return;
|
|
181
|
+
};
|
|
173
182
|
}
|
|
174
183
|
const status = code ?? -1;
|
|
175
184
|
// G3: redact trước khi output vào conversation/log/TUI.
|
|
176
185
|
const redacted = redactSecrets(stdout);
|
|
177
186
|
const redactedErr = redactSecrets(stderr);
|
|
178
187
|
if (status === 0) {
|
|
179
|
-
|
|
180
|
-
}
|
|
181
|
-
else {
|
|
182
|
-
finish({
|
|
183
|
-
ok: false,
|
|
184
|
-
output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
|
|
185
|
-
});
|
|
188
|
+
return { ok: true, output: clamp(`${echoLine}\n(exit 0)\n${redacted}`) };
|
|
186
189
|
}
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
output: clamp(`${echoLine}\n(exit ${status})\n--- stdout ---\n${redacted}\n--- stderr ---\n${redactedErr}`),
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
// 'close' chờ EOF của pipe; grandchild lì (khác process group — hiếm, hoặc
|
|
196
|
+
// spawn của chính command) có thể giữ nó mở vĩnh viễn. Đã kill mà child
|
|
197
|
+
// exit rồi thì resolve ngay trên 'exit', đừng đợi close.
|
|
198
|
+
child.on("exit", (code) => {
|
|
199
|
+
exited = true;
|
|
200
|
+
if (killedReason)
|
|
201
|
+
finish(buildResult(code));
|
|
202
|
+
});
|
|
203
|
+
child.on("close", (code) => {
|
|
204
|
+
finish(buildResult(code));
|
|
187
205
|
});
|
|
188
206
|
});
|
|
189
207
|
}
|
package/dist/tools/search.js
CHANGED
|
@@ -91,7 +91,14 @@ export function toolGrep(workdir, args) {
|
|
|
91
91
|
// status >= 2 → rg error; fall through to grep fallback.
|
|
92
92
|
}
|
|
93
93
|
// Fallback: grep -rn (portable) — output already path:line:text.
|
|
94
|
+
// CI runners (ubuntu-latest) không có rg → đường này là mainstream, phải
|
|
95
|
+
// hỗ trợ include + skip noise dirs như rg; grep BSD không hiểu các cờ này
|
|
96
|
+
// sẽ exit ≥2 → rơi tiếp xuống jsGrepFallback (xử lý đúng, không mất sạch).
|
|
94
97
|
const gArgs = ["-rn", "--binary-files=without-match", "-I", "-m", "5", "-e", pattern];
|
|
98
|
+
for (const d of SEARCH_SKIP)
|
|
99
|
+
gArgs.push(`--exclude-dir=${d}`);
|
|
100
|
+
if (args.include)
|
|
101
|
+
gArgs.push("--include", args.include);
|
|
95
102
|
if (ctxBefore > 0)
|
|
96
103
|
gArgs.push("-B", String(ctxBefore));
|
|
97
104
|
if (ctxAfter > 0)
|
package/dist/tui/app.js
CHANGED
|
@@ -73,6 +73,8 @@ export function TuiApp({ config, initialTask }) {
|
|
|
73
73
|
const [input, setInput] = useState(initialTask ?? "");
|
|
74
74
|
/** Rows scrolled up from the bottom; 0 = follow the newest content. */
|
|
75
75
|
const [scrollOffset, setScrollOffset] = useState(0);
|
|
76
|
+
/** Ctrl+O: native terminal text selection (mouse tracking released). */
|
|
77
|
+
const [selectMode, setSelectMode] = useState(false);
|
|
76
78
|
const [permMode, setPermMode] = useState(cfg.permissionMode);
|
|
77
79
|
// T-03: active permission prompt (modal) — the run blocks until answered.
|
|
78
80
|
const [permPrompt, setPermPrompt] = useState(null);
|
|
@@ -102,7 +104,7 @@ export function TuiApp({ config, initialTask }) {
|
|
|
102
104
|
// Scroll offset khi đọc lại dùng rows ước lượng; khi BÁM ĐÁY dùng chiều
|
|
103
105
|
// cao THẬT của cửa sổ đã mount (đo rẻ vì cửa sổ ~viewport+overscan) —
|
|
104
106
|
// estimate sai lệch bao nhiêu thì dòng cuối vẫn luôn thấy.
|
|
105
|
-
const { viewportRef, viewport, contentRef, content, viewportRowsRef } = useScrollMetrics(setScrollOffset);
|
|
107
|
+
const { viewportRef, viewport, contentRef, content, viewportRowsRef } = useScrollMetrics(setScrollOffset, !selectMode);
|
|
106
108
|
const viewportRows = viewport.clientHeight || 24;
|
|
107
109
|
const win = useMemo(() => {
|
|
108
110
|
const total = estimateTotalRows(state.items);
|
|
@@ -154,8 +156,19 @@ export function TuiApp({ config, initialTask }) {
|
|
|
154
156
|
useTuiKeybinds({
|
|
155
157
|
scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen,
|
|
156
158
|
setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef,
|
|
157
|
-
setScrollOffset, exit,
|
|
159
|
+
setScrollOffset, selectMode, setSelectMode, exit,
|
|
158
160
|
});
|
|
161
|
+
// WARMUP (plan/WARMUP-PLAN.md): tạo agent + prefill prefix system+tools nền
|
|
162
|
+
// NGAY khi mở TUI — TTFT của câu chat đầu không còn phải trả cold-load model
|
|
163
|
+
// và 20–60k token prefill. Bỏ qua khi có initialTask (submit trả prefill ngay
|
|
164
|
+
// sau mount, warm-up chỉ nhân đôi việc — settleWarmup sẽ abort nó).
|
|
165
|
+
const warmupRef = useRef(false);
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
if (initialTask || warmupRef.current)
|
|
168
|
+
return;
|
|
169
|
+
warmupRef.current = true;
|
|
170
|
+
void createAgent().warmup();
|
|
171
|
+
}, [initialTask]);
|
|
159
172
|
// Submit an initial task passed on the command line once, after mount.
|
|
160
173
|
const bootRef = useRef(false);
|
|
161
174
|
useEffect(() => {
|
|
@@ -190,7 +203,7 @@ export function TuiApp({ config, initialTask }) {
|
|
|
190
203
|
_jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: cfg }), _jsxs(Box, { flexDirection: "row", flexGrow: 1, flexShrink: 1, minHeight: 0, children: [_jsx(Box, { ref: viewportRef, flexDirection: "column", flexGrow: 1, flexShrink: 1, minHeight: 0, overflow: "hidden", contentOffsetY: contentOffset, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (
|
|
191
204
|
// Welcome: căn giữa cả 2 chiều — height = chiều cao đo được của
|
|
192
205
|
// viewport (flexGrow không giãn được vì content box flexShrink=0).
|
|
193
|
-
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] })] })) : (state.items.slice(win.start, win.end).map((item, i) => (_jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, win.start + i)))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
|
|
206
|
+
_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", height: viewport.clientHeight || undefined, children: [(columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) >= 54 ? (_jsx(Box, { flexDirection: "column", alignItems: "center", children: (isAsciiMode() ? BANNER_ASCII : BANNER).map((line, i) => (_jsx(Text, { bold: true, color: theme.brand, children: line }, i))) })) : (_jsx(Text, { bold: true, color: theme.brand, children: "\u2691 SCoder" })), _jsx(Text, { dimColor: true, children: `autonomous coding agent · v${getVersion()}` }), _jsx(Text, {}), _jsx(Text, { dimColor: true, children: "type a task to start \u2014 the agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "\u23CE" }), _jsx(Text, { dimColor: true, children: " send " }), _jsx(Text, { bold: true, color: theme.accent, children: "ctrl+s" }), _jsx(Text, { dimColor: true, children: " sessions " }), _jsx(Text, { bold: true, color: theme.accent, children: "tab" }), _jsx(Text, { dimColor: true, children: " complete " }), _jsx(Text, { bold: true, color: theme.accent, children: "/help" }), _jsx(Text, { dimColor: true, children: " commands" })] }), _jsxs(Text, { children: [_jsx(Text, { bold: true, color: theme.accent, children: "ctrl+o" }), _jsx(Text, { dimColor: true, children: " select/copy (t\u1EAFt mouse tracking \u0111\u1EC3 t\u00F4 \u0111en k\u00E9o chu\u1ED9t)" })] }), state.warmup?.active && (_jsxs(Box, { flexDirection: "column", alignItems: "center", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: state.warmup.message ?? "đang warm-up…" }), _jsxs(Box, { children: [_jsxs(Text, { color: theme.accent, children: ["█".repeat(Math.round((state.warmup.percent / 100) * 32)), "░".repeat(32 - Math.round((state.warmup.percent / 100) * 32))] }), _jsxs(Text, { dimColor: true, children: [" ", state.warmup.percent, "%"] })] })] }))] })) : (state.items.slice(win.start, win.end).map((item, i) => (_jsx(ChatItemView, { item: item, width: (columns ?? 100) - (showSidebar ? SIDEBAR_WIDTH + 2 : 4) }, win.start + i)))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: theme.success, bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: theme.error, bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: cfg, rows: viewport.clientHeight || undefined })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: cfg.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), paletteOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.accent, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: "Command palette (Ctrl+P to close)" }), SLASH_COMMANDS.slice(0, 40).map((c) => (_jsxs(Text, { children: [c.cmd.padEnd(12), " ", c.desc] }, c.cmd)))] })), helpOpen && (_jsxs(Box, { flexDirection: "column", borderStyle: boxBorderStyle(), borderColor: theme.dim, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "Help (?)" }), _jsx(Text, { children: formatSlashHelp("scoder tui") })] })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, paddingTop: 0, paddingBottom: 0, flexShrink: 0, alignSelf: "stretch", children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: cfg, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null, selectMode: selectMode })] }));
|
|
194
207
|
}
|
|
195
208
|
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
196
209
|
export async function runTui(config, initialTask) {
|
package/dist/tui/charset.js
CHANGED
|
@@ -6,6 +6,18 @@
|
|
|
6
6
|
* ASCII mode activates when SCODER_ASCII=1 or TERM=dumb. Colors are NOT
|
|
7
7
|
* handled here: Ink/chalk already respect NO_COLOR and FORCE_COLOR.
|
|
8
8
|
*/
|
|
9
|
+
// Connection-mask glyph table: index = up(1) | right(2) | down(4) | left(8).
|
|
10
|
+
// A cell shows the arms it has: up+right=╰ · up+left=╯ · down+right=┌ ·
|
|
11
|
+
// down+left=┐ · no-left=├ · no-right=┤ · no-down=┴ · no-up=┬ — so elbow
|
|
12
|
+
// routing needs no corner special-casing.
|
|
13
|
+
const LINE_UNICODE = [
|
|
14
|
+
" ", "│", "─", "╰", "│", "│", "┌", "├",
|
|
15
|
+
"─", "╯", "─", "┴", "┐", "┤", "┬", "┼",
|
|
16
|
+
];
|
|
17
|
+
const LINE_ASCII = [
|
|
18
|
+
" ", "|", "-", "+", "|", "|", "+", "+",
|
|
19
|
+
"-", "+", "-", "+", "+", "+", "+", "+",
|
|
20
|
+
];
|
|
9
21
|
const UNICODE = {
|
|
10
22
|
tl: "┌", tr: "┐", bl: "└", br: "┘",
|
|
11
23
|
h: "─", v: "│",
|
|
@@ -15,6 +27,13 @@ const UNICODE = {
|
|
|
15
27
|
ok: "✓", fail: "✗", active: "◆", pending: "○",
|
|
16
28
|
cont: "↪",
|
|
17
29
|
spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
30
|
+
line: LINE_UNICODE,
|
|
31
|
+
hHeavy: "═", vHeavy: "║", hDotted: "╌", vDotted: "┆",
|
|
32
|
+
arrowUp: "▲", arrowRight: "▶", arrowDown: "▼", arrowLeft: "◀",
|
|
33
|
+
cornerRect: ["┌", "┐", "┘", "└"],
|
|
34
|
+
cornerRound: ["╭", "╮", "╯", "╰"],
|
|
35
|
+
cornerDiamond: ["╱", "╲", "╱", "╲"],
|
|
36
|
+
loop: "↩",
|
|
18
37
|
};
|
|
19
38
|
const ASCII = {
|
|
20
39
|
tl: "+", tr: "+", bl: "+", br: "+",
|
|
@@ -25,6 +44,13 @@ const ASCII = {
|
|
|
25
44
|
ok: "v", fail: "x", active: ">", pending: "o",
|
|
26
45
|
cont: ">",
|
|
27
46
|
spinner: ["-", "\\", "|", "/"],
|
|
47
|
+
line: LINE_ASCII,
|
|
48
|
+
hHeavy: "=", vHeavy: "|", hDotted: ".", vDotted: ":",
|
|
49
|
+
arrowUp: "^", arrowRight: ">", arrowDown: "v", arrowLeft: "<",
|
|
50
|
+
cornerRect: ["+", "+", "+", "+"],
|
|
51
|
+
cornerRound: ["+", "+", "+", "+"],
|
|
52
|
+
cornerDiamond: ["/", "\\", "/", "\\"],
|
|
53
|
+
loop: "<-",
|
|
28
54
|
};
|
|
29
55
|
export function isAsciiMode() {
|
|
30
56
|
return process.env.SCODER_ASCII === "1" || process.env.TERM === "dumb";
|
package/dist/tui/chrome.js
CHANGED
|
@@ -67,9 +67,10 @@ function PhaseBreadcrumb({ phases }) {
|
|
|
67
67
|
/** Metrics cho narrow-terminal — không còn dùng (sidebar đã thay thế), giữ để tham khảo. */
|
|
68
68
|
/**
|
|
69
69
|
* Status bar — bottom row. Left: spinner · status · elapsed · model.
|
|
70
|
-
* Right: scroll position OR keyboard hints.
|
|
70
|
+
* Right: scroll position OR keyboard hints. In select mode (Ctrl+O) the hints
|
|
71
|
+
* swap to the copy reminder — the mouse belongs to the terminal there.
|
|
71
72
|
*/
|
|
72
|
-
export function StatusBar({ state, scroll }) {
|
|
73
|
+
export function StatusBar({ state, scroll, selectMode = false }) {
|
|
73
74
|
const { columns } = useWindowSize();
|
|
74
75
|
const width = Math.max(40, (columns ?? 100) - 6);
|
|
75
76
|
const statusColor = state.status === "running" ? theme.accent :
|
|
@@ -86,10 +87,12 @@ export function StatusBar({ state, scroll }) {
|
|
|
86
87
|
state.status === "aborted" ? "⚠" :
|
|
87
88
|
state.status === "idle" ? "○" : null;
|
|
88
89
|
const model = state.model || "—";
|
|
89
|
-
// Right side: scroll position OR
|
|
90
|
-
const rightText =
|
|
91
|
-
?
|
|
92
|
-
:
|
|
90
|
+
// Right side: select-mode reminder OR scroll position OR keybind hints
|
|
91
|
+
const rightText = selectMode
|
|
92
|
+
? "⊝ select · drag to highlight · ⌘C/ctrl+c copy · ctrl+o back"
|
|
93
|
+
: scroll
|
|
94
|
+
? `scroll ${scroll} · esc→tail`
|
|
95
|
+
: `⏎ send · ctrl+s sessions · ctrl+t ${state.thinkingVisible ? "◉" : "○"} · esc abort · ctrl+c quit`;
|
|
93
96
|
const leftBudget = Math.max(24, width - rightText.length - 3);
|
|
94
97
|
let left = statusLabel;
|
|
95
98
|
if (model && model !== "—") {
|
|
@@ -3,13 +3,18 @@ import { useInput } from "ink";
|
|
|
3
3
|
import { useEffect, useRef } from "react";
|
|
4
4
|
import { markAborted } from "../state.js";
|
|
5
5
|
export function useTuiKeybinds(deps) {
|
|
6
|
-
const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, exit } = deps;
|
|
6
|
+
const { scrollOffset, permPromptRef, setPermPrompt, agentRef, runningRef, sessionPickerOpen, setSessionPickerOpen, setPaletteOpen, inputRef, setHelpOpen, update, viewportRowsRef, setScrollOffset, selectMode, setSelectMode, exit } = deps;
|
|
7
7
|
// Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
|
|
8
8
|
// the first Esc returns to the bottom instead.
|
|
9
9
|
const scrollOffsetRef = useRef(0);
|
|
10
10
|
useEffect(() => {
|
|
11
11
|
scrollOffsetRef.current = scrollOffset;
|
|
12
12
|
}, [scrollOffset]);
|
|
13
|
+
// useInput closures go stale between renders (same reason as scrollOffsetRef).
|
|
14
|
+
const selectModeRef = useRef(false);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
selectModeRef.current = selectMode;
|
|
17
|
+
}, [selectMode]);
|
|
13
18
|
// T-03: permission modal keybinds — intercepts y/a/n/q while open.
|
|
14
19
|
useInput((input, key) => {
|
|
15
20
|
const p = permPromptRef.current;
|
|
@@ -47,6 +52,24 @@ export function useTuiKeybinds(deps) {
|
|
|
47
52
|
update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
|
|
48
53
|
return;
|
|
49
54
|
}
|
|
55
|
+
// Ctrl+O: toggle select mode — mouse tracking off (native drag-select +
|
|
56
|
+
// Cmd/Ctrl+C copy) / on (wheel scroll). Scroll with PgUp/PgDn while off.
|
|
57
|
+
if (key.ctrl && _input === "o") {
|
|
58
|
+
const next = !selectModeRef.current;
|
|
59
|
+
selectModeRef.current = next;
|
|
60
|
+
setSelectMode(next);
|
|
61
|
+
update((s) => ({
|
|
62
|
+
...s,
|
|
63
|
+
items: [...s.items, {
|
|
64
|
+
kind: "notice",
|
|
65
|
+
text: next
|
|
66
|
+
? "select mode ON — drag to highlight, copy with ⌘C/Ctrl+C · scroll via PgUp/PgDn · Ctrl+O back to mouse wheel"
|
|
67
|
+
: "select mode OFF — mouse wheel scrolling restored",
|
|
68
|
+
level: "info",
|
|
69
|
+
}],
|
|
70
|
+
}));
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
50
73
|
if (key.pageUp || key.pageDown || key.home || key.end) {
|
|
51
74
|
const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
|
|
52
75
|
if (key.pageUp)
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
/** Scroll viewport metrics + mouse wheel (SGR 1006) wiring for the TUI.
|
|
2
2
|
* P1-8: contentRef giờ đo CỬA SỔ item đã mount (không phải cả transcript) —
|
|
3
3
|
* chi phí đo O(window) nên vẫn rẻ; số đo thật dùng cho follow-bottom chính
|
|
4
|
-
* xác, ước lượng (transcriptWindow) chỉ dùng khi scroll lên trên.
|
|
4
|
+
* xác, ước lượng (transcriptWindow) chỉ dùng khi scroll lên trên.
|
|
5
|
+
* mouseEnabled=false (select mode, Ctrl+O): nhả mouse tracking để terminal
|
|
6
|
+
* cho tô đen kéo chuột + copy thường — wheel scroll tắt, dùng PgUp/PgDn. */
|
|
5
7
|
import { useCallback, useEffect, useRef } from "react";
|
|
6
8
|
import { useBoxMetrics, useStdin } from "ink";
|
|
7
9
|
import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "../mouse.js";
|
|
8
|
-
export function useScrollMetrics(setScrollOffset) {
|
|
10
|
+
export function useScrollMetrics(setScrollOffset, mouseEnabled = true) {
|
|
9
11
|
const viewportRef = useRef(null);
|
|
10
12
|
const contentRef = useRef(null);
|
|
11
13
|
const viewport = useBoxMetrics(viewportRef);
|
|
@@ -17,6 +19,8 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
17
19
|
// T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
|
|
18
20
|
// drops unrepresentable CSI sequences, so wheel events must be read here.
|
|
19
21
|
// Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
|
|
22
|
+
// Select mode (mouseEnabled=false): MOUSE_DISABLE → native terminal drag
|
|
23
|
+
// selection/copy returns; setRawMode calls stay balanced across toggles.
|
|
20
24
|
const { stdin, setRawMode, isRawModeSupported } = useStdin();
|
|
21
25
|
const wheelScroll = useCallback((dir) => {
|
|
22
26
|
setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
|
|
@@ -24,6 +28,10 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
24
28
|
useEffect(() => {
|
|
25
29
|
if (process.stdout.isTTY !== true || !isRawModeSupported)
|
|
26
30
|
return;
|
|
31
|
+
if (!mouseEnabled) {
|
|
32
|
+
process.stdout.write(MOUSE_DISABLE);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
27
35
|
process.stdout.write(MOUSE_ENABLE);
|
|
28
36
|
setRawMode(true);
|
|
29
37
|
const onData = (chunk) => {
|
|
@@ -35,7 +43,8 @@ export function useScrollMetrics(setScrollOffset) {
|
|
|
35
43
|
return () => {
|
|
36
44
|
stdin.off("data", onData);
|
|
37
45
|
process.stdout.write(MOUSE_DISABLE);
|
|
46
|
+
setRawMode(false);
|
|
38
47
|
};
|
|
39
|
-
}, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
|
|
48
|
+
}, [stdin, setRawMode, isRawModeSupported, wheelScroll, mouseEnabled]);
|
|
40
49
|
return { viewportRef, viewport, contentRef, content, viewportRowsRef };
|
|
41
50
|
}
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
3
6
|
import { theme } from "../theme.js";
|
|
4
7
|
import { charset } from "../charset.js";
|
|
5
8
|
import { clipCells, measure, padEndCells } from "../text-width.js";
|
|
9
|
+
import { cleanMermaidLabel, renderFlowchart, MAX_GRAPH_ROWS } from "./graph.js";
|
|
6
10
|
// ── Chart / timeline specs (unchanged data formats) ─────────────────────────
|
|
7
11
|
function scalar(value) {
|
|
8
12
|
if (value == null)
|
|
@@ -121,9 +125,9 @@ const MERMAID_NODE_RX = /([\p{L}\p{N}_]+)\s*(\[\s*(?:[^\]]*)\s*\]|\(\s*(?:[^)]*)
|
|
|
121
125
|
/** Node id with optional shape, so `A[Start] --> B{Valid?}` still parses. */
|
|
122
126
|
const NODE_WITH_SHAPE = String.raw `([\p{L}\p{N}_]+)(?:\(\([^)]*\)\)|\[[^\]]*\]|\([^)]*\)|\{[^}]*\})?`;
|
|
123
127
|
const MERMAID_EDGE_RX = new RegExp(NODE_WITH_SHAPE + String.raw `\s*(--?>?|-.->|==>|<--?>|x--?x|o--?o|---)\s*(?:\|([^|]*)\|)?\s*` + NODE_WITH_SHAPE, "gu");
|
|
124
|
-
/**
|
|
128
|
+
/** Shape wrapper → display label: `"[Start<br/>2016]"` → `"Start 2016"`. */
|
|
125
129
|
function nodeLabel(shape) {
|
|
126
|
-
return shape.replace(/^[[({]+|[)\]}]+$/g, "").trim()
|
|
130
|
+
return cleanMermaidLabel(shape.replace(/^[[({]+|[)\]}]+$/g, "").trim());
|
|
127
131
|
}
|
|
128
132
|
export function summarizeMermaid(source) {
|
|
129
133
|
const text = source.join("\n");
|
|
@@ -191,15 +195,90 @@ export function summarizeMermaid(source) {
|
|
|
191
195
|
lines: [`${type || "unknown"} — unknown diagram type`],
|
|
192
196
|
};
|
|
193
197
|
}
|
|
194
|
-
const MERMAID_EXPORT_HINT = "export: npx @mermaid-js/mermaid-cli -i <file> -o out.svg";
|
|
195
198
|
const MAX_SOURCE_LINES = 12;
|
|
196
|
-
|
|
199
|
+
const exported = new Set();
|
|
200
|
+
/** `Angular 12-14 <br/> 2021` → `angular-12-14-2021-a1b2c3`. */
|
|
201
|
+
function diagramSlug(source) {
|
|
202
|
+
const first = source.join(" ").match(/\["?([^"\]]{3,40})"\?\]/)?.[1] ?? "diagram";
|
|
203
|
+
const base = first
|
|
204
|
+
.toLowerCase()
|
|
205
|
+
.replace(/<[^>]+>/g, " ")
|
|
206
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
207
|
+
.replace(/^-+|-+$/g, "")
|
|
208
|
+
.slice(0, 24);
|
|
209
|
+
const hash = createHash("sha1").update(source.join("\n")).digest("hex").slice(0, 6);
|
|
210
|
+
return `${base || "diagram"}-${hash}`;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Save the diagram source so the export hint becomes a command that actually
|
|
214
|
+
* runs. Idempotent per source and silent on failure — rendering must never
|
|
215
|
+
* break because the filesystem did. `.scoder/` is gitignored.
|
|
216
|
+
*/
|
|
217
|
+
function exportDiagram(source) {
|
|
218
|
+
const path = join(".scoder", "diagrams", `${diagramSlug(source)}.mmd`);
|
|
219
|
+
if (exported.has(path))
|
|
220
|
+
return path;
|
|
221
|
+
try {
|
|
222
|
+
mkdirSync(join(".scoder", "diagrams"), { recursive: true });
|
|
223
|
+
writeFileSync(path, `${source.join("\n")}\n`, "utf8");
|
|
224
|
+
exported.add(path);
|
|
225
|
+
return path;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Export hint, pre-split so the real path is never clipped by a box border:
|
|
233
|
+
* break before the arguments and indent the continuation line.
|
|
234
|
+
*/
|
|
235
|
+
function exportHintLines(file, width) {
|
|
236
|
+
if (!file) {
|
|
237
|
+
return [
|
|
238
|
+
"export: save these lines to a .mmd file, then",
|
|
239
|
+
" npx @mermaid-js/mermaid-cli -i FILE -o diagram.svg",
|
|
240
|
+
];
|
|
241
|
+
}
|
|
242
|
+
const oneLine = `export: npx @mermaid-js/mermaid-cli -i ${file} -o diagram.svg`;
|
|
243
|
+
if (measure(oneLine) <= width)
|
|
244
|
+
return [oneLine];
|
|
245
|
+
const args = `-i ${file} -o diagram.svg`;
|
|
246
|
+
return [
|
|
247
|
+
"export: npx @mermaid-js/mermaid-cli",
|
|
248
|
+
` ${measure(args) <= width - 2 ? args : clipCells(args, Math.max(4, width - 2))}`,
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
/** Cell kind → text props: boxes/edges stay quiet, labels carry the meaning. */
|
|
252
|
+
function cellStyle(kind) {
|
|
253
|
+
switch (kind) {
|
|
254
|
+
case "box": return { color: theme.mermaid };
|
|
255
|
+
case "arrow": return { color: theme.mermaid };
|
|
256
|
+
case "edge": return { color: theme.dim };
|
|
257
|
+
case "label": return { bold: true };
|
|
258
|
+
case "elabel": return { color: theme.accent };
|
|
259
|
+
default: return {};
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** A flowchart drawn for real: box-drawing nodes, routed edges, edge labels. */
|
|
263
|
+
export function FlowchartView({ raster, source, width }) {
|
|
264
|
+
const file = exportDiagram(source);
|
|
265
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: theme.mermaid, bold: true, children: "\u25C9 " }), "flowchart ", raster.direction, " \u00B7 ", raster.nodes, " nodes \u00B7 ", raster.edges, " edges", raster.mode === "strip" ? " · chain layout" : ""] }), raster.rows.map((segs, y) => (_jsx(Text, { children: segs.map((seg, i) => (_jsx(Text, { ...cellStyle(seg.kind), children: seg.text }, i))) }, y))), raster.notes.map((note, i) => (_jsx(Text, { dimColor: true, children: `${charset.bullet} ${clipCells(note, Math.max(8, width - 2))}` }, `note-${i}`))), exportHintLines(file, Math.max(24, width)).map((line, i) => (_jsx(Text, { dimColor: true, children: line }, `hint-${i}`)))] }));
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Mermaid block: draw the graph when it fits the pane; otherwise an honest
|
|
269
|
+
* structured summary (supported types) or source + note (unsupported).
|
|
270
|
+
*/
|
|
197
271
|
export function MermaidPreview({ source, width }) {
|
|
272
|
+
const graph = renderFlowchart(source, Math.max(24, width), MAX_GRAPH_ROWS);
|
|
273
|
+
if (graph)
|
|
274
|
+
return _jsx(FlowchartView, { raster: graph, source: source, width: width });
|
|
198
275
|
const summary = summarizeMermaid(source);
|
|
199
276
|
if (summary.supported) {
|
|
200
|
-
|
|
277
|
+
const boxWidth = Math.min(width, 72);
|
|
278
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: " \u25C9 mermaid diagram" }), _jsx(ContentBox, { title: "\u25C8 diagram preview", lines: [...summary.lines, "", ...exportHintLines(exportDiagram(source), boxWidth - 4)], width: boxWidth, color: theme.mermaid })] }));
|
|
201
279
|
}
|
|
202
|
-
|
|
280
|
+
const file = exportDiagram(source);
|
|
281
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { dimColor: true, children: [" \u25C9 mermaid diagram \u2014 ", summary.lines[0]] }), source.slice(0, MAX_SOURCE_LINES).map((l, i) => (_jsxs(Text, { color: theme.mermaid, children: [" ", clipCells(l, Math.max(8, width - 2))] }, i))), source.length > MAX_SOURCE_LINES && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", source.length - MAX_SOURCE_LINES, " more lines"] })), exportHintLines(file, Math.max(24, width - 2)).map((line, i) => (_jsxs(Text, { dimColor: true, children: [" ", line] }, `hint-${i}`)))] }));
|
|
203
282
|
}
|
|
204
283
|
// ── Chart fence ─────────────────────────────────────────────────────────────
|
|
205
284
|
export function ChartPreview({ spec, width }) {
|