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.
@@ -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
- const killChild = (reason) => {
124
- killedReason = reason;
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
- child.kill("SIGTERM");
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
- // SIGKILL fallback if the process ignores SIGTERM
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
- try {
132
- if (child.exitCode === null)
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
- child.on("close", (code) => {
170
+ const buildResult = (code) => {
160
171
  if (killedReason === "timeout") {
161
- finish({
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
- finish({
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
- finish({ ok: true, output: clamp(`${echoLine}\n(exit 0)\n${redacted}`) });
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
  }
@@ -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) {
@@ -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";
@@ -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 helpful keybinds
90
- const rightText = scroll
91
- ? `scroll ${scroll} · esc→tail`
92
- : `⏎ send · ctrl+s sessions · ctrl+t ${state.thinkingVisible ? "◉" : "○"} · esc abort · ctrl+c quit`;
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
- /** Strip surrounding shape brackets to get the label: "[Start]" → "Start". */
128
+ /** Shape wrapper → display label: `"[Start<br/>2016]"` → `"Start 2016"`. */
125
129
  function nodeLabel(shape) {
126
- return shape.replace(/^[[({]+|[)\]}]+$/g, "").trim().replace(/^"|"$/g, "");
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
- /** Mermaid block: structured summary box; unsupported types → source + note. */
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
- 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, "", MERMAID_EXPORT_HINT], width: Math.min(width, 72), color: theme.mermaid })] }));
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
- 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"] })), _jsxs(Text, { dimColor: true, children: [" ", MERMAID_EXPORT_HINT] })] }));
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 }) {