s-app-scoder-agent 0.6.0 → 0.7.0

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.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +599 -468
  4. package/dist/agent.js +825 -175
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +409 -0
  11. package/dist/index.js +121 -76
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +123 -9
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +97 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +28 -1
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +513 -19
  30. package/dist/tui/app.js +498 -198
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +145 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +200 -2
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +101 -0
  44. package/dist/tui/state.js +182 -6
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/watch.js +52 -0
  48. package/package.json +16 -8
  49. package/dist/tui-direct.mjs +0 -4
package/dist/tui/app.js CHANGED
@@ -1,225 +1,470 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * Full-screen Ink TUI for scoder — "mission control for the build loop".
4
4
  *
5
5
  * Identity (deliberately distinct from chat-first agent UIs): the build is
6
6
  * the mission, so the screen is organized around it —
7
- * · Build Rail (top): brand, build-status lamp, step fuel gauge ▰▰▰▱▱▱
7
+ * · Build Rail (top): brand, model/permission badges, build lamp, fuel gauge
8
8
  * · Flight-recorder log: each tool entry is stamped `07 ▸ name(args)`
9
9
  * · `✎` diff cards rendered inline for every file edit
10
+ * · Assistant messages render as markdown (code fences, headers, lists)
11
+ * · Input: ↑/↓ history · Tab command completion · live candidates hint
10
12
  * Driven entirely by AgentEvents (the same stream the HTTP/SSE server uses).
11
13
  */
12
14
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
- import { Box, Text, useApp, useInput, useStdout } from "ink";
15
+ import { Box, Text, useApp, useBoxMetrics, useInput, useStdin, useWindowSize } from "ink";
14
16
  import { Agent } from "../agent.js";
15
17
  import { setQuiet } from "../ui.js";
18
+ import { discoverSkills } from "../skills.js";
19
+ import { discoverAgents } from "../agents.js";
20
+ import { resolveSessionId } from "../session.js";
16
21
  import { applyEvent, initialState, markAborted } from "./state.js";
17
- import { formatSlashHelp, parseSlashCommand } from "./commands.js";
18
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
19
- /** Fuel gauge is scaled to this many cells regardless of maxSteps. */
20
- const GAUGE_CELLS = 10;
21
- function useSpinner(active) {
22
- const [frame, setFrame] = useState(0);
23
- useEffect(() => {
24
- if (!active)
25
- return;
26
- const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
27
- return () => clearInterval(timer);
28
- }, [active]);
29
- return SPINNER_FRAMES[frame];
30
- }
31
- function formatArgs(args) {
32
- return Object.entries(args)
33
- .map(([k, v]) => {
34
- const s = typeof v === "string" ? v : JSON.stringify(v) ?? "";
35
- return `${k}=${s.length > 60 ? s.slice(0, 60) + "…" : s.replace(/\n/g, " ")}`;
36
- })
37
- .join(" ");
38
- }
39
- /** Scaled step fuel gauge: ▰ filled / ▱ remaining, e.g. ▰▰▰▰▱▱▱▱ 4/40. */
40
- function fuelGauge(step, maxSteps) {
41
- if (maxSteps <= 0)
42
- return null;
43
- const filled = Math.min(GAUGE_CELLS, Math.round((Math.min(step, maxSteps) / maxSteps) * GAUGE_CELLS));
44
- return "▰".repeat(filled) + "▱".repeat(GAUGE_CELLS - filled);
45
- }
46
- const OUTPUT_PREVIEW_LINES = 4;
47
- function ToolItemView({ item, spinner }) {
48
- const running = item.ok === null;
49
- const icon = running ? _jsx(Text, { color: "cyan", children: spinner }) : item.ok ? _jsx(Text, { color: "green", children: "\u2714" }) : _jsx(Text, { color: "red", children: "\u2718" });
50
- const lines = item.output.length > 0 ? item.output.replace(/\n+$/, "").split("\n") : [];
51
- const preview = lines.slice(-OUTPUT_PREVIEW_LINES);
52
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8"] }), " ", icon, " ", _jsx(Text, { color: "cyan", children: item.name }), _jsxs(Text, { dimColor: true, children: ["(", formatArgs(item.args), ")"] }), running && _jsx(Text, { dimColor: true, children: " \u00B7 running\u2026" })] }), preview.map((line, i) => (_jsx(Text, { dimColor: i < preview.length - 1, children: line.length > 160 ? line.slice(0, 160) + "…" : line }, i)))] }));
53
- }
54
- /** One colored line of a unified diff (+ green, - red, @@ cyan, header bold). */
55
- function DiffLine({ line }) {
56
- if (line.startsWith("+++") || line.startsWith("---"))
57
- return _jsx(Text, { bold: true, children: line });
58
- if (line.startsWith("@@"))
59
- return _jsx(Text, { color: "cyan", children: line });
60
- if (line.startsWith("+"))
61
- return _jsx(Text, { color: "green", children: line });
62
- if (line.startsWith("-"))
63
- return _jsx(Text, { color: "red", children: line });
64
- return _jsx(Text, { children: line });
65
- }
66
- const MAX_DIFF_RENDER_LINES = 24;
67
- function DiffItemView({ item }) {
68
- const lines = item.text.length > 0 ? item.text.split("\n") : [];
69
- const shown = lines.slice(0, MAX_DIFF_RENDER_LINES);
70
- const hidden = lines.length - shown.length;
71
- return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8"] }), " ", _jsx(Text, { color: "magenta", bold: true, children: "\u270E" }), " ", _jsxs(Text, { color: "green", children: ["+", item.added] }), " ", _jsxs(Text, { color: "red", children: ["-", item.removed] }), " ", _jsx(Text, { bold: true, children: item.path }), item.truncated && _jsx(Text, { color: "yellow", children: " (diff truncated)" })] }), shown.map((line, i) => (_jsx(DiffLine, { line: line.length > 160 ? line.slice(0, 160) + "…" : line }, i))), hidden > 0 && _jsxs(Text, { dimColor: true, children: ["\u2026 +", hidden, " more diff lines"] })] }));
72
- }
73
- function ChatItemView({ item, spinner }) {
74
- switch (item.kind) {
75
- case "user":
76
- return (_jsxs(Box, { marginTop: 1, children: [_jsxs(Text, { color: "blue", bold: true, children: ["\u276F", " "] }), _jsx(Text, { bold: true, children: item.text })] }));
77
- case "assistant":
78
- return (_jsx(Box, { marginTop: item.text ? 1 : 0, paddingLeft: 1, children: _jsx(Text, { dimColor: item.streaming, children: item.text || (item.streaming ? "" : "(no text)") }) }));
79
- case "tool":
80
- return _jsx(ToolItemView, { item: item, spinner: spinner });
81
- case "diff":
82
- return _jsx(DiffItemView, { item: item });
83
- case "build":
84
- return (_jsxs(Box, { paddingLeft: 1, children: [item.success ? _jsx(Text, { color: "green", bold: true, children: "\u2714 build passed" }) : _jsx(Text, { color: "red", bold: true, children: "\u2718 build failed" }), _jsxs(Text, { dimColor: true, children: [" \u2014 ", item.command] })] }));
85
- case "notice":
86
- return (_jsx(Box, { paddingLeft: 1, children: _jsxs(Text, { color: item.level === "error" ? "red" : item.level === "warn" ? "yellow" : "gray", children: [item.level === "error" ? "✘ " : "⚠ ", item.text.length > 500 ? item.text.slice(0, 500) + "…" : item.text] }) }));
87
- }
88
- }
89
- /** Build Rail — scoder's signature top bar: lamp + build command + fuel gauge. */
90
- function BuildRail({ state, config }) {
91
- const lampColor = state.buildLamp === "ok" ? "green" : state.buildLamp === "fail" ? "red" : "gray";
92
- const lampLabel = state.buildLamp === "ok" ? "build ok" : state.buildLamp === "fail" ? "build fail" : "no build yet";
93
- const gauge = fuelGauge(state.step, state.maxSteps);
94
- return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsx(Text, { bold: true, color: "magenta", children: "scoder" }), _jsxs(Text, { children: [_jsxs(Text, { color: lampColor, children: ["\u25CF ", lampLabel] }), _jsxs(Text, { dimColor: true, children: [" ", state.buildCmd || config.buildCmd || "auto"] })] }), _jsxs(Text, { children: [gauge && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "magenta", children: gauge }), _jsxs(Text, { dimColor: true, children: [" ", state.step, "/", state.maxSteps] })] })), !gauge && _jsx(Text, { dimColor: true, children: "\u2014 idle" })] })] }));
95
- }
96
- function StatusBar({ state, spinner }) {
97
- const statusColor = state.status === "running" ? "cyan" : state.status === "success" ? "green" : state.status === "failed" ? "red" : "gray";
98
- const statusLabel = state.status === "running" ? `running ${state.step}/${state.maxSteps}` : state.status === "idle" ? "idle" : state.status;
99
- return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, justifyContent: "space-between", children: [_jsxs(Text, { children: [_jsx(Text, { color: statusColor, children: state.status === "running" ? `${spinner} ${statusLabel}` : statusLabel }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", state.model || "—"] }), state.usage.calls > 0 && (_jsxs(Text, { dimColor: true, children: [" ", "\u00B7 tok ", state.usage.inputTokens, "/", state.usage.outputTokens, state.usage.cachedTokens > 0 ? ` (+${state.usage.cachedTokens} cached)` : ""] }))] }), _jsx(Text, { dimColor: true, children: "\u23CE send \u00B7 pgup/pgdn scroll \u00B7 esc abort \u00B7 ctrl+c quit" })] }));
100
- }
101
- function Input({ valueRef, value, onChange, onSubmit }) {
102
- useInput((input, key) => {
103
- const current = valueRef.current;
104
- if (key.return) {
105
- onSubmit(current);
106
- return;
107
- }
108
- if (key.backspace || key.delete) {
109
- const next = current.slice(0, -1);
110
- valueRef.current = next;
111
- onChange(next);
112
- return;
113
- }
114
- if (input.length > 0 && !key.ctrl && !key.meta && !key.escape && !key.upArrow && !key.downArrow && !key.tab) {
115
- // A chunk may contain several pasted chars — even a trailing newline
116
- // (terminal pastes / PTY writes deliver "exit\r" as one chunk).
117
- const parts = input.split(/[\r\n]+/);
118
- const next = current + parts[0];
119
- valueRef.current = next;
120
- onChange(next);
121
- if (parts.length > 1)
122
- onSubmit(next); // newline in the chunk = submit
123
- }
124
- });
125
- return (_jsxs(Box, { children: [_jsx(Text, { color: "magenta", bold: true, children: "❯ " }), _jsx(Text, { children: value }), _jsx(Text, { dimColor: true, children: "\u258C" })] }));
126
- }
127
- export function TuiApp({ config, initialTask, bootNotice }) {
22
+ import { parseSlashCommand } from "./commands.js";
23
+ import { dispatchCommand } from "./command-registry.js";
24
+ import { ChatItemView } from "./chat-views.js"; // T2
25
+ import { BuildRail, StatusBar, SIDEBAR_MIN_COLS } from "./chrome.js"; // T2
26
+ import { Input } from "./input.js"; // T2
27
+ import { SessionPicker } from "./session-picker.js"; // session picker modal
28
+ import { fileIndex } from "./at-complete.js";
29
+ import { parseWheel, MOUSE_ENABLE, MOUSE_DISABLE } from "./mouse.js"; // T5
30
+ import { getTheme, setActiveTheme, theme, THEMES, useNamedTheme } from "./theme.js";
31
+ import { reviewFromResponse, reviewAnswerToCall } from "./review.js"; // T2
32
+ import { PermissionModal } from "./permission-modal.js"; // T2
33
+ import { Sidebar } from "./sidebar.js"; // sidebar opencode
34
+ import { getVersion } from "../version.js";
35
+ export function TuiApp({ config, initialTask }) {
128
36
  const { exit } = useApp();
129
- const { stdout } = useStdout();
37
+ // T-09: theme resolve at mount (.scoder/theme.json > builtin). Proxy-based
38
+ // theme reads re-render via a bump.
39
+ const [, bump] = useState(0);
40
+ const bumpFrame = useCallback(() => bump((n) => n + 1), []);
41
+ useEffect(() => {
42
+ setActiveTheme(getTheme(config.workdir));
43
+ bumpFrame();
44
+ }, [config.workdir, bumpFrame]);
130
45
  const [state, setState] = useState(initialState);
131
46
  const [input, setInput] = useState(initialTask ?? "");
132
- const [scrollOffset, setScrollOffset] = useState(0); // 0 = pinned to bottom
47
+ /** Rows scrolled up from the bottom; 0 = follow the newest content. */
48
+ const [scrollOffset, setScrollOffset] = useState(0);
49
+ const [permMode, setPermMode] = useState(config.permissionMode);
50
+ // T-03: active permission prompt (modal) — the run blocks until answered.
51
+ const [permPrompt, setPermPrompt] = useState(null);
52
+ const permPromptRef = useRef(permPrompt);
53
+ permPromptRef.current = permPrompt;
54
+ // Session picker modal state (open via Ctrl+S)
55
+ const [sessionPickerOpen, setSessionPickerOpen] = useState(false);
133
56
  const inputRef = useRef(initialTask ?? "");
134
57
  const writeInput = useCallback((v) => {
135
58
  inputRef.current = v;
136
59
  setInput(v);
137
60
  }, []);
61
+ const historyRef = useRef([]);
138
62
  const runningRef = useRef(false);
139
63
  const agentRef = useRef(null);
140
- const spinner = useSpinner(state.status === "running");
141
- // Boot notice (e.g. "permission=ask is not supported in the TUI") as a chat
142
- // item, so it scrolls with the conversation instead of being lost off-screen.
143
- const bootedRef = useRef(false);
64
+ // ── Prompt-gate review flow (/review) ──────────────────────────────────────
65
+ // State machine relaying the universal-prompt-gate-mcp text-fallback flow:
66
+ // call → pending_level_selection → user picks level → pending_user_selection
67
+ // → user picks original/rewritten/update/cancel → approved/cancelled.
68
+ // `argsBase` holds the immutable prompt/context/language; `rewrittenPrompt`
69
+ // is echoed back verbatim from the server's pending result (never from the
70
+ // user or LLM) so only exactly what the user saw can be approved.
71
+ const reviewRef = useRef(null);
72
+ const reviewBusyRef = useRef(false);
73
+ // ── Scrollable chat viewport (Ink 8 alt-screen) ──
74
+ // The viewport clips overflow; the inner content wrapper keeps its natural
75
+ // height so it can be measured and offset upward for scrolling.
76
+ const viewportRef = useRef(null);
77
+ const contentRef = useRef(null);
78
+ const viewport = useBoxMetrics(viewportRef);
79
+ const content = useBoxMetrics(contentRef);
80
+ const viewportRowsRef = useRef(null);
81
+ useEffect(() => {
82
+ viewportRowsRef.current = viewport.clientHeight || null;
83
+ }, [viewport.clientHeight]);
84
+ const maxScroll = Math.max(0, content.height - viewport.clientHeight);
85
+ // scrollOffset counts rows UP from the bottom; 0 = follow the newest item.
86
+ const scrollTop = Math.max(0, maxScroll - scrollOffset);
87
+ // T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
88
+ // drops unrepresentable CSI sequences, so wheel events must be read here.
89
+ // Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
90
+ const { stdin, setRawMode, isRawModeSupported } = useStdin();
91
+ const wheelScroll = useCallback((dir) => {
92
+ setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
93
+ }, []);
144
94
  useEffect(() => {
145
- if (bootedRef.current)
95
+ if (process.stdout.isTTY !== true || !isRawModeSupported)
146
96
  return;
147
- bootedRef.current = true;
148
- if (bootNotice)
149
- setState((s) => ({ ...s, items: [{ kind: "notice", text: bootNotice, level: "warn" }, ...s.items] }));
150
- }, [bootNotice]);
151
- const pushNotice = useCallback((text, level = "info") => setState((s) => ({ ...s, items: [...s.items, { kind: "notice", text, level }] })), []);
97
+ process.stdout.write(MOUSE_ENABLE);
98
+ setRawMode(true);
99
+ const onData = (chunk) => {
100
+ const dir = parseWheel(String(chunk));
101
+ if (dir)
102
+ wheelScroll(dir);
103
+ };
104
+ stdin.on("data", onData);
105
+ return () => {
106
+ stdin.off("data", onData);
107
+ process.stdout.write(MOUSE_DISABLE);
108
+ };
109
+ }, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
110
+ const skills = useMemo(() => discoverSkills(config.workdir), [config.workdir]);
111
+ const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
112
+ // T-02: completion sources (agents + files) computed once per mount.
113
+ const atSources = useMemo(() => ({
114
+ skills: skillNames,
115
+ agents: discoverAgents(config.workdir).filter((a) => a.mode !== "subagent" || a.name === "explore").map((a) => a.name),
116
+ files: fileIndex(config.workdir),
117
+ }), [config.workdir, skillNames]);
118
+ // Mirror of state for the submit path (isFirst, /cost, /model) and the
119
+ // abort handler — kept in sync synchronously by update() below, so it is
120
+ // exact even between renders (event bursts land between them).
121
+ const stateRef = useRef(state);
122
+ /**
123
+ * Central state updater — the single source of truth is stateRef, updated
124
+ * synchronously so event handlers never race a render cycle.
125
+ */
126
+ const update = useCallback((fn) => {
127
+ stateRef.current = fn(stateRef.current);
128
+ setState(stateRef.current);
129
+ }, []);
130
+ /**
131
+ * Event batching: streaming (`llm_delta`) and live tool output (`tool_output`)
132
+ * can emit hundreds of events per second; each one previously triggered a
133
+ * full-tree re-render (the flicker). Buffer incoming events and flush them
134
+ * through the reducer at most every FLUSH_MS (~20 fps, well above what the
135
+ * eye needs for streaming text).
136
+ */
137
+ const FLUSH_MS = 50;
138
+ const eventQueue = useRef([]);
139
+ const flushTimer = useRef(null);
140
+ const flushEvents = useCallback(() => {
141
+ flushTimer.current = null;
142
+ const events = eventQueue.current;
143
+ if (events.length === 0)
144
+ return;
145
+ eventQueue.current = [];
146
+ update((s) => {
147
+ let next = s;
148
+ for (const ev of events)
149
+ next = applyEvent(next, ev);
150
+ return { ...next, items: [...next.items] };
151
+ });
152
+ }, [update]);
153
+ const queueEvent = useCallback((ev) => {
154
+ // T-03: permission modal must appear instantly (not batched) — the run
155
+ // blocks on the answer.
156
+ if (ev.type === "permission_request") {
157
+ setPermPrompt(ev);
158
+ return;
159
+ }
160
+ eventQueue.current.push(ev);
161
+ if (!flushTimer.current)
162
+ flushTimer.current = setTimeout(flushEvents, FLUSH_MS);
163
+ }, [flushEvents]);
164
+ // Flush on unmount so a trailing burst is never dropped.
165
+ useEffect(() => () => {
166
+ if (flushTimer.current)
167
+ clearTimeout(flushTimer.current);
168
+ flushEvents();
169
+ }, [flushEvents]);
170
+ const pushNotice = useCallback((text, level = "info") => update((s) => ({ ...s, items: [...s.items, { kind: "notice", text, level }] })), [update]);
171
+ /** Session picker: when a session is selected, resume it. */
172
+ const handleSessionSelect = useCallback(async (session) => {
173
+ setSessionPickerOpen(false);
174
+ if (runningRef.current) {
175
+ pushNotice("agent is running — wait or Esc first", "warn");
176
+ return;
177
+ }
178
+ const s = resolveSessionId(config.workdir, session.id);
179
+ if (!s) {
180
+ pushNotice(`session not found: ${session.id}`, "error");
181
+ return;
182
+ }
183
+ // Restore full TUI state from the session (items + step count + model info).
184
+ update(() => ({
185
+ ...initialState(),
186
+ items: s.messages
187
+ .filter((m) => m.role === "user" || m.role === "assistant")
188
+ .map((m) => m.role === "user"
189
+ ? { kind: "user", text: typeof m.content === "string" ? m.content : "" }
190
+ : { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false }),
191
+ step: s.steps ?? 0,
192
+ maxSteps: s.maxSteps ?? config.maxSteps,
193
+ model: s.model || config.model,
194
+ provider: s.provider || config.provider,
195
+ status: "idle",
196
+ summary: null,
197
+ }));
198
+ runningRef.current = true;
199
+ setScrollOffset(0);
200
+ const agent = createAgent();
201
+ try {
202
+ await agent.resume(s);
203
+ runningRef.current = false;
204
+ }
205
+ catch (err) {
206
+ runningRef.current = false;
207
+ pushNotice(`resume failed: ${err.message}`, "error");
208
+ }
209
+ }, [config, createAgent, pushNotice, update]);
210
+ /** T1: CommandHost cho registry dùng chung với REPL. */
211
+ const tuiHost = useCallback(() => ({
212
+ agent: () => agentRef.current,
213
+ config,
214
+ isRunning: () => runningRef.current,
215
+ replaceAgent: (next) => {
216
+ agentRef.current?.abort();
217
+ agentRef.current = next;
218
+ if (!next) {
219
+ runningRef.current = false;
220
+ setScrollOffset(0);
221
+ eventQueue.current = [];
222
+ update(() => initialState());
223
+ }
224
+ setPermMode(config.permissionMode);
225
+ },
226
+ resumeAndRun: async (s) => {
227
+ const agent = createAgent();
228
+ // Replace the pane with the restored transcript, then continue.
229
+ update(() => ({
230
+ ...initialState(),
231
+ items: s.messages
232
+ .filter((m) => m.role === "user" || m.role === "assistant")
233
+ .map((m) => (m.role === "user" ? { kind: "user", text: typeof m.content === "string" ? m.content : "" } : { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false })),
234
+ }));
235
+ runningRef.current = true;
236
+ setScrollOffset(0);
237
+ try {
238
+ await agent.resume(s);
239
+ runningRef.current = false;
240
+ }
241
+ catch (err) {
242
+ runningRef.current = false;
243
+ pushNotice(`resume failed: ${err.message}`, "error");
244
+ }
245
+ return { success: true, steps: 0, buildCmd: config.buildCmd, summary: "resumed" };
246
+ },
247
+ }), [config, update, pushNotice, createAgent]);
248
+ /** One round of the /review state machine: call the gate, render the result. */
249
+ const reviewStep = useCallback(async (tool, args) => {
250
+ const agent = agentRef.current ?? createAgent();
251
+ reviewBusyRef.current = true;
252
+ try {
253
+ const r = await agent.callMcpTool(tool, args);
254
+ const out = reviewFromResponse(reviewRef.current, tool, args, r);
255
+ reviewRef.current = out.state;
256
+ if (out.notice)
257
+ pushNotice(out.notice.text, out.notice.level);
258
+ }
259
+ catch (err) {
260
+ reviewRef.current = null;
261
+ pushNotice(`prompt gate failed: ${err.message}`, "error");
262
+ }
263
+ finally {
264
+ reviewBusyRef.current = false;
265
+ }
266
+ }, [createAgent, pushNotice]);
267
+ /** Handle a user reply while /review is waiting (numbers 1-4 / feedback text). */
268
+ const reviewAnswer = useCallback((text) => {
269
+ const rv = reviewRef.current;
270
+ if (!rv || reviewBusyRef.current)
271
+ return false;
272
+ const out = reviewAnswerToCall(rv, text);
273
+ if (out.notice)
274
+ pushNotice(out.notice.text, out.notice.level);
275
+ if (out.nextCall)
276
+ void reviewStep(out.nextCall.tool, out.nextCall.args);
277
+ return true;
278
+ }, [reviewStep, pushNotice]);
279
+ // Close any lingering streaming messages when the user sends a new query —
280
+ // prevents green/dim text from the previous response appearing stale.
281
+ const closeStreamingItems = useCallback(() => {
282
+ update((s) => {
283
+ let changed = false;
284
+ for (const it of s.items) {
285
+ if (it.kind === "assistant" && it.streaming) {
286
+ it.streaming = false;
287
+ changed = true;
288
+ }
289
+ }
290
+ return changed ? { ...s, items: [...s.items] } : s;
291
+ });
292
+ }, [update]);
293
+ /**
294
+ * Shared entry point for every task submission:
295
+ * 1. Close any still-streaming assistant message from the previous response.
296
+ * 2. Record the query in history.
297
+ * 3. Clear the input field.
298
+ * Then the caller handles steer vs. new-run separately.
299
+ */
300
+ const preSubmit = useCallback((task) => {
301
+ closeStreamingItems();
302
+ historyRef.current = [...historyRef.current, task].slice(-100);
303
+ writeInput("");
304
+ }, [closeStreamingItems, writeInput]);
152
305
  const submit = useCallback(async (text) => {
153
306
  const task = text.trim();
154
307
  if (!task)
155
308
  return;
309
+ // Prompt-gate review in progress — numeric replies drive the flow.
310
+ if (reviewRef.current) {
311
+ writeInput("");
312
+ historyRef.current = [...historyRef.current, task].slice(-100);
313
+ reviewAnswer(task);
314
+ return;
315
+ }
156
316
  // "exit"/"quit" always works — aborts a running agent first.
157
317
  if (task === "exit" || task === "quit") {
158
318
  agentRef.current?.abort();
159
319
  exit();
160
320
  return;
161
321
  }
162
- // Slash commands — handled by the UI itself, never sent to the LLM.
322
+ // Slash commands — TUI-specific first (/review /diff /theme /undo),
323
+ // then the shared registry (T1) for the rest.
163
324
  const slash = parseSlashCommand(task);
164
325
  if (slash) {
165
326
  writeInput("");
166
- if (slash.cmd === "/exit" || slash.cmd === "/quit") {
167
- agentRef.current?.abort();
168
- exit();
327
+ if (slash.cmd === "/review") {
328
+ if (!slash.arg) {
329
+ pushNotice("usage: /review <prompt> — rewrites it, you pick original/rewritten/update/cancel", "warn");
330
+ return;
331
+ }
332
+ if (!Object.keys(config.mcpServers ?? {}).some((s) => s === "prompt-gate")) {
333
+ pushNotice("no \"prompt-gate\" MCP server configured — add it to ~/.scoder/config.json", "error");
334
+ return;
335
+ }
336
+ // Context grounded in this session: last user item + current workdir.
337
+ const lastUser = [...stateRef.current.items].reverse().find((i) => i.kind === "user");
338
+ const context = `User is in scoder TUI at ${config.workdir}${lastUser ? `; earlier they asked: "${lastUser.text.slice(0, 120)}"` : ""}.`;
339
+ void reviewStep("mcp__prompt-gate__review_and_select_prompt", {
340
+ prompt: slash.arg,
341
+ context,
342
+ language: "vietnamese",
343
+ });
169
344
  return;
170
345
  }
171
- if (slash.cmd === "/help") {
172
- pushNotice(formatSlashHelp("scoder tui"));
346
+ if (slash.cmd === "/diff") {
347
+ // T-06: /diff turn | session | branch (default: turn). TUI-specific —
348
+ // turn scope reads the reducer's diff items.
349
+ const scope = slash.arg.trim() || "turn";
350
+ const diffs = stateRef.current.items.filter((i) => i.kind === "diff");
351
+ if (scope === "turn") {
352
+ // Diffs since the last user message (current turn).
353
+ let count = 0;
354
+ const turnDiffs = [];
355
+ for (let i = stateRef.current.items.length - 1; i >= 0; i--) {
356
+ const it = stateRef.current.items[i];
357
+ if (it.kind === "user" && count > 0)
358
+ break;
359
+ if (it.kind === "diff") {
360
+ count++;
361
+ turnDiffs.unshift(`✎ ${it.path} (+${it.added}/-${it.removed})\n${it.text}`);
362
+ }
363
+ }
364
+ pushNotice(turnDiffs.length ? turnDiffs.join("\n\n") : "no file edits in this turn yet");
365
+ }
366
+ else if (scope === "session") {
367
+ const total = diffs.reduce((a, d) => ({ add: a.add + d.added, rem: a.rem + d.removed }), { add: 0, rem: 0 });
368
+ pushNotice(diffs.length === 0
369
+ ? "no file edits this session"
370
+ : diffs.map((d) => `✎ ${d.path} (+${d.added}/-${d.removed})`).join("\n") + `\n${diffs.length} files · +${total.add}/-${total.rem}`);
371
+ }
372
+ else if (scope === "branch") {
373
+ const agent = agentRef.current;
374
+ const snaps = agent?.snapshots.list() ?? [];
375
+ if (!agent || snaps.length === 0) {
376
+ pushNotice("no snapshot checkpoints — /diff branch needs at least one captured edit batch");
377
+ }
378
+ else {
379
+ const d = agent.snapshots.diff(snaps[0].tree, null);
380
+ pushNotice(d ? d : "worktree matches the first checkpoint (no net change)");
381
+ }
382
+ }
383
+ else {
384
+ pushNotice("usage: /diff [turn|session|branch]", "warn");
385
+ }
173
386
  return;
174
387
  }
175
- if (slash.cmd === "/clear") {
176
- agentRef.current?.abort();
177
- agentRef.current = null;
178
- runningRef.current = false;
179
- writeInput("");
180
- setScrollOffset(0);
181
- setState(initialState());
388
+ if (slash.cmd === "/theme") {
389
+ // T-09: palette hot-swap.
390
+ if (!slash.arg) {
391
+ pushNotice(`themes: ${Object.keys(THEMES).join(", ")} — /theme <name>; project default qua .scoder/theme.json`);
392
+ return;
393
+ }
394
+ pushNotice(useNamedTheme(slash.arg.trim()), "info");
395
+ bumpFrame();
182
396
  return;
183
397
  }
184
- if (slash.cmd === "/cost") {
185
- const u = stateRef.current.usage;
186
- pushNotice(u.calls > 0
187
- ? `session usage: ${u.calls} LLM calls · tokens in ${u.inputTokens} / out ${u.outputTokens}` +
188
- (u.cachedTokens > 0 ? ` (+${u.cachedTokens} cached)` : "")
189
- : "no LLM calls yet in this session");
398
+ if (slash.cmd === "/undo") {
399
+ // T-07: /undo [edit|turn|files]
400
+ // (bare)|files — revert files to the previous checkpoint.
401
+ // edit — files + trim conversation back one turn, then the user
402
+ // can edit and re-send (fork/edit-resend, opencode UX).
403
+ const agent = agentRef.current;
404
+ const mode = slash.arg.trim() || "files";
405
+ const cps = agent?.snapshots.list() ?? [];
406
+ if (!agent || cps.length < 2) {
407
+ pushNotice("undo: no snapshot checkpoint to revert to", "warn");
408
+ return;
409
+ }
410
+ if (mode !== "files" && mode !== "edit") {
411
+ pushNotice("usage: /undo [files|edit]", "warn");
412
+ return;
413
+ }
414
+ // Revert to the second-newest checkpoint (newest = current state).
415
+ const target = cps[cps.length - 2];
416
+ const ok = agent.snapshots.restore(target.tree);
417
+ if (!ok) {
418
+ pushNotice("undo: restore failed", "warn");
419
+ return;
420
+ }
421
+ if (mode === "edit") {
422
+ // Fork: trim the transcript UI back to the last user message and
423
+ // put it in the input for editing + re-send.
424
+ const items = stateRef.current.items;
425
+ let lastUserIdx = -1;
426
+ for (let i = items.length - 1; i >= 0; i--)
427
+ if (items[i].kind === "user") {
428
+ lastUserIdx = i;
429
+ break;
430
+ }
431
+ const resend = lastUserIdx >= 0 && items[lastUserIdx].kind === "user" ? items[lastUserIdx].text : "";
432
+ update((s) => ({ ...s, items: lastUserIdx >= 0 ? items.slice(0, lastUserIdx) : s.items, status: "idle", summary: null }));
433
+ if (resend)
434
+ writeInput(resend);
435
+ agent.trimLastTurn();
436
+ pushNotice(`undo edit: files → ${target.id}; last task back in the input — edit + re-send`, "info");
437
+ }
438
+ else {
439
+ pushNotice(`undo: reverted to ${target.id} (${target.label})`);
440
+ }
190
441
  return;
191
442
  }
192
- if (slash.cmd === "/model") {
193
- const s = stateRef.current;
194
- pushNotice(`model: ${s.model || config.model || "—"} · provider: ${s.provider || config.provider || "auto"}`);
195
- return;
443
+ // T1: phần còn lại qua registry dùng chung với REPL.
444
+ const { handled, outcome } = await dispatchCommand(task, tuiHost(), pushNotice);
445
+ if (handled && outcome?.quit) {
446
+ agentRef.current?.abort();
447
+ exit();
196
448
  }
197
- pushNotice(`unknown command ${slash.cmd} — try /help`, "warn");
198
449
  return;
199
450
  }
200
- if (runningRef.current)
201
- return; // keep the typed text for when the agent is idle
202
- writeInput("");
451
+ if (runningRef.current) {
452
+ // Steer: user input while the agent runs — deliver at next step
453
+ // boundary (H-08, "steer by default" — pattern from opencode).
454
+ preSubmit(task);
455
+ agentRef.current?.steer(task);
456
+ update((s) => ({ ...s, items: [...s.items, { kind: "user", text: `${task} (steered)` }] }));
457
+ return;
458
+ }
459
+ preSubmit(task);
203
460
  runningRef.current = true;
204
461
  setScrollOffset(0);
205
462
  // isFirst must be computed BEFORE pushing the user item below.
206
463
  const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
207
464
  // runTurn() doesn't emit run_start, so the TUI shows the user's message
208
465
  // itself; applyEvent's run_start dedupe keeps turn 1 from doubling.
209
- setState((s) => ({ ...s, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
210
- let agent = agentRef.current;
211
- if (!agent) {
212
- agent = new Agent(config);
213
- // Functional updates: React feeds each updater the latest queued
214
- // state, so events can never land on a stale snapshot (a ref mirror
215
- // updated in useEffect races events arriving within one render batch
216
- // — tool results then miss their items and stay "running…" forever).
217
- agent.onEvent = (ev) => setState((prev) => {
218
- const next = applyEvent(prev, ev);
219
- return { ...next, items: [...next.items] };
220
- });
221
- agentRef.current = agent;
222
- }
466
+ update((s) => ({ ...s, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
467
+ const agent = createAgent();
223
468
  try {
224
469
  // First submission runs the full closed loop; later ones continue the session.
225
470
  const result = isFirst ? await agent.run(task) : await agent.runTurn(task);
@@ -228,33 +473,76 @@ export function TuiApp({ config, initialTask, bootNotice }) {
228
473
  }
229
474
  catch (err) {
230
475
  runningRef.current = false;
231
- setState((s) => applyEvent({ ...s, items: [...s.items] }, { type: "error", message: err.message }));
476
+ // Surface the failure through the reducer (not just a notice) so the
477
+ // status leaves "running" and the error is visible as the final line.
478
+ update((s) => applyEvent({ ...s, items: [...s.items] }, { type: "finish", success: false, steps: s.step, summary: `Run failed: ${err.message}` }));
479
+ update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: err.message, level: "error" }] }));
232
480
  }
233
- }, [config, pushNotice, writeInput, exit]);
234
- // Mirror of state for the submit path (isFirst, /cost, /model) and the
235
- // abort handler — approximate between renders, exact whenever idle.
236
- const stateRef = useRef(state);
237
- useEffect(() => {
238
- stateRef.current = state;
239
- }, [state]);
481
+ }, [config, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, reviewAnswer, reviewStep, tuiHost, preSubmit]);
482
+ /** Create (or return) the session agent — permission mode always current. */
483
+ function createAgent() {
484
+ if (agentRef.current)
485
+ return agentRef.current;
486
+ const agent = new Agent({ ...config, permissionMode: permMode });
487
+ // Events go through the batching queue (see FLUSH_MS above): applyEvent
488
+ // still receives the latest state because update() keeps stateRef in
489
+ // sync synchronously — tool results can never miss their items.
490
+ agent.onEvent = queueEvent;
491
+ agentRef.current = agent;
492
+ return agent;
493
+ }
240
494
  // Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
241
495
  // the first Esc returns to the bottom instead.
242
496
  const scrollOffsetRef = useRef(0);
243
497
  useEffect(() => {
244
498
  scrollOffsetRef.current = scrollOffset;
245
499
  }, [scrollOffset]);
500
+ // T-03: permission modal keybinds — intercepts y/a/n/q while open.
501
+ useInput((input, key) => {
502
+ const p = permPromptRef.current;
503
+ if (!p)
504
+ return;
505
+ const answer = key.return ? "yes" : input.toLowerCase() === "y" ? "yes" : input.toLowerCase() === "a" ? "always" : input.toLowerCase() === "n" ? "no" : input.toLowerCase() === "q" ? "quit" : key.escape ? "no" : null;
506
+ if (!answer)
507
+ return;
508
+ setPermPrompt(null);
509
+ agentRef.current?.resolvePermission(p.id, answer);
510
+ });
511
+ // T-01/T-02: scroll + abort keybinds.
246
512
  useInput((_input, key) => {
247
- if (key.pageUp)
248
- setScrollOffset((o) => Math.min(stateRef.current.items.length, o + 10));
249
- if (key.pageDown)
250
- setScrollOffset((o) => Math.max(0, o - 10));
513
+ if (permPromptRef.current)
514
+ return; // modal owns the keyboard
515
+ // ── Ctrl+S: open session picker (when not running, not already open) ──
516
+ if (key.ctrl && _input === "s") {
517
+ if (!runningRef.current && !sessionPickerOpen) {
518
+ setSessionPickerOpen(true);
519
+ }
520
+ return;
521
+ }
522
+ // T-10: ctrl+t toggles the thinking stream visibility.
523
+ if (key.ctrl && _input === "t") {
524
+ update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
525
+ return;
526
+ }
527
+ if (key.pageUp || key.pageDown || key.home || key.end) {
528
+ const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
529
+ if (key.pageUp)
530
+ setScrollOffset((o) => o + page);
531
+ if (key.pageDown)
532
+ setScrollOffset((o) => Math.max(0, o - page));
533
+ if (key.home)
534
+ setScrollOffset(999_999); // top (clamped to maxScroll in render)
535
+ if (key.end)
536
+ setScrollOffset(0); // bottom = follow
537
+ return;
538
+ }
251
539
  if (key.escape) {
252
540
  if (scrollOffsetRef.current > 0) {
253
541
  setScrollOffset(0);
254
542
  return;
255
543
  }
256
544
  if (runningRef.current) {
257
- setState((s) => ({ ...markAborted(s), items: [...s.items] }));
545
+ update((s) => ({ ...markAborted(s), items: [...s.items] }));
258
546
  agentRef.current?.abort();
259
547
  }
260
548
  }
@@ -266,7 +554,7 @@ export function TuiApp({ config, initialTask, bootNotice }) {
266
554
  return;
267
555
  if (runningRef.current && ctrlCArmed.current === null) {
268
556
  ctrlCArmed.current = Date.now();
269
- setState((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
557
+ update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
270
558
  setTimeout(() => (ctrlCArmed.current = null), 2000);
271
559
  return;
272
560
  }
@@ -286,28 +574,40 @@ export function TuiApp({ config, initialTask, bootNotice }) {
286
574
  return null;
287
575
  return state.summary;
288
576
  }, [state.status, state.summary]);
289
- // Scrollback viewport: by default pin to the newest items that fit on
290
- // screen; PgUp/PgDn moves the window over the flight-recorder log.
291
- const rows = stdout.rows ?? 24;
292
- const viewItems = Math.max(6, rows - 9);
293
- const total = state.items.length;
294
- const start = Math.max(0, Math.min(total - viewItems, total - viewItems - scrollOffset));
295
- const end = Math.max(start, Math.min(total, total - scrollOffset));
296
- const visible = state.items.slice(start, end);
297
- return (_jsxs(Box, { flexDirection: "column", height: "100%", children: [_jsx(BuildRail, { state: state, config: config }), _jsxs(Box, { flexDirection: "column", paddingX: 1, flexGrow: 1, children: [total === 0 && (_jsx(Text, { dimColor: true, children: "Type a task and press Enter. The agent works autonomously until the build passes. (/help for commands)" })), scrollOffset > 0 && _jsx(Text, { dimColor: true, children: "\u2191 scrollback \u2014 PgDn to return to the newest" }), visible.map((item, i) => (_jsx(ChatItemView, { item: item, spinner: spinner }, start + i))), state.status === "running" && state.step > 0 && (_jsxs(Text, { dimColor: true, children: [spinner, " step ", state.step, "/", state.maxSteps, " \u2014 thinking\u2026"] })), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: "green", bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: "red", bold: true, children: ["\u2718 ", lastSummary] })) }))] }), _jsx(Box, { paddingX: 1, children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v) }) }), _jsx(StatusBar, { state: state, spinner: spinner })] }));
577
+ // When the run ends, jump back to the bottom so the final summary is always
578
+ // visible — a user scrolled up would otherwise never see the conclusion.
579
+ const prevStatusRef = useRef(state.status);
580
+ useEffect(() => {
581
+ if (prevStatusRef.current === "running" && state.status !== "running")
582
+ setScrollOffset(0);
583
+ prevStatusRef.current = state.status;
584
+ }, [state.status]);
585
+ // The viewport renders items at full height so scroll math (content.height) is
586
+ // always correct. Ink clips with overflow="hidden"; contentOffsetY shifts the
587
+ // content column upward to reveal items above the current bottom.
588
+ const { rows, columns } = useWindowSize();
589
+ const showSidebar = (columns ?? 100) >= SIDEBAR_MIN_COLS;
590
+ return (
591
+ // flexShrink={0}: pinned chrome must never be squeezed when the chat
592
+ // content overflows — Ink defaults every box to flexShrink:1, which
593
+ // shrank the input bar to zero rows once the log outgrew the terminal.
594
+ _jsxs(Box, { flexDirection: "column", height: rows || 24, flexShrink: 0, children: [_jsx(BuildRail, { state: state, config: config }), _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: scrollTop, children: _jsxs(Box, { ref: contentRef, flexDirection: "column", paddingX: 1, flexShrink: 0, children: [state.items.length === 0 ? (_jsxs(Box, { flexDirection: "column", alignItems: "center", justifyContent: "center", flexGrow: 1, children: [_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 agent plans, edits, and builds" }), _jsx(Text, {}), _jsxs(Text, { dimColor: true, children: ["\u23CE send \u00B7 ", _jsx(Text, { bold: true, children: "ctrl+s" }), " sessions \u00B7 tab complete \u00B7 /help \u00B7 /skills"] })] })) : (state.items.map((item, i) => _jsx(ChatItemView, { item: item }, i))), lastSummary && (_jsx(Box, { marginTop: 1, children: state.status === "success" ? (_jsxs(Text, { color: "green", bold: true, children: ["\u2714 ", lastSummary] })) : state.status === "aborted" ? (_jsxs(Text, { color: "yellow", bold: true, children: ["\u23F9 ", lastSummary] })) : (_jsxs(Text, { color: "red", bold: true, children: ["\u2718 ", lastSummary] })) }))] }) }), showSidebar && _jsx(Sidebar, { state: state, config: config })] }), sessionPickerOpen && (_jsx(SessionPicker, { workdir: config.workdir, onSelect: handleSessionSelect, onClose: () => setSessionPickerOpen(false) })), permPrompt && _jsx(PermissionModal, { prompt: permPrompt }), _jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(Input, { valueRef: inputRef, value: input, onChange: writeInput, onSubmit: (v) => void submit(v), historyRef: historyRef, skillNames: skillNames, agentRef: agentRef, config: config, atSources: atSources, modalRef: permPromptRef }) }), _jsx(StatusBar, { state: state, scroll: scrollOffset > 0 ? `↑ ${Math.min(scrollOffset, maxScroll)}/${maxScroll}` : null })] }));
298
595
  }
299
596
  /** Mount the TUI (called from the `scoder tui` subcommand). */
300
597
  export async function runTui(config, initialTask) {
301
598
  setQuiet(true);
302
- // `ask` prompts via readline on stdin, which corrupts the alt-screen TUI
303
- // (same rule as `scoder serve`): fall back to auto with an in-pane notice.
304
- let bootNotice = null;
305
- if (config.permissionMode === "ask") {
306
- config = { ...config, permissionMode: "auto" };
307
- bootNotice = "permission=ask is not supported in the TUI — falling back to auto";
308
- }
599
+ // T-03: permission=ask works in the TUI — prompts route through the
600
+ // in-pane modal (permission_request events), not stdin readline.
309
601
  const { render } = await import("ink");
310
- const { waitUntilExit } = render(_jsx(TuiApp, { config: config, initialTask: initialTask, bootNotice: bootNotice }));
602
+ // Alternate screen buffer (vim/htop style): the app owns the whole screen,
603
+ // the BuildRail stays pinned at the top and the input at the bottom while
604
+ // the chat pane scrolls internally. Terminal content is restored on exit.
605
+ // Fallback: terminals without alt-screen support (TERM=dumb, non-TTY pipes,
606
+ // some CI runners) render inline instead — the layout still works.
607
+ const altScreen = process.stdout.isTTY === true && process.env.TERM !== "dumb";
608
+ const { waitUntilExit } = render(_jsx(TuiApp, { config: config, initialTask: initialTask }), {
609
+ alternateScreen: altScreen,
610
+ });
311
611
  await waitUntilExit();
312
612
  // Ink restores the terminal on unmount; exit explicitly so provider
313
613
  // keep-alive sockets (undici pool) can't keep the event loop alive.