s-app-scoder-agent 0.8.21 → 0.9.3

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.
@@ -1,14 +1,121 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { theme } from "./theme.js";
4
- const MAX_LINE = 160;
4
+ const DEFAULT_WIDTH = 100;
5
5
  const MAX_CODE_LINES = 60;
6
- function clip(line) {
7
- return line.length > MAX_LINE ? line.slice(0, MAX_LINE - 1) + "…" : line;
6
+ function clip(line, width = DEFAULT_WIDTH) {
7
+ const safeWidth = Math.max(8, width);
8
+ return line.length > safeWidth ? line.slice(0, safeWidth - 1) + "…" : line;
9
+ }
10
+ function contentWidth(width) {
11
+ return Math.max(32, Math.floor(width ?? DEFAULT_WIDTH));
12
+ }
13
+ function scalar(value) {
14
+ if (value == null)
15
+ return "";
16
+ return typeof value === "string" ? value.trim() : String(value);
17
+ }
18
+ /** Parse JSON or the intentionally small line format accepted by chart fences. */
19
+ export function parseChartSpec(source, language = "chart") {
20
+ const raw = source.trim();
21
+ if (!raw)
22
+ return null;
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ }
27
+ catch {
28
+ parsed = null;
29
+ }
30
+ const object = parsed && typeof parsed === "object" && !Array.isArray(parsed)
31
+ ? parsed
32
+ : {};
33
+ const typeRaw = scalar(object.type) || language.split(/[:\s]+/)[1] || "bar";
34
+ const type = typeRaw.toLowerCase();
35
+ const rows = Array.isArray(parsed)
36
+ ? parsed
37
+ : Array.isArray(object.data) ? object.data : [];
38
+ const data = rows.flatMap((row, index) => {
39
+ if (Array.isArray(row)) {
40
+ const value = Number(row[1]);
41
+ return Number.isFinite(value) ? [{ label: scalar(row[0]) || String(index + 1), value }] : [];
42
+ }
43
+ if (row && typeof row === "object") {
44
+ const r = row;
45
+ const value = Number(r.value ?? r.y ?? r.amount);
46
+ return Number.isFinite(value) ? [{ label: scalar(r.label ?? r.name ?? r.x) || String(index + 1), value }] : [];
47
+ }
48
+ return [];
49
+ });
50
+ // Friendly fallback: `label | value` or `label, value` per line.
51
+ if (data.length === 0) {
52
+ for (const [index, line] of raw.split("\n").entries()) {
53
+ if (!line.trim() || /^(type|title|data)\s*:/i.test(line))
54
+ continue;
55
+ const parts = line.split(/\s*[|,]\s*/);
56
+ const value = Number(parts.at(-1));
57
+ if (parts.length >= 2 && Number.isFinite(value))
58
+ data.push({ label: parts.slice(0, -1).join(" ").trim() || String(index + 1), value });
59
+ }
60
+ }
61
+ if (data.length === 0 || !["bar", "line", "pie"].includes(type))
62
+ return null;
63
+ const title = scalar(object.title);
64
+ return {
65
+ type: type,
66
+ ...(title ? { title } : {}),
67
+ data: data.slice(0, 40),
68
+ };
69
+ }
70
+ /** Parse JSON or `time | title | detail | status` timeline rows. */
71
+ export function parseTimelineSpec(source) {
72
+ const raw = source.trim();
73
+ if (!raw)
74
+ return [];
75
+ let parsed;
76
+ try {
77
+ parsed = JSON.parse(raw);
78
+ }
79
+ catch {
80
+ parsed = null;
81
+ }
82
+ const rows = Array.isArray(parsed)
83
+ ? parsed
84
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.events)
85
+ ? parsed.events
86
+ : [];
87
+ const events = rows.flatMap((row) => {
88
+ if (!row || typeof row !== "object")
89
+ return [];
90
+ const r = row;
91
+ const title = scalar(r.title ?? r.name ?? r.event);
92
+ if (!title)
93
+ return [];
94
+ const status = scalar(r.status).toLowerCase();
95
+ return [{
96
+ time: scalar(r.time ?? r.date ?? r.timestamp),
97
+ title,
98
+ detail: scalar(r.detail ?? r.description ?? r.body) || undefined,
99
+ status: ["done", "active", "pending", "error"].includes(status) ? status : undefined,
100
+ }];
101
+ });
102
+ if (events.length > 0)
103
+ return events.slice(0, 40);
104
+ return raw.split("\n").flatMap((line) => {
105
+ const trimmed = line.replace(/^[-*]\s*/, "").trim();
106
+ if (!trimmed)
107
+ return [];
108
+ const parts = trimmed.split(/\s*\|\s*/);
109
+ if (parts.length < 2)
110
+ return [];
111
+ const status = parts[3]?.toLowerCase();
112
+ return [{ time: parts[0], title: parts[1], detail: parts[2] || undefined,
113
+ status: ["done", "active", "pending", "error"].includes(status) ? status : undefined }];
114
+ }).slice(0, 40);
8
115
  }
9
116
  // ── Syntax highlighting ───────────────────────────────────────────────────────
10
117
  /** T-15: token-based syntax highlighter for TypeScript/JavaScript/JSON/shell. */
11
- function highlightCode(code) {
118
+ function highlightCode(code, width = DEFAULT_WIDTH) {
12
119
  const kw = new Set(["const", "let", "var", "function", "async", "await", "return",
13
120
  "if", "else", "for", "while", "do", "switch", "case", "break", "continue",
14
121
  "try", "catch", "finally", "throw", "class", "extends", "import", "export",
@@ -18,7 +125,9 @@ function highlightCode(code) {
18
125
  const typeKw = new Set(["string", "number", "boolean", "any", "unknown", "never",
19
126
  "null", "undefined", "void", "object", "true", "false", "Promise", "Array",
20
127
  "Record", "Partial", "Required", "Omit", "Pick", "keyof", "infer"]);
21
- return code.slice(0, MAX_CODE_LINES).map((line, i) => {
128
+ const codeWidth = Math.max(8, width - 6);
129
+ return code.slice(0, MAX_CODE_LINES).map((sourceLine, i) => {
130
+ const line = clip(sourceLine, codeWidth);
22
131
  const parts = [];
23
132
  const re = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(\b0x[\da-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?\b)|(\w+)/g;
24
133
  let last = 0;
@@ -105,6 +214,37 @@ function mermaidPreview(code) {
105
214
  result.push("└─────────────────────────────────────────────────────┘");
106
215
  return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: result.map((l, i) => (_jsx(Text, { color: theme.mermaid, children: i === 0 || i === result.length - 1 ? _jsx(Text, { bold: true, children: l }) : l }, i))) }));
107
216
  }
217
+ function chartPreview(spec, width) {
218
+ const chartWidth = Math.max(24, Math.min(72, width - 8));
219
+ const max = Math.max(...spec.data.map((p) => Math.abs(p.value)), 1);
220
+ const min = Math.min(...spec.data.map((p) => p.value), 0);
221
+ const range = Math.max(1, max - min);
222
+ const labelWidth = Math.min(14, Math.max(4, ...spec.data.map((p) => p.label.length)));
223
+ const plotWidth = Math.max(12, chartWidth - labelWidth - 10);
224
+ const title = spec.title ? ` ${spec.title} ` : ` ${spec.type} chart `;
225
+ const rows = spec.data.map((point, index) => {
226
+ const ratio = (point.value - min) / range;
227
+ const filled = Math.max(0, Math.min(plotWidth, Math.round(ratio * plotWidth)));
228
+ const bar = "█".repeat(filled) + "░".repeat(plotWidth - filled);
229
+ const marker = spec.type === "line" ? (index === 0 ? "╭" : index === spec.data.length - 1 ? "╰" : "│") : "▌";
230
+ return ` ${point.label.slice(0, labelWidth).padEnd(labelWidth)} ${marker}${bar} ${String(point.value)}`;
231
+ });
232
+ if (spec.type === "pie") {
233
+ const total = spec.data.reduce((sum, p) => sum + Math.max(0, p.value), 0) || 1;
234
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: ["\u250C\u2500", title, "─".repeat(Math.max(0, chartWidth - title.length - 2)), "\u2510"] }), spec.data.map((p, i) => {
235
+ const pct = Math.round((Math.max(0, p.value) / total) * 100);
236
+ const pieBarWidth = Math.max(1, chartWidth - labelWidth - 9);
237
+ const pieBar = "█".repeat(Math.min(pieBarWidth, Math.max(1, Math.round((pct / 100) * pieBarWidth))));
238
+ return _jsxs(Text, { color: theme.code, children: ["\u2502 ", p.label.slice(0, labelWidth).padEnd(labelWidth), " ", String(pct).padStart(3), "% ", pieBar.padEnd(pieBarWidth, " ")] }, i);
239
+ }), _jsxs(Text, { color: theme.table, bold: true, children: ["\u2514", "─".repeat(chartWidth), "\u2518"] })] }));
240
+ }
241
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: ["\u250C\u2500", title, "─".repeat(Math.max(0, chartWidth - title.length - 2)), "\u2510"] }), rows.map((row, i) => _jsxs(Text, { color: theme.code, children: ["\u2502", row] }, i)), _jsxs(Text, { color: theme.table, bold: true, children: ["\u2514", "─".repeat(chartWidth), "\u2518"] })] }));
242
+ }
243
+ function timelinePreview(events, width) {
244
+ const detailWidth = Math.max(16, width - 18);
245
+ const icon = (status) => status === "done" ? "✓" : status === "error" ? "✗" : status === "active" ? "◆" : "○";
246
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: [" timeline \u00B7 ", events.length, " event", events.length === 1 ? "" : "s"] }), events.map((event, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: event.status === "error" ? theme.error : event.status === "done" ? theme.success : theme.accent, bold: true, children: icon(event.status) }), _jsxs(Text, { dimColor: true, children: [" ", clip(event.time || "—", 16).padEnd(16), " "] }), _jsx(Text, { bold: true, children: clip(event.title, detailWidth) })] }), event.detail && _jsxs(Text, { dimColor: true, children: [" ", i === events.length - 1 ? " " : "│", " ", clip(event.detail, detailWidth)] }), i < events.length - 1 && _jsx(Text, { dimColor: true, children: " \u2502" })] }, i)))] }));
247
+ }
108
248
  /** Render inline markdown tokens as nested Ink <Text> elements. */
109
249
  function renderInline(text, keyPrefix) {
110
250
  const parts = [];
@@ -134,7 +274,8 @@ function renderInline(text, keyPrefix) {
134
274
  parts.push(text.slice(last));
135
275
  return parts;
136
276
  }
137
- export function Markdown({ text }) {
277
+ export function Markdown({ text, width }) {
278
+ const paneWidth = contentWidth(width);
138
279
  const lines = text.split("\n");
139
280
  const blocks = [];
140
281
  let i = 0;
@@ -154,8 +295,18 @@ export function Markdown({ text }) {
154
295
  blocks.push(_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: " \u25C9 mermaid diagram" }), mermaidPreview(code)] }, key++));
155
296
  continue;
156
297
  }
298
+ if ((lang === "chart" || lang.startsWith("chart:")) && code.length > 0) {
299
+ const chart = parseChartSpec(code.join("\n"), lang);
300
+ blocks.push(chart ? _jsx(Box, { children: chartPreview(chart, paneWidth) }, key++) : _jsx(Text, { dimColor: true, children: " (invalid chart data)" }, key++));
301
+ continue;
302
+ }
303
+ if ((lang === "timeline" || lang === "events") && code.length > 0) {
304
+ const events = parseTimelineSpec(code.join("\n"));
305
+ blocks.push(events.length > 0 ? _jsx(Box, { children: timelinePreview(events, paneWidth) }, key++) : _jsx(Text, { dimColor: true, children: " (empty timeline)" }, key++));
306
+ continue;
307
+ }
157
308
  // T-15: syntax-highlighted code block
158
- blocks.push(_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop: 1, children: [lang && _jsx(Text, { dimColor: true, bold: true, children: lang }), code.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty)" })) : (highlightCode(code)), code.length > MAX_CODE_LINES && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", code.length - MAX_CODE_LINES, " more lines"] }))] }, key++));
309
+ blocks.push(_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop: 1, children: [lang && _jsx(Text, { dimColor: true, bold: true, children: lang }), code.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty)" })) : (highlightCode(code, paneWidth)), code.length > MAX_CODE_LINES && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", code.length - MAX_CODE_LINES, " more lines"] }))] }, key++));
159
310
  continue;
160
311
  }
161
312
  // Headers
@@ -175,12 +326,19 @@ export function Markdown({ text }) {
175
326
  while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i]))
176
327
  rows.push(parseRow(lines[i++]));
177
328
  // Column widths (bounded — wide cells clip via clip()).
178
- const widths = header.map((h, c) => Math.min(MAX_CELL, Math.max(h.length, ...rows.map((r) => (r[c] ?? "").length))));
329
+ const available = Math.max(20, paneWidth - 4);
330
+ const naturalWidths = header.map((h, c) => Math.min(MAX_CELL, Math.max(h.length, ...rows.map((r) => (r[c] ?? "").length))));
331
+ const separators = Math.max(0, header.length - 1) * 3 + 4;
332
+ const totalNatural = naturalWidths.reduce((sum, n) => sum + n, 0);
333
+ const widths = naturalWidths.map((n) => totalNatural + separators <= available ? n : Math.max(3, Math.floor((n / Math.max(1, totalNatural)) * (available - separators))));
179
334
  const renderRow = (cells, chars, color, bold, rowKey) => {
180
- const line = chars.l + cells.map((c, ci) => (c.length > widths[ci] ? c.slice(0, widths[ci] - 1) + "…" : c).padEnd(widths[ci], " ")).join(chars.m) + chars.r;
181
- return _jsx(Text, { color: color, bold: bold, children: clip(line) }, rowKey);
335
+ const line = chars.l + widths.map((width, ci) => {
336
+ const cell = cells[ci] ?? "";
337
+ return (cell.length > width ? cell.slice(0, Math.max(1, width - 1)) + "…" : cell).padEnd(width, " ");
338
+ }).join(chars.m) + chars.r;
339
+ return _jsx(Text, { color: color, bold: bold, children: clip(line, paneWidth) }, rowKey);
182
340
  };
183
- blocks.push(_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [renderRow(header, { l: "┌ ", m: " ┬ ", r: " ┐" }, undefined, true, `th${key}`), renderRow(header.map(() => ""), { l: "├", m: "┼", r: "┤" }, "gray", false, `ts${key}`), rows.map((r, ri) => renderRow(r, { l: "│ ", m: " │ ", r: " │" }, undefined, false, `tr${key}-${ri}`))] }, key++));
341
+ blocks.push(_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [renderRow(header, { l: "┌ ", m: " ┬ ", r: " ┐" }, theme.table, true, `th${key}`), renderRow(header.map(() => ""), { l: "├", m: "┼", r: "┤" }, "gray", false, `ts${key}`), rows.map((r, ri) => renderRow(r, { l: "│ ", m: " │ ", r: " │" }, undefined, false, `tr${key}-${ri}`))] }, key++));
184
342
  continue;
185
343
  }
186
344
  // Bullets + task lists: - [ ] pending · - [x] completed
@@ -222,7 +380,7 @@ export function Markdown({ text }) {
222
380
  i++;
223
381
  continue;
224
382
  }
225
- blocks.push(_jsx(Text, { children: renderInline(clip(line), `t${key}`) }, key++));
383
+ blocks.push(_jsx(Text, { children: renderInline(clip(line, paneWidth), `t${key}`) }, key++));
226
384
  i++;
227
385
  }
228
386
  return _jsx(Box, { flexDirection: "column", children: blocks });
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { contextWindowFor } from "../model-catalog.js";
4
4
  import { resolveModelPricing } from "../constants.js";
@@ -44,20 +44,30 @@ function MetricsSection({ state }) {
44
44
  const gauge = fuelGauge(state.step, state.maxSteps);
45
45
  const cost = (state.usage.inputTokens / 1_000_000) * resolveModelPricing(state.model).input +
46
46
  (state.usage.outputTokens / 1_000_000) * resolveModelPricing(state.model).output;
47
- // tok/s: output tokens / giây thô (chưa floor — 0.6s run đầu vẫn ra số).
48
- const tokPerSec = hasUsage && elapsedMs != null && elapsedMs > 500
49
- ? Math.round(state.usage.outputTokens / (elapsedMs / 1000))
50
- : null;
51
- // Live estimate based on accumulated streaming chars (chars / ~4 ≈ tokens).
52
- const liveTokPerSec = state.streamingOutputChars > 0 && elapsedMs != null && elapsedMs > 250
53
- ? Math.round((state.streamingOutputChars / 4) / (elapsedMs / 1000))
47
+ // tok/s of the most recently completed LLM call (output tokens / call duration).
48
+ // This is NOT averaged over the whole run (which includes tool/build time and
49
+ // dilutes the number); it reflects the model's actual generation speed.
50
+ const tokPerSec = state.lastLlmTokPerSec;
51
+ // Live estimate during an active stream: chars so far / elapsed since the
52
+ // first delta of this stream.
53
+ const streamElapsedMs = state.streamStartedAt != null ? Date.now() - state.streamStartedAt : null;
54
+ const liveTokPerSec = state.streamingOutputChars > 0 && streamElapsedMs != null && streamElapsedMs > 250
55
+ ? Math.round((state.streamingOutputChars / 4) / (streamElapsedMs / 1000))
54
56
  : null;
55
57
  const win = contextWindowFor(state.provider, state.model);
56
- const ctxPct = hasUsage && win > 0 ? Math.round((state.usage.inputTokens / win) * 100) : null;
57
- // ctx giờ hiển thị đủ số: `input/ctxWin (pct%)`.
58
+ // `ctx` should reflect the CURRENT context window pressure (estimateTokens of
59
+ // the live message history), NOT the cumulative input tokens spent across the
60
+ // whole session (state.usage.inputTokens grows forever and can exceed 100%).
61
+ // state.context is emitted by the agent after every LLM turn; fall back to the
62
+ // cumulative counter only before the first turn has reported.
63
+ const ctxTokens = state.context?.tokens ?? (hasUsage ? state.usage.inputTokens : null);
64
+ const ctxWindow = state.context?.window ?? win;
65
+ const ctxPct = state.context?.pct ??
66
+ (ctxTokens != null && ctxWindow > 0 ? Math.min(100, Math.round((ctxTokens / ctxWindow) * 100)) : null);
67
+ // ctx giờ hiển thị đủ số: `current/ctxWin (pct%)`.
58
68
  const ctxFmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n));
59
- const ctxDisplay = ctxPct != null
60
- ? `${ctxFmt(state.usage.inputTokens)}/${ctxFmt(win)} (${ctxPct}%)`
69
+ const ctxDisplay = ctxTokens != null && ctxPct != null
70
+ ? `${ctxFmt(ctxTokens)}/${ctxFmt(ctxWindow)} (${ctxPct}%)`
61
71
  : null;
62
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 }))] })) }));
63
73
  }
@@ -92,8 +102,21 @@ function McpSection({ state, config }) {
92
102
  return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: color, children: [" ", mark, " "] }), _jsx(Text, { color: color, children: fit(n, budget) }), _jsxs(Text, { dimColor: true, children: [" ", st] })] }, n));
93
103
  }) })) }));
94
104
  }
105
+ function SubagentSection({ state }) {
106
+ if (state.subagents.length === 0)
107
+ return _jsx(_Fragment, {});
108
+ return (_jsx(Section, { title: "SUBAGENTS", children: state.subagents.map((s) => {
109
+ const color = s.status === "running" ? theme.accent :
110
+ s.status === "ok" ? theme.success :
111
+ theme.error;
112
+ const mark = s.status === "running" ? "◐" : s.status === "ok" ? "✔" : "✘";
113
+ const summary = s.summary ? ` — ${s.summary.slice(0, 50)}${s.summary.length > 50 ? "…" : ""}` : "";
114
+ const steps = s.steps != null ? ` (${s.steps} steps)` : "";
115
+ return (_jsxs(Text, { wrap: "truncate", dimColor: true, children: [_jsxs(Text, { color: color, children: [" ", mark, " "] }), _jsx(Text, { color: color, children: s.name }), _jsxs(Text, { dimColor: true, children: [summary, steps] })] }, s.id));
116
+ }) }));
117
+ }
95
118
  // ── Root ─────────────────────────────────────────────────────────────────────
96
119
  /** Sidebar phải — mọi trạng thái sống của run, cập nhật theo AgentEvent. */
97
120
  export function Sidebar({ state, config }) {
98
- 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(BuildSection, { state: state, config: 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 })] }));
99
122
  }
package/dist/tui/state.js CHANGED
@@ -20,6 +20,14 @@ export function initialState() {
20
20
  phases: [],
21
21
  mcpServers: {},
22
22
  streamingOutputChars: 0,
23
+ streamStartedAt: null,
24
+ llmCallStartedAt: null,
25
+ lastLlmTokPerSec: null,
26
+ agent: "build",
27
+ context: null,
28
+ queue: { pending: 0, steering: 0 },
29
+ verification: null,
30
+ subagents: [],
23
31
  };
24
32
  }
25
33
  const MAX_ITEMS = 400;
@@ -41,6 +49,8 @@ export function estimateItemRows(item) {
41
49
  return 1 + Math.ceil((item.text?.length ?? 0) / 80) + (item.text ? item.text.split("\n").length : 0);
42
50
  case "tool":
43
51
  return 1 + 2; // title + preview 2 lines
52
+ case "compaction":
53
+ return 1;
44
54
  case "diff":
45
55
  return 3 + Math.min(24, (item.text?.split("\n").length ?? 0)); // header + ≤24 lines + border
46
56
  case "todo":
@@ -111,12 +121,18 @@ function capTail(text, max = MAX_OUTPUT_CHARS) {
111
121
  export function toolPhase(name) {
112
122
  if (name === "write_todos")
113
123
  return "plan";
114
- if (name === "list_files" || name === "read_file" || name === "web_fetch" || name === "load_skill")
124
+ if (name === "list_files" || name === "read_file" || name === "grep" || name === "glob" || name === "web_fetch" || name === "load_skill" || name === "repo_map")
115
125
  return "explore";
116
- if (name === "write_file" || name === "edit_file")
126
+ if (name === "write_file" || name === "edit_file" || name === "apply_patch")
117
127
  return "edit";
118
128
  if (name === "run_command")
119
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";
120
136
  return null;
121
137
  }
122
138
  /** Guardrail event: đếm + giữ decision gần nhất (ngắn, tự giải thích cho header). */
@@ -156,6 +172,11 @@ export function applyEvent(state, ev) {
156
172
  state.buildCmd = ev.buildCmd;
157
173
  state.buildLamp = "none";
158
174
  state.permission = ev.permission;
175
+ state.agent = ev.agent ?? "build";
176
+ state.context = null;
177
+ state.queue = { pending: 0, steering: 0 };
178
+ state.verification = null;
179
+ state.subagents = [];
159
180
  state.startedAt = Date.now();
160
181
  state.endedAt = null;
161
182
  state.status = "running";
@@ -167,6 +188,9 @@ export function applyEvent(state, ev) {
167
188
  state.lastGuardrail = null;
168
189
  state.phases = [];
169
190
  state.streamingOutputChars = 0;
191
+ state.streamStartedAt = null;
192
+ state.llmCallStartedAt = null;
193
+ state.lastLlmTokPerSec = null;
170
194
  // The TUI pushes the user's message itself when submitting; avoid a
171
195
  // duplicate when run_start carries the same task.
172
196
  const last = lastItem(state);
@@ -194,6 +218,10 @@ export function applyEvent(state, ev) {
194
218
  case "step": {
195
219
  state.step = ev.step;
196
220
  state.maxSteps = ev.maxSteps;
221
+ // New LLM turn: reset per-stream tracking.
222
+ state.streamingOutputChars = 0;
223
+ state.streamStartedAt = null;
224
+ state.llmCallStartedAt = Date.now();
197
225
  // A new step starts: close any still-"streaming" assistant message.
198
226
  const last = lastItem(state);
199
227
  if (last && last.kind === "assistant" && last.streaming)
@@ -203,6 +231,9 @@ export function applyEvent(state, ev) {
203
231
  break;
204
232
  }
205
233
  case "llm_delta": {
234
+ if (state.streamStartedAt == null) {
235
+ state.streamStartedAt = state.llmCallStartedAt ?? Date.now();
236
+ }
206
237
  state.streamingOutputChars += ev.delta.length;
207
238
  const last = lastItem(state);
208
239
  if (last && last.kind === "assistant" && last.streaming) {
@@ -226,6 +257,17 @@ export function applyEvent(state, ev) {
226
257
  state.usage.inputTokens += ev.usage.input_tokens ?? 0;
227
258
  state.usage.outputTokens += ev.usage.output_tokens ?? 0;
228
259
  state.usage.cachedTokens += ev.usage.cached_tokens ?? 0;
260
+ // Per-call speed from the actual call duration, not the whole run.
261
+ const start = state.streamStartedAt ?? state.llmCallStartedAt;
262
+ if (start != null) {
263
+ const elapsedMs = Math.max(1, Date.now() - start);
264
+ const out = ev.usage.output_tokens ?? 0;
265
+ if (out > 0) {
266
+ state.lastLlmTokPerSec = Math.round(out / (elapsedMs / 1000));
267
+ }
268
+ }
269
+ state.streamingOutputChars = 0;
270
+ state.streamStartedAt = null;
229
271
  break;
230
272
  }
231
273
  case "tool_call": {
@@ -320,10 +362,67 @@ export function applyEvent(state, ev) {
320
362
  appendItem(state, { kind: "notice", text: ev.line, level: ev.line.includes("unavailable") ? "warn" : "info" });
321
363
  break;
322
364
  }
365
+ case "steer_queued": {
366
+ // Pending depth tracked via the absolute `queue` event; nothing else.
367
+ state.queue = { ...state.queue, pending: state.queue.pending + 1, steering: state.queue.steering + (ev.mode === "steer" ? 1 : 0) };
368
+ break;
369
+ }
370
+ case "steer_delivered": {
371
+ state.queue = { pending: 0, steering: 0 };
372
+ appendItem(state, { kind: "notice", text: `steer: ${ev.count} message(s) delivered`, level: "info" });
373
+ break;
374
+ }
375
+ case "queue": {
376
+ state.queue = { pending: ev.pending, steering: ev.steering };
377
+ break;
378
+ }
379
+ case "compaction": {
380
+ appendItem(state, { kind: "compaction", tokensBefore: ev.tokensBefore, tokensAfter: ev.tokensAfter, manual: ev.manual });
381
+ break;
382
+ }
383
+ case "context": {
384
+ state.context = { tokens: ev.tokens, window: ev.window, pct: ev.pct };
385
+ break;
386
+ }
387
+ case "agent_start": {
388
+ // Track in the tree list; rendering lands in the sidebar (Phase 7).
389
+ const rest = state.subagents.filter((s) => s.id !== ev.id);
390
+ rest.push({ id: ev.id, name: ev.name, description: ev.description, status: "running" });
391
+ state.subagents = rest.slice(-20);
392
+ break;
393
+ }
394
+ case "agent_end": {
395
+ state.subagents = state.subagents.map((s) => s.id === ev.id ? { ...s, status: ev.ok ? "ok" : "failed", steps: ev.steps, summary: ev.summary } : s);
396
+ break;
397
+ }
398
+ case "tool_progress": {
399
+ // Attach latest progress message to the running tool card (if any).
400
+ for (let i = state.items.length - 1; i >= 0; i--) {
401
+ const it = state.items[i];
402
+ if (it.kind === "tool" && it.ok === null && (it.callId === ev.id || it.name === ev.name)) {
403
+ if (ev.message)
404
+ it.output = capTail(`${it.output}\n… ${ev.message}`.trim());
405
+ break;
406
+ }
407
+ if (it.kind === "tool")
408
+ break;
409
+ }
410
+ break;
411
+ }
412
+ case "question_request": {
413
+ // Surfaced via the dedicated ask_user modal (Phase 3/7); queue a notice
414
+ // so the transcript isn't silent while waiting for user input.
415
+ appendItem(state, { kind: "notice", text: `❓ ${ev.question}`, level: "info" });
416
+ break;
417
+ }
323
418
  case "finish": {
324
419
  state.status = ev.success ? "success" : /interrupted|aborted/i.test(ev.summary) ? "aborted" : "failed";
325
420
  state.summary = ev.summary;
326
421
  state.endedAt = Date.now();
422
+ if (ev.usage)
423
+ state.usage = { ...ev.usage };
424
+ if (ev.verification)
425
+ state.verification = { ...ev.verification };
327
426
  // Close ALL dangling streaming assistant messages — not just the last
328
427
  // item: a notice/tool item appended after the assistant text would
329
428
  // otherwise leave it stuck in raw streaming mode (invisible text).
package/dist/tui/theme.js CHANGED
@@ -37,6 +37,10 @@ export const THEMES = {
37
37
  light: LIGHT, "high-contrast": HIGH_CONTRAST, solarized: SOLARIZED,
38
38
  };
39
39
  export function defaultThemeName(env = process.env) {
40
+ if (env.SCODER_THEME && THEMES[env.SCODER_THEME])
41
+ return env.SCODER_THEME;
42
+ if (env.SCODER_PLAIN === "1" || env.NO_COLOR === "1" || process.env.NO_COLOR === "1")
43
+ return "mono";
40
44
  if (env.TERM === "dumb" || !env.TERM)
41
45
  return "mono";
42
46
  if (env.COLOR_COUNT && Number(env.COLOR_COUNT) < 16)
@@ -0,0 +1,145 @@
1
+ // ── Export ────────────────────────────────────────────────────────────────────
2
+ /** Render a ChatItem to its plain-text representation for export. */
3
+ export function itemToText(item) {
4
+ switch (item.kind) {
5
+ case "user": return `## you\n\n${item.text}`;
6
+ case "assistant": return `## agent\n\n${item.text}`;
7
+ case "thinking": return `## thinking\n\n${item.text}`;
8
+ case "tool": {
9
+ const args = Object.entries(item.args).map(([k, v]) => ` ${k}=${JSON.stringify(v)}`).join("\n");
10
+ const elapsed = item.durationMs != null ? ` (${item.durationMs}ms)` : "";
11
+ const status = item.ok === null ? "running" : item.ok ? "ok" : "FAILED";
12
+ return `## tool: ${item.name}${elapsed}\n\n${status}${args ? "\n" + args : ""}\n\n${item.output}`;
13
+ }
14
+ case "diff":
15
+ return `## diff: ${item.path} (+${item.added}/-${item.removed})\n\n${item.text}`;
16
+ case "build":
17
+ return `## build: ${item.command}\n\n${item.success ? "✓ passed" : "✗ FAILED"}`;
18
+ case "todo": {
19
+ const rows = item.items.map((t) => `- [${t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " "}] ${t.content}`);
20
+ return `## plan\n\n${rows.join("\n")}`;
21
+ }
22
+ case "compaction":
23
+ return `## compacted (${item.tokensBefore} → ${item.tokensAfter} tokens)${item.manual ? " [manual]" : ""}`;
24
+ case "notice":
25
+ return `${item.level === "error" ? "✗" : item.level === "warn" ? "⚠" : ""} ${item.text}`;
26
+ }
27
+ }
28
+ /** Format a chat item as Markdown (GitHub-flavored). */
29
+ export function itemToMarkdown(item) {
30
+ const ts = item.kind === "tool" ? `[${item.step.toString().padStart(2, "0")}]` : "";
31
+ switch (item.kind) {
32
+ case "user":
33
+ return `## you\n\n> ${item.text}`;
34
+ case "assistant":
35
+ return `## agent\n\n${item.text}`;
36
+ case "thinking":
37
+ return `> ⊙ ${item.text.split("\n").slice(-4).join(" | ")}`;
38
+ case "tool": {
39
+ const elapsed = item.durationMs != null ? ` — ${(item.durationMs / 1000).toFixed(1)}s` : "";
40
+ const ok = item.ok === null ? "⏳ running" : item.ok ? "✅" : "❌";
41
+ const args = Object.keys(item.args).length > 0
42
+ ? `\`${item.name}(${Object.entries(item.args).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(", ")})\``
43
+ : `\`${item.name}\``;
44
+ return `${ts} ${ok} **${args}**${elapsed}\n\n\`\`\`\n${item.output}\n\`\`\``;
45
+ }
46
+ case "diff":
47
+ return `${ts} ✎ \`${item.path}\` \`+${item.added}/-${item.removed}\`\n\`\`\`diff\n${item.text}\n\`\`\``;
48
+ case "build":
49
+ return `${item.success ? "✅" : "❌"} **build**: \`${item.command}\``;
50
+ case "todo": {
51
+ const rows = item.items.map((t) => `- [${t.status === "completed" ? "x" : t.status === "in_progress" ? ">" : " "}] ${t.content}`);
52
+ return `## plan\n\n${rows.join("\n")}`;
53
+ }
54
+ case "compaction":
55
+ return `\n---\n*compacted: ${item.tokensBefore} → ${item.tokensAfter} tokens*${item.manual ? " (manual)" : ""}\n---\n`;
56
+ case "notice":
57
+ return `${item.level === "error" ? "> ❌" : item.level === "warn" ? "> ⚠" : ">"} ${item.text}`;
58
+ }
59
+ }
60
+ /** Export transcript to the requested format. */
61
+ export function exportTranscript(items, format = "md") {
62
+ if (format === "json") {
63
+ return JSON.stringify(items, null, 2);
64
+ }
65
+ return items.map(itemToMarkdown).join("\n\n");
66
+ }
67
+ /** Case-insensitive search over transcript items.
68
+ * Returns all items whose text representation matches `query`.
69
+ * Searches: assistant/thinking text, tool name+output, diff path+text, notice text.
70
+ */
71
+ export function findInTranscript(items, query) {
72
+ if (!query.trim())
73
+ return [];
74
+ const lc = query.toLowerCase();
75
+ const results = [];
76
+ for (let i = 0; i < items.length; i++) {
77
+ const item = items[i];
78
+ const matched = (text) => text.toLowerCase().includes(lc);
79
+ let hit = false;
80
+ let snippet = "";
81
+ switch (item.kind) {
82
+ case "assistant":
83
+ case "thinking":
84
+ if (matched(item.text)) {
85
+ hit = true;
86
+ snippet = snippetOf(item.text, query);
87
+ }
88
+ break;
89
+ case "tool":
90
+ if (matched(item.name) || matched(item.output) || matched(JSON.stringify(item.args))) {
91
+ hit = true;
92
+ snippet = snippetOf(`${item.name}: ${item.output}`, query);
93
+ }
94
+ break;
95
+ case "diff":
96
+ if (matched(item.path) || matched(item.text)) {
97
+ hit = true;
98
+ snippet = snippetOf(`${item.path} · ${item.text}`, query);
99
+ }
100
+ break;
101
+ case "user":
102
+ if (matched(item.text)) {
103
+ hit = true;
104
+ snippet = snippetOf(item.text, query);
105
+ }
106
+ break;
107
+ case "notice":
108
+ if (matched(item.text)) {
109
+ hit = true;
110
+ snippet = snippetOf(item.text, query);
111
+ }
112
+ break;
113
+ case "build":
114
+ if (matched(item.command)) {
115
+ hit = true;
116
+ snippet = snippetOf(item.command, query);
117
+ }
118
+ break;
119
+ default:
120
+ break;
121
+ }
122
+ if (hit)
123
+ results.push({ index: i, kind: item.kind, snippet });
124
+ }
125
+ return results;
126
+ }
127
+ /** Return a ~120-char snippet around the first occurrence of `query` in `text`. */
128
+ function snippetOf(text, query) {
129
+ const pos = text.toLowerCase().indexOf(query.toLowerCase());
130
+ if (pos === -1)
131
+ return text.slice(0, 120);
132
+ const start = Math.max(0, pos - 40);
133
+ const end = Math.min(text.length, pos + query.length + 80);
134
+ return (start > 0 ? "…" : "") + text.slice(start, end) + (end < text.length ? "…" : "");
135
+ }
136
+ // ── Copy ─────────────────────────────────────────────────────────────────────
137
+ /** Return the copyable text for a chat item (for clipboard). */
138
+ export function copyTextForItem(item) {
139
+ switch (item.kind) {
140
+ case "diff": return item.text;
141
+ case "tool": return item.output;
142
+ case "assistant": return item.text;
143
+ default: return itemToText(item);
144
+ }
145
+ }