s-app-scoder-agent 0.5.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.
package/README.md CHANGED
@@ -50,14 +50,17 @@ npx app-scoder-agent "your task"
50
50
  ## Quick Start
51
51
 
52
52
  ```bash
53
- # Set your API key
53
+ # One-time setup wizard — writes .scoderrc.json (base URL, key, model, build cmd)
54
+ scoder init
55
+
56
+ # …or set your API key manually
54
57
  export SCODER_API_KEY=sk-... # any OpenAI-compatible key (OpenAI, Groq, OpenRouter, Ollama, …)
55
58
  export OPENAI_BASE_URL=https://api.openai.com/v1 # optional, for custom endpoints
56
59
 
57
60
  # Run a task
58
61
  scoder "add a ping function to src/network.ts"
59
62
 
60
- # Interactive REPL
63
+ # Interactive REPL (supports /help, /clear, /cost, /model)
61
64
  scoder
62
65
  ```
63
66
 
@@ -248,6 +251,28 @@ scoder streams everything it can, live, as it happens:
248
251
  - **SIGINT-safe** — Ctrl+C kills the running command immediately; streamed rendering never blocks the event loop.
249
252
  - Output honors `NO_COLOR` and degrades to plain text when piped (CI-friendly).
250
253
 
254
+ ## Full-Screen TUI (`scoder tui`)
255
+
256
+ **Mission Control for the build loop** — a terminal UI built with [Ink](https://github.com/vadimdemedes/ink) (React for terminals), driven by the same `AgentEvent` stream the HTTP API exposes:
257
+
258
+ ```bash
259
+ scoder tui # start an interactive full-screen session
260
+ scoder tui "fix the bug" # auto-submit an initial task
261
+ ```
262
+
263
+ Features:
264
+
265
+ - **Build Rail** (top bar) — brand, build lamp (`● build ok` / `● build fail` / `no build yet`), permission mode, and a step fuel gauge (`▰▰▰▱▱▱▱▱▱▱`) that fills as the agent burns through its step budget
266
+ - **Chat pane** — your messages, streaming assistant text, and every tool call with live stdout/stderr preview (`✔/✘` status, spinner while running), prefixed with a flight-recorder step number (`03▸`)
267
+ - **Diff cards** (`✎`) — every file write/edit renders as a unified diff (`+` green / `-` red / `@@` cyan hunk headers); the same diff also flows out of the HTTP SSE stream
268
+ - **Status bar** — model, token usage (input/output/cached), keybind hints
269
+ - **Slash commands** — `/help`, `/clear`, `/cost`, `/model`, `/exit` (identical to the REPL)
270
+ - **Multi-turn** — keep typing follow-ups after a task finishes; the conversation context carries over (same as the REPL)
271
+ - **Scrollback** — `PgUp`/`PgDn` page through history; `Esc` returns to the live tail (press again to abort)
272
+ - **Keybinds** — `Enter` send · `PgUp`/`PgDn` scroll · `Esc` reset scroll / abort · `Ctrl+C` quit
273
+ - Non-TTY environments (CI, pipes) automatically fall back to the classic REPL
274
+ - `permission: "ask"` is not supported in the TUI (no readline prompts on the alt screen) — it falls back to `auto`
275
+
251
276
  ## HTTP Server Mode (`scoder serve`)
252
277
 
253
278
  Run scoder as a local HTTP service — submit tasks programmatically and consume progress as Server-Sent Events:
@@ -316,6 +341,8 @@ scoder detects the build command automatically:
316
341
  ```
317
342
  scoder [options] "<task>" Run a task autonomously until the build passes
318
343
  scoder Start interactive session
344
+ scoder init Setup wizard — writes .scoderrc.json (+ connection test)
345
+ scoder tui [task] Full-screen terminal UI (Mission Control)
319
346
  scoder serve [--port] [--host] HTTP server mode (see above)
320
347
  scoder --help | --version Help and version
321
348
  ```
@@ -373,30 +400,34 @@ scoder "upgrade express to v5 — check the official migration guide first"
373
400
  ```
374
401
  ┌─────────────────────────────────────────────┐
375
402
  │ CLI (src/index.ts) │
376
- │ arg parsing, REPL, serve, SIGINT │
377
- └───────────────┬─────────────────────────────┘
378
- │
379
- ┌───────────────▼─────────────────────────────┐
380
- │ Agent (src/agent.ts) │
381
- │ Closed-loop orchestrator │
382
- │ history trimming, error injection │
383
- │ emits AgentEvent (UI / SSE consumers) │
384
- └──────┬─────────────┬───────────────┬────────┘
385
- │ │ │
386
- ┌──────▼───────┐ ┌──▼───────────┐ ┌▼──────────────────┐
387
- │ LLM Client │ │ Tool Registry│ │ Server │
388
- │ (src/llm.ts) │ │(src/tools.ts)│ │ (src/server.ts) │
389
- │ OpenAI compat│ │ + web_fetch │ │ HTTP API + SSE │
390
- │ (anthropic: │ │ (src/ │ │ runs in background│
391
- │ provider- │ │ webfetch.ts)│ └───────────────────┘
392
- │ anthropic) │ └──────────────┘
393
- └──────────────┘
394
- │
395
- ▼
396
- ┌─────────────────────────────────────────────┐
397
- │ Build Checker (src/build.ts) │
398
- │ run build → exit 0 = stop condition met │
399
- └─────────────────────────────────────────────┘
403
+ │ arg parsing, REPL, serve, tui, SIGINT │
404
+ └───────┬─────────────────────┬───────────────┘
405
+ │ │
406
+ ┌───────▼───────────┐ ┌──────▼─────────────────────┐
407
+ │ TUI (src/tui/) │ │ Agent (src/agent.ts) │
408
+ │ Ink/React full- │ │ Closed-loop orchestrator │
409
+ │ screen chat UI, │─▶│ history trimming, error │
410
+ │ renders the same │ │ injection │
411
+ │ AgentEvent stream│ │ emits AgentEvent │
412
+ └───────────────────┘ └──┬──────────┬──────────────┘
413
+ │ │
414
+ │ └────────────────────┐
415
+ ▼ ▼
416
+ ┌───────────────────────────────┐ ┌──────────────────────────┐
417
+ │ LLM Client (src/llm.ts) │ │ Server (src/server.ts) │
418
+ │ OpenAI-compatible + native │ │ HTTP API + SSE │
419
+ │ Anthropic provider │ │ runs in background │
420
+ └──────────────┬────────────────┘ └──────────────────────────┘
421
+ ▼
422
+ ┌────────────────────────────────────────┐
423
+ │ Tool Registry (src/tools.ts) │
424
+ │ + web_fetch (src/webfetch.ts) │
425
+ └──────────────┬─────────────────────────┘
426
+ ▼
427
+ ┌───────────────────────────────┐
428
+ │ Build Checker (src/build.ts) │
429
+ │ run build → exit 0 = stop │
430
+ └───────────────────────────────┘
400
431
  ```
401
432
 
402
433
  ## Integration Testing
@@ -413,12 +444,14 @@ Test suites (run with `npm test` — auto-discovers `test/*.test.mjs`):
413
444
  - `test/tools.test.mjs` — safePath traversal, symlink escape, file-size cap, write/edit/list, live `run_command` output streaming
414
445
  - `test/webfetch.test.mjs` — HTML→text, JSON, redirects, SSRF guard, binary refusal, truncation
415
446
  - `test/server.test.mjs` — HTTP API: run lifecycle via SSE, abort, auth, live `tool_output` events
447
+ - `test/tui.test.mjs` — full-screen TUI in a real PTY (via `test/pty-run.py`): closed loop render + slash commands (`/help`, `/cost`) + clean exit
448
+ - `test/tui-state.test.mjs` — event reducer unit tests: diff items, build lamp, abort marking, slash-command parsing & help formatting
416
449
  - `test/core.test.mjs` — unified diff, retry backoff, context trimming
417
450
  - `test/config.test.mjs` — env/rc/cli precedence, logFile mapping, permission lists
418
451
  - `test/session.test.mjs` — session store, smart prune, resolve
419
452
  - `test/cost.test.mjs` — token usage & cost aggregation
420
453
  - `test/anthropic.test.mjs` — message translation + native SSE streaming
421
- - `test/llm.test.mjs` — OpenAI-compatible chat + SSE streaming + redaction
454
+ - `test/llm.test.mjs` — OpenAI-compatible chat + SSE streaming + redaction + wire-format normalization
422
455
  - `test/run-e2e.mjs` — closed-loop integration (mock LLM server)
423
456
  - `test/run-mcp-test.mjs` — MCP smoke (mock stdio server)
424
457
 
package/dist/agent.js CHANGED
@@ -148,6 +148,10 @@ export class Agent {
148
148
  * Record usage of one LLM call: render token counts, log an `llm_usage`
149
149
  * event, and accumulate totals into session state.
150
150
  */
151
+ /** Token usage totals accumulated in this session (for REPL `/cost`). */
152
+ getUsage() {
153
+ return { ...this.usageTotals };
154
+ }
151
155
  recordLlmUsage(usage) {
152
156
  if (!usage)
153
157
  return;
@@ -482,7 +486,14 @@ export class Agent {
482
486
  this.endToolStream();
483
487
  this.currentToolCall = null;
484
488
  ui.toolResult(tc.name, result.ok, result.output);
485
- this.emit({ type: "tool_result", step: steps, name: tc.name, ok: result.ok, output: result.output });
489
+ this.emit({
490
+ type: "tool_result",
491
+ step: steps,
492
+ name: tc.name,
493
+ ok: result.ok,
494
+ output: result.output,
495
+ ...(result.diff ? { diff: result.diff } : {}),
496
+ });
486
497
  stepToolResults.set(tc.id, { ok: result.ok, output: result.output });
487
498
  this.pushToolResult(tc, summariseOutput(result.output));
488
499
  }
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import { listSessions, resolveSessionId, pruneSessions } from "./session.js";
8
8
  import { JsonlLogger } from "./logger.js";
9
9
  import { collectUsage, formatCostReport } from "./cost.js";
10
10
  import { getVersion as getPackageVersion } from "./version.js";
11
+ import { formatSlashHelp, parseSlashCommand } from "./tui/commands.js";
11
12
  import * as ui from "./ui.js";
12
13
  // Single source of truth: read version from package.json (see src/version.ts).
13
14
  const VERSION = getPackageVersion();
@@ -16,6 +17,8 @@ const HELP = `scoder — autonomous coding agent CLI
16
17
  USAGE
17
18
  scoder [options] "<task>" Run a task autonomously until the build passes
18
19
  scoder Start interactive session (chat + tools)
20
+ scoder tui [task] Full-screen terminal UI (chat pane, live tool feed)
21
+ scoder init First-run wizard: writes .scoderrc.json + tests the connection
19
22
  scoder list List saved sessions for this directory
20
23
  scoder resume <id|last> [task] Resume a saved session (optionally with a follow-up task)
21
24
  scoder logs [id|last] [filter] Print a session's JSONL log (requires --log-file runs)
@@ -173,6 +176,15 @@ function parseArgs(argv) {
173
176
  args.subcommand = "serve";
174
177
  args.task = null;
175
178
  }
179
+ else if (rest[0] === "tui") {
180
+ // scoder tui [initial-task] — full-screen Ink interface
181
+ args.subcommand = "tui";
182
+ args.task = rest.length > 1 ? rest.slice(1).join(" ") : null;
183
+ }
184
+ else if (rest[0] === "init") {
185
+ args.subcommand = "init";
186
+ args.task = null;
187
+ }
176
188
  return args;
177
189
  }
178
190
  async function runOnce(cfg, task) {
@@ -193,13 +205,79 @@ async function runOnce(cfg, task) {
193
205
  return 2;
194
206
  }
195
207
  }
208
+ /**
209
+ * `scoder init` — first-run wizard: asks for the essentials, writes
210
+ * .scoderrc.json, then offers a live connection test against the provider.
211
+ */
212
+ export async function runInit() {
213
+ const { existsSync, writeFileSync } = await import("node:fs");
214
+ const rcPath = resolve(process.cwd(), ".scoderrc.json");
215
+ ui.banner("scoder init — configure this project", [
216
+ ["file", rcPath],
217
+ ["note", "answers are saved to .scoderrc.json (env vars still override)"],
218
+ ]);
219
+ if (existsSync(rcPath)) {
220
+ const rl0 = createInterface({ input: process.stdin, output: process.stdout });
221
+ const overwrite = await new Promise((res) => rl0.question(`.scoderrc.json already exists — overwrite? [y/N] `, res));
222
+ rl0.close();
223
+ if (!/^y(es)?$/i.test(overwrite.trim())) {
224
+ ui.info("kept the existing .scoderrc.json");
225
+ return;
226
+ }
227
+ }
228
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
229
+ const ask = (q, def) => new Promise((res) => rl.question(`${q}${def ? ` [${def}]` : ""}: `, (a) => res(a.trim() || def)));
230
+ const baseUrl = await ask("API base URL (OpenAI-compatible)", "https://api.openai.com/v1");
231
+ const apiKey = await ask("API key (SCODER_API_KEY/OPENAI_API_KEY also work at runtime)", "");
232
+ const model = await ask("Model", "gpt-4o-mini");
233
+ const buildCmd = await ask("Build command (empty = auto-detect)", "");
234
+ const permission = (await ask("Permission mode (auto | ask | readonly)", "auto")).toLowerCase();
235
+ rl.close();
236
+ const provider = /api\.anthropic\.com/.test(baseUrl) || /^claude-/i.test(model) ? "anthropic" : "openai";
237
+ const rc = {
238
+ model,
239
+ baseUrl,
240
+ provider,
241
+ ...(buildCmd ? { buildCmd } : {}),
242
+ permissions: { mode: ["auto", "ask", "readonly"].includes(permission) ? permission : "auto" },
243
+ };
244
+ writeFileSync(rcPath, JSON.stringify(rc, null, 2) + "\n");
245
+ ui.success(`wrote ${rcPath} (provider: ${provider})`);
246
+ const rl2 = createInterface({ input: process.stdin, output: process.stdout });
247
+ const doTest = await new Promise((res) => rl2.question("Test the connection now? [Y/n] ", res));
248
+ rl2.close();
249
+ if (!/^y(es)?$/i.test(doTest.trim()) && doTest.trim() !== "") {
250
+ ui.info("skipped connection test");
251
+ return;
252
+ }
253
+ if (!apiKey) {
254
+ ui.warn("no API key provided — set SCODER_API_KEY/OPENAI_API_KEY before running tasks");
255
+ return;
256
+ }
257
+ ui.info(`connecting to ${baseUrl} (${provider}) …`);
258
+ try {
259
+ const { LlmClient } = await import("./llm.js");
260
+ const { AnthropicClient } = await import("./provider-anthropic.js");
261
+ const client = provider === "anthropic"
262
+ ? new AnthropicClient({ baseUrl, apiKey, model, maxTokens: 16, temperature: 0 })
263
+ : new LlmClient({ baseUrl, apiKey, model, maxTokens: 16, temperature: 0 });
264
+ const res = await client.chat([{ role: "user", content: "Reply with exactly: OK" }], []);
265
+ const reply = (res.message.content ?? "").trim();
266
+ ui.success(`connection OK — model replied: ${JSON.stringify(reply)}`);
267
+ }
268
+ catch (err) {
269
+ ui.error(`connection test failed: ${err.message}`);
270
+ process.exitCode = 2;
271
+ }
272
+ }
196
273
  async function repl(cfg) {
197
274
  ui.header(`scoder interactive — model: ${cfg.model}`);
198
275
  ui.info('Type a task and press Enter. "exit" or Ctrl+D to quit.');
199
276
  const rl = createInterface({ input: process.stdin, output: process.stdout, prompt: "you> " });
200
277
  // One shared session for the whole REPL conversation (plan P0.5 §4):
201
278
  // every input is a follow-up turn, context carries over, saved continuously.
202
- const agent = new Agent(cfg);
279
+ // /clear swaps in a fresh Agent (the /clear branch reassigns this).
280
+ let agent = new Agent(cfg);
203
281
  process.on("SIGINT", () => {
204
282
  ui.warn("\nSIGINT — aborting current task...");
205
283
  agent.abort();
@@ -212,6 +290,37 @@ async function repl(cfg) {
212
290
  continue;
213
291
  if (input === "exit" || input === "quit")
214
292
  break;
293
+ // Slash commands — handled by the UI, never sent to the LLM.
294
+ const slash = parseSlashCommand(input);
295
+ if (slash) {
296
+ if (slash.cmd === "/exit" || slash.cmd === "/quit")
297
+ break;
298
+ if (slash.cmd === "/help") {
299
+ console.log(formatSlashHelp("scoder"));
300
+ continue;
301
+ }
302
+ if (slash.cmd === "/clear") {
303
+ agent.abort();
304
+ agent = new Agent(cfg);
305
+ first = true;
306
+ ui.info("conversation cleared — context reset");
307
+ continue;
308
+ }
309
+ if (slash.cmd === "/cost") {
310
+ const u = agent.getUsage();
311
+ console.log(u.calls > 0
312
+ ? `session usage: ${u.calls} LLM calls · tokens in ${u.inputTokens} / out ${u.outputTokens}` +
313
+ (u.cachedTokens > 0 ? ` (+${u.cachedTokens} cached)` : "")
314
+ : "no LLM calls yet in this session");
315
+ continue;
316
+ }
317
+ if (slash.cmd === "/model") {
318
+ console.log(`model: ${cfg.model} · provider: ${cfg.provider} · build: ${cfg.buildCmd ?? "auto-detect"}`);
319
+ continue;
320
+ }
321
+ ui.error(`unknown command ${slash.cmd} — try /help`);
322
+ continue;
323
+ }
215
324
  try {
216
325
  const result = first ? await agent.run(input) : await agent.runTurn(input);
217
326
  first = false;
@@ -256,6 +365,10 @@ async function main() {
256
365
  logFile: args.logFile,
257
366
  });
258
367
  // Subcommands
368
+ if (args.subcommand === "init") {
369
+ await runInit();
370
+ return;
371
+ }
259
372
  if (args.subcommand === "list") {
260
373
  const sessions = listSessions(cfg.workdir);
261
374
  if (sessions.length === 0) {
@@ -320,6 +433,19 @@ async function main() {
320
433
  console.log(formatCostReport(sessionCosts));
321
434
  return;
322
435
  }
436
+ if (args.subcommand === "tui") {
437
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
438
+ ui.warn("TUI requires an interactive terminal (TTY) — falling back to the classic REPL.");
439
+ await repl(cfg);
440
+ return;
441
+ }
442
+ if (!cfg.apiKey) {
443
+ ui.warn("No API key configured — tasks will fail until SCODER_API_KEY (or OPENAI_API_KEY) is set.");
444
+ }
445
+ const { runTui } = await import("./tui/app.js");
446
+ await runTui(cfg, args.task);
447
+ return;
448
+ }
323
449
  if (args.subcommand === "serve") {
324
450
  const { startServer } = await import("./server.js");
325
451
  if (!cfg.apiKey) {
package/dist/llm.js CHANGED
@@ -76,6 +76,29 @@ function parseSSELine(line) {
76
76
  // ---------------------------------------------------------------------------
77
77
  // LlmClient
78
78
  // ---------------------------------------------------------------------------
79
+ /**
80
+ * Serialize conversation history to the OpenAI wire format. Internal ToolCall
81
+ * shape is `{id, name, arguments}`; the API expects
82
+ * `{id, type: "function", function: {name, arguments}}`. Some providers
83
+ * (Upstage/solar) reject assistant tool_calls in the internal shape with a
84
+ * misleading "Invalid function name: ''" error — always normalize here.
85
+ */
86
+ function toWireMessages(messages) {
87
+ return messages.map((m) => {
88
+ if (m.role === "assistant" && m.tool_calls) {
89
+ return {
90
+ role: m.role,
91
+ content: m.content,
92
+ tool_calls: m.tool_calls.map((tc) => ({
93
+ id: tc.id,
94
+ type: "function",
95
+ function: { name: tc.name, arguments: tc.arguments },
96
+ })),
97
+ };
98
+ }
99
+ return m;
100
+ });
101
+ }
79
102
  export class LlmClient {
80
103
  opts;
81
104
  streamEnabled;
@@ -92,7 +115,7 @@ export class LlmClient {
92
115
  const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
93
116
  const body = JSON.stringify({
94
117
  model: this.opts.model,
95
- messages,
118
+ messages: toWireMessages(messages),
96
119
  tools: tools.length > 0 ? tools : undefined,
97
120
  temperature: this.opts.temperature,
98
121
  max_tokens: this.opts.maxTokens,
@@ -153,7 +176,7 @@ export class LlmClient {
153
176
  const { signal, cleanup } = combinedSignal(opts.signal, LLM_REQUEST_TIMEOUT_MS);
154
177
  const bodyStr = JSON.stringify({
155
178
  model: this.opts.model,
156
- messages,
179
+ messages: toWireMessages(messages),
157
180
  tools: tools.length > 0 ? tools : undefined,
158
181
  temperature: this.opts.temperature,
159
182
  max_tokens: this.opts.maxTokens,
package/dist/tools.js CHANGED
@@ -89,16 +89,21 @@ function toolReadFile(workdir, args) {
89
89
  return { ok: true, output: clamp(numbered) };
90
90
  });
91
91
  }
92
+ function toToolDiff(path, diff) {
93
+ if (!diff.text && !diff.truncated)
94
+ return undefined;
95
+ return { path, text: diff.text, added: diff.added, removed: diff.removed, truncated: diff.truncated };
96
+ }
92
97
  /** Shared permission/diff gate for file writes. Returns null = approved. */
93
- async function gateWrite(ctx, path, oldContent, newContent) {
98
+ async function gateWrite(ctx, path, diff) {
94
99
  const mode = ctx.permissionMode ?? "auto";
95
- const diff = unifiedDiff(path, oldContent, newContent);
96
100
  if (mode === "readonly") {
97
101
  if (diff.text)
98
102
  ui.printDiff(diff.text);
99
103
  return {
100
104
  ok: false,
101
105
  output: `ERROR: readonly mode — write to ${path} denied. The diff above is shown for review only; nothing was written.`,
106
+ diff: toToolDiff(path, diff),
102
107
  };
103
108
  }
104
109
  if (mode === "ask" && ctx.permissions) {
@@ -106,7 +111,11 @@ async function gateWrite(ctx, path, oldContent, newContent) {
106
111
  ui.printDiff(diff.text);
107
112
  const allowed = await ctx.permissions.confirmEdit(path, diff.text);
108
113
  if (!allowed) {
109
- return { ok: false, output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.` };
114
+ return {
115
+ ok: false,
116
+ output: `ERROR: user rejected the write to ${path}. Try a different approach or explain why it is needed.`,
117
+ diff: toToolDiff(path, diff),
118
+ };
110
119
  }
111
120
  }
112
121
  else if (diff.text && !diff.truncated) {
@@ -132,12 +141,17 @@ async function toolWriteFile(ctx, args) {
132
141
  catch {
133
142
  // new file
134
143
  }
135
- const gate = await gateWrite(ctx, args.path, oldContent, args.content);
144
+ const diff = unifiedDiff(args.path, oldContent, args.content);
145
+ const gate = await gateWrite(ctx, args.path, diff);
136
146
  if (gate)
137
147
  return gate;
138
148
  mkdirSync(dirname(abs), { recursive: true });
139
149
  writeFileSync(abs, args.content, "utf8");
140
- return { ok: true, output: `Wrote ${args.content.split("\n").length} lines to ${args.path}` };
150
+ return {
151
+ ok: true,
152
+ output: `Wrote ${args.content.split("\n").length} lines to ${args.path}`,
153
+ diff: toToolDiff(args.path, diff),
154
+ };
141
155
  }
142
156
  async function toolEditFile(ctx, args) {
143
157
  let abs;
@@ -158,11 +172,16 @@ async function toolEditFile(ctx, args) {
158
172
  // Function replacement: avoids JS $-pattern interpolation ($&, $', $`, $1…)
159
173
  // which would corrupt literal text like "$&" in the new content.
160
174
  const newContent = content.replace(args.old_string, () => args.new_string);
161
- const gate = await gateWrite(ctx, args.path, content, newContent);
175
+ const diff = unifiedDiff(args.path, content, newContent);
176
+ const gate = await gateWrite(ctx, args.path, diff);
162
177
  if (gate)
163
178
  return gate;
164
179
  writeFileSync(abs, newContent, "utf8");
165
- return { ok: true, output: `Edited ${args.path}` };
180
+ return {
181
+ ok: true,
182
+ output: `Edited ${args.path}`,
183
+ diff: toToolDiff(args.path, diff),
184
+ };
166
185
  }
167
186
  function toolListFiles(workdir, args) {
168
187
  return toResult(() => {
@@ -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");
package/dist/ui.js CHANGED
@@ -1,6 +1,14 @@
1
1
  import process from "node:process";
2
2
  // Honor NO_COLOR and non-TTY output: drop ANSI codes so logs/CI stay clean.
3
3
  const COLOR = process.env.NO_COLOR === undefined && process.stdout.isTTY === true;
4
+ // When a full-screen TUI is active (scoder tui), the Agent's console output
5
+ // would corrupt Ink's alt-screen rendering. The TUI renders from AgentEvents
6
+ // instead; setting QUIET suppresses every direct ui.* write.
7
+ let QUIET = false;
8
+ /** Suppress all direct console writes from ui.* (used by the Ink TUI frontend). */
9
+ export function setQuiet(quiet) {
10
+ QUIET = quiet;
11
+ }
4
12
  const RESET = COLOR ? "\x1b[0m" : "";
5
13
  const BOLD = COLOR ? "\x1b[1m" : "";
6
14
  const DIM = COLOR ? "\x1b[2m" : "";
@@ -13,43 +21,65 @@ function ts() {
13
21
  return new Date().toISOString().slice(11, 23);
14
22
  }
15
23
  export function info(msg) {
24
+ if (QUIET)
25
+ return;
16
26
  console.log(`${DIM}[${ts()}]${RESET} ${msg}`);
17
27
  }
18
28
  export function step(stepNum, total, msg) {
29
+ if (QUIET)
30
+ return;
19
31
  console.log(`${BLUE}[${String(stepNum).padStart(2)}/${total}]${RESET} ${BOLD}▸ ${msg}${RESET}`);
20
32
  }
21
33
  export function success(msg) {
34
+ if (QUIET)
35
+ return;
22
36
  console.log(`${GREEN}✓${RESET} ${GREEN}${msg}${RESET}`);
23
37
  }
24
38
  export function warn(msg) {
39
+ if (QUIET)
40
+ return;
25
41
  console.log(`${YELLOW}⚠${RESET} ${YELLOW}${msg}${RESET}`);
26
42
  }
27
43
  export function error(msg) {
44
+ if (QUIET)
45
+ return;
28
46
  console.error(`${RED}✗${RESET} ${RED}${msg}${RESET}`);
29
47
  }
30
48
  export function llm(prefix, content) {
49
+ if (QUIET)
50
+ return;
31
51
  console.log(`${CYAN}${prefix}:${RESET} ${content.slice(0, 300)}${content.length > 300 ? " ..." : ""}`);
32
52
  }
33
53
  export function toolCall(name, args) {
54
+ if (QUIET)
55
+ return;
34
56
  const a = Object.entries(args)
35
57
  .map(([k, v]) => `${k}=${JSON.stringify(v)?.slice(0, 80)}`)
36
58
  .join(" ");
37
59
  console.log(`${CYAN}→ ${name}(${a})${RESET}`);
38
60
  }
39
61
  export function toolResult(name, ok, output) {
62
+ if (QUIET)
63
+ return;
40
64
  const preview = output.split("\n")[0]?.slice(0, 120) ?? "";
41
65
  const icon = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
42
66
  console.log(`${icon} ${CYAN}${name}${RESET} ${DIM}→${RESET} ${preview}`);
43
67
  }
44
68
  export function buildStatus(success, output) {
69
+ if (QUIET)
70
+ return;
45
71
  const icon = success ? `${GREEN}✓ BUILD SUCCESS` : `${RED}✗ BUILD FAILED`;
46
72
  console.log(`\n${icon}${RESET}\n${output.split("\n").slice(-10).join("\n")}\n`);
47
73
  }
48
74
  export function header(msg) {
75
+ if (QUIET)
76
+ return;
49
77
  console.log(`\n${BOLD}${CYAN}── ${msg} ${"─".repeat(Math.max(2, 60 - msg.length))}${RESET}`);
50
78
  }
51
79
  /** Startup banner: a clean key/value box (plain-text fallback when piped). */
52
80
  export function banner(title, rows) {
81
+ if (QUIET)
82
+ return;
53
83
  const keyWidth = Math.max(8, ...rows.map(([k]) => k.length));
54
84
  const inner = Math.max(title.length + 4, ...rows.map(([, v]) => keyWidth + Math.max(...String(v).split("\n").map((l) => l.length)) + 1));
55
85
  console.log(`${BOLD}${CYAN}╭─ ${title} ${"─".repeat(Math.max(1, inner - title.length - 3))}╮${RESET}`);
@@ -73,6 +103,8 @@ let _streamColor = CYAN;
73
103
  let _streamPrefix = "";
74
104
  /** Start a streaming line. Call llmStreamChunk() for each delta, then llmStreamEnd(). */
75
105
  export function llmStreamStart(prefix = "assistant", color = CYAN) {
106
+ if (QUIET)
107
+ return;
76
108
  _streamLine = "";
77
109
  _streamColor = color;
78
110
  _streamPrefix = prefix;
@@ -80,11 +112,15 @@ export function llmStreamStart(prefix = "assistant", color = CYAN) {
80
112
  }
81
113
  /** Append a text delta to the current streaming line. */
82
114
  export function llmStreamChunk(delta) {
115
+ if (QUIET)
116
+ return;
83
117
  _streamLine += delta;
84
118
  process.stdout.write(delta);
85
119
  }
86
120
  /** Finish the streaming line (append suffix + newline). */
87
121
  export function llmStreamEnd(suffix = "") {
122
+ if (QUIET)
123
+ return "";
88
124
  const line = _streamLine;
89
125
  _streamLine = "";
90
126
  process.stdout.write(`${suffix}\n`);
@@ -97,7 +133,7 @@ let _toolStreamLineOpen = false; // last chunk did not end with "\n"
97
133
  let _toolStreamChars = 0;
98
134
  /** Begin a live tool-output block. Output is printed dimmed, capped at 20k chars. */
99
135
  export function toolStreamStart() {
100
- if (_toolStreamOpen)
136
+ if (QUIET || _toolStreamOpen)
101
137
  return;
102
138
  _toolStreamOpen = true;
103
139
  _toolStreamLineOpen = false;
@@ -106,7 +142,7 @@ export function toolStreamStart() {
106
142
  }
107
143
  /** Append a tool-output delta to the live block. */
108
144
  export function toolStreamChunk(delta) {
109
- if (!_toolStreamOpen)
145
+ if (QUIET || !_toolStreamOpen)
110
146
  return;
111
147
  if (_toolStreamChars >= TOOL_STREAM_MAX_CHARS)
112
148
  return;
@@ -126,7 +162,7 @@ export function toolStreamChunk(delta) {
126
162
  }
127
163
  /** Finish the live tool-output block (closes the partial last line). */
128
164
  export function toolStreamEnd() {
129
- if (!_toolStreamOpen)
165
+ if (QUIET || !_toolStreamOpen)
130
166
  return;
131
167
  _toolStreamOpen = false;
132
168
  process.stdout.write(_toolStreamLineOpen ? `\n${DIM}└${RESET}\n` : `${DIM}└${RESET}\n`);
@@ -138,6 +174,8 @@ export function isToolStreamOpen() {
138
174
  // ── Diff rendering ───────────────────────────────────────────────────────────
139
175
  /** Print a unified diff with colors (+ green, - red, @@ cyan). */
140
176
  export function printDiff(diffText) {
177
+ if (QUIET)
178
+ return;
141
179
  for (const line of diffText.split("\n")) {
142
180
  if (line.startsWith("+++") || line.startsWith("---")) {
143
181
  console.log(`${BOLD}${line}${RESET}`);
@@ -158,5 +196,7 @@ export function printDiff(diffText) {
158
196
  }
159
197
  /** Print a one-line diff summary: "✎ +12 -3 lines → src/foo.ts". */
160
198
  export function diffSummary(path, added, removed) {
199
+ if (QUIET)
200
+ return;
161
201
  console.log(`${CYAN}✎${RESET} ${BOLD}${path}${RESET} ${GREEN}+${added}${RESET} ${RED}-${removed}${RESET} lines`);
162
202
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s-app-scoder-agent",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Autonomous coding agent CLI - similar to opencode/cline/claude. Runs tasks until build succeeds.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -46,8 +46,13 @@
46
46
  "devDependencies": {
47
47
  "@eslint/js": "^10.0.1",
48
48
  "@types/node": "^22.0.0",
49
+ "@types/react": "^18.3.31",
49
50
  "eslint": "^10.13.0",
50
51
  "typescript": "^5.6.0",
51
52
  "typescript-eslint": "^8.72.1"
53
+ },
54
+ "dependencies": {
55
+ "ink": "^5.2.1",
56
+ "react": "^18.3.1"
52
57
  }
53
58
  }