s-app-scoder-agent 0.6.0 → 0.8.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 +600 -468
  4. package/dist/agent.js +841 -197
  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 +423 -0
  11. package/dist/index.js +129 -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 +103 -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 +32 -2
  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 +505 -198
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +146 -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 +99 -0
  44. package/dist/tui/state.js +192 -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,477 @@
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
+ // MCP connections live for the session (one connect, no per-message re-spawn).
65
+ // Dispose on unmount so child server processes never outlive the TUI.
66
+ useEffect(() => () => {
67
+ agentRef.current?.dispose();
68
+ agentRef.current = null;
69
+ }, []);
70
+ // ── Prompt-gate review flow (/review) ──────────────────────────────────────
71
+ // State machine relaying the universal-prompt-gate-mcp text-fallback flow:
72
+ // call → pending_level_selection → user picks level → pending_user_selection
73
+ // → user picks original/rewritten/update/cancel → approved/cancelled.
74
+ // `argsBase` holds the immutable prompt/context/language; `rewrittenPrompt`
75
+ // is echoed back verbatim from the server's pending result (never from the
76
+ // user or LLM) so only exactly what the user saw can be approved.
77
+ const reviewRef = useRef(null);
78
+ const reviewBusyRef = useRef(false);
79
+ // ── Scrollable chat viewport (Ink 8 alt-screen) ──
80
+ // The viewport clips overflow; the inner content wrapper keeps its natural
81
+ // height so it can be measured and offset upward for scrolling.
82
+ const viewportRef = useRef(null);
83
+ const contentRef = useRef(null);
84
+ const viewport = useBoxMetrics(viewportRef);
85
+ const content = useBoxMetrics(contentRef);
86
+ const viewportRowsRef = useRef(null);
144
87
  useEffect(() => {
145
- if (bootedRef.current)
88
+ viewportRowsRef.current = viewport.clientHeight || null;
89
+ }, [viewport.clientHeight]);
90
+ const maxScroll = Math.max(0, content.height - viewport.clientHeight);
91
+ // scrollOffset counts rows UP from the bottom; 0 = follow the newest item.
92
+ const scrollTop = Math.max(0, maxScroll - scrollOffset);
93
+ // T5: mouse wheel scroll (SGR 1006) — raw stdin listener; Ink's useInput
94
+ // drops unrepresentable CSI sequences, so wheel events must be read here.
95
+ // Only in alt-screen TTY (mouse tracking sequences corrupt plain pipes).
96
+ const { stdin, setRawMode, isRawModeSupported } = useStdin();
97
+ const wheelScroll = useCallback((dir) => {
98
+ setScrollOffset((o) => Math.max(0, o + (dir === "up" ? 3 : -3)));
99
+ }, []);
100
+ useEffect(() => {
101
+ if (process.stdout.isTTY !== true || !isRawModeSupported)
102
+ return;
103
+ process.stdout.write(MOUSE_ENABLE);
104
+ setRawMode(true);
105
+ const onData = (chunk) => {
106
+ const dir = parseWheel(String(chunk));
107
+ if (dir)
108
+ wheelScroll(dir);
109
+ };
110
+ stdin.on("data", onData);
111
+ return () => {
112
+ stdin.off("data", onData);
113
+ process.stdout.write(MOUSE_DISABLE);
114
+ };
115
+ }, [stdin, setRawMode, isRawModeSupported, wheelScroll]);
116
+ const skills = useMemo(() => discoverSkills(config.workdir), [config.workdir]);
117
+ const skillNames = useMemo(() => skills.map((s) => s.name), [skills]);
118
+ // T-02: completion sources (agents + files) computed once per mount.
119
+ const atSources = useMemo(() => ({
120
+ skills: skillNames,
121
+ agents: discoverAgents(config.workdir).filter((a) => a.mode !== "subagent" || a.name === "explore").map((a) => a.name),
122
+ files: fileIndex(config.workdir),
123
+ }), [config.workdir, skillNames]);
124
+ // Mirror of state for the submit path (isFirst, /cost, /model) and the
125
+ // abort handler — kept in sync synchronously by update() below, so it is
126
+ // exact even between renders (event bursts land between them).
127
+ const stateRef = useRef(state);
128
+ /**
129
+ * Central state updater — the single source of truth is stateRef, updated
130
+ * synchronously so event handlers never race a render cycle.
131
+ */
132
+ const update = useCallback((fn) => {
133
+ stateRef.current = fn(stateRef.current);
134
+ setState(stateRef.current);
135
+ }, []);
136
+ /**
137
+ * Event batching: streaming (`llm_delta`) and live tool output (`tool_output`)
138
+ * can emit hundreds of events per second; each one previously triggered a
139
+ * full-tree re-render (the flicker). Buffer incoming events and flush them
140
+ * through the reducer at most every FLUSH_MS (~20 fps, well above what the
141
+ * eye needs for streaming text).
142
+ */
143
+ const FLUSH_MS = 50;
144
+ const eventQueue = useRef([]);
145
+ const flushTimer = useRef(null);
146
+ const flushEvents = useCallback(() => {
147
+ flushTimer.current = null;
148
+ const events = eventQueue.current;
149
+ if (events.length === 0)
150
+ return;
151
+ eventQueue.current = [];
152
+ update((s) => {
153
+ let next = s;
154
+ for (const ev of events)
155
+ next = applyEvent(next, ev);
156
+ return { ...next, items: [...next.items] };
157
+ });
158
+ }, [update]);
159
+ const queueEvent = useCallback((ev) => {
160
+ // T-03: permission modal must appear instantly (not batched) — the run
161
+ // blocks on the answer.
162
+ if (ev.type === "permission_request") {
163
+ setPermPrompt(ev);
146
164
  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 }] })), []);
165
+ }
166
+ eventQueue.current.push(ev);
167
+ if (!flushTimer.current)
168
+ flushTimer.current = setTimeout(flushEvents, FLUSH_MS);
169
+ }, [flushEvents]);
170
+ // Flush on unmount so a trailing burst is never dropped.
171
+ useEffect(() => () => {
172
+ if (flushTimer.current)
173
+ clearTimeout(flushTimer.current);
174
+ flushEvents();
175
+ }, [flushEvents]);
176
+ const pushNotice = useCallback((text, level = "info") => update((s) => ({ ...s, items: [...s.items, { kind: "notice", text, level }] })), [update]);
177
+ /** Session picker: when a session is selected, resume it. */
178
+ const handleSessionSelect = useCallback(async (session) => {
179
+ setSessionPickerOpen(false);
180
+ if (runningRef.current) {
181
+ pushNotice("agent is running — wait or Esc first", "warn");
182
+ return;
183
+ }
184
+ const s = resolveSessionId(config.workdir, session.id);
185
+ if (!s) {
186
+ pushNotice(`session not found: ${session.id}`, "error");
187
+ return;
188
+ }
189
+ // Restore full TUI state from the session (items + step count + model info).
190
+ update(() => ({
191
+ ...initialState(),
192
+ items: s.messages
193
+ .filter((m) => m.role === "user" || m.role === "assistant")
194
+ .map((m) => m.role === "user"
195
+ ? { kind: "user", text: typeof m.content === "string" ? m.content : "" }
196
+ : { kind: "assistant", text: typeof m.content === "string" ? m.content : "", streaming: false }),
197
+ step: s.steps ?? 0,
198
+ maxSteps: s.maxSteps ?? config.maxSteps,
199
+ model: s.model || config.model,
200
+ provider: s.provider || config.provider,
201
+ status: "idle",
202
+ summary: null,
203
+ }));
204
+ runningRef.current = true;
205
+ setScrollOffset(0);
206
+ const agent = createAgent();
207
+ try {
208
+ await agent.resume(s);
209
+ runningRef.current = false;
210
+ }
211
+ catch (err) {
212
+ runningRef.current = false;
213
+ pushNotice(`resume failed: ${err.message}`, "error");
214
+ }
215
+ }, [config, createAgent, pushNotice, update]);
216
+ /** T1: CommandHost cho registry dùng chung với REPL. */
217
+ const tuiHost = useCallback(() => ({
218
+ agent: () => agentRef.current,
219
+ config,
220
+ isRunning: () => runningRef.current,
221
+ replaceAgent: (next) => {
222
+ agentRef.current?.abort();
223
+ agentRef.current?.dispose();
224
+ agentRef.current = next;
225
+ if (!next) {
226
+ runningRef.current = false;
227
+ setScrollOffset(0);
228
+ eventQueue.current = [];
229
+ update(() => initialState());
230
+ }
231
+ setPermMode(config.permissionMode);
232
+ },
233
+ resumeAndRun: async (s) => {
234
+ const agent = createAgent();
235
+ // Replace the pane with the restored transcript, then continue.
236
+ update(() => ({
237
+ ...initialState(),
238
+ items: s.messages
239
+ .filter((m) => m.role === "user" || m.role === "assistant")
240
+ .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 })),
241
+ }));
242
+ runningRef.current = true;
243
+ setScrollOffset(0);
244
+ try {
245
+ await agent.resume(s);
246
+ runningRef.current = false;
247
+ }
248
+ catch (err) {
249
+ runningRef.current = false;
250
+ pushNotice(`resume failed: ${err.message}`, "error");
251
+ }
252
+ return { success: true, steps: 0, buildCmd: config.buildCmd, summary: "resumed" };
253
+ },
254
+ }), [config, update, pushNotice, createAgent]);
255
+ /** One round of the /review state machine: call the gate, render the result. */
256
+ const reviewStep = useCallback(async (tool, args) => {
257
+ const agent = agentRef.current ?? createAgent();
258
+ reviewBusyRef.current = true;
259
+ try {
260
+ const r = await agent.callMcpTool(tool, args);
261
+ const out = reviewFromResponse(reviewRef.current, tool, args, r);
262
+ reviewRef.current = out.state;
263
+ if (out.notice)
264
+ pushNotice(out.notice.text, out.notice.level);
265
+ }
266
+ catch (err) {
267
+ reviewRef.current = null;
268
+ pushNotice(`prompt gate failed: ${err.message}`, "error");
269
+ }
270
+ finally {
271
+ reviewBusyRef.current = false;
272
+ }
273
+ }, [createAgent, pushNotice]);
274
+ /** Handle a user reply while /review is waiting (numbers 1-4 / feedback text). */
275
+ const reviewAnswer = useCallback((text) => {
276
+ const rv = reviewRef.current;
277
+ if (!rv || reviewBusyRef.current)
278
+ return false;
279
+ const out = reviewAnswerToCall(rv, text);
280
+ if (out.notice)
281
+ pushNotice(out.notice.text, out.notice.level);
282
+ if (out.nextCall)
283
+ void reviewStep(out.nextCall.tool, out.nextCall.args);
284
+ return true;
285
+ }, [reviewStep, pushNotice]);
286
+ // Close any lingering streaming messages when the user sends a new query —
287
+ // prevents green/dim text from the previous response appearing stale.
288
+ const closeStreamingItems = useCallback(() => {
289
+ update((s) => {
290
+ let changed = false;
291
+ for (const it of s.items) {
292
+ if (it.kind === "assistant" && it.streaming) {
293
+ it.streaming = false;
294
+ changed = true;
295
+ }
296
+ }
297
+ return changed ? { ...s, items: [...s.items] } : s;
298
+ });
299
+ }, [update]);
300
+ /**
301
+ * Shared entry point for every task submission:
302
+ * 1. Close any still-streaming assistant message from the previous response.
303
+ * 2. Record the query in history.
304
+ * 3. Clear the input field.
305
+ * Then the caller handles steer vs. new-run separately.
306
+ */
307
+ const preSubmit = useCallback((task) => {
308
+ closeStreamingItems();
309
+ historyRef.current = [...historyRef.current, task].slice(-100);
310
+ writeInput("");
311
+ }, [closeStreamingItems, writeInput]);
152
312
  const submit = useCallback(async (text) => {
153
313
  const task = text.trim();
154
314
  if (!task)
155
315
  return;
316
+ // Prompt-gate review in progress — numeric replies drive the flow.
317
+ if (reviewRef.current) {
318
+ writeInput("");
319
+ historyRef.current = [...historyRef.current, task].slice(-100);
320
+ reviewAnswer(task);
321
+ return;
322
+ }
156
323
  // "exit"/"quit" always works — aborts a running agent first.
157
324
  if (task === "exit" || task === "quit") {
158
325
  agentRef.current?.abort();
159
326
  exit();
160
327
  return;
161
328
  }
162
- // Slash commands — handled by the UI itself, never sent to the LLM.
329
+ // Slash commands — TUI-specific first (/review /diff /theme /undo),
330
+ // then the shared registry (T1) for the rest.
163
331
  const slash = parseSlashCommand(task);
164
332
  if (slash) {
165
333
  writeInput("");
166
- if (slash.cmd === "/exit" || slash.cmd === "/quit") {
167
- agentRef.current?.abort();
168
- exit();
334
+ if (slash.cmd === "/review") {
335
+ if (!slash.arg) {
336
+ pushNotice("usage: /review <prompt> — rewrites it, you pick original/rewritten/update/cancel", "warn");
337
+ return;
338
+ }
339
+ if (!Object.keys(config.mcpServers ?? {}).some((s) => s === "prompt-gate")) {
340
+ pushNotice("no \"prompt-gate\" MCP server configured — add it to ~/.scoder/config.json", "error");
341
+ return;
342
+ }
343
+ // Context grounded in this session: last user item + current workdir.
344
+ const lastUser = [...stateRef.current.items].reverse().find((i) => i.kind === "user");
345
+ const context = `User is in scoder TUI at ${config.workdir}${lastUser ? `; earlier they asked: "${lastUser.text.slice(0, 120)}"` : ""}.`;
346
+ void reviewStep("mcp__prompt-gate__review_and_select_prompt", {
347
+ prompt: slash.arg,
348
+ context,
349
+ language: "vietnamese",
350
+ });
169
351
  return;
170
352
  }
171
- if (slash.cmd === "/help") {
172
- pushNotice(formatSlashHelp("scoder tui"));
353
+ if (slash.cmd === "/diff") {
354
+ // T-06: /diff turn | session | branch (default: turn). TUI-specific —
355
+ // turn scope reads the reducer's diff items.
356
+ const scope = slash.arg.trim() || "turn";
357
+ const diffs = stateRef.current.items.filter((i) => i.kind === "diff");
358
+ if (scope === "turn") {
359
+ // Diffs since the last user message (current turn).
360
+ let count = 0;
361
+ const turnDiffs = [];
362
+ for (let i = stateRef.current.items.length - 1; i >= 0; i--) {
363
+ const it = stateRef.current.items[i];
364
+ if (it.kind === "user" && count > 0)
365
+ break;
366
+ if (it.kind === "diff") {
367
+ count++;
368
+ turnDiffs.unshift(`✎ ${it.path} (+${it.added}/-${it.removed})\n${it.text}`);
369
+ }
370
+ }
371
+ pushNotice(turnDiffs.length ? turnDiffs.join("\n\n") : "no file edits in this turn yet");
372
+ }
373
+ else if (scope === "session") {
374
+ const total = diffs.reduce((a, d) => ({ add: a.add + d.added, rem: a.rem + d.removed }), { add: 0, rem: 0 });
375
+ pushNotice(diffs.length === 0
376
+ ? "no file edits this session"
377
+ : diffs.map((d) => `✎ ${d.path} (+${d.added}/-${d.removed})`).join("\n") + `\n${diffs.length} files · +${total.add}/-${total.rem}`);
378
+ }
379
+ else if (scope === "branch") {
380
+ const agent = agentRef.current;
381
+ const snaps = agent?.snapshots.list() ?? [];
382
+ if (!agent || snaps.length === 0) {
383
+ pushNotice("no snapshot checkpoints — /diff branch needs at least one captured edit batch");
384
+ }
385
+ else {
386
+ const d = agent.snapshots.diff(snaps[0].tree, null);
387
+ pushNotice(d ? d : "worktree matches the first checkpoint (no net change)");
388
+ }
389
+ }
390
+ else {
391
+ pushNotice("usage: /diff [turn|session|branch]", "warn");
392
+ }
173
393
  return;
174
394
  }
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());
395
+ if (slash.cmd === "/theme") {
396
+ // T-09: palette hot-swap.
397
+ if (!slash.arg) {
398
+ pushNotice(`themes: ${Object.keys(THEMES).join(", ")} — /theme <name>; project default qua .scoder/theme.json`);
399
+ return;
400
+ }
401
+ pushNotice(useNamedTheme(slash.arg.trim()), "info");
402
+ bumpFrame();
182
403
  return;
183
404
  }
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");
405
+ if (slash.cmd === "/undo") {
406
+ // T-07: /undo [edit|turn|files]
407
+ // (bare)|files — revert files to the previous checkpoint.
408
+ // edit — files + trim conversation back one turn, then the user
409
+ // can edit and re-send (fork/edit-resend, opencode UX).
410
+ const agent = agentRef.current;
411
+ const mode = slash.arg.trim() || "files";
412
+ const cps = agent?.snapshots.list() ?? [];
413
+ if (!agent || cps.length < 2) {
414
+ pushNotice("undo: no snapshot checkpoint to revert to", "warn");
415
+ return;
416
+ }
417
+ if (mode !== "files" && mode !== "edit") {
418
+ pushNotice("usage: /undo [files|edit]", "warn");
419
+ return;
420
+ }
421
+ // Revert to the second-newest checkpoint (newest = current state).
422
+ const target = cps[cps.length - 2];
423
+ const ok = agent.snapshots.restore(target.tree);
424
+ if (!ok) {
425
+ pushNotice("undo: restore failed", "warn");
426
+ return;
427
+ }
428
+ if (mode === "edit") {
429
+ // Fork: trim the transcript UI back to the last user message and
430
+ // put it in the input for editing + re-send.
431
+ const items = stateRef.current.items;
432
+ let lastUserIdx = -1;
433
+ for (let i = items.length - 1; i >= 0; i--)
434
+ if (items[i].kind === "user") {
435
+ lastUserIdx = i;
436
+ break;
437
+ }
438
+ const resend = lastUserIdx >= 0 && items[lastUserIdx].kind === "user" ? items[lastUserIdx].text : "";
439
+ update((s) => ({ ...s, items: lastUserIdx >= 0 ? items.slice(0, lastUserIdx) : s.items, status: "idle", summary: null }));
440
+ if (resend)
441
+ writeInput(resend);
442
+ agent.trimLastTurn();
443
+ pushNotice(`undo edit: files → ${target.id}; last task back in the input — edit + re-send`, "info");
444
+ }
445
+ else {
446
+ pushNotice(`undo: reverted to ${target.id} (${target.label})`);
447
+ }
190
448
  return;
191
449
  }
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;
450
+ // T1: phần còn lại qua registry dùng chung với REPL.
451
+ const { handled, outcome } = await dispatchCommand(task, tuiHost(), pushNotice);
452
+ if (handled && outcome?.quit) {
453
+ agentRef.current?.abort();
454
+ exit();
196
455
  }
197
- pushNotice(`unknown command ${slash.cmd} — try /help`, "warn");
198
456
  return;
199
457
  }
200
- if (runningRef.current)
201
- return; // keep the typed text for when the agent is idle
202
- writeInput("");
458
+ if (runningRef.current) {
459
+ // Steer: user input while the agent runs — deliver at next step
460
+ // boundary (H-08, "steer by default" — pattern from opencode).
461
+ preSubmit(task);
462
+ agentRef.current?.steer(task);
463
+ update((s) => ({ ...s, items: [...s.items, { kind: "user", text: `${task} (steered)` }] }));
464
+ return;
465
+ }
466
+ preSubmit(task);
203
467
  runningRef.current = true;
204
468
  setScrollOffset(0);
205
469
  // isFirst must be computed BEFORE pushing the user item below.
206
470
  const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
207
471
  // runTurn() doesn't emit run_start, so the TUI shows the user's message
208
472
  // 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
- }
473
+ update((s) => ({ ...s, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
474
+ const agent = createAgent();
223
475
  try {
224
476
  // First submission runs the full closed loop; later ones continue the session.
225
477
  const result = isFirst ? await agent.run(task) : await agent.runTurn(task);
@@ -228,33 +480,76 @@ export function TuiApp({ config, initialTask, bootNotice }) {
228
480
  }
229
481
  catch (err) {
230
482
  runningRef.current = false;
231
- setState((s) => applyEvent({ ...s, items: [...s.items] }, { type: "error", message: err.message }));
483
+ // Surface the failure through the reducer (not just a notice) so the
484
+ // status leaves "running" and the error is visible as the final line.
485
+ update((s) => applyEvent({ ...s, items: [...s.items] }, { type: "finish", success: false, steps: s.step, summary: `Run failed: ${err.message}` }));
486
+ update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: err.message, level: "error" }] }));
232
487
  }
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]);
488
+ }, [config, pushNotice, writeInput, exit, update, queueEvent, skills, skillNames, permMode, reviewAnswer, reviewStep, tuiHost, preSubmit]);
489
+ /** Create (or return) the session agent — permission mode always current. */
490
+ function createAgent() {
491
+ if (agentRef.current)
492
+ return agentRef.current;
493
+ const agent = new Agent({ ...config, permissionMode: permMode });
494
+ // Events go through the batching queue (see FLUSH_MS above): applyEvent
495
+ // still receives the latest state because update() keeps stateRef in
496
+ // sync synchronously — tool results can never miss their items.
497
+ agent.onEvent = queueEvent;
498
+ agentRef.current = agent;
499
+ return agent;
500
+ }
240
501
  // Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
241
502
  // the first Esc returns to the bottom instead.
242
503
  const scrollOffsetRef = useRef(0);
243
504
  useEffect(() => {
244
505
  scrollOffsetRef.current = scrollOffset;
245
506
  }, [scrollOffset]);
507
+ // T-03: permission modal keybinds — intercepts y/a/n/q while open.
508
+ useInput((input, key) => {
509
+ const p = permPromptRef.current;
510
+ if (!p)
511
+ return;
512
+ 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;
513
+ if (!answer)
514
+ return;
515
+ setPermPrompt(null);
516
+ agentRef.current?.resolvePermission(p.id, answer);
517
+ });
518
+ // T-01/T-02: scroll + abort keybinds.
246
519
  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));
520
+ if (permPromptRef.current)
521
+ return; // modal owns the keyboard
522
+ // ── Ctrl+S: open session picker (when not running, not already open) ──
523
+ if (key.ctrl && _input === "s") {
524
+ if (!runningRef.current && !sessionPickerOpen) {
525
+ setSessionPickerOpen(true);
526
+ }
527
+ return;
528
+ }
529
+ // T-10: ctrl+t toggles the thinking stream visibility.
530
+ if (key.ctrl && _input === "t") {
531
+ update((s) => ({ ...s, thinkingVisible: !s.thinkingVisible }));
532
+ return;
533
+ }
534
+ if (key.pageUp || key.pageDown || key.home || key.end) {
535
+ const page = Math.max(1, Math.floor((viewportRowsRef.current ?? 20) * 0.8));
536
+ if (key.pageUp)
537
+ setScrollOffset((o) => o + page);
538
+ if (key.pageDown)
539
+ setScrollOffset((o) => Math.max(0, o - page));
540
+ if (key.home)
541
+ setScrollOffset(999_999); // top (clamped to maxScroll in render)
542
+ if (key.end)
543
+ setScrollOffset(0); // bottom = follow
544
+ return;
545
+ }
251
546
  if (key.escape) {
252
547
  if (scrollOffsetRef.current > 0) {
253
548
  setScrollOffset(0);
254
549
  return;
255
550
  }
256
551
  if (runningRef.current) {
257
- setState((s) => ({ ...markAborted(s), items: [...s.items] }));
552
+ update((s) => ({ ...markAborted(s), items: [...s.items] }));
258
553
  agentRef.current?.abort();
259
554
  }
260
555
  }
@@ -266,7 +561,7 @@ export function TuiApp({ config, initialTask, bootNotice }) {
266
561
  return;
267
562
  if (runningRef.current && ctrlCArmed.current === null) {
268
563
  ctrlCArmed.current = Date.now();
269
- setState((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
564
+ update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
270
565
  setTimeout(() => (ctrlCArmed.current = null), 2000);
271
566
  return;
272
567
  }
@@ -286,28 +581,40 @@ export function TuiApp({ config, initialTask, bootNotice }) {
286
581
  return null;
287
582
  return state.summary;
288
583
  }, [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 })] }));
584
+ // When the run ends, jump back to the bottom so the final summary is always
585
+ // visible — a user scrolled up would otherwise never see the conclusion.
586
+ const prevStatusRef = useRef(state.status);
587
+ useEffect(() => {
588
+ if (prevStatusRef.current === "running" && state.status !== "running")
589
+ setScrollOffset(0);
590
+ prevStatusRef.current = state.status;
591
+ }, [state.status]);
592
+ // The viewport renders items at full height so scroll math (content.height) is
593
+ // always correct. Ink clips with overflow="hidden"; contentOffsetY shifts the
594
+ // content column upward to reveal items above the current bottom.
595
+ const { rows, columns } = useWindowSize();
596
+ const showSidebar = (columns ?? 100) >= SIDEBAR_MIN_COLS;
597
+ return (
598
+ // flexShrink={0}: pinned chrome must never be squeezed when the chat
599
+ // content overflows — Ink defaults every box to flexShrink:1, which
600
+ // shrank the input bar to zero rows once the log outgrew the terminal.
601
+ _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
602
  }
299
603
  /** Mount the TUI (called from the `scoder tui` subcommand). */
300
604
  export async function runTui(config, initialTask) {
301
605
  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
- }
606
+ // T-03: permission=ask works in the TUI — prompts route through the
607
+ // in-pane modal (permission_request events), not stdin readline.
309
608
  const { render } = await import("ink");
310
- const { waitUntilExit } = render(_jsx(TuiApp, { config: config, initialTask: initialTask, bootNotice: bootNotice }));
609
+ // Alternate screen buffer (vim/htop style): the app owns the whole screen,
610
+ // the BuildRail stays pinned at the top and the input at the bottom while
611
+ // the chat pane scrolls internally. Terminal content is restored on exit.
612
+ // Fallback: terminals without alt-screen support (TERM=dumb, non-TTY pipes,
613
+ // some CI runners) render inline instead — the layout still works.
614
+ const altScreen = process.stdout.isTTY === true && process.env.TERM !== "dumb";
615
+ const { waitUntilExit } = render(_jsx(TuiApp, { config: config, initialTask: initialTask }), {
616
+ alternateScreen: altScreen,
617
+ });
311
618
  await waitUntilExit();
312
619
  // Ink restores the terminal on unmount; exit explicitly so provider
313
620
  // keep-alive sockets (undici pool) can't keep the event loop alive.