s-app-scoder-agent 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +599 -435
  4. package/dist/agent.js +829 -168
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +409 -0
  11. package/dist/index.js +216 -45
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +148 -11
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +97 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +28 -1
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +537 -24
  30. package/dist/tui/app.js +615 -0
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +145 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +222 -0
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +101 -0
  44. package/dist/tui/state.js +342 -0
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/ui.js +43 -3
  48. package/dist/watch.js +52 -0
  49. package/package.json +18 -5
@@ -0,0 +1,342 @@
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
+ permission: "",
13
+ startedAt: null,
14
+ endedAt: null,
15
+ usage: { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
16
+ summary: null,
17
+ guardrailCount: 0,
18
+ lastGuardrail: null,
19
+ thinkingVisible: false,
20
+ phases: [],
21
+ mcpServers: {},
22
+ streamingOutputChars: 0,
23
+ };
24
+ }
25
+ const MAX_ITEMS = 400;
26
+ const MAX_OUTPUT_CHARS = 20_000;
27
+ const MAX_TEXT_CHARS = 40_000;
28
+ /**
29
+ * T4: ước lượng chiều cao (rows) của một ChatItem khi render — dùng cho
30
+ * virtualization (chỉ render window quanh viewport thay vì cap cứng).
31
+ * Over-estimate nhẹ an toàn hơn under-estimate (viewport luôn đủ phủ).
32
+ * ponytail: heuristic theo chars, không đo Ink thật — nâng cấp thành đo
33
+ * height thật (useBoxMetrics per item) khi heuristic lệch đáng kể.
34
+ */
35
+ export function estimateItemRows(item) {
36
+ switch (item.kind) {
37
+ case "user":
38
+ return 2 + Math.ceil(item.text.length / 80);
39
+ case "assistant":
40
+ case "thinking":
41
+ return 1 + Math.ceil((item.text?.length ?? 0) / 80) + (item.text ? item.text.split("\n").length : 0);
42
+ case "tool":
43
+ return 1 + 2; // title + preview 2 lines
44
+ case "diff":
45
+ return 3 + Math.min(24, (item.text?.split("\n").length ?? 0)); // header + ≤24 lines + border
46
+ case "todo":
47
+ return 2 + item.items.length;
48
+ case "build":
49
+ return 1;
50
+ case "notice":
51
+ return 1 + Math.ceil(item.text.length / 2000);
52
+ }
53
+ }
54
+ /**
55
+ * T4: chọn window item để render: lấy từ cuối lên tới khi tổng rows ước
56
+ * lượng ≥ viewportRows + margin (render dư phía trên để scroll mượt), nhưng
57
+ * giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không mutate).
58
+ */
59
+ export function visibleWindow(items, viewportRows, margin = 50) {
60
+ const budget = viewportRows + margin;
61
+ let acc = 0;
62
+ let start = items.length;
63
+ while (start > 0 && acc < budget) {
64
+ start--;
65
+ acc += estimateItemRows(items[start]);
66
+ }
67
+ return start === 0 ? items : items.slice(start);
68
+ }
69
+ /**
70
+ * Plan mới nhất cho sidebar — todo items được append mỗi lần update nhưng
71
+ * sidebar chỉ cần snapshot hiện tại (last-wins), null khi chưa có plan.
72
+ */
73
+ export function latestPlan(items) {
74
+ for (let i = items.length - 1; i >= 0; i--) {
75
+ const it = items[i];
76
+ if (it.kind === "todo")
77
+ return it;
78
+ }
79
+ return null;
80
+ }
81
+ function appendItem(state, item) {
82
+ state.items.push(item);
83
+ if (state.items.length > MAX_ITEMS)
84
+ state.items.splice(0, state.items.length - MAX_ITEMS);
85
+ }
86
+ function lastItem(state) {
87
+ return state.items[state.items.length - 1];
88
+ }
89
+ /** Cap long strings, keeping the tail (errors usually live at the end). */
90
+ function capTail(text, max = MAX_OUTPUT_CHARS) {
91
+ if (text.length <= max)
92
+ return text;
93
+ return `…[truncated ${text.length - max} chars]\n${text.slice(text.length - max)}`;
94
+ }
95
+ /**
96
+ * Map tên tool → phase của breadcrumb (plan→explore→edit→build→verify).
97
+ * Trả về null với tool không thuộc phase nào (mcp__*...).
98
+ */
99
+ export function toolPhase(name) {
100
+ if (name === "write_todos")
101
+ return "plan";
102
+ if (name === "list_files" || name === "read_file" || name === "web_fetch" || name === "load_skill")
103
+ return "explore";
104
+ if (name === "write_file" || name === "edit_file")
105
+ return "edit";
106
+ if (name === "run_command")
107
+ return "build";
108
+ return null;
109
+ }
110
+ /** Guardrail event: đếm + giữ decision gần nhất (ngắn, tự giải thích cho header). */
111
+ function applyGuardrail(state, ev) {
112
+ // history-trim là routine context management — đếm làm gì, hiện làm gì:
113
+ // header chỉ hiển thị những can thiệp THẬT sự đáng chú ý (inject/finish/verify).
114
+ if (ev.action === "trim")
115
+ return;
116
+ state.guardrailCount += 1;
117
+ // Thông điệp thân thiện theo rule — không dump raw "rule · reason" lên UI.
118
+ const HUMAN = {
119
+ "wrap-up": "sắp hết bước — yêu cầu agent chốt việc và chạy build",
120
+ "chat-done": "lượt chat thuần — kết thúc không cần build",
121
+ "completion-claim": "agent báo hoàn thành — buộc chạy build xác minh",
122
+ "text-only-streak": "agent chỉ nói không làm — dừng run",
123
+ "continue-nudge": "agent trả lời text mà không dùng tool — nhắc tiếp tục làm việc",
124
+ "doom-loop": "lặp lẫn lộn cùng tool call — yêu cầu đổi hướng",
125
+ "build-stop": "build pass — kết thúc run",
126
+ };
127
+ state.lastGuardrail = HUMAN[ev.rule] ?? `${ev.rule}: ${ev.reason}`;
128
+ if (state.lastGuardrail.length > 72)
129
+ state.lastGuardrail = state.lastGuardrail.slice(0, 69) + "…";
130
+ }
131
+ /**
132
+ * Fold one AgentEvent into the state (mutates `state`, returns it for
133
+ * reducer-style chaining). Unknown events are ignored.
134
+ */
135
+ export function applyEvent(state, ev) {
136
+ switch (ev.type) {
137
+ case "permission_request": {
138
+ // T-03: surfaced via a dedicated React state hook in TuiApp (not the
139
+ // chat items — the modal is modal, not transcript).
140
+ break;
141
+ }
142
+ case "run_start": {
143
+ state.model = ev.model;
144
+ state.provider = ev.provider;
145
+ state.workdir = ev.workdir;
146
+ state.buildCmd = ev.buildCmd;
147
+ state.buildLamp = "none";
148
+ state.permission = ev.permission;
149
+ state.startedAt = Date.now();
150
+ state.endedAt = null;
151
+ state.status = "running";
152
+ state.step = 0;
153
+ state.summary = null;
154
+ state.usage = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
155
+ // Reset tracking của run mới.
156
+ state.guardrailCount = 0;
157
+ state.lastGuardrail = null;
158
+ state.phases = [];
159
+ state.streamingOutputChars = 0;
160
+ // The TUI pushes the user's message itself when submitting; avoid a
161
+ // duplicate when run_start carries the same task.
162
+ const last = lastItem(state);
163
+ if (!(last && last.kind === "user" && last.text === ev.task)) {
164
+ appendItem(state, { kind: "user", text: ev.task });
165
+ }
166
+ break;
167
+ }
168
+ case "llm_reasoning": {
169
+ // T-10: thinking stream — chỉ append khi thinkingVisible; vẫn giữ trong
170
+ // reasoning buffer riêng để toggle bật lại thấy (đơn giản: drop khi off).
171
+ if (!state.thinkingVisible)
172
+ break;
173
+ const last = lastItem(state);
174
+ if (last && last.kind === "thinking" && last.streaming) {
175
+ last.text += ev.delta;
176
+ if (last.text.length > MAX_TEXT_CHARS)
177
+ last.text = last.text.slice(-MAX_TEXT_CHARS);
178
+ }
179
+ else {
180
+ appendItem(state, { kind: "thinking", text: ev.delta, streaming: true });
181
+ }
182
+ break;
183
+ }
184
+ case "step": {
185
+ state.step = ev.step;
186
+ state.maxSteps = ev.maxSteps;
187
+ // A new step starts: close any still-"streaming" assistant message.
188
+ const last = lastItem(state);
189
+ if (last && last.kind === "assistant" && last.streaming)
190
+ last.streaming = false;
191
+ if (last && last.kind === "thinking" && last.streaming)
192
+ last.streaming = false;
193
+ break;
194
+ }
195
+ case "llm_delta": {
196
+ state.streamingOutputChars += ev.delta.length;
197
+ const last = lastItem(state);
198
+ if (last && last.kind === "assistant" && last.streaming) {
199
+ last.text += ev.delta;
200
+ if (last.text.length > MAX_TEXT_CHARS)
201
+ last.text = last.text.slice(-MAX_TEXT_CHARS);
202
+ }
203
+ else {
204
+ if (last && last.kind === "assistant" && last.streaming === false && last.text === "") {
205
+ // reuse an empty assistant placeholder from a previous step
206
+ last.streaming = true;
207
+ }
208
+ else {
209
+ appendItem(state, { kind: "assistant", text: ev.delta, streaming: true });
210
+ }
211
+ }
212
+ break;
213
+ }
214
+ case "llm_usage": {
215
+ state.usage.calls += 1;
216
+ state.usage.inputTokens += ev.usage.input_tokens ?? 0;
217
+ state.usage.outputTokens += ev.usage.output_tokens ?? 0;
218
+ state.usage.cachedTokens += ev.usage.cached_tokens ?? 0;
219
+ break;
220
+ }
221
+ case "tool_call": {
222
+ appendItem(state, {
223
+ kind: "tool",
224
+ name: ev.name,
225
+ args: ev.args,
226
+ callId: ev.id,
227
+ ok: null,
228
+ output: "",
229
+ step: ev.step,
230
+ startedAt: Date.now(),
231
+ });
232
+ // Phase tracking cho breadcrumb (không trùng phase liền trước).
233
+ const phase = toolPhase(ev.name);
234
+ if (phase && state.phases[state.phases.length - 1] !== phase)
235
+ state.phases.push(phase);
236
+ break;
237
+ }
238
+ case "tool_output": {
239
+ // Live stdout/stderr chunks attach to the most recent running tool item.
240
+ for (let i = state.items.length - 1; i >= 0; i--) {
241
+ const it = state.items[i];
242
+ if (it.kind === "tool" && it.ok === null && it.name === ev.name) {
243
+ it.output += ev.delta;
244
+ if (it.output.length > MAX_OUTPUT_CHARS)
245
+ it.output = capTail(it.output);
246
+ break;
247
+ }
248
+ // Stop searching at the previous tool boundary to avoid cross-attachment.
249
+ if (it.kind === "tool")
250
+ break;
251
+ }
252
+ break;
253
+ }
254
+ case "tool_result": {
255
+ // T6: correlate theo callId (parallel calls cùng tên vẫn đúng card);
256
+ // fallback theo name cho event cũ không id.
257
+ for (let i = state.items.length - 1; i >= 0; i--) {
258
+ const it = state.items[i];
259
+ const match = it.kind === "tool" && it.ok === null && (ev.id ? it.callId === ev.id : it.name === ev.name);
260
+ if (match) {
261
+ it.ok = ev.ok;
262
+ if (it.startedAt != null)
263
+ it.durationMs = Date.now() - it.startedAt;
264
+ // Prefer the final (complete) result over accumulated live chunks.
265
+ if (ev.output.length >= it.output.length)
266
+ it.output = ev.output;
267
+ if (ev.image)
268
+ it.image = ev.image;
269
+ break;
270
+ }
271
+ }
272
+ // File-writing tools attach a unified diff — render it as its own item
273
+ // so edits are reviewable at a glance (the scoder "✎" signature).
274
+ if (ev.diff) {
275
+ appendItem(state, {
276
+ kind: "diff",
277
+ path: ev.diff.path,
278
+ text: ev.diff.text,
279
+ added: ev.diff.added,
280
+ removed: ev.diff.removed,
281
+ truncated: ev.diff.truncated,
282
+ step: ev.step,
283
+ });
284
+ }
285
+ break;
286
+ }
287
+ case "build": {
288
+ state.buildLamp = ev.success ? "ok" : "fail";
289
+ if (state.phases[state.phases.length - 1] !== "verify")
290
+ state.phases.push("verify");
291
+ appendItem(state, { kind: "build", success: ev.success, command: ev.command });
292
+ break;
293
+ }
294
+ case "todo": {
295
+ // Plan updates render as a live checkbox list in the chat pane.
296
+ appendItem(state, { kind: "todo", items: ev.items.map((t) => ({ ...t })) });
297
+ break;
298
+ }
299
+ case "guardrail": {
300
+ applyGuardrail(state, ev);
301
+ break;
302
+ }
303
+ case "mcp_status": {
304
+ // Sidebar: structured event (server+state) → cập nhật map, không spam chat.
305
+ // onStatus riêng vẫn đẩy notice text đầy đủ cho transcript.
306
+ if (ev.server && ev.state) {
307
+ state.mcpServers = { ...state.mcpServers, [ev.server]: ev.state };
308
+ break;
309
+ }
310
+ appendItem(state, { kind: "notice", text: ev.line, level: ev.line.includes("unavailable") ? "warn" : "info" });
311
+ break;
312
+ }
313
+ case "finish": {
314
+ state.status = ev.success ? "success" : /interrupted|aborted/i.test(ev.summary) ? "aborted" : "failed";
315
+ state.summary = ev.summary;
316
+ state.endedAt = Date.now();
317
+ // Close ALL dangling streaming assistant messages — not just the last
318
+ // item: a notice/tool item appended after the assistant text would
319
+ // otherwise leave it stuck in raw streaming mode (invisible text).
320
+ for (const it of state.items) {
321
+ if (it.kind === "assistant" && it.streaming)
322
+ it.streaming = false;
323
+ }
324
+ break;
325
+ }
326
+ case "error": {
327
+ appendItem(state, { kind: "notice", text: ev.message, level: "error" });
328
+ break;
329
+ }
330
+ }
331
+ return state;
332
+ }
333
+ /** Mark the current run aborted from the UI side (before the finish event). */
334
+ export function markAborted(state) {
335
+ state.status = "aborted";
336
+ state.summary = "abort requested…";
337
+ state.endedAt = Date.now();
338
+ const last = lastItem(state);
339
+ if (last && last.kind === "assistant")
340
+ last.streaming = false;
341
+ return state;
342
+ }
@@ -0,0 +1,90 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { Text } from "ink";
3
+ import { readFileSync, existsSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ const MISSION = {
6
+ brand: "magenta", accent: "cyan", success: "green", error: "red", warn: "yellow",
7
+ user: "blue", dim: "gray", border: "gray",
8
+ mermaid: "magentaBright", thinking: "gray", code: "green", table: "cyan",
9
+ };
10
+ const PAPER = {
11
+ brand: "blue", accent: "blue", success: "green", error: "red", warn: "yellow",
12
+ user: "black", dim: "gray", border: "gray",
13
+ mermaid: "magentaBright", thinking: "gray", code: "green", table: "cyan",
14
+ };
15
+ const MONO = {
16
+ brand: "white", accent: "white", success: "white", error: "white", warn: "white",
17
+ user: "white", dim: "gray", border: "gray",
18
+ mermaid: "whiteBright", thinking: "white", code: "whiteBright", table: "whiteBright",
19
+ };
20
+ const LIGHT = {
21
+ brand: "magentaBright", accent: "cyanBright", success: "greenBright", error: "redBright",
22
+ warn: "yellowBright", user: "blueBright", dim: "blackBright", border: "blackBright",
23
+ mermaid: "magentaBright", thinking: "gray", code: "greenBright", table: "cyanBright",
24
+ };
25
+ const HIGH_CONTRAST = {
26
+ brand: "whiteBright", accent: "cyanBright", success: "greenBright", error: "redBright",
27
+ warn: "yellowBright", user: "whiteBright", dim: "white", border: "whiteBright",
28
+ mermaid: "magentaBright", thinking: "whiteBright", code: "greenBright", table: "cyanBright",
29
+ };
30
+ const SOLARIZED = {
31
+ brand: "magentaBright", accent: "cyanBright", success: "greenBright", error: "redBright",
32
+ warn: "yellowBright", user: "blueBright", dim: "gray", border: "gray",
33
+ mermaid: "magentaBright", thinking: "gray", code: "greenBright", table: "cyanBright",
34
+ };
35
+ export const THEMES = {
36
+ mission: MISSION, paper: PAPER, mono: MONO,
37
+ light: LIGHT, "high-contrast": HIGH_CONTRAST, solarized: SOLARIZED,
38
+ };
39
+ export function defaultThemeName(env = process.env) {
40
+ if (env.TERM === "dumb" || !env.TERM)
41
+ return "mono";
42
+ if (env.COLOR_COUNT && Number(env.COLOR_COUNT) < 16)
43
+ return "mono";
44
+ return "mission";
45
+ }
46
+ export function getTheme(workdir) {
47
+ const p = join(workdir, ".scoder", "theme.json");
48
+ if (existsSync(p)) {
49
+ try {
50
+ const raw = JSON.parse(readFileSync(p, "utf8"));
51
+ const base = raw.name ? THEMES[raw.name] ?? THEMES[defaultThemeName()] : THEMES[defaultThemeName()];
52
+ return { ...base, ...Object.fromEntries(Object.entries(raw).filter(([k]) => k in base)) };
53
+ }
54
+ catch { /* malformed → default */ }
55
+ }
56
+ return THEMES[defaultThemeName()];
57
+ }
58
+ let active = MISSION;
59
+ export function setActiveTheme(t) { active = t; }
60
+ export function useNamedTheme(name) {
61
+ if (name === "list")
62
+ return `themes: ${Object.keys(THEMES).join(", ")} — /theme <name>`;
63
+ const t = THEMES[name];
64
+ if (!t)
65
+ return `ERROR: unknown theme "${name}" — available: ${Object.keys(THEMES).join(", ")}`;
66
+ active = t;
67
+ return `theme → ${name}`;
68
+ }
69
+ export const theme = new Proxy({}, {
70
+ get(_t, prop) { return active[prop]; },
71
+ });
72
+ /** Icon per tool name — compact visual vocabulary for the flight recorder. */
73
+ const TOOL_ICONS = {
74
+ list_files: "🗂", read_file: "📖", write_file: "📝", edit_file: "✏️",
75
+ run_command: "⚡", web_fetch: "🌐", write_todos: "☑",
76
+ };
77
+ export function toolIcon(name) {
78
+ if (TOOL_ICONS[name])
79
+ return TOOL_ICONS[name];
80
+ if (name.startsWith("mcp__"))
81
+ return "🔌";
82
+ return "🔧";
83
+ }
84
+ export function fit(text, max) {
85
+ return text.length <= max ? text : text.slice(0, Math.max(0, max - 1)) + "…";
86
+ }
87
+ /** The agent's signature mark, used as an avatar on assistant output. */
88
+ export function AgentMark() {
89
+ return _jsx(Text, { color: theme.brand, bold: true, children: "\u25C6 " });
90
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * T5: vim-motion cho Input — mode normal/insert, word motions.
3
+ * Module thuần (test được); Input chỉ wire keymap.
4
+ */
5
+ /** w — đầu từ kế tiếp (vim: skip word chars rồi skip whitespace). */
6
+ export function wordForward(s, i) {
7
+ while (i < s.length && /\S/.test(s[i]))
8
+ i++;
9
+ while (i < s.length && /\s/.test(s[i]))
10
+ i++;
11
+ return Math.min(i, s.length);
12
+ }
13
+ /** b — đầu từ trước. */
14
+ export function wordBack(s, i) {
15
+ i--;
16
+ while (i > 0 && /\s/.test(s[i]))
17
+ i--;
18
+ while (i > 0 && /\S/.test(s[i - 1]))
19
+ i--;
20
+ return Math.max(0, i);
21
+ }
22
+ /** e — cuối từ hiện tại/kế tiếp. */
23
+ export function wordEnd(s, i) {
24
+ i++;
25
+ while (i < s.length - 1 && /\s/.test(s[i]))
26
+ i++;
27
+ while (i < s.length - 1 && /\S/.test(s[i + 1]))
28
+ i++;
29
+ return Math.min(i, Math.max(0, s.length - 1));
30
+ }
31
+ /** Áp dụng một phím normal-mode. Trả về unhandled=true khi không phải motion. */
32
+ export function applyNormalKey(value, cursor, input) {
33
+ const ok = (v, c, mode = "normal") => ({ value: v, cursor: c, mode, unhandled: false });
34
+ switch (input) {
35
+ case "h":
36
+ return ok(value, Math.max(0, cursor - 1));
37
+ case "l":
38
+ return ok(value, Math.min(value.length, cursor + 1));
39
+ case "w":
40
+ return ok(value, wordForward(value, cursor));
41
+ case "b":
42
+ return ok(value, wordBack(value, cursor));
43
+ case "e":
44
+ return ok(value, wordEnd(value, cursor));
45
+ case "0":
46
+ return ok(value, 0);
47
+ case "$":
48
+ return ok(value, value.length);
49
+ case "x":
50
+ return cursor < value.length ? ok(value.slice(0, cursor) + value.slice(cursor + 1), cursor) : ok(value, cursor);
51
+ case "i":
52
+ return ok(value, cursor, "insert");
53
+ case "a":
54
+ return ok(value, Math.min(value.length, cursor + 1), "insert");
55
+ case "A":
56
+ return ok(value, value.length, "insert");
57
+ default:
58
+ return { value, cursor, mode: "normal", unhandled: true };
59
+ }
60
+ }
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
  }