s-app-scoder-agent 0.9.3 → 0.9.7
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 +615 -638
- package/dist/{guardrails.js → agent/guardrails.js} +1 -1
- package/dist/agent/interaction.js +70 -0
- package/dist/agent/mcp-bridge.js +136 -0
- package/dist/agent/prompt-context.js +83 -0
- package/dist/agent/run-loop.js +318 -0
- package/dist/agent/session-persistence.js +61 -0
- package/dist/agent/tool-executor.js +158 -0
- package/dist/agent/types.js +88 -0
- package/dist/agent/verification.js +99 -0
- package/dist/agent.js +239 -1002
- package/dist/cli/args.js +230 -0
- package/dist/cli/init.js +79 -0
- package/dist/cli/main.js +211 -0
- package/dist/cli/mcp-command.js +185 -0
- package/dist/cli/repl.js +77 -0
- package/dist/cli/run-once.js +26 -0
- package/dist/{tui/commands.js → commands/definitions.js} +6 -16
- package/dist/commands/registry.js +260 -0
- package/dist/{config.js → config/config.js} +1 -1
- package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
- package/dist/{prompt.js → context/prompt.js} +111 -9
- package/dist/{skills.js → context/skills.js} +41 -2
- package/dist/index.js +5 -798
- package/dist/io/console-reporter.js +24 -0
- package/dist/io/reporter.js +1 -0
- package/dist/mcp/connection.js +227 -0
- package/dist/mcp/env.js +8 -0
- package/dist/{mcp.js → mcp/manager.js} +3 -515
- package/dist/mcp/sse.js +28 -0
- package/dist/mcp/transport-http.js +140 -0
- package/dist/mcp/transport-stdio.js +98 -0
- package/dist/mcp/types.js +11 -0
- package/dist/media/image.js +149 -0
- package/dist/{server.js → server/server.js} +1 -1
- package/dist/{cost.js → session/cost.js} +1 -1
- package/dist/{session.js → session/session.js} +1 -1
- package/dist/{version.js → shared/version.js} +1 -1
- package/dist/tools/apply-patch.js +62 -0
- package/dist/tools/edit-file.js +29 -0
- package/dist/tools/file-edit-common.js +124 -0
- package/dist/tools/list-files.js +64 -0
- package/dist/tools/path-safety.js +75 -0
- package/dist/tools/read-file.js +64 -0
- package/dist/tools/registry.js +297 -0
- package/dist/tools/run-command.js +192 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/search.js +237 -0
- package/dist/tools/task-tool.js +48 -0
- package/dist/tools/types.js +1 -0
- package/dist/tools/write-file.js +34 -0
- package/dist/tui/app.js +59 -532
- package/dist/tui/ask-user-modal.js +2 -1
- package/dist/tui/at-complete.js +2 -93
- package/dist/tui/charset.js +41 -0
- package/dist/tui/chat-views.js +72 -16
- package/dist/tui/chrome.js +11 -5
- package/dist/tui/hooks/use-command-host.js +26 -0
- package/dist/tui/hooks/use-event-queue.js +69 -0
- package/dist/tui/hooks/use-keybinds.js +87 -0
- package/dist/tui/hooks/use-scroll.js +38 -0
- package/dist/tui/hooks/use-session-resume.js +47 -0
- package/dist/tui/hooks/use-submit.js +106 -0
- package/dist/tui/image.js +2 -149
- package/dist/tui/input.js +1 -1
- package/dist/tui/markdown/ast.js +5 -0
- package/dist/tui/markdown/diagram.js +239 -0
- package/dist/tui/markdown/inline-render.js +24 -0
- package/dist/tui/markdown/inline.js +93 -0
- package/dist/tui/markdown/parse.js +139 -0
- package/dist/tui/markdown/render.js +146 -0
- package/dist/tui/markdown/table.js +106 -0
- package/dist/tui/markdown.js +32 -379
- package/dist/tui/permission-modal.js +2 -1
- package/dist/tui/selectors.js +157 -0
- package/dist/tui/session-picker.js +4 -3
- package/dist/tui/sidebar.js +130 -15
- package/dist/tui/state.js +47 -118
- package/dist/tui/text-width.js +139 -0
- package/dist/tui/theme.js +7 -2
- package/dist/{build.js → workspace/build.js} +1 -1
- package/dist/workspace/file-index.js +93 -0
- package/package.json +5 -1
- package/dist/tools.js +0 -1223
- package/dist/tui/command-registry.js +0 -477
- package/dist/tui/review.js +0 -64
- package/dist/tui/transcript.js +0 -145
- /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
- /package/dist/{agents.js → context/agents.js} +0 -0
- /package/dist/{compaction.js → context/compaction.js} +0 -0
- /package/dist/{context.js → context/context.js} +0 -0
- /package/dist/{memory.js → context/memory.js} +0 -0
- /package/dist/{repo-map.js → context/repo-map.js} +0 -0
- /package/dist/{ui.js → io/terminal.js} +0 -0
- /package/dist/{llm.js → llm/llm.js} +0 -0
- /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
- /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
- /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
- /package/dist/{retry.js → llm/retry.js} +0 -0
- /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
- /package/dist/{permissions.js → security/permissions.js} +0 -0
- /package/dist/{sandbox.js → security/sandbox.js} +0 -0
- /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
- /package/dist/{logger.js → session/logger.js} +0 -0
- /package/dist/{steering.js → session/steering.js} +0 -0
- /package/dist/{constants.js → shared/constants.js} +0 -0
- /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
- /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
- /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
- /package/dist/{diff.js → workspace/diff.js} +0 -0
- /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
- /package/dist/{watch.js → workspace/watch.js} +0 -0
package/dist/tui/sidebar.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
-
import { contextWindowFor } from "../model-catalog.js";
|
|
4
|
-
import { resolveModelPricing } from "../constants.js";
|
|
5
|
-
import { latestPlan } from "./state.js";
|
|
3
|
+
import { contextWindowFor } from "../llm/model-catalog.js";
|
|
4
|
+
import { resolveModelPricing } from "../shared/constants.js";
|
|
6
5
|
import { fit, theme } from "./theme.js";
|
|
6
|
+
import { charset } from "./charset.js";
|
|
7
7
|
import { fuelGauge, gaugeColor, SIDEBAR_WIDTH } from "./chrome.js";
|
|
8
8
|
const INNER = SIDEBAR_WIDTH - 4; // border + paddingX
|
|
9
9
|
const MAX_PLAN_ROWS = 14; // cap chiều cao — plan dài thì gộp "+N more"
|
|
10
10
|
function Section({ title, children }) {
|
|
11
|
-
|
|
11
|
+
// Header chuẩn design-system: rule mảnh + tiêu đề bold + rule lấp đầy chiều
|
|
12
|
+
// ngang còn lại — phân tách section rõ ràng mà không cần khung nặng.
|
|
13
|
+
const rule = charset.h.repeat(Math.max(0, INNER - title.length - 3));
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { dimColor: true, children: [charset.h, " "] }), _jsx(Text, { bold: true, children: title }), _jsxs(Text, { dimColor: true, children: [" ", rule] })] }), children] }));
|
|
12
15
|
}
|
|
13
16
|
function SectionLine({ label, value, color }) {
|
|
14
17
|
return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { dimColor: true, children: [" ", fit(label, 8), " "] }), _jsx(Text, { color: color, children: value })] }));
|
|
@@ -28,15 +31,19 @@ function PlanRow({ t, width }) {
|
|
|
28
31
|
}
|
|
29
32
|
return (_jsxs(Text, { wrap: "truncate", dimColor: true, children: [" ○ ", fit(t.content, width - 4)] }));
|
|
30
33
|
}
|
|
31
|
-
function PlanSection({ state }) {
|
|
32
|
-
const plan =
|
|
33
|
-
return (_jsx(Section, { title: "PLAN", children: !plan || plan.
|
|
34
|
-
const done = plan.
|
|
35
|
-
return _jsxs(Text, { children: [" ", _jsx(Text, { bold: true, children: done }), _jsxs(Text, { dimColor: true, children: ["/", plan.
|
|
36
|
-
})(), plan.
|
|
34
|
+
function PlanSection({ state, cap }) {
|
|
35
|
+
const plan = state.plan;
|
|
36
|
+
return (_jsx(Section, { title: "PLAN", children: !plan || plan.length === 0 ? (_jsx(Text, { dimColor: true, children: " no plan yet" })) : (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [(() => {
|
|
37
|
+
const done = plan.filter((t) => t.status === "completed").length;
|
|
38
|
+
return _jsxs(Text, { children: [" ", _jsx(Text, { bold: true, children: done }), _jsxs(Text, { dimColor: true, children: ["/", plan.length, " steps"] })] });
|
|
39
|
+
})(), plan.slice(0, cap).map((t, i) => (_jsx(PlanRow, { t: t, width: INNER }, i))), plan.length > cap && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", plan.length - cap, " more"] }))] })) }));
|
|
37
40
|
}
|
|
38
41
|
// ── Metrics (tok/s · ctx · cost · elapsed · fuel gauge · guardrails) ─────────
|
|
39
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Danh sách dòng metrics — dùng chung cho render VÀ ước lượng chiều cao
|
|
44
|
+
* (sidebar budget), nên 2 chỗ không bao giờ lệch nhau.
|
|
45
|
+
*/
|
|
46
|
+
function buildMetricsRows(state) {
|
|
40
47
|
const fmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
41
48
|
const hasUsage = state.usage.calls > 0;
|
|
42
49
|
const elapsedMs = state.startedAt ? (state.endedAt ?? Date.now()) - state.startedAt : null;
|
|
@@ -69,7 +76,42 @@ function MetricsSection({ state }) {
|
|
|
69
76
|
const ctxDisplay = ctxTokens != null && ctxPct != null
|
|
70
77
|
? `${ctxFmt(ctxTokens)}/${ctxFmt(ctxWindow)} (${ctxPct}%)`
|
|
71
78
|
: null;
|
|
72
|
-
|
|
79
|
+
if (!hasUsage && elapsedSec == null)
|
|
80
|
+
return [];
|
|
81
|
+
const rows = [];
|
|
82
|
+
if (gauge) {
|
|
83
|
+
rows.push(_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { dimColor: true, children: " steps " }), _jsx(Text, { color: gaugeColor(state.step, state.maxSteps), children: gauge }), _jsxs(Text, { dimColor: true, children: [" ", state.step, "/", state.maxSteps] })] }, "gauge"));
|
|
84
|
+
}
|
|
85
|
+
if (hasUsage) {
|
|
86
|
+
rows.push(_jsx(SectionLine, { label: "toks", value: `${fmt(state.usage.inputTokens)} → ${fmt(state.usage.outputTokens)}` }, "toks"));
|
|
87
|
+
}
|
|
88
|
+
if (!hasUsage && state.streamingOutputChars > 0) {
|
|
89
|
+
rows.push(_jsx(SectionLine, { label: "toks", value: `~${fmt(Math.round(state.streamingOutputChars / 4))} (streaming)`, color: theme.accent }, "toks"));
|
|
90
|
+
}
|
|
91
|
+
// Show one speed line: live ~N during streaming, then N once usage events fire.
|
|
92
|
+
if (tokPerSec != null) {
|
|
93
|
+
rows.push(_jsx(SectionLine, { label: "speed", value: `${fmt(tokPerSec)} tok/s`, color: theme.accent }, "speed"));
|
|
94
|
+
}
|
|
95
|
+
if (liveTokPerSec != null && tokPerSec == null) {
|
|
96
|
+
rows.push(_jsx(SectionLine, { label: "speed", value: `~${fmt(liveTokPerSec)} tok/s`, color: theme.accent }, "speed"));
|
|
97
|
+
}
|
|
98
|
+
if (ctxDisplay != null)
|
|
99
|
+
rows.push(_jsx(SectionLine, { label: "ctx", value: ctxDisplay }, "ctx"));
|
|
100
|
+
if (cost > 0)
|
|
101
|
+
rows.push(_jsx(SectionLine, { label: "cost", value: `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}` }, "cost"));
|
|
102
|
+
if (elapsedSec != null) {
|
|
103
|
+
rows.push(_jsx(SectionLine, { label: "elapsed", value: `${Math.floor(elapsedSec / 60)}:${String(elapsedSec % 60).padStart(2, "0")}` }, "elapsed"));
|
|
104
|
+
}
|
|
105
|
+
if (state.usage.cachedTokens > 0) {
|
|
106
|
+
rows.push(_jsx(SectionLine, { label: "cached", value: fmt(state.usage.cachedTokens) }, "cached"));
|
|
107
|
+
}
|
|
108
|
+
if (state.guardrailCount > 0) {
|
|
109
|
+
rows.push(_jsx(SectionLine, { label: "guard", value: String(state.guardrailCount), color: theme.warn }, "guard"));
|
|
110
|
+
}
|
|
111
|
+
return rows;
|
|
112
|
+
}
|
|
113
|
+
function MetricsSection({ rows }) {
|
|
114
|
+
return (_jsx(Section, { title: "METRICS", children: rows.length === 0 ? (_jsx(Text, { dimColor: true, children: " starting\u2026" })) : (_jsx(Box, { flexDirection: "column", flexShrink: 0, children: rows })) }));
|
|
73
115
|
}
|
|
74
116
|
// ── Phases (plan › explore › edit › build › verify — vertical) ───────────────
|
|
75
117
|
const PHASES = ["plan", "explore", "edit", "build", "verify"];
|
|
@@ -116,7 +158,80 @@ function SubagentSection({ state }) {
|
|
|
116
158
|
}) }));
|
|
117
159
|
}
|
|
118
160
|
// ── Root ─────────────────────────────────────────────────────────────────────
|
|
119
|
-
/**
|
|
120
|
-
|
|
121
|
-
|
|
161
|
+
/** Chiều cao ước lượng (gồm title + marginBottom=1) của các section tĩnh. */
|
|
162
|
+
const SESSION_ROWS = 4; // title + model + dir
|
|
163
|
+
const PHASES_ROWS = 7; // title + 5 phase
|
|
164
|
+
const PHASES_IDLE_ROWS = 3; // title + "— idle"
|
|
165
|
+
/**
|
|
166
|
+
* Sidebar phải — mọi trạng thái sống của run, cập nhật theo AgentEvent.
|
|
167
|
+
*
|
|
168
|
+
* Thứ tự section CỐ ĐỊNH (layout gốc): SESSION → MCP → METRICS → PHASES →
|
|
169
|
+
* PLAN → SUBAGENTS → BUILD.
|
|
170
|
+
*
|
|
171
|
+
* Height-aware: nội dung sidebar thường CAO HƠN chiều cao khả dụng (terminal
|
|
172
|
+
* 24–30 dòng trừ BuildRail/input/StatusBar). Greedy-fit theo đúng thứ tự
|
|
173
|
+
* hiển thị với budget chiều cao thật (`rows` do app.tsx đo từ khung chính):
|
|
174
|
+
* section nào không vừa trọn vẹn thì BỎ QUA (không clip nửa chừng). Riêng
|
|
175
|
+
* PLAN co giãn được — cap số dòng theo chỗ còn lại + dòng "… +N more".
|
|
176
|
+
*
|
|
177
|
+
* `rows` = chiều cao ngoài của sidebar (= chiều cao chat viewport cùng hàng).
|
|
178
|
+
* undefined/0 (chưa đo xong) → không giới hạn (behavior cũ, dùng trong tests).
|
|
179
|
+
*/
|
|
180
|
+
export function Sidebar({ state, config, rows }) {
|
|
181
|
+
const avail = rows != null && rows > 0 ? Math.max(8, rows - 2 /* border trên/dưới */) : Number.POSITIVE_INFINITY;
|
|
182
|
+
let used = SESSION_ROWS;
|
|
183
|
+
const fits = (h) => used + h <= avail;
|
|
184
|
+
const body = [];
|
|
185
|
+
// MCP — tĩnh theo config.
|
|
186
|
+
const mcpH = 1 + Math.max(1, Object.keys(config.mcpServers ?? {}).length) + 1;
|
|
187
|
+
if (fits(mcpH)) {
|
|
188
|
+
used += mcpH;
|
|
189
|
+
body.push(_jsx(McpSection, { state: state, config: config }, "mcp"));
|
|
190
|
+
}
|
|
191
|
+
// METRICS — sống theo run.
|
|
192
|
+
const metricsRows = buildMetricsRows(state);
|
|
193
|
+
const metricsH = 1 + Math.max(1, metricsRows.length) + 1;
|
|
194
|
+
if (fits(metricsH)) {
|
|
195
|
+
used += metricsH;
|
|
196
|
+
body.push(_jsx(MetricsSection, { rows: metricsRows }, "metrics"));
|
|
197
|
+
}
|
|
198
|
+
// PHASES.
|
|
199
|
+
const phasesH = state.phases.length === 0 ? PHASES_IDLE_ROWS : PHASES_ROWS;
|
|
200
|
+
if (fits(phasesH)) {
|
|
201
|
+
used += phasesH;
|
|
202
|
+
body.push(_jsx(PhaseSection, { state: state }, "phases"));
|
|
203
|
+
}
|
|
204
|
+
// PLAN — co giãn theo chỗ còn lại (plan rows từng dòng tự có nghĩa, khác
|
|
205
|
+
// các section all-or-nothing). Overhead = title + dòng đếm + margin = 3;
|
|
206
|
+
// khi bị cap chừa thêm 1 dòng "… +N more".
|
|
207
|
+
const planItems = state.plan ?? [];
|
|
208
|
+
if (planItems.length === 0) {
|
|
209
|
+
if (fits(3)) {
|
|
210
|
+
used += 3;
|
|
211
|
+
body.push(_jsx(PlanSection, { state: state, cap: MAX_PLAN_ROWS }, "plan"));
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
let cap = Math.min(MAX_PLAN_ROWS, planItems.length, avail - used - 3);
|
|
216
|
+
if (cap < planItems.length)
|
|
217
|
+
cap -= 1; // chừa dòng "… +N more"
|
|
218
|
+
if (cap >= 1) {
|
|
219
|
+
used += 3 + cap + (cap < planItems.length ? 1 : 0);
|
|
220
|
+
body.push(_jsx(PlanSection, { state: state, cap: cap }, "plan"));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// SUBAGENTS — chỉ khi có.
|
|
224
|
+
if (state.subagents.length > 0) {
|
|
225
|
+
const h = 1 + state.subagents.length + 1;
|
|
226
|
+
if (fits(h)) {
|
|
227
|
+
used += h;
|
|
228
|
+
body.push(_jsx(SubagentSection, { state: state }, "subagents"));
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// BUILD — lamp đã có trên BuildRail header, ưu tiên cuối.
|
|
232
|
+
const buildH = 1 + 1 + (state.buildCmd || config.buildCmd ? 1 : 0) + 1;
|
|
233
|
+
if (fits(buildH)) {
|
|
234
|
+
body.push(_jsx(BuildSection, { state: state, config: config }, "build"));
|
|
235
|
+
}
|
|
236
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.border, width: SIDEBAR_WIDTH, paddingX: 1, flexShrink: 0, overflow: "hidden", children: [_jsx(SessionSection, { state: state, config: config }), body] }));
|
|
122
237
|
}
|
package/dist/tui/state.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toolPhase } from "./selectors.js";
|
|
1
2
|
export function initialState() {
|
|
2
3
|
return {
|
|
3
4
|
items: [],
|
|
@@ -28,78 +29,12 @@ export function initialState() {
|
|
|
28
29
|
queue: { pending: 0, steering: 0 },
|
|
29
30
|
verification: null,
|
|
30
31
|
subagents: [],
|
|
32
|
+
plan: null,
|
|
31
33
|
};
|
|
32
34
|
}
|
|
33
35
|
const MAX_ITEMS = 400;
|
|
34
36
|
const MAX_OUTPUT_CHARS = 20_000;
|
|
35
37
|
const MAX_TEXT_CHARS = 40_000;
|
|
36
|
-
/**
|
|
37
|
-
* T4: ước lượng chiều cao (rows) của một ChatItem khi render — dùng cho
|
|
38
|
-
* virtualization (chỉ render window quanh viewport thay vì cap cứng).
|
|
39
|
-
* Over-estimate nhẹ an toàn hơn under-estimate (viewport luôn đủ phủ).
|
|
40
|
-
* ponytail: heuristic theo chars, không đo Ink thật — nâng cấp thành đo
|
|
41
|
-
* height thật (useBoxMetrics per item) khi heuristic lệch đáng kể.
|
|
42
|
-
*/
|
|
43
|
-
export function estimateItemRows(item) {
|
|
44
|
-
switch (item.kind) {
|
|
45
|
-
case "user":
|
|
46
|
-
return 2 + Math.ceil(item.text.length / 80);
|
|
47
|
-
case "assistant":
|
|
48
|
-
case "thinking":
|
|
49
|
-
return 1 + Math.ceil((item.text?.length ?? 0) / 80) + (item.text ? item.text.split("\n").length : 0);
|
|
50
|
-
case "tool":
|
|
51
|
-
return 1 + 2; // title + preview 2 lines
|
|
52
|
-
case "compaction":
|
|
53
|
-
return 1;
|
|
54
|
-
case "diff":
|
|
55
|
-
return 3 + Math.min(24, (item.text?.split("\n").length ?? 0)); // header + ≤24 lines + border
|
|
56
|
-
case "todo":
|
|
57
|
-
return 2 + item.items.length;
|
|
58
|
-
case "build":
|
|
59
|
-
return 1;
|
|
60
|
-
case "notice":
|
|
61
|
-
return 1 + Math.ceil(item.text.length / 2000);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
/**
|
|
65
|
-
* T4: chọn window item để render: lấy từ cuối lên tới khi tổng rows ước
|
|
66
|
-
* lượng ≥ viewportRows + margin (render dư phía trên để scroll mượt), nhưng
|
|
67
|
-
* giữ toàn bộ khi tổng nhỏ. Trả về slice của items (không mutate).
|
|
68
|
-
*/
|
|
69
|
-
export function visibleWindow(items, viewportRows, margin = 50) {
|
|
70
|
-
const budget = viewportRows + margin;
|
|
71
|
-
let acc = 0;
|
|
72
|
-
let start = items.length;
|
|
73
|
-
while (start > 0 && acc < budget) {
|
|
74
|
-
start--;
|
|
75
|
-
acc += estimateItemRows(items[start]);
|
|
76
|
-
}
|
|
77
|
-
return start === 0 ? items : items.slice(start);
|
|
78
|
-
}
|
|
79
|
-
/**
|
|
80
|
-
* Plan mới nhất cho sidebar — todo items được append mỗi lần update nhưng
|
|
81
|
-
* sidebar chỉ cần snapshot hiện tại (last-wins), null khi chưa có plan.
|
|
82
|
-
*
|
|
83
|
-
* Reset realtime: mỗi lần user gửi message mới, plan reset — chỉ hiện plan
|
|
84
|
-
* được viết SAU user message cuối cùng. Tránh stale plan từ task trước hiện
|
|
85
|
-
* trong task mới.
|
|
86
|
-
*/
|
|
87
|
-
export function latestPlan(items) {
|
|
88
|
-
// Tìm user message cuối cùng — plan chỉ reset tại đây.
|
|
89
|
-
let lastUserIdx = -1;
|
|
90
|
-
for (let i = items.length - 1; i >= 0; i--) {
|
|
91
|
-
if (items[i].kind === "user") {
|
|
92
|
-
lastUserIdx = i;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
// Chỉ tìm todo SAU user cuối.
|
|
97
|
-
for (let i = items.length - 1; i > lastUserIdx; i--) {
|
|
98
|
-
if (items[i].kind === "todo")
|
|
99
|
-
return items[i];
|
|
100
|
-
}
|
|
101
|
-
return null;
|
|
102
|
-
}
|
|
103
38
|
function appendItem(state, item) {
|
|
104
39
|
state.items.push(item);
|
|
105
40
|
if (state.items.length > MAX_ITEMS)
|
|
@@ -108,33 +43,21 @@ function appendItem(state, item) {
|
|
|
108
43
|
function lastItem(state) {
|
|
109
44
|
return state.items[state.items.length - 1];
|
|
110
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Thay một chat item bằng bản copy đã patch — KHÔNG mutate in-place.
|
|
48
|
+
* ChatItemView là React.memo: mutate in-place khiến prevProps.item ===
|
|
49
|
+
* nextProps.item (cùng object), comparator so object với chính nó luôn
|
|
50
|
+
* "bằng" → memo đóng băng UI (stream đứng ở chunk đầu, tool kẹt "running").
|
|
51
|
+
*/
|
|
52
|
+
function patchItem(state, index, patch) {
|
|
53
|
+
state.items[index] = { ...state.items[index], ...patch };
|
|
54
|
+
}
|
|
111
55
|
/** Cap long strings, keeping the tail (errors usually live at the end). */
|
|
112
56
|
function capTail(text, max = MAX_OUTPUT_CHARS) {
|
|
113
57
|
if (text.length <= max)
|
|
114
58
|
return text;
|
|
115
59
|
return `…[truncated ${text.length - max} chars]\n${text.slice(text.length - max)}`;
|
|
116
60
|
}
|
|
117
|
-
/**
|
|
118
|
-
* Map tên tool → phase của breadcrumb (plan→explore→edit→build→verify).
|
|
119
|
-
* Trả về null với tool không thuộc phase nào (mcp__*...).
|
|
120
|
-
*/
|
|
121
|
-
export function toolPhase(name) {
|
|
122
|
-
if (name === "write_todos")
|
|
123
|
-
return "plan";
|
|
124
|
-
if (name === "list_files" || name === "read_file" || name === "grep" || name === "glob" || name === "web_fetch" || name === "load_skill" || name === "repo_map")
|
|
125
|
-
return "explore";
|
|
126
|
-
if (name === "write_file" || name === "edit_file" || name === "apply_patch")
|
|
127
|
-
return "edit";
|
|
128
|
-
if (name === "run_command")
|
|
129
|
-
return "build";
|
|
130
|
-
if (name === "task")
|
|
131
|
-
return "delegate";
|
|
132
|
-
if (name === "ask_user")
|
|
133
|
-
return "clear";
|
|
134
|
-
if (name.startsWith("mcp__"))
|
|
135
|
-
return "mcp";
|
|
136
|
-
return null;
|
|
137
|
-
}
|
|
138
61
|
/** Guardrail event: đếm + giữ decision gần nhất (ngắn, tự giải thích cho header). */
|
|
139
62
|
function applyGuardrail(state, ev) {
|
|
140
63
|
// history-trim là routine context management — đếm làm gì, hiện làm gì:
|
|
@@ -154,6 +77,9 @@ function applyGuardrail(state, ev) {
|
|
|
154
77
|
};
|
|
155
78
|
state.lastGuardrail = HUMAN[ev.rule] ?? `${ev.rule}: ${ev.reason}`;
|
|
156
79
|
}
|
|
80
|
+
// Pure selectors live in ./selectors.js (Phase 5.4) — re-exported so existing
|
|
81
|
+
// import sites (app.tsx, hooks, tests) keep working unchanged.
|
|
82
|
+
export { estimateItemRows, visibleWindow, latestPlan, toolPhase } from "./selectors.js";
|
|
157
83
|
/**
|
|
158
84
|
* Fold one AgentEvent into the state (mutates `state`, returns it for
|
|
159
85
|
* reducer-style chaining). Unknown events are ignored.
|
|
@@ -197,6 +123,8 @@ export function applyEvent(state, ev) {
|
|
|
197
123
|
if (!(last && last.kind === "user" && last.text === ev.task)) {
|
|
198
124
|
appendItem(state, { kind: "user", text: ev.task });
|
|
199
125
|
}
|
|
126
|
+
// Plan của task trước không được lọt sang task mới.
|
|
127
|
+
state.plan = null;
|
|
200
128
|
break;
|
|
201
129
|
}
|
|
202
130
|
case "llm_reasoning": {
|
|
@@ -206,9 +134,10 @@ export function applyEvent(state, ev) {
|
|
|
206
134
|
break;
|
|
207
135
|
const last = lastItem(state);
|
|
208
136
|
if (last && last.kind === "thinking" && last.streaming) {
|
|
209
|
-
last.text
|
|
210
|
-
if (
|
|
211
|
-
|
|
137
|
+
let text = last.text + ev.delta;
|
|
138
|
+
if (text.length > MAX_TEXT_CHARS)
|
|
139
|
+
text = text.slice(-MAX_TEXT_CHARS);
|
|
140
|
+
patchItem(state, state.items.length - 1, { text });
|
|
212
141
|
}
|
|
213
142
|
else {
|
|
214
143
|
appendItem(state, { kind: "thinking", text: ev.delta, streaming: true });
|
|
@@ -224,10 +153,9 @@ export function applyEvent(state, ev) {
|
|
|
224
153
|
state.llmCallStartedAt = Date.now();
|
|
225
154
|
// A new step starts: close any still-"streaming" assistant message.
|
|
226
155
|
const last = lastItem(state);
|
|
227
|
-
if (last && last.kind === "assistant" && last.streaming)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
last.streaming = false;
|
|
156
|
+
if (last && (last.kind === "assistant" || last.kind === "thinking") && last.streaming) {
|
|
157
|
+
patchItem(state, state.items.length - 1, { streaming: false });
|
|
158
|
+
}
|
|
231
159
|
break;
|
|
232
160
|
}
|
|
233
161
|
case "llm_delta": {
|
|
@@ -237,14 +165,15 @@ export function applyEvent(state, ev) {
|
|
|
237
165
|
state.streamingOutputChars += ev.delta.length;
|
|
238
166
|
const last = lastItem(state);
|
|
239
167
|
if (last && last.kind === "assistant" && last.streaming) {
|
|
240
|
-
last.text
|
|
241
|
-
if (
|
|
242
|
-
|
|
168
|
+
let text = last.text + ev.delta;
|
|
169
|
+
if (text.length > MAX_TEXT_CHARS)
|
|
170
|
+
text = text.slice(-MAX_TEXT_CHARS);
|
|
171
|
+
patchItem(state, state.items.length - 1, { text });
|
|
243
172
|
}
|
|
244
173
|
else {
|
|
245
174
|
if (last && last.kind === "assistant" && last.streaming === false && last.text === "") {
|
|
246
175
|
// reuse an empty assistant placeholder from a previous step
|
|
247
|
-
|
|
176
|
+
patchItem(state, state.items.length - 1, { streaming: true });
|
|
248
177
|
}
|
|
249
178
|
else {
|
|
250
179
|
appendItem(state, { kind: "assistant", text: ev.delta, streaming: true });
|
|
@@ -292,9 +221,10 @@ export function applyEvent(state, ev) {
|
|
|
292
221
|
for (let i = state.items.length - 1; i >= 0; i--) {
|
|
293
222
|
const it = state.items[i];
|
|
294
223
|
if (it.kind === "tool" && it.ok === null && it.name === ev.name) {
|
|
295
|
-
it.output
|
|
296
|
-
if (
|
|
297
|
-
|
|
224
|
+
let output = it.output + ev.delta;
|
|
225
|
+
if (output.length > MAX_OUTPUT_CHARS)
|
|
226
|
+
output = capTail(output);
|
|
227
|
+
patchItem(state, i, { output });
|
|
298
228
|
break;
|
|
299
229
|
}
|
|
300
230
|
// Stop searching at the previous tool boundary to avoid cross-attachment.
|
|
@@ -310,14 +240,13 @@ export function applyEvent(state, ev) {
|
|
|
310
240
|
const it = state.items[i];
|
|
311
241
|
const match = it.kind === "tool" && it.ok === null && (ev.id ? it.callId === ev.id : it.name === ev.name);
|
|
312
242
|
if (match) {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
it.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
it.image = ev.image;
|
|
243
|
+
patchItem(state, i, {
|
|
244
|
+
ok: ev.ok,
|
|
245
|
+
durationMs: it.startedAt != null ? Date.now() - it.startedAt : it.durationMs,
|
|
246
|
+
// Prefer the final (complete) result over accumulated live chunks.
|
|
247
|
+
output: ev.output.length >= it.output.length ? ev.output : it.output,
|
|
248
|
+
image: ev.image ?? it.image,
|
|
249
|
+
});
|
|
321
250
|
break;
|
|
322
251
|
}
|
|
323
252
|
}
|
|
@@ -344,7 +273,9 @@ export function applyEvent(state, ev) {
|
|
|
344
273
|
break;
|
|
345
274
|
}
|
|
346
275
|
case "todo": {
|
|
347
|
-
//
|
|
276
|
+
// Snapshot last-wins cho sidebar PLAN (field riêng, không bị MAX_ITEMS
|
|
277
|
+
// eviction xoá trên run dài). Chat item vẫn append để giữ transcript.
|
|
278
|
+
state.plan = ev.items.map((t) => ({ ...t }));
|
|
348
279
|
appendItem(state, { kind: "todo", items: ev.items.map((t) => ({ ...t })) });
|
|
349
280
|
break;
|
|
350
281
|
}
|
|
@@ -401,7 +332,7 @@ export function applyEvent(state, ev) {
|
|
|
401
332
|
const it = state.items[i];
|
|
402
333
|
if (it.kind === "tool" && it.ok === null && (it.callId === ev.id || it.name === ev.name)) {
|
|
403
334
|
if (ev.message)
|
|
404
|
-
|
|
335
|
+
patchItem(state, i, { output: capTail(`${it.output}\n… ${ev.message}`.trim()) });
|
|
405
336
|
break;
|
|
406
337
|
}
|
|
407
338
|
if (it.kind === "tool")
|
|
@@ -426,10 +357,7 @@ export function applyEvent(state, ev) {
|
|
|
426
357
|
// Close ALL dangling streaming assistant messages — not just the last
|
|
427
358
|
// item: a notice/tool item appended after the assistant text would
|
|
428
359
|
// otherwise leave it stuck in raw streaming mode (invisible text).
|
|
429
|
-
|
|
430
|
-
if (it.kind === "assistant" && it.streaming)
|
|
431
|
-
it.streaming = false;
|
|
432
|
-
}
|
|
360
|
+
state.items = state.items.map((it) => it.kind === "assistant" && it.streaming ? { ...it, streaming: false } : it);
|
|
433
361
|
break;
|
|
434
362
|
}
|
|
435
363
|
case "error": {
|
|
@@ -445,7 +373,8 @@ export function markAborted(state) {
|
|
|
445
373
|
state.summary = "abort requested…";
|
|
446
374
|
state.endedAt = Date.now();
|
|
447
375
|
const last = lastItem(state);
|
|
448
|
-
if (last && last.kind === "assistant")
|
|
449
|
-
|
|
376
|
+
if (last && last.kind === "assistant" && last.streaming) {
|
|
377
|
+
patchItem(state, state.items.length - 1, { streaming: false });
|
|
378
|
+
}
|
|
450
379
|
return state;
|
|
451
380
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display-width helpers — SINGLE source of truth for measuring, clipping,
|
|
3
|
+
* padding and wrapping visible text in the TUI.
|
|
4
|
+
*
|
|
5
|
+
* Rules:
|
|
6
|
+
* - Width is measured in terminal cells (via string-width): emoji and CJK are
|
|
7
|
+
* 2 cells, combining marks (e.g. Vietnamese tone marks) are 0 cells.
|
|
8
|
+
* - ANSI escape sequences are ignored when measuring, passed through
|
|
9
|
+
* untouched, and never split when clipping/wrapping.
|
|
10
|
+
* - Grapheme clusters (emoji ZWJ sequences, base + combining marks) are never
|
|
11
|
+
* split either.
|
|
12
|
+
*
|
|
13
|
+
* Every pad/clip/truncate of user-visible strings in src/tui/ must go through
|
|
14
|
+
* here — never use String.prototype.padEnd/slice directly on display text.
|
|
15
|
+
*/
|
|
16
|
+
import stringWidth from "string-width";
|
|
17
|
+
/** Visible cell width of a string (ANSI ignored, emoji/CJK = 2 cells). */
|
|
18
|
+
export function measure(text) {
|
|
19
|
+
return stringWidth(text);
|
|
20
|
+
}
|
|
21
|
+
// eslint-disable-next-line no-control-regex -- ANSI escape sequences are control chars by definition
|
|
22
|
+
const ANSI_RX = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07/g;
|
|
23
|
+
/** Split into tokens: ANSI sequences (width 0) vs plain text chunks. */
|
|
24
|
+
function tokenize(text) {
|
|
25
|
+
const tokens = [];
|
|
26
|
+
let last = 0;
|
|
27
|
+
for (const m of text.matchAll(ANSI_RX)) {
|
|
28
|
+
if (m.index > last)
|
|
29
|
+
tokens.push({ ansi: false, value: text.slice(last, m.index) });
|
|
30
|
+
tokens.push({ ansi: true, value: m[0] });
|
|
31
|
+
last = m.index + m[0].length;
|
|
32
|
+
}
|
|
33
|
+
if (last < text.length)
|
|
34
|
+
tokens.push({ ansi: false, value: text.slice(last) });
|
|
35
|
+
return tokens;
|
|
36
|
+
}
|
|
37
|
+
/** Iterate grapheme clusters (keeps emoji ZWJ sequences + combining marks whole). */
|
|
38
|
+
function* graphemes(text) {
|
|
39
|
+
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
40
|
+
for (const seg of segmenter.segment(text))
|
|
41
|
+
yield seg.segment;
|
|
42
|
+
}
|
|
43
|
+
/** Iterate display units: ANSI tokens (width 0) and grapheme clusters. */
|
|
44
|
+
function* units(text) {
|
|
45
|
+
for (const token of tokenize(text)) {
|
|
46
|
+
if (token.ansi) {
|
|
47
|
+
yield { value: token.value, width: 0 };
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
for (const g of graphemes(token.value))
|
|
51
|
+
yield { value: g, width: stringWidth(g) };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Slice to the longest prefix fitting `width` cells (no ellipsis). */
|
|
56
|
+
export function sliceCells(text, width) {
|
|
57
|
+
if (width <= 0)
|
|
58
|
+
return "";
|
|
59
|
+
if (measure(text) <= width)
|
|
60
|
+
return text;
|
|
61
|
+
let out = "";
|
|
62
|
+
let used = 0;
|
|
63
|
+
for (const u of units(text)) {
|
|
64
|
+
if (u.width > 0 && used + u.width > width)
|
|
65
|
+
break;
|
|
66
|
+
out += u.value;
|
|
67
|
+
used += u.width;
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Clip to at most `width` cells, appending "…" when truncated.
|
|
73
|
+
* Never splits a grapheme cluster or ANSI sequence.
|
|
74
|
+
*/
|
|
75
|
+
export function clipCells(text, width) {
|
|
76
|
+
const safe = Math.max(1, width);
|
|
77
|
+
if (measure(text) <= safe)
|
|
78
|
+
return text;
|
|
79
|
+
return sliceCells(text, Math.max(0, safe - 1)) + "…";
|
|
80
|
+
}
|
|
81
|
+
/** padEnd by display cells (pads with spaces to exactly `width` cells). */
|
|
82
|
+
export function padEndCells(text, width) {
|
|
83
|
+
const missing = width - measure(text);
|
|
84
|
+
return missing > 0 ? text + " ".repeat(missing) : text;
|
|
85
|
+
}
|
|
86
|
+
/** padStart by display cells. */
|
|
87
|
+
export function padStartCells(text, width) {
|
|
88
|
+
const missing = width - measure(text);
|
|
89
|
+
return missing > 0 ? " ".repeat(missing) + text : text;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Word-wrap to `width` cells. Breaks at spaces when possible; hard-breaks
|
|
93
|
+
* long words at the cell boundary. Returns at least one line per input line.
|
|
94
|
+
* ANSI sequences and grapheme clusters are never split.
|
|
95
|
+
*/
|
|
96
|
+
export function wrapCells(text, width) {
|
|
97
|
+
const safe = Math.max(4, width);
|
|
98
|
+
const out = [];
|
|
99
|
+
for (const rawLine of text.split("\n")) {
|
|
100
|
+
if (measure(rawLine) <= safe) {
|
|
101
|
+
out.push(rawLine);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
let line = "";
|
|
105
|
+
let lineW = 0;
|
|
106
|
+
for (const word of rawLine.split(/(\s+)/)) {
|
|
107
|
+
if (word === "")
|
|
108
|
+
continue;
|
|
109
|
+
const wordW = measure(word);
|
|
110
|
+
if (lineW + wordW <= safe) {
|
|
111
|
+
line += word;
|
|
112
|
+
lineW += wordW;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Word fits on a fresh line → break before it.
|
|
116
|
+
if (wordW <= safe && word.trim() !== "") {
|
|
117
|
+
if (line.trim() !== "")
|
|
118
|
+
out.push(line.trimEnd());
|
|
119
|
+
line = word.trimStart();
|
|
120
|
+
lineW = measure(line);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Long word (or whitespace overflow): hard-break by cells.
|
|
124
|
+
for (const u of units(word)) {
|
|
125
|
+
if (u.width > 0 && lineW + u.width > safe) {
|
|
126
|
+
out.push(line);
|
|
127
|
+
line = "";
|
|
128
|
+
lineW = 0;
|
|
129
|
+
}
|
|
130
|
+
if (u.width > 0 && u.value.trim() === "" && line === "")
|
|
131
|
+
continue; // drop leading ws
|
|
132
|
+
line += u.value;
|
|
133
|
+
lineW += u.width;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
out.push(line.trimEnd());
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
}
|
package/dist/tui/theme.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Text } from "ink";
|
|
3
|
+
import { clipCells } from "./text-width.js";
|
|
3
4
|
import { readFileSync, existsSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
const MISSION = {
|
|
@@ -36,10 +37,14 @@ export const THEMES = {
|
|
|
36
37
|
mission: MISSION, paper: PAPER, mono: MONO,
|
|
37
38
|
light: LIGHT, "high-contrast": HIGH_CONTRAST, solarized: SOLARIZED,
|
|
38
39
|
};
|
|
40
|
+
/** NO_COLOR spec (no-color.org): mọi giá trị non-empty ≠ "0" đều tắt màu. */
|
|
41
|
+
function noColorSet(v) {
|
|
42
|
+
return v !== undefined && v !== "" && v !== "0";
|
|
43
|
+
}
|
|
39
44
|
export function defaultThemeName(env = process.env) {
|
|
40
45
|
if (env.SCODER_THEME && THEMES[env.SCODER_THEME])
|
|
41
46
|
return env.SCODER_THEME;
|
|
42
|
-
if (env.SCODER_PLAIN === "1" || env.NO_COLOR
|
|
47
|
+
if (env.SCODER_PLAIN === "1" || noColorSet(env.NO_COLOR) || noColorSet(process.env.NO_COLOR))
|
|
43
48
|
return "mono";
|
|
44
49
|
if (env.TERM === "dumb" || !env.TERM)
|
|
45
50
|
return "mono";
|
|
@@ -86,7 +91,7 @@ export function toolIcon(name) {
|
|
|
86
91
|
return "🔧";
|
|
87
92
|
}
|
|
88
93
|
export function fit(text, max) {
|
|
89
|
-
return text
|
|
94
|
+
return clipCells(text, max);
|
|
90
95
|
}
|
|
91
96
|
/** The agent's signature mark, used as an avatar on assistant output. */
|
|
92
97
|
export function AgentMark() {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
|
-
import { toolRunCommand } from "
|
|
3
|
+
import { toolRunCommand } from "../tools/run-command.js";
|
|
4
4
|
/** Detect a sensible build command from project files in workdir. */
|
|
5
5
|
export function detectBuildCommand(workdir) {
|
|
6
6
|
const pkgPath = resolve(workdir, "package.json");
|