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 +60 -27
- package/dist/agent.js +12 -1
- package/dist/index.js +127 -1
- package/dist/llm.js +25 -2
- package/dist/tools.js +26 -7
- package/dist/tui/app.js +315 -0
- package/dist/tui/commands.js +24 -0
- package/dist/tui/state.js +166 -0
- package/dist/tui-direct.mjs +4 -0
- package/dist/ui.js +43 -3
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -50,14 +50,17 @@ npx app-scoder-agent "your task"
|
|
|
50
50
|
## Quick Start
|
|
51
51
|
|
|
52
52
|
```bash
|
|
53
|
-
#
|
|
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
|
|
383
|
-
│
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
│
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
│ (
|
|
391
|
-
│
|
|
392
|
-
│
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
│
|
|
398
|
-
|
|
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({
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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(() => {
|
package/dist/tui/app.js
ADDED
|
@@ -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.
|
|
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
|
}
|