s-app-scoder-agent 0.6.0 → 0.8.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 +600 -468
- package/dist/agent.js +841 -197
- 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 +423 -0
- package/dist/index.js +129 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- 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 +103 -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 +32 -2
- 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 +513 -19
- package/dist/tui/app.js +505 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +146 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- 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 +99 -0
- package/dist/tui/state.js +192 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
package/dist/tui/state.js
CHANGED
|
@@ -9,13 +9,87 @@ export function initialState() {
|
|
|
9
9
|
workdir: "",
|
|
10
10
|
buildCmd: "",
|
|
11
11
|
buildLamp: "none",
|
|
12
|
+
permission: "",
|
|
13
|
+
startedAt: null,
|
|
14
|
+
endedAt: null,
|
|
12
15
|
usage: { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 },
|
|
13
16
|
summary: null,
|
|
17
|
+
guardrailCount: 0,
|
|
18
|
+
lastGuardrail: null,
|
|
19
|
+
thinkingVisible: false,
|
|
20
|
+
phases: [],
|
|
21
|
+
mcpServers: {},
|
|
22
|
+
streamingOutputChars: 0,
|
|
14
23
|
};
|
|
15
24
|
}
|
|
16
25
|
const MAX_ITEMS = 400;
|
|
17
26
|
const MAX_OUTPUT_CHARS = 20_000;
|
|
18
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
|
+
* Reset realtime: mỗi lần user gửi message mới, plan reset — chỉ hiện plan
|
|
74
|
+
* được viết SAU user message cuối cùng. Tránh stale plan từ task trước hiện
|
|
75
|
+
* trong task mới.
|
|
76
|
+
*/
|
|
77
|
+
export function latestPlan(items) {
|
|
78
|
+
// Tìm user message cuối cùng — plan chỉ reset tại đây.
|
|
79
|
+
let lastUserIdx = -1;
|
|
80
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
81
|
+
if (items[i].kind === "user") {
|
|
82
|
+
lastUserIdx = i;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Chỉ tìm todo SAU user cuối.
|
|
87
|
+
for (let i = items.length - 1; i > lastUserIdx; i--) {
|
|
88
|
+
if (items[i].kind === "todo")
|
|
89
|
+
return items[i];
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
19
93
|
function appendItem(state, item) {
|
|
20
94
|
state.items.push(item);
|
|
21
95
|
if (state.items.length > MAX_ITEMS)
|
|
@@ -30,22 +104,69 @@ function capTail(text, max = MAX_OUTPUT_CHARS) {
|
|
|
30
104
|
return text;
|
|
31
105
|
return `…[truncated ${text.length - max} chars]\n${text.slice(text.length - max)}`;
|
|
32
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Map tên tool → phase của breadcrumb (plan→explore→edit→build→verify).
|
|
109
|
+
* Trả về null với tool không thuộc phase nào (mcp__*...).
|
|
110
|
+
*/
|
|
111
|
+
export function toolPhase(name) {
|
|
112
|
+
if (name === "write_todos")
|
|
113
|
+
return "plan";
|
|
114
|
+
if (name === "list_files" || name === "read_file" || name === "web_fetch" || name === "load_skill")
|
|
115
|
+
return "explore";
|
|
116
|
+
if (name === "write_file" || name === "edit_file")
|
|
117
|
+
return "edit";
|
|
118
|
+
if (name === "run_command")
|
|
119
|
+
return "build";
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
/** Guardrail event: đếm + giữ decision gần nhất (ngắn, tự giải thích cho header). */
|
|
123
|
+
function applyGuardrail(state, ev) {
|
|
124
|
+
// history-trim là routine context management — đếm làm gì, hiện làm gì:
|
|
125
|
+
// header chỉ hiển thị những can thiệp THẬT sự đáng chú ý (inject/finish/verify).
|
|
126
|
+
if (ev.action === "trim")
|
|
127
|
+
return;
|
|
128
|
+
state.guardrailCount += 1;
|
|
129
|
+
// Thông điệp thân thiện theo rule — không dump raw "rule · reason" lên UI.
|
|
130
|
+
const HUMAN = {
|
|
131
|
+
"wrap-up": "sắp hết bước — yêu cầu agent chốt việc và chạy build",
|
|
132
|
+
"chat-done": "lượt chat thuần — kết thúc không cần build",
|
|
133
|
+
"completion-claim": "agent báo hoàn thành — buộc chạy build xác minh",
|
|
134
|
+
"text-only-streak": "agent chỉ nói không làm — dừng run",
|
|
135
|
+
"continue-nudge": "trả lời text chưa dùng tool — nhắc tiếp tục",
|
|
136
|
+
"doom-loop": "lặp lẫn lộn cùng tool call — yêu cầu đổi hướng",
|
|
137
|
+
"build-stop": "build pass — kết thúc run",
|
|
138
|
+
};
|
|
139
|
+
state.lastGuardrail = HUMAN[ev.rule] ?? `${ev.rule}: ${ev.reason}`;
|
|
140
|
+
}
|
|
33
141
|
/**
|
|
34
142
|
* Fold one AgentEvent into the state (mutates `state`, returns it for
|
|
35
143
|
* reducer-style chaining). Unknown events are ignored.
|
|
36
144
|
*/
|
|
37
145
|
export function applyEvent(state, ev) {
|
|
38
146
|
switch (ev.type) {
|
|
147
|
+
case "permission_request": {
|
|
148
|
+
// T-03: surfaced via a dedicated React state hook in TuiApp (not the
|
|
149
|
+
// chat items — the modal is modal, not transcript).
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
39
152
|
case "run_start": {
|
|
40
153
|
state.model = ev.model;
|
|
41
154
|
state.provider = ev.provider;
|
|
42
155
|
state.workdir = ev.workdir;
|
|
43
156
|
state.buildCmd = ev.buildCmd;
|
|
44
157
|
state.buildLamp = "none";
|
|
158
|
+
state.permission = ev.permission;
|
|
159
|
+
state.startedAt = Date.now();
|
|
160
|
+
state.endedAt = null;
|
|
45
161
|
state.status = "running";
|
|
46
162
|
state.step = 0;
|
|
47
163
|
state.summary = null;
|
|
48
164
|
state.usage = { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 };
|
|
165
|
+
// Reset tracking của run mới.
|
|
166
|
+
state.guardrailCount = 0;
|
|
167
|
+
state.lastGuardrail = null;
|
|
168
|
+
state.phases = [];
|
|
169
|
+
state.streamingOutputChars = 0;
|
|
49
170
|
// The TUI pushes the user's message itself when submitting; avoid a
|
|
50
171
|
// duplicate when run_start carries the same task.
|
|
51
172
|
const last = lastItem(state);
|
|
@@ -54,6 +175,22 @@ export function applyEvent(state, ev) {
|
|
|
54
175
|
}
|
|
55
176
|
break;
|
|
56
177
|
}
|
|
178
|
+
case "llm_reasoning": {
|
|
179
|
+
// T-10: thinking stream — chỉ append khi thinkingVisible; vẫn giữ trong
|
|
180
|
+
// reasoning buffer riêng để toggle bật lại thấy (đơn giản: drop khi off).
|
|
181
|
+
if (!state.thinkingVisible)
|
|
182
|
+
break;
|
|
183
|
+
const last = lastItem(state);
|
|
184
|
+
if (last && last.kind === "thinking" && last.streaming) {
|
|
185
|
+
last.text += ev.delta;
|
|
186
|
+
if (last.text.length > MAX_TEXT_CHARS)
|
|
187
|
+
last.text = last.text.slice(-MAX_TEXT_CHARS);
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
appendItem(state, { kind: "thinking", text: ev.delta, streaming: true });
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
57
194
|
case "step": {
|
|
58
195
|
state.step = ev.step;
|
|
59
196
|
state.maxSteps = ev.maxSteps;
|
|
@@ -61,9 +198,12 @@ export function applyEvent(state, ev) {
|
|
|
61
198
|
const last = lastItem(state);
|
|
62
199
|
if (last && last.kind === "assistant" && last.streaming)
|
|
63
200
|
last.streaming = false;
|
|
201
|
+
if (last && last.kind === "thinking" && last.streaming)
|
|
202
|
+
last.streaming = false;
|
|
64
203
|
break;
|
|
65
204
|
}
|
|
66
205
|
case "llm_delta": {
|
|
206
|
+
state.streamingOutputChars += ev.delta.length;
|
|
67
207
|
const last = lastItem(state);
|
|
68
208
|
if (last && last.kind === "assistant" && last.streaming) {
|
|
69
209
|
last.text += ev.delta;
|
|
@@ -89,7 +229,20 @@ export function applyEvent(state, ev) {
|
|
|
89
229
|
break;
|
|
90
230
|
}
|
|
91
231
|
case "tool_call": {
|
|
92
|
-
appendItem(state, {
|
|
232
|
+
appendItem(state, {
|
|
233
|
+
kind: "tool",
|
|
234
|
+
name: ev.name,
|
|
235
|
+
args: ev.args,
|
|
236
|
+
callId: ev.id,
|
|
237
|
+
ok: null,
|
|
238
|
+
output: "",
|
|
239
|
+
step: ev.step,
|
|
240
|
+
startedAt: Date.now(),
|
|
241
|
+
});
|
|
242
|
+
// Phase tracking cho breadcrumb (không trùng phase liền trước).
|
|
243
|
+
const phase = toolPhase(ev.name);
|
|
244
|
+
if (phase && state.phases[state.phases.length - 1] !== phase)
|
|
245
|
+
state.phases.push(phase);
|
|
93
246
|
break;
|
|
94
247
|
}
|
|
95
248
|
case "tool_output": {
|
|
@@ -109,13 +262,20 @@ export function applyEvent(state, ev) {
|
|
|
109
262
|
break;
|
|
110
263
|
}
|
|
111
264
|
case "tool_result": {
|
|
265
|
+
// T6: correlate theo callId (parallel calls cùng tên vẫn đúng card);
|
|
266
|
+
// fallback theo name cho event cũ không id.
|
|
112
267
|
for (let i = state.items.length - 1; i >= 0; i--) {
|
|
113
268
|
const it = state.items[i];
|
|
114
|
-
|
|
269
|
+
const match = it.kind === "tool" && it.ok === null && (ev.id ? it.callId === ev.id : it.name === ev.name);
|
|
270
|
+
if (match) {
|
|
115
271
|
it.ok = ev.ok;
|
|
272
|
+
if (it.startedAt != null)
|
|
273
|
+
it.durationMs = Date.now() - it.startedAt;
|
|
116
274
|
// Prefer the final (complete) result over accumulated live chunks.
|
|
117
275
|
if (ev.output.length >= it.output.length)
|
|
118
276
|
it.output = ev.output;
|
|
277
|
+
if (ev.image)
|
|
278
|
+
it.image = ev.image;
|
|
119
279
|
break;
|
|
120
280
|
}
|
|
121
281
|
}
|
|
@@ -136,16 +296,41 @@ export function applyEvent(state, ev) {
|
|
|
136
296
|
}
|
|
137
297
|
case "build": {
|
|
138
298
|
state.buildLamp = ev.success ? "ok" : "fail";
|
|
299
|
+
if (state.phases[state.phases.length - 1] !== "verify")
|
|
300
|
+
state.phases.push("verify");
|
|
139
301
|
appendItem(state, { kind: "build", success: ev.success, command: ev.command });
|
|
140
302
|
break;
|
|
141
303
|
}
|
|
304
|
+
case "todo": {
|
|
305
|
+
// Plan updates render as a live checkbox list in the chat pane.
|
|
306
|
+
appendItem(state, { kind: "todo", items: ev.items.map((t) => ({ ...t })) });
|
|
307
|
+
break;
|
|
308
|
+
}
|
|
309
|
+
case "guardrail": {
|
|
310
|
+
applyGuardrail(state, ev);
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case "mcp_status": {
|
|
314
|
+
// Sidebar: structured event (server+state) → cập nhật map, không spam chat.
|
|
315
|
+
// onStatus riêng vẫn đẩy notice text đầy đủ cho transcript.
|
|
316
|
+
if (ev.server && ev.state) {
|
|
317
|
+
state.mcpServers = { ...state.mcpServers, [ev.server]: ev.state };
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
appendItem(state, { kind: "notice", text: ev.line, level: ev.line.includes("unavailable") ? "warn" : "info" });
|
|
321
|
+
break;
|
|
322
|
+
}
|
|
142
323
|
case "finish": {
|
|
143
324
|
state.status = ev.success ? "success" : /interrupted|aborted/i.test(ev.summary) ? "aborted" : "failed";
|
|
144
325
|
state.summary = ev.summary;
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
326
|
+
state.endedAt = Date.now();
|
|
327
|
+
// Close ALL dangling streaming assistant messages — not just the last
|
|
328
|
+
// item: a notice/tool item appended after the assistant text would
|
|
329
|
+
// otherwise leave it stuck in raw streaming mode (invisible text).
|
|
330
|
+
for (const it of state.items) {
|
|
331
|
+
if (it.kind === "assistant" && it.streaming)
|
|
332
|
+
it.streaming = false;
|
|
333
|
+
}
|
|
149
334
|
break;
|
|
150
335
|
}
|
|
151
336
|
case "error": {
|
|
@@ -159,6 +344,7 @@ export function applyEvent(state, ev) {
|
|
|
159
344
|
export function markAborted(state) {
|
|
160
345
|
state.status = "aborted";
|
|
161
346
|
state.summary = "abort requested…";
|
|
347
|
+
state.endedAt = Date.now();
|
|
162
348
|
const last = lastItem(state);
|
|
163
349
|
if (last && last.kind === "assistant")
|
|
164
350
|
last.streaming = false;
|
|
@@ -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/watch.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M4: hot-reload — watch .scoderrc + .scoder/skills/ (fs.watch, debounce
|
|
3
|
+
* 500ms). onChange(kind) — Agent quyết định hành động (reload skills /
|
|
4
|
+
* restart MCP). Module thuần, không dependency.
|
|
5
|
+
*/
|
|
6
|
+
import { watch, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
const DEBOUNCE_MS = 500;
|
|
9
|
+
/** Watch rc file + skills dir. Fire onChange(kind) debounced (giữa các burst). */
|
|
10
|
+
export function watchWorkdir(workdir, onChange) {
|
|
11
|
+
const watchers = [];
|
|
12
|
+
const timers = new Map();
|
|
13
|
+
const fire = (kind) => {
|
|
14
|
+
const t = timers.get(kind);
|
|
15
|
+
if (t)
|
|
16
|
+
clearTimeout(t);
|
|
17
|
+
timers.set(kind, setTimeout(() => {
|
|
18
|
+
timers.delete(kind);
|
|
19
|
+
onChange(kind);
|
|
20
|
+
}, DEBOUNCE_MS));
|
|
21
|
+
};
|
|
22
|
+
// .scoderrc (hoặc .scoderrc.json) — config changes.
|
|
23
|
+
for (const name of [".scoderrc.json", ".scoderrc"]) {
|
|
24
|
+
const p = join(workdir, name);
|
|
25
|
+
if (!existsSync(p))
|
|
26
|
+
continue;
|
|
27
|
+
try {
|
|
28
|
+
watchers.push(watch(p, () => fire("config")));
|
|
29
|
+
}
|
|
30
|
+
catch { /* race — file removed between existsSync and watch */ }
|
|
31
|
+
}
|
|
32
|
+
// .scoder/skills/ — skill files added/removed/edited.
|
|
33
|
+
const skillsDir = join(workdir, ".scoder", "skills");
|
|
34
|
+
if (existsSync(skillsDir)) {
|
|
35
|
+
try {
|
|
36
|
+
watchers.push(watch(skillsDir, { recursive: true }, () => fire("skills")));
|
|
37
|
+
}
|
|
38
|
+
catch { /* platform không hỗ trợ recursive (hiếm trên macOS/Windows OK) */ }
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
close() {
|
|
42
|
+
for (const w of watchers) {
|
|
43
|
+
try {
|
|
44
|
+
w.close();
|
|
45
|
+
}
|
|
46
|
+
catch { /* already closed */ }
|
|
47
|
+
}
|
|
48
|
+
for (const t of timers.values())
|
|
49
|
+
clearTimeout(t);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "s-app-scoder-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Autonomous coding agent CLI
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Autonomous coding agent CLI — writes, edits, and builds code autonomously until the project compiles without errors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"bin": {
|
|
@@ -10,17 +10,19 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc",
|
|
12
12
|
"prepare": "npm run build",
|
|
13
|
-
"prepublishOnly": "npm run lint && npm run typecheck
|
|
13
|
+
"prepublishOnly": "npm run lint && npm run typecheck",
|
|
14
14
|
"start": "node dist/index.js",
|
|
15
15
|
"dev": "tsc --watch",
|
|
16
16
|
"typecheck": "tsc --noEmit",
|
|
17
17
|
"lint": "eslint src/",
|
|
18
18
|
"coverage": "npm run build && node --test --experimental-test-coverage test/*.test.mjs",
|
|
19
|
+
"eval": "node test/run-eval.mjs",
|
|
19
20
|
"test": "npm run build && node --test test/*.test.mjs && node test/run-e2e.mjs && node test/run-mcp-test.mjs"
|
|
20
21
|
},
|
|
21
22
|
"engines": {
|
|
22
|
-
"node": ">=
|
|
23
|
+
"node": ">=20"
|
|
23
24
|
},
|
|
25
|
+
"author": "nbhson",
|
|
24
26
|
"keywords": [
|
|
25
27
|
"cli",
|
|
26
28
|
"agent",
|
|
@@ -41,18 +43,24 @@
|
|
|
41
43
|
"files": [
|
|
42
44
|
"dist",
|
|
43
45
|
"README.md",
|
|
44
|
-
"LICENSE"
|
|
46
|
+
"LICENSE",
|
|
47
|
+
".scoderrc.example.json"
|
|
45
48
|
],
|
|
46
49
|
"devDependencies": {
|
|
47
50
|
"@eslint/js": "^10.0.1",
|
|
48
51
|
"@types/node": "^22.0.0",
|
|
49
|
-
"@types/react": "^
|
|
52
|
+
"@types/react": "^19.3.0",
|
|
50
53
|
"eslint": "^10.13.0",
|
|
51
54
|
"typescript": "^5.6.0",
|
|
52
55
|
"typescript-eslint": "^8.72.1"
|
|
53
56
|
},
|
|
54
57
|
"dependencies": {
|
|
55
|
-
"
|
|
56
|
-
"
|
|
58
|
+
"fuzzysort": "^3.1.0",
|
|
59
|
+
"gpt-tokenizer": "^4.1.0",
|
|
60
|
+
"ink": "^8.0.0",
|
|
61
|
+
"react": "^19.3.1",
|
|
62
|
+
"react-dom": "^19.3.1",
|
|
63
|
+
"tree-sitter": "^0.25.1",
|
|
64
|
+
"tree-sitter-bash": "^0.25.1"
|
|
57
65
|
}
|
|
58
66
|
}
|
package/dist/tui-direct.mjs
DELETED
|
@@ -1,4 +0,0 @@
|
|
|
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");
|