s-app-scoder-agent 0.5.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.
- package/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -435
- package/dist/agent.js +829 -168
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +216 -45
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +148 -11
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +537 -24
- package/dist/tui/app.js +615 -0
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +222 -0
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +342 -0
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/ui.js +43 -3
- package/dist/watch.js +52 -0
- package/package.json +18 -5
package/dist/tui/app.js
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Full-screen Ink TUI for scoder — "mission control for the build loop".
|
|
4
|
+
*
|
|
5
|
+
* Identity (deliberately distinct from chat-first agent UIs): the build is
|
|
6
|
+
* the mission, so the screen is organized around it —
|
|
7
|
+
* · Build Rail (top): brand, model/permission badges, build lamp, fuel gauge
|
|
8
|
+
* · Flight-recorder log: each tool entry is stamped `07 ▸ name(args)`
|
|
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
|
|
12
|
+
* Driven entirely by AgentEvents (the same stream the HTTP/SSE server uses).
|
|
13
|
+
*/
|
|
14
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
15
|
+
import { Box, Text, useApp, useBoxMetrics, useInput, useStdin, useWindowSize } from "ink";
|
|
16
|
+
import { Agent } from "../agent.js";
|
|
17
|
+
import { setQuiet } from "../ui.js";
|
|
18
|
+
import { discoverSkills } from "../skills.js";
|
|
19
|
+
import { discoverAgents } from "../agents.js";
|
|
20
|
+
import { resolveSessionId } from "../session.js";
|
|
21
|
+
import { applyEvent, initialState, markAborted } from "./state.js";
|
|
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 }) {
|
|
36
|
+
const { exit } = useApp();
|
|
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]);
|
|
45
|
+
const [state, setState] = useState(initialState);
|
|
46
|
+
const [input, setInput] = useState(initialTask ?? "");
|
|
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);
|
|
56
|
+
const inputRef = useRef(initialTask ?? "");
|
|
57
|
+
const writeInput = useCallback((v) => {
|
|
58
|
+
inputRef.current = v;
|
|
59
|
+
setInput(v);
|
|
60
|
+
}, []);
|
|
61
|
+
const historyRef = useRef([]);
|
|
62
|
+
const runningRef = useRef(false);
|
|
63
|
+
const agentRef = useRef(null);
|
|
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
|
+
}, []);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (process.stdout.isTTY !== true || !isRawModeSupported)
|
|
96
|
+
return;
|
|
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]);
|
|
305
|
+
const submit = useCallback(async (text) => {
|
|
306
|
+
const task = text.trim();
|
|
307
|
+
if (!task)
|
|
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
|
+
}
|
|
316
|
+
// "exit"/"quit" always works — aborts a running agent first.
|
|
317
|
+
if (task === "exit" || task === "quit") {
|
|
318
|
+
agentRef.current?.abort();
|
|
319
|
+
exit();
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
// Slash commands — TUI-specific first (/review /diff /theme /undo),
|
|
323
|
+
// then the shared registry (T1) for the rest.
|
|
324
|
+
const slash = parseSlashCommand(task);
|
|
325
|
+
if (slash) {
|
|
326
|
+
writeInput("");
|
|
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
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
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
|
+
}
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
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();
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
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
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
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();
|
|
448
|
+
}
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
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);
|
|
460
|
+
runningRef.current = true;
|
|
461
|
+
setScrollOffset(0);
|
|
462
|
+
// isFirst must be computed BEFORE pushing the user item below.
|
|
463
|
+
const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
|
|
464
|
+
// runTurn() doesn't emit run_start, so the TUI shows the user's message
|
|
465
|
+
// itself; applyEvent's run_start dedupe keeps turn 1 from doubling.
|
|
466
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "user", text: task }], status: "running", summary: null }));
|
|
467
|
+
const agent = createAgent();
|
|
468
|
+
try {
|
|
469
|
+
// First submission runs the full closed loop; later ones continue the session.
|
|
470
|
+
const result = isFirst ? await agent.run(task) : await agent.runTurn(task);
|
|
471
|
+
void result;
|
|
472
|
+
runningRef.current = false;
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
runningRef.current = false;
|
|
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" }] }));
|
|
480
|
+
}
|
|
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
|
+
}
|
|
494
|
+
// Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
|
|
495
|
+
// the first Esc returns to the bottom instead.
|
|
496
|
+
const scrollOffsetRef = useRef(0);
|
|
497
|
+
useEffect(() => {
|
|
498
|
+
scrollOffsetRef.current = scrollOffset;
|
|
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.
|
|
512
|
+
useInput((_input, key) => {
|
|
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
|
+
}
|
|
539
|
+
if (key.escape) {
|
|
540
|
+
if (scrollOffsetRef.current > 0) {
|
|
541
|
+
setScrollOffset(0);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
if (runningRef.current) {
|
|
545
|
+
update((s) => ({ ...markAborted(s), items: [...s.items] }));
|
|
546
|
+
agentRef.current?.abort();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
// Ctrl+C quits (press again within 2s to confirm while running).
|
|
551
|
+
const ctrlCArmed = useRef(null);
|
|
552
|
+
useInput((_input, key) => {
|
|
553
|
+
if (!key.ctrl || _input !== "c")
|
|
554
|
+
return;
|
|
555
|
+
if (runningRef.current && ctrlCArmed.current === null) {
|
|
556
|
+
ctrlCArmed.current = Date.now();
|
|
557
|
+
update((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
|
|
558
|
+
setTimeout(() => (ctrlCArmed.current = null), 2000);
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
agentRef.current?.abort();
|
|
562
|
+
exit();
|
|
563
|
+
});
|
|
564
|
+
// Submit an initial task passed on the command line once, after mount.
|
|
565
|
+
const bootRef = useRef(false);
|
|
566
|
+
useEffect(() => {
|
|
567
|
+
if (initialTask && !bootRef.current && input === initialTask) {
|
|
568
|
+
bootRef.current = true;
|
|
569
|
+
void submit(initialTask);
|
|
570
|
+
}
|
|
571
|
+
}, [initialTask, input, submit]);
|
|
572
|
+
const lastSummary = useMemo(() => {
|
|
573
|
+
if (state.status === "running" || state.status === "idle")
|
|
574
|
+
return null;
|
|
575
|
+
return state.summary;
|
|
576
|
+
}, [state.status, state.summary]);
|
|
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 })] }));
|
|
595
|
+
}
|
|
596
|
+
/** Mount the TUI (called from the `scoder tui` subcommand). */
|
|
597
|
+
export async function runTui(config, initialTask) {
|
|
598
|
+
setQuiet(true);
|
|
599
|
+
// T-03: permission=ask works in the TUI — prompts route through the
|
|
600
|
+
// in-pane modal (permission_request events), not stdin readline.
|
|
601
|
+
const { render } = await import("ink");
|
|
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
|
+
});
|
|
611
|
+
await waitUntilExit();
|
|
612
|
+
// Ink restores the terminal on unmount; exit explicitly so provider
|
|
613
|
+
// keep-alive sockets (undici pool) can't keep the event loop alive.
|
|
614
|
+
process.exit(0);
|
|
615
|
+
}
|