s-app-scoder-agent 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -435
- package/dist/agent.js +829 -168
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +216 -45
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +148 -11
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +537 -24
- package/dist/tui/app.js +615 -0
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +222 -0
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +342 -0
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/ui.js +43 -3
- package/dist/watch.js +52 -0
- package/package.json +18 -5
|
@@ -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
|
+
}
|
package/dist/tui/vim.js
ADDED
|
@@ -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
|
}
|