s-app-scoder-agent 0.8.21 → 0.9.2
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 +31 -5
- package/dist/agent.js +662 -65
- package/dist/agents-guide.js +201 -0
- package/dist/agents.js +76 -3
- package/dist/compaction.js +12 -4
- package/dist/config.js +16 -2
- package/dist/context.js +15 -3
- package/dist/diagnostics.js +188 -0
- package/dist/index.js +277 -6
- package/dist/mcp-import.js +204 -0
- package/dist/mcp.js +553 -54
- package/dist/memory.js +140 -0
- package/dist/permissions.js +18 -3
- package/dist/prompt.js +204 -84
- package/dist/repo-map.js +177 -0
- package/dist/sandbox.js +6 -5
- package/dist/session.js +29 -8
- package/dist/skills.js +211 -62
- package/dist/snapshot.js +5 -0
- package/dist/steering.js +4 -0
- package/dist/tools.js +374 -57
- package/dist/tui/app.js +79 -28
- package/dist/tui/ask-user-modal.js +25 -0
- package/dist/tui/chat-views.js +15 -8
- package/dist/tui/command-registry.js +284 -9
- package/dist/tui/commands.js +224 -37
- package/dist/tui/markdown.js +170 -12
- package/dist/tui/sidebar.js +36 -13
- package/dist/tui/state.js +101 -2
- package/dist/tui/theme.js +4 -0
- package/dist/tui/transcript.js +145 -0
- package/dist/watch.js +8 -0
- package/dist/webfetch.js +5 -2
- package/package.json +1 -1
package/dist/tui/markdown.js
CHANGED
|
@@ -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
|
|
4
|
+
const DEFAULT_WIDTH = 100;
|
|
5
5
|
const MAX_CODE_LINES = 60;
|
|
6
|
-
function clip(line) {
|
|
7
|
-
|
|
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
|
-
|
|
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
|
|
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 +
|
|
181
|
-
|
|
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: " ┐" },
|
|
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 });
|
package/dist/tui/sidebar.js
CHANGED
|
@@ -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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// Live estimate
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
//
|
|
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(
|
|
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
|
+
}
|