s-app-scoder-agent 0.4.0 → 0.6.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.
@@ -0,0 +1,315 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } 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, build-status lamp, step fuel gauge ▰▰▰▱▱▱
8
+ * · Flight-recorder log: each tool entry is stamped `07 ▸ name(args)`
9
+ * · `✎` diff cards rendered inline for every file edit
10
+ * Driven entirely by AgentEvents (the same stream the HTTP/SSE server uses).
11
+ */
12
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
+ import { Box, Text, useApp, useInput, useStdout } from "ink";
14
+ import { Agent } from "../agent.js";
15
+ import { setQuiet } from "../ui.js";
16
+ 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 }) {
128
+ const { exit } = useApp();
129
+ const { stdout } = useStdout();
130
+ const [state, setState] = useState(initialState);
131
+ const [input, setInput] = useState(initialTask ?? "");
132
+ const [scrollOffset, setScrollOffset] = useState(0); // 0 = pinned to bottom
133
+ const inputRef = useRef(initialTask ?? "");
134
+ const writeInput = useCallback((v) => {
135
+ inputRef.current = v;
136
+ setInput(v);
137
+ }, []);
138
+ const runningRef = useRef(false);
139
+ 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);
144
+ useEffect(() => {
145
+ if (bootedRef.current)
146
+ 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 }] })), []);
152
+ const submit = useCallback(async (text) => {
153
+ const task = text.trim();
154
+ if (!task)
155
+ return;
156
+ // "exit"/"quit" always works — aborts a running agent first.
157
+ if (task === "exit" || task === "quit") {
158
+ agentRef.current?.abort();
159
+ exit();
160
+ return;
161
+ }
162
+ // Slash commands — handled by the UI itself, never sent to the LLM.
163
+ const slash = parseSlashCommand(task);
164
+ if (slash) {
165
+ writeInput("");
166
+ if (slash.cmd === "/exit" || slash.cmd === "/quit") {
167
+ agentRef.current?.abort();
168
+ exit();
169
+ return;
170
+ }
171
+ if (slash.cmd === "/help") {
172
+ pushNotice(formatSlashHelp("scoder tui"));
173
+ return;
174
+ }
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());
182
+ return;
183
+ }
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");
190
+ return;
191
+ }
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;
196
+ }
197
+ pushNotice(`unknown command ${slash.cmd} — try /help`, "warn");
198
+ return;
199
+ }
200
+ if (runningRef.current)
201
+ return; // keep the typed text for when the agent is idle
202
+ writeInput("");
203
+ runningRef.current = true;
204
+ setScrollOffset(0);
205
+ // isFirst must be computed BEFORE pushing the user item below.
206
+ const isFirst = stateRef.current.items.every((i) => i.kind !== "user");
207
+ // runTurn() doesn't emit run_start, so the TUI shows the user's message
208
+ // 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
+ }
223
+ try {
224
+ // First submission runs the full closed loop; later ones continue the session.
225
+ const result = isFirst ? await agent.run(task) : await agent.runTurn(task);
226
+ void result;
227
+ runningRef.current = false;
228
+ }
229
+ catch (err) {
230
+ runningRef.current = false;
231
+ setState((s) => applyEvent({ ...s, items: [...s.items] }, { type: "error", message: err.message }));
232
+ }
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]);
240
+ // Esc aborts the running task (like SIGINT in the CLI); when scrolled up,
241
+ // the first Esc returns to the bottom instead.
242
+ const scrollOffsetRef = useRef(0);
243
+ useEffect(() => {
244
+ scrollOffsetRef.current = scrollOffset;
245
+ }, [scrollOffset]);
246
+ 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));
251
+ if (key.escape) {
252
+ if (scrollOffsetRef.current > 0) {
253
+ setScrollOffset(0);
254
+ return;
255
+ }
256
+ if (runningRef.current) {
257
+ setState((s) => ({ ...markAborted(s), items: [...s.items] }));
258
+ agentRef.current?.abort();
259
+ }
260
+ }
261
+ });
262
+ // Ctrl+C quits (press again within 2s to confirm while running).
263
+ const ctrlCArmed = useRef(null);
264
+ useInput((_input, key) => {
265
+ if (!key.ctrl || _input !== "c")
266
+ return;
267
+ if (runningRef.current && ctrlCArmed.current === null) {
268
+ ctrlCArmed.current = Date.now();
269
+ setState((s) => ({ ...s, items: [...s.items, { kind: "notice", text: "Press Ctrl+C again to quit", level: "warn" }] }));
270
+ setTimeout(() => (ctrlCArmed.current = null), 2000);
271
+ return;
272
+ }
273
+ agentRef.current?.abort();
274
+ exit();
275
+ });
276
+ // Submit an initial task passed on the command line once, after mount.
277
+ const bootRef = useRef(false);
278
+ useEffect(() => {
279
+ if (initialTask && !bootRef.current && input === initialTask) {
280
+ bootRef.current = true;
281
+ void submit(initialTask);
282
+ }
283
+ }, [initialTask, input, submit]);
284
+ const lastSummary = useMemo(() => {
285
+ if (state.status === "running" || state.status === "idle")
286
+ return null;
287
+ return state.summary;
288
+ }, [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 })] }));
298
+ }
299
+ /** Mount the TUI (called from the `scoder tui` subcommand). */
300
+ export async function runTui(config, initialTask) {
301
+ 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
+ }
309
+ const { render } = await import("ink");
310
+ const { waitUntilExit } = render(_jsx(TuiApp, { config: config, initialTask: initialTask, bootNotice: bootNotice }));
311
+ await waitUntilExit();
312
+ // Ink restores the terminal on unmount; exit explicitly so provider
313
+ // keep-alive sockets (undici pool) can't keep the event loop alive.
314
+ process.exit(0);
315
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Slash commands shared by the classic REPL (src/index.ts) and the Ink TUI
3
+ * (src/tui/app.tsx). Pure parsing/formatting only — each frontend executes
4
+ * the command against its own context, so both stay in sync on the surface.
5
+ */
6
+ export const SLASH_COMMANDS = [
7
+ { cmd: "/help", desc: "show this command list" },
8
+ { cmd: "/clear", desc: "clear the conversation and reset agent context" },
9
+ { cmd: "/cost", desc: "token usage of the current session" },
10
+ { cmd: "/model", desc: "show the active model & provider" },
11
+ { cmd: "/exit", desc: "quit (aliases: /quit, exit, quit)" },
12
+ ];
13
+ /** Parse "/cmd arg…"; returns null when the input is not a slash command. */
14
+ export function parseSlashCommand(input) {
15
+ const m = /^\/([a-zA-Z][a-zA-Z0-9]*)(?:\s+(.*\S))?\s*$/.exec(input.trim());
16
+ if (!m)
17
+ return null;
18
+ return { cmd: "/" + m[1].toLowerCase(), arg: m[2] ?? "" };
19
+ }
20
+ /** Format the /help listing (shared so both frontends render identically). */
21
+ export function formatSlashHelp(commandName) {
22
+ const rows = SLASH_COMMANDS.map((c) => ` ${c.cmd.padEnd(8)} ${c.desc}`).join("\n");
23
+ return `Commands (${commandName}):\n${rows}\n Anything else is sent to the agent as a task.`;
24
+ }
@@ -0,0 +1,166 @@
1
+ export function initialState() {
2
+ return {
3
+ items: [],
4
+ status: "idle",
5
+ step: 0,
6
+ maxSteps: 0,
7
+ model: "",
8
+ provider: "",
9
+ workdir: "",
10
+ buildCmd: "",
11
+ buildLamp: "none",
12
+ usage: { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
13
+ summary: null,
14
+ };
15
+ }
16
+ const MAX_ITEMS = 400;
17
+ const MAX_OUTPUT_CHARS = 20_000;
18
+ const MAX_TEXT_CHARS = 40_000;
19
+ function appendItem(state, item) {
20
+ state.items.push(item);
21
+ if (state.items.length > MAX_ITEMS)
22
+ state.items.splice(0, state.items.length - MAX_ITEMS);
23
+ }
24
+ function lastItem(state) {
25
+ return state.items[state.items.length - 1];
26
+ }
27
+ /** Cap long strings, keeping the tail (errors usually live at the end). */
28
+ function capTail(text, max = MAX_OUTPUT_CHARS) {
29
+ if (text.length <= max)
30
+ return text;
31
+ return `…[truncated ${text.length - max} chars]\n${text.slice(text.length - max)}`;
32
+ }
33
+ /**
34
+ * Fold one AgentEvent into the state (mutates `state`, returns it for
35
+ * reducer-style chaining). Unknown events are ignored.
36
+ */
37
+ export function applyEvent(state, ev) {
38
+ switch (ev.type) {
39
+ case "run_start": {
40
+ state.model = ev.model;
41
+ state.provider = ev.provider;
42
+ state.workdir = ev.workdir;
43
+ state.buildCmd = ev.buildCmd;
44
+ state.buildLamp = "none";
45
+ state.status = "running";
46
+ state.step = 0;
47
+ state.summary = null;
48
+ state.usage = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
49
+ // The TUI pushes the user's message itself when submitting; avoid a
50
+ // duplicate when run_start carries the same task.
51
+ const last = lastItem(state);
52
+ if (!(last && last.kind === "user" && last.text === ev.task)) {
53
+ appendItem(state, { kind: "user", text: ev.task });
54
+ }
55
+ break;
56
+ }
57
+ case "step": {
58
+ state.step = ev.step;
59
+ state.maxSteps = ev.maxSteps;
60
+ // A new step starts: close any still-"streaming" assistant message.
61
+ const last = lastItem(state);
62
+ if (last && last.kind === "assistant" && last.streaming)
63
+ last.streaming = false;
64
+ break;
65
+ }
66
+ case "llm_delta": {
67
+ const last = lastItem(state);
68
+ if (last && last.kind === "assistant" && last.streaming) {
69
+ last.text += ev.delta;
70
+ if (last.text.length > MAX_TEXT_CHARS)
71
+ last.text = last.text.slice(-MAX_TEXT_CHARS);
72
+ }
73
+ else {
74
+ if (last && last.kind === "assistant" && last.streaming === false && last.text === "") {
75
+ // reuse an empty assistant placeholder from a previous step
76
+ last.streaming = true;
77
+ }
78
+ else {
79
+ appendItem(state, { kind: "assistant", text: ev.delta, streaming: true });
80
+ }
81
+ }
82
+ break;
83
+ }
84
+ case "llm_usage": {
85
+ state.usage.calls += 1;
86
+ state.usage.inputTokens += ev.usage.input_tokens ?? 0;
87
+ state.usage.outputTokens += ev.usage.output_tokens ?? 0;
88
+ state.usage.cachedTokens += ev.usage.cached_tokens ?? 0;
89
+ break;
90
+ }
91
+ case "tool_call": {
92
+ appendItem(state, { kind: "tool", name: ev.name, args: ev.args, ok: null, output: "", step: ev.step });
93
+ break;
94
+ }
95
+ case "tool_output": {
96
+ // Live stdout/stderr chunks attach to the most recent running tool item.
97
+ for (let i = state.items.length - 1; i >= 0; i--) {
98
+ const it = state.items[i];
99
+ if (it.kind === "tool" && it.ok === null && it.name === ev.name) {
100
+ it.output += ev.delta;
101
+ if (it.output.length > MAX_OUTPUT_CHARS)
102
+ it.output = capTail(it.output);
103
+ break;
104
+ }
105
+ // Stop searching at the previous tool boundary to avoid cross-attachment.
106
+ if (it.kind === "tool")
107
+ break;
108
+ }
109
+ break;
110
+ }
111
+ case "tool_result": {
112
+ for (let i = state.items.length - 1; i >= 0; i--) {
113
+ const it = state.items[i];
114
+ if (it.kind === "tool" && it.name === ev.name && it.ok === null) {
115
+ it.ok = ev.ok;
116
+ // Prefer the final (complete) result over accumulated live chunks.
117
+ if (ev.output.length >= it.output.length)
118
+ it.output = ev.output;
119
+ break;
120
+ }
121
+ }
122
+ // File-writing tools attach a unified diff — render it as its own item
123
+ // so edits are reviewable at a glance (the scoder "✎" signature).
124
+ if (ev.diff) {
125
+ appendItem(state, {
126
+ kind: "diff",
127
+ path: ev.diff.path,
128
+ text: ev.diff.text,
129
+ added: ev.diff.added,
130
+ removed: ev.diff.removed,
131
+ truncated: ev.diff.truncated,
132
+ step: ev.step,
133
+ });
134
+ }
135
+ break;
136
+ }
137
+ case "build": {
138
+ state.buildLamp = ev.success ? "ok" : "fail";
139
+ appendItem(state, { kind: "build", success: ev.success, command: ev.command });
140
+ break;
141
+ }
142
+ case "finish": {
143
+ state.status = ev.success ? "success" : /interrupted|aborted/i.test(ev.summary) ? "aborted" : "failed";
144
+ state.summary = ev.summary;
145
+ // Close any dangling streaming assistant message.
146
+ const last = lastItem(state);
147
+ if (last && last.kind === "assistant")
148
+ last.streaming = false;
149
+ break;
150
+ }
151
+ case "error": {
152
+ appendItem(state, { kind: "notice", text: ev.message, level: "error" });
153
+ break;
154
+ }
155
+ }
156
+ return state;
157
+ }
158
+ /** Mark the current run aborted from the UI side (before the finish event). */
159
+ export function markAborted(state) {
160
+ state.status = "aborted";
161
+ state.summary = "abort requested…";
162
+ const last = lastItem(state);
163
+ if (last && last.kind === "assistant")
164
+ last.streaming = false;
165
+ return state;
166
+ }
@@ -0,0 +1,4 @@
1
+ import { runTui } from "./tui/app.js";
2
+ console.log("calling runTui");
3
+ await runTui({ workdir: process.cwd(), model: "m", provider: "openai", apiKey: "k", permissionMode: "auto", baseUrl: "http://127.0.0.1:1", maxSteps: 5, stream: false }, null);
4
+ console.log("returned");