s-app-scoder-agent 0.9.2 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/README.md +615 -638
  2. package/dist/{guardrails.js → agent/guardrails.js} +1 -1
  3. package/dist/agent/interaction.js +70 -0
  4. package/dist/agent/mcp-bridge.js +136 -0
  5. package/dist/agent/prompt-context.js +83 -0
  6. package/dist/agent/run-loop.js +318 -0
  7. package/dist/agent/session-persistence.js +61 -0
  8. package/dist/agent/tool-executor.js +158 -0
  9. package/dist/agent/types.js +88 -0
  10. package/dist/agent/verification.js +99 -0
  11. package/dist/agent.js +239 -1002
  12. package/dist/cli/args.js +230 -0
  13. package/dist/cli/init.js +79 -0
  14. package/dist/cli/main.js +211 -0
  15. package/dist/cli/mcp-command.js +185 -0
  16. package/dist/cli/repl.js +77 -0
  17. package/dist/cli/run-once.js +26 -0
  18. package/dist/{tui/commands.js → commands/definitions.js} +6 -16
  19. package/dist/commands/registry.js +260 -0
  20. package/dist/{config.js → config/config.js} +1 -1
  21. package/dist/{prompt-builder.js → context/prompt-builder.js} +12 -1
  22. package/dist/{prompt.js → context/prompt.js} +111 -9
  23. package/dist/{skills.js → context/skills.js} +41 -2
  24. package/dist/index.js +5 -798
  25. package/dist/io/console-reporter.js +24 -0
  26. package/dist/io/reporter.js +1 -0
  27. package/dist/mcp/connection.js +227 -0
  28. package/dist/mcp/env.js +8 -0
  29. package/dist/{mcp.js → mcp/manager.js} +3 -515
  30. package/dist/mcp/sse.js +28 -0
  31. package/dist/mcp/transport-http.js +140 -0
  32. package/dist/mcp/transport-stdio.js +98 -0
  33. package/dist/mcp/types.js +11 -0
  34. package/dist/media/image.js +149 -0
  35. package/dist/{server.js → server/server.js} +1 -1
  36. package/dist/{cost.js → session/cost.js} +1 -1
  37. package/dist/{session.js → session/session.js} +1 -1
  38. package/dist/{version.js → shared/version.js} +1 -1
  39. package/dist/tools/apply-patch.js +62 -0
  40. package/dist/tools/edit-file.js +29 -0
  41. package/dist/tools/file-edit-common.js +124 -0
  42. package/dist/tools/list-files.js +64 -0
  43. package/dist/tools/path-safety.js +75 -0
  44. package/dist/tools/read-file.js +64 -0
  45. package/dist/tools/registry.js +297 -0
  46. package/dist/tools/run-command.js +192 -0
  47. package/dist/tools/schemas.js +8 -0
  48. package/dist/tools/search.js +237 -0
  49. package/dist/tools/task-tool.js +48 -0
  50. package/dist/tools/types.js +1 -0
  51. package/dist/tools/write-file.js +34 -0
  52. package/dist/tui/app.js +59 -532
  53. package/dist/tui/ask-user-modal.js +2 -1
  54. package/dist/tui/at-complete.js +2 -93
  55. package/dist/tui/charset.js +41 -0
  56. package/dist/tui/chat-views.js +72 -16
  57. package/dist/tui/chrome.js +11 -4
  58. package/dist/tui/hooks/use-command-host.js +26 -0
  59. package/dist/tui/hooks/use-event-queue.js +69 -0
  60. package/dist/tui/hooks/use-keybinds.js +87 -0
  61. package/dist/tui/hooks/use-scroll.js +38 -0
  62. package/dist/tui/hooks/use-session-resume.js +47 -0
  63. package/dist/tui/hooks/use-submit.js +106 -0
  64. package/dist/tui/image.js +2 -149
  65. package/dist/tui/input.js +1 -1
  66. package/dist/tui/markdown/ast.js +5 -0
  67. package/dist/tui/markdown/diagram.js +239 -0
  68. package/dist/tui/markdown/inline-render.js +24 -0
  69. package/dist/tui/markdown/inline.js +93 -0
  70. package/dist/tui/markdown/parse.js +139 -0
  71. package/dist/tui/markdown/render.js +146 -0
  72. package/dist/tui/markdown/table.js +106 -0
  73. package/dist/tui/markdown.js +32 -379
  74. package/dist/tui/permission-modal.js +2 -1
  75. package/dist/tui/selectors.js +157 -0
  76. package/dist/tui/session-picker.js +4 -3
  77. package/dist/tui/sidebar.js +130 -15
  78. package/dist/tui/state.js +47 -118
  79. package/dist/tui/text-width.js +139 -0
  80. package/dist/tui/theme.js +7 -2
  81. package/dist/{build.js → workspace/build.js} +1 -1
  82. package/dist/workspace/file-index.js +93 -0
  83. package/package.json +5 -1
  84. package/dist/tools.js +0 -1223
  85. package/dist/tui/command-registry.js +0 -477
  86. package/dist/tui/review.js +0 -64
  87. package/dist/tui/transcript.js +0 -145
  88. /package/dist/{agents-guide.js → context/agents-guide.js} +0 -0
  89. /package/dist/{agents.js → context/agents.js} +0 -0
  90. /package/dist/{compaction.js → context/compaction.js} +0 -0
  91. /package/dist/{context.js → context/context.js} +0 -0
  92. /package/dist/{memory.js → context/memory.js} +0 -0
  93. /package/dist/{repo-map.js → context/repo-map.js} +0 -0
  94. /package/dist/{ui.js → io/terminal.js} +0 -0
  95. /package/dist/{llm.js → llm/llm.js} +0 -0
  96. /package/dist/{model-catalog.js → llm/model-catalog.js} +0 -0
  97. /package/dist/{provider-anthropic.js → llm/provider-anthropic.js} +0 -0
  98. /package/dist/{provider-registry.js → llm/provider-registry.js} +0 -0
  99. /package/dist/{retry.js → llm/retry.js} +0 -0
  100. /package/dist/{mcp-import.js → mcp/mcp-import.js} +0 -0
  101. /package/dist/{permissions.js → security/permissions.js} +0 -0
  102. /package/dist/{sandbox.js → security/sandbox.js} +0 -0
  103. /package/dist/{shell-scanner.js → security/shell-scanner.js} +0 -0
  104. /package/dist/{logger.js → session/logger.js} +0 -0
  105. /package/dist/{steering.js → session/steering.js} +0 -0
  106. /package/dist/{constants.js → shared/constants.js} +0 -0
  107. /package/dist/{json-schema.js → shared/json-schema.js} +0 -0
  108. /package/dist/{webfetch.js → tools/webfetch.js} +0 -0
  109. /package/dist/{diagnostics.js → workspace/diagnostics.js} +0 -0
  110. /package/dist/{diff.js → workspace/diff.js} +0 -0
  111. /package/dist/{snapshot.js → workspace/snapshot.js} +0 -0
  112. /package/dist/{watch.js → workspace/watch.js} +0 -0
@@ -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
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, flexShrink: 0, children: [_jsxs(Text, { dimColor: true, bold: true, children: ["\u2500 ", title] }), children] }));
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 = latestPlan(state.items);
33
- return (_jsx(Section, { title: "PLAN", children: !plan || plan.items.length === 0 ? (_jsx(Text, { dimColor: true, children: " no plan yet" })) : (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [(() => {
34
- const done = plan.items.filter((t) => t.status === "completed").length;
35
- return _jsxs(Text, { children: [" ", _jsx(Text, { bold: true, children: done }), _jsxs(Text, { dimColor: true, children: ["/", plan.items.length, " steps"] })] });
36
- })(), plan.items.slice(0, MAX_PLAN_ROWS).map((t, i) => (_jsx(PlanRow, { t: t, width: INNER }, i))), plan.items.length > MAX_PLAN_ROWS && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", plan.items.length - MAX_PLAN_ROWS, " more"] }))] })) }));
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
- function MetricsSection({ state }) {
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
- return (_jsx(Section, { title: "METRICS", children: !hasUsage && elapsedSec == null ? (_jsx(Text, { dimColor: true, children: " starting\u2026" })) : (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [gauge && (_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] })] })), hasUsage && (_jsx(SectionLine, { label: "toks", value: `${fmt(state.usage.inputTokens)} → ${fmt(state.usage.outputTokens)}` })), !hasUsage && state.streamingOutputChars > 0 && (_jsx(SectionLine, { label: "toks", value: `~${fmt(Math.round(state.streamingOutputChars / 4))} (streaming)`, color: theme.accent })), tokPerSec != null && (_jsx(SectionLine, { label: "speed", value: `${fmt(tokPerSec)} tok/s`, color: theme.accent })), liveTokPerSec != null && tokPerSec == null && (_jsx(SectionLine, { label: "speed", value: `~${fmt(liveTokPerSec)} tok/s`, color: theme.accent })), ctxDisplay != null && _jsx(SectionLine, { label: "ctx", value: ctxDisplay }), cost > 0 && _jsx(SectionLine, { label: "cost", value: `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}` }), elapsedSec != null && (_jsx(SectionLine, { label: "elapsed", value: `${Math.floor(elapsedSec / 60)}:${String(elapsedSec % 60).padStart(2, "0")}` })), state.usage.cachedTokens > 0 && (_jsx(SectionLine, { label: "cached", value: fmt(state.usage.cachedTokens) })), state.guardrailCount > 0 && (_jsx(SectionLine, { label: "guard", value: String(state.guardrailCount), color: theme.warn }))] })) }));
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
- /** Sidebar phải — mọi trạng thái sống của run, cập nhật theo AgentEvent. */
120
- export function Sidebar({ state, config }) {
121
- 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 }), _jsx(McpSection, { state: state, config: config }), _jsx(MetricsSection, { state: state }), _jsx(PhaseSection, { state: state }), _jsx(PlanSection, { state: state }), _jsx(SubagentSection, { state: state }), _jsx(BuildSection, { state: state, config: config })] }));
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 += ev.delta;
210
- if (last.text.length > MAX_TEXT_CHARS)
211
- last.text = last.text.slice(-MAX_TEXT_CHARS);
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
- last.streaming = false;
229
- if (last && last.kind === "thinking" && last.streaming)
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 += ev.delta;
241
- if (last.text.length > MAX_TEXT_CHARS)
242
- last.text = last.text.slice(-MAX_TEXT_CHARS);
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
- last.streaming = true;
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 += ev.delta;
296
- if (it.output.length > MAX_OUTPUT_CHARS)
297
- it.output = capTail(it.output);
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
- it.ok = ev.ok;
314
- if (it.startedAt != null)
315
- it.durationMs = Date.now() - it.startedAt;
316
- // Prefer the final (complete) result over accumulated live chunks.
317
- if (ev.output.length >= it.output.length)
318
- it.output = ev.output;
319
- if (ev.image)
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
- // Plan updates render as a live checkbox list in the chat pane.
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
- it.output = capTail(`${it.output}\n… ${ev.message}`.trim());
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
- for (const it of state.items) {
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
- last.streaming = false;
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 === "1" || process.env.NO_COLOR === "1")
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.length <= max ? text : text.slice(0, Math.max(0, max - 1)) + "…";
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 "./tools.js";
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");