s-app-scoder-agent 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.scoderrc.example.json +97 -0
- package/LICENSE +21 -21
- package/README.md +599 -468
- package/dist/agent.js +825 -175
- package/dist/agents.js +70 -0
- package/dist/compaction.js +114 -0
- package/dist/config.js +178 -12
- package/dist/context.js +119 -14
- package/dist/cost.js +56 -6
- package/dist/guardrails.js +409 -0
- package/dist/index.js +121 -76
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +123 -9
- package/dist/mcp.js +397 -70
- package/dist/model-catalog.js +175 -0
- package/dist/permissions.js +328 -35
- package/dist/prompt-builder.js +47 -0
- package/dist/prompt.js +97 -0
- package/dist/provider-anthropic.js +113 -35
- package/dist/provider-registry.js +116 -0
- package/dist/retry.js +3 -2
- package/dist/sandbox.js +96 -0
- package/dist/server.js +28 -1
- package/dist/session.js +282 -11
- package/dist/shell-scanner.js +112 -0
- package/dist/skills.js +122 -0
- package/dist/snapshot.js +170 -0
- package/dist/steering.js +35 -0
- package/dist/tools.js +513 -19
- package/dist/tui/app.js +498 -198
- package/dist/tui/at-complete.js +93 -0
- package/dist/tui/chat-views.js +145 -0
- package/dist/tui/chrome.js +96 -0
- package/dist/tui/command-registry.js +189 -0
- package/dist/tui/commands.js +200 -2
- package/dist/tui/image.js +149 -0
- package/dist/tui/input.js +214 -0
- package/dist/tui/markdown.js +229 -0
- package/dist/tui/mouse.js +20 -0
- package/dist/tui/permission-modal.js +34 -0
- package/dist/tui/review.js +64 -0
- package/dist/tui/session-picker.js +68 -0
- package/dist/tui/sidebar.js +101 -0
- package/dist/tui/state.js +182 -6
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/watch.js +52 -0
- package/package.json +16 -8
- package/dist/tui-direct.mjs +0 -4
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import { theme } from "./theme.js";
|
|
4
|
+
const MAX_LINE = 160;
|
|
5
|
+
const MAX_CODE_LINES = 60;
|
|
6
|
+
function clip(line) {
|
|
7
|
+
return line.length > MAX_LINE ? line.slice(0, MAX_LINE - 1) + "…" : line;
|
|
8
|
+
}
|
|
9
|
+
// ── Syntax highlighting ───────────────────────────────────────────────────────
|
|
10
|
+
/** T-15: token-based syntax highlighter for TypeScript/JavaScript/JSON/shell. */
|
|
11
|
+
function highlightCode(code) {
|
|
12
|
+
const kw = new Set(["const", "let", "var", "function", "async", "await", "return",
|
|
13
|
+
"if", "else", "for", "while", "do", "switch", "case", "break", "continue",
|
|
14
|
+
"try", "catch", "finally", "throw", "class", "extends", "import", "export",
|
|
15
|
+
"from", "default", "new", "typeof", "instanceof", "void", "in", "of",
|
|
16
|
+
"interface", "type", "enum", "implements", "private", "public", "protected",
|
|
17
|
+
"readonly", "abstract", "override", "declare", "namespace", "module"]);
|
|
18
|
+
const typeKw = new Set(["string", "number", "boolean", "any", "unknown", "never",
|
|
19
|
+
"null", "undefined", "void", "object", "true", "false", "Promise", "Array",
|
|
20
|
+
"Record", "Partial", "Required", "Omit", "Pick", "keyof", "infer"]);
|
|
21
|
+
return code.slice(0, MAX_CODE_LINES).map((line, i) => {
|
|
22
|
+
const parts = [];
|
|
23
|
+
const re = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(\b0x[\da-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?\b)|(\w+)/g;
|
|
24
|
+
let last = 0;
|
|
25
|
+
let k = 0;
|
|
26
|
+
let m;
|
|
27
|
+
while ((m = re.exec(line)) !== null) {
|
|
28
|
+
if (m.index > last)
|
|
29
|
+
parts.push(line.slice(last, m.index));
|
|
30
|
+
const tok = m[0];
|
|
31
|
+
if (m[1])
|
|
32
|
+
parts.push(_jsx(Text, { color: theme.code, children: tok }, `c${i}-${k++}`));
|
|
33
|
+
else if (m[2])
|
|
34
|
+
parts.push(_jsx(Text, { color: theme.thinking, children: tok }, `c${i}-${k++}`));
|
|
35
|
+
else if (m[3])
|
|
36
|
+
parts.push(_jsx(Text, { color: "magenta", children: tok }, `c${i}-${k++}`));
|
|
37
|
+
else if (kw.has(tok))
|
|
38
|
+
parts.push(_jsx(Text, { bold: true, color: "cyan", children: tok }, `c${i}-${k++}`));
|
|
39
|
+
else if (typeKw.has(tok))
|
|
40
|
+
parts.push(_jsx(Text, { color: "yellow", children: tok }, `c${i}-${k++}`));
|
|
41
|
+
else
|
|
42
|
+
parts.push(line.slice(m.index, m.index + tok.length));
|
|
43
|
+
last = m.index + tok.length;
|
|
44
|
+
}
|
|
45
|
+
if (last < line.length)
|
|
46
|
+
parts.push(line.slice(last));
|
|
47
|
+
return (_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(i + 1).padStart(3, " "), " \u2502 "] }), parts.length > 0 ? parts : line] }, i));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// ── Mermaid ASCII preview ──────────────────────────────────────────────────────
|
|
51
|
+
/** T-15: parse mermaid source → compact ASCII art preview. */
|
|
52
|
+
function mermaidPreview(code) {
|
|
53
|
+
const text = code.join("\n").trim();
|
|
54
|
+
const isFlowchart = /^(graph|flowchart|stateDiagram|erDiagram|gitGraph)/m.test(text);
|
|
55
|
+
const isSequence = /^sequenceDiagram/m.test(text);
|
|
56
|
+
const result = [];
|
|
57
|
+
result.push("┌─ ◉ diagram preview ──────────────────────────────────┐");
|
|
58
|
+
if (isFlowchart) {
|
|
59
|
+
const nodeMap = new Map();
|
|
60
|
+
const edges = [];
|
|
61
|
+
const nodeRe = /([A-Z][A-Za-z0-9_]*)(\[[^\]]*\]|\([^)]*\)|\{[^}]*\}|\(\([^)]*\)\))/g;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = nodeRe.exec(text)) !== null) {
|
|
64
|
+
nodeMap.set(m[1], m[2] ?? m[3] ?? m[4] ?? m[5] ?? m[1]);
|
|
65
|
+
}
|
|
66
|
+
const edgeRe = /([A-Z][A-Za-z0-9_]*)\s*(-->?|-.->|==>|<-->|===>|---)\|?([^|]+)\|?\s*([A-Z][A-Za-z0-9_]*)/g;
|
|
67
|
+
while ((m = edgeRe.exec(text)) !== null) {
|
|
68
|
+
edges.push([m[1], "", m[4]]);
|
|
69
|
+
}
|
|
70
|
+
const nodes = [...nodeMap.entries()].slice(0, 6);
|
|
71
|
+
for (const [id, label] of nodes) {
|
|
72
|
+
result.push(`│ [${id}] ${label.slice(0, 24).padEnd(24)} │`);
|
|
73
|
+
}
|
|
74
|
+
if (nodes.length > 0)
|
|
75
|
+
result.push("│ ───────────────────────────────── │");
|
|
76
|
+
for (const [from, , to] of edges.slice(0, 4)) {
|
|
77
|
+
const fl = nodeMap.get(from) ?? from;
|
|
78
|
+
const tl = nodeMap.get(to) ?? to;
|
|
79
|
+
result.push(`│ ${fl.slice(0, 8)} → ${tl.slice(0, 8).padEnd(8)} │`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (isSequence) {
|
|
83
|
+
const parts = [];
|
|
84
|
+
const partRe = /(?:participant|actor)\s+(\w+)/gi;
|
|
85
|
+
let m;
|
|
86
|
+
while ((m = partRe.exec(text)) !== null)
|
|
87
|
+
parts.push(m[1]);
|
|
88
|
+
if (parts.length > 0)
|
|
89
|
+
result.push(`│ participants: ${parts.slice(0, 5).join(", ")} │`);
|
|
90
|
+
result.push("│ ───────────────────────────────── │");
|
|
91
|
+
const msgRe = /([\w]+)\s+(?:->|-->|-.>|=>)\s+([\w]+)(?:\s*:\s*(.*))?/gi;
|
|
92
|
+
while ((m = msgRe.exec(text)) !== null) {
|
|
93
|
+
if (result.length >= 10) {
|
|
94
|
+
result.push("│ … (more messages) │");
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
const from = (m[1] ?? "").slice(0, 8), to = (m[2] ?? "").slice(0, 8), msg = (m[3] ?? "").slice(0, 20);
|
|
98
|
+
result.push(`│ ${from} → ${to.padEnd(8)} : ${msg} │`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
result.push("│ ◈ mermaid diagram │");
|
|
103
|
+
result.push("│ (run: npx @mermaid-js/mermaid-cli -i <file>) │");
|
|
104
|
+
}
|
|
105
|
+
result.push("└─────────────────────────────────────────────────────┘");
|
|
106
|
+
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
|
+
}
|
|
108
|
+
/** Render inline markdown tokens as nested Ink <Text> elements. */
|
|
109
|
+
function renderInline(text, keyPrefix) {
|
|
110
|
+
const parts = [];
|
|
111
|
+
const re = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(\[[^\]\n]+\]\([^)\n]+\))/g;
|
|
112
|
+
let last = 0;
|
|
113
|
+
let k = 0;
|
|
114
|
+
let m;
|
|
115
|
+
while ((m = re.exec(text)) !== null) {
|
|
116
|
+
if (m.index > last)
|
|
117
|
+
parts.push(text.slice(last, m.index));
|
|
118
|
+
const tok = m[0];
|
|
119
|
+
if (tok.startsWith("`")) {
|
|
120
|
+
parts.push(_jsx(Text, { color: theme.code, children: tok.slice(1, -1) }, `${keyPrefix}-c${k++}`));
|
|
121
|
+
}
|
|
122
|
+
else if (tok.startsWith("**")) {
|
|
123
|
+
parts.push(_jsx(Text, { bold: true, children: tok.slice(2, -2) }, `${keyPrefix}-b${k++}`));
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const lm = /\[([^\]]+)\]\(([^)]+)\)/.exec(tok);
|
|
127
|
+
if (lm) {
|
|
128
|
+
parts.push(_jsxs(Text, { children: [_jsx(Text, { color: "blue", children: lm[1] }), _jsxs(Text, { dimColor: true, children: [" (", lm[2], ")"] })] }, `${keyPrefix}-l${k++}`));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
last = m.index + tok.length;
|
|
132
|
+
}
|
|
133
|
+
if (last < text.length)
|
|
134
|
+
parts.push(text.slice(last));
|
|
135
|
+
return parts;
|
|
136
|
+
}
|
|
137
|
+
export function Markdown({ text }) {
|
|
138
|
+
const lines = text.split("\n");
|
|
139
|
+
const blocks = [];
|
|
140
|
+
let i = 0;
|
|
141
|
+
let key = 0;
|
|
142
|
+
while (i < lines.length) {
|
|
143
|
+
const line = lines[i];
|
|
144
|
+
// Fenced code block
|
|
145
|
+
if (line.startsWith("```")) {
|
|
146
|
+
const lang = line.slice(3).trim().toLowerCase();
|
|
147
|
+
const code = [];
|
|
148
|
+
i++;
|
|
149
|
+
while (i < lines.length && !lines[i].startsWith("```"))
|
|
150
|
+
code.push(lines[i++]);
|
|
151
|
+
i++; // consume closing fence (or EOF)
|
|
152
|
+
// T-15: mermaid diagram → visual ASCII preview
|
|
153
|
+
if (lang === "mermaid" && code.length > 0) {
|
|
154
|
+
blocks.push(_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: " \u25C9 mermaid diagram" }), mermaidPreview(code)] }, key++));
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
// 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++));
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
// Headers
|
|
162
|
+
const h = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
163
|
+
if (h) {
|
|
164
|
+
blocks.push(_jsx(Text, { bold: true, color: h[1].length <= 2 ? "magenta" : "cyan", children: renderInline(h[2], `h${key}`) }, key++));
|
|
165
|
+
i++;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
// Table (GFM): header | separator | rows — collect the whole block.
|
|
169
|
+
if (/^\s*\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\s*\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
|
|
170
|
+
const MAX_CELL = 40;
|
|
171
|
+
const parseRow = (l) => l.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map((c) => c.trim());
|
|
172
|
+
const header = parseRow(line);
|
|
173
|
+
i += 2; // header + separator
|
|
174
|
+
const rows = [];
|
|
175
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i]))
|
|
176
|
+
rows.push(parseRow(lines[i++]));
|
|
177
|
+
// 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))));
|
|
179
|
+
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);
|
|
182
|
+
};
|
|
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++));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Bullets + task lists: - [ ] pending · - [x] completed
|
|
187
|
+
const b = /^(\s*)[-*]\s+(.*)$/.exec(line);
|
|
188
|
+
if (b) {
|
|
189
|
+
const body = b[2];
|
|
190
|
+
const taskMatch = /^\[([ xX])\]\s+(.*)$/.exec(body);
|
|
191
|
+
const indent = " ".repeat(Math.floor(b[1].length / 2));
|
|
192
|
+
if (taskMatch) {
|
|
193
|
+
const checked = taskMatch[1].toLowerCase() === "x";
|
|
194
|
+
const taskBody = taskMatch[2];
|
|
195
|
+
blocks.push(_jsxs(Text, { children: [_jsxs(Text, { color: theme.accent, children: [indent, checked ? "☑" : "☐", " "] }), checked
|
|
196
|
+
? _jsx(Text, { dimColor: true, strikethrough: true, children: renderInline(taskBody, `b${key}`) })
|
|
197
|
+
: renderInline(taskBody, `b${key}`)] }, key++));
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
blocks.push(_jsxs(Text, { children: [_jsxs(Text, { color: theme.accent, children: [indent, "\u2022 "] }), renderInline(body, `b${key}`)] }, key++));
|
|
201
|
+
}
|
|
202
|
+
i++;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Numbered list — keep the numbering as-is; nested indent preserved.
|
|
206
|
+
const n = /^(\s*)(\d+\.)\s+(.*)$/.exec(line);
|
|
207
|
+
if (n) {
|
|
208
|
+
const indent = " ".repeat(Math.floor(n[1].length / 2));
|
|
209
|
+
blocks.push(_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: indent }), renderInline(`${n[2]} ${n[3]}`, `n${key}`)] }, key++));
|
|
210
|
+
i++;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
// Quote
|
|
214
|
+
if (/^>\s?/.test(line)) {
|
|
215
|
+
blocks.push(_jsxs(Text, { dimColor: true, children: [_jsx(Text, { color: theme.dim, children: "\u2502" }), " ", _jsx(Text, { italic: true, children: clip(line.replace(/^>\s?/, "")) })] }, key++));
|
|
216
|
+
i++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// Blank line → single spacer
|
|
220
|
+
if (line.trim() === "") {
|
|
221
|
+
blocks.push(_jsx(Text, { children: " " }, key++));
|
|
222
|
+
i++;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
blocks.push(_jsx(Text, { children: renderInline(clip(line), `t${key}`) }, key++));
|
|
226
|
+
i++;
|
|
227
|
+
}
|
|
228
|
+
return _jsx(Box, { flexDirection: "column", children: blocks });
|
|
229
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T5: mouse wheel (SGR 1006) — parse từ stdin raw chunk.
|
|
3
|
+
* Module thuần; TuiApp attach listener qua useStdin().
|
|
4
|
+
*/
|
|
5
|
+
/** `\x1b[<64;x;yM` = wheel up, `\x1b[<65;...` = wheel down (SGR mode). */
|
|
6
|
+
export function parseWheel(chunk) {
|
|
7
|
+
// eslint-disable-next-line no-control-regex -- matching the ESC control sequence is the whole point here
|
|
8
|
+
const m = /\x1b\[<(\d+);\d+;\d+[Mm]/.exec(chunk);
|
|
9
|
+
if (!m)
|
|
10
|
+
return null;
|
|
11
|
+
const btn = Number(m[1]);
|
|
12
|
+
if (btn === 64)
|
|
13
|
+
return "up";
|
|
14
|
+
if (btn === 65)
|
|
15
|
+
return "down";
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
/** Bật/tắt mouse tracking (SGR 1000 + 1006) — chỉ khi alt-screen TTY. */
|
|
19
|
+
export const MOUSE_ENABLE = "\x1b[?1000h\x1b[?1006h";
|
|
20
|
+
export const MOUSE_DISABLE = "\x1b[?1000l\x1b[?1006l";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* T2/T3: PermissionModal — khối modal permission_request. T3: full unified
|
|
4
|
+
* diff có màu (DiffLine), scroll được (pageUp/pageDown do TuiApp điều phối
|
|
5
|
+
* qua useInput chung), không cắt cứng 12 dòng.
|
|
6
|
+
*/
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { Box, Text, useInput } from "ink";
|
|
9
|
+
import { DiffLine } from "./chat-views.js";
|
|
10
|
+
import { theme } from "./theme.js";
|
|
11
|
+
const MODAL_DIFF_WINDOW = 20; // rows hiển thị cùng lúc; scroll bằng pgup/pgdn
|
|
12
|
+
/**
|
|
13
|
+
* Permission request modal — elevated visual with a bold colored header
|
|
14
|
+
* bar, the command preview, a scrollable diff (if any), and the key legend.
|
|
15
|
+
*/
|
|
16
|
+
export function PermissionModal({ prompt }) {
|
|
17
|
+
const [offset, setOffset] = useState(0);
|
|
18
|
+
const lines = prompt.diff ? prompt.diff.split("\n") : [];
|
|
19
|
+
useInput((_input, key) => {
|
|
20
|
+
if (key.pageUp)
|
|
21
|
+
setOffset((o) => Math.min(o + MODAL_DIFF_WINDOW, Math.max(0, lines.length - 1)));
|
|
22
|
+
if (key.pageDown)
|
|
23
|
+
setOffset((o) => Math.max(0, o - MODAL_DIFF_WINDOW));
|
|
24
|
+
if (key.home)
|
|
25
|
+
setOffset(lines.length); // top (clamped below)
|
|
26
|
+
if (key.end)
|
|
27
|
+
setOffset(0); // bottom
|
|
28
|
+
});
|
|
29
|
+
const start = Math.max(0, Math.min(offset, Math.max(0, lines.length - MODAL_DIFF_WINDOW)));
|
|
30
|
+
const shown = lines.slice(start, start + MODAL_DIFF_WINDOW);
|
|
31
|
+
const hasMoreAbove = start > 0;
|
|
32
|
+
const hasMoreBelow = start + MODAL_DIFF_WINDOW < lines.length;
|
|
33
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "bold", borderColor: theme.warn, paddingX: 1, paddingY: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsxs(Text, { color: theme.warn, bold: true, children: ["\u26A0 permission \u2014 ", prompt.kind] }), _jsxs(Text, { dimColor: true, children: ["timeout ", prompt.timeoutSecs, "s \u2192 deny"] })] }), _jsx(Text, { bold: true, children: prompt.label }), prompt.diff && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [hasMoreAbove && _jsxs(Text, { dimColor: true, children: ["\u2191 ", start, " earlier lines"] }), shown.map((l, i) => (_jsx(DiffLine, { line: l.length > 160 ? l.slice(0, 160) + "…" : l }, start + i))), hasMoreBelow && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", lines.length - start - MODAL_DIFF_WINDOW, " more lines"] }))] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: "─".repeat(28) }), _jsx(Text, { dimColor: true, children: " y \u00B7 a \u00B7 n \u00B7 q to answer" }), prompt.diff && _jsx(Text, { dimColor: true, children: " pgup \u00B7 pgdn \u00B7 home \u00B7 end scroll diff" })] })] }));
|
|
34
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** Process one MCP gate response — returns notice + optional next call. */
|
|
2
|
+
export function reviewFromResponse(prev, tool, args, r) {
|
|
3
|
+
const err = (text, level = "error") => ({ ok: false, notice: { text, level }, nextCall: null, state: null });
|
|
4
|
+
if (!r.ok)
|
|
5
|
+
return err(`prompt gate error: ${r.output.slice(0, 500)}`);
|
|
6
|
+
const sc = (r.structured ?? {});
|
|
7
|
+
const status = typeof sc.status === "string" ? sc.status : null;
|
|
8
|
+
if (status === "pending_level_selection") {
|
|
9
|
+
return {
|
|
10
|
+
ok: true,
|
|
11
|
+
notice: {
|
|
12
|
+
text: "Which rewrite level?\n [1] light — minimal, close to original\n [2] balanced — structure + context + criteria\n [3] detailed — full spec with acceptance criteria\nReply 1, 2 or 3.",
|
|
13
|
+
level: "info",
|
|
14
|
+
},
|
|
15
|
+
nextCall: null,
|
|
16
|
+
state: { stage: "level", tool, argsBase: args },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
if (status === "pending_user_selection") {
|
|
20
|
+
const rewritten = typeof sc.rewritten_prompt === "string" ? sc.rewritten_prompt : "";
|
|
21
|
+
return {
|
|
22
|
+
ok: true,
|
|
23
|
+
notice: {
|
|
24
|
+
text: `--- ORIGINAL ---\n${String(sc.original_prompt ?? "(missing)")}\n\n--- REWRITTEN ---\n${rewritten}\n\nWhich should be used?\n [1] original\n [2] rewritten\n [3] update — describe changes, rewrite again\n [4] cancel\nReply 1-4 (or 3 + your changes).`,
|
|
25
|
+
level: "info",
|
|
26
|
+
},
|
|
27
|
+
nextCall: null,
|
|
28
|
+
state: { stage: "selection", tool, argsBase: args, rewrittenPrompt: rewritten },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (status === "approved")
|
|
32
|
+
return { ok: true, notice: { text: `✔ prompt approved:\n${String(sc.selected_prompt ?? "")}`, level: "info" }, nextCall: null, state: null };
|
|
33
|
+
if (status === "cancelled")
|
|
34
|
+
return { ok: true, notice: { text: "⏹ prompt gate cancelled — nothing approved.", level: "info" }, nextCall: null, state: null };
|
|
35
|
+
return err(`prompt gate: unexpected status ${status ?? "(none)"}\n${r.output.slice(0, 500)}`, "warn");
|
|
36
|
+
}
|
|
37
|
+
/** Handle a user reply while /review is waiting (numbers 1-4 / feedback text). */
|
|
38
|
+
export function reviewAnswerToCall(rv, text) {
|
|
39
|
+
const m = /^([1-4])(?:\s+([\s\S]+))?$/.exec(text.trim());
|
|
40
|
+
if (rv.stage === "level") {
|
|
41
|
+
if (!m)
|
|
42
|
+
return { notice: { text: "reply 1, 2 or 3 to pick the rewrite level", level: "warn" }, nextCall: null };
|
|
43
|
+
const levels = ["light", "balanced", "detailed"];
|
|
44
|
+
return { notice: null, nextCall: { tool: rv.tool, args: { ...rv.argsBase, rewrite_level: levels[Number(m[1]) - 1] } } };
|
|
45
|
+
}
|
|
46
|
+
// stage === "selection"
|
|
47
|
+
if (!m)
|
|
48
|
+
return { notice: { text: "reply 1-4 (or 3 + your requested changes)", level: "warn" }, nextCall: null };
|
|
49
|
+
const n = m[1];
|
|
50
|
+
if (n === "3") {
|
|
51
|
+
const feedback = m[2]?.trim();
|
|
52
|
+
if (!feedback)
|
|
53
|
+
return { notice: { text: "update needs your requested changes: 3 <what to change>", level: "warn" }, nextCall: null };
|
|
54
|
+
return { notice: null, nextCall: { tool: rv.tool, args: { ...rv.argsBase, selection: "update", feedback, rewritten_prompt: rv.rewrittenPrompt } } };
|
|
55
|
+
}
|
|
56
|
+
const selection = n === "1" ? "original" : n === "2" ? "rewritten" : "cancel";
|
|
57
|
+
return {
|
|
58
|
+
notice: null,
|
|
59
|
+
nextCall: {
|
|
60
|
+
tool: rv.tool,
|
|
61
|
+
args: { ...rv.argsBase, selection, ...(selection === "rewritten" ? { rewritten_prompt: rv.rewrittenPrompt } : {}) },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Session Picker Modal — keyboard-navigable list of saved sessions.
|
|
4
|
+
*
|
|
5
|
+
* Opens via "/" when the agent is idle (no task running).
|
|
6
|
+
* ↑/↓ navigate, Enter select, Esc cancel.
|
|
7
|
+
*
|
|
8
|
+
* Shows: id (truncated), task, date, step count, build status.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useState } from "react";
|
|
11
|
+
import { Box, Text, useInput } from "ink";
|
|
12
|
+
import { listSessions } from "../session.js";
|
|
13
|
+
import { theme, fit } from "./theme.js";
|
|
14
|
+
const DISPLAY_LIMIT = 12;
|
|
15
|
+
export function SessionPicker({ workdir, onSelect, onClose }) {
|
|
16
|
+
const [sessions, setSessions] = useState([]);
|
|
17
|
+
const [selected, setSelected] = useState(0);
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const all = listSessions(workdir).slice(0, DISPLAY_LIMIT);
|
|
20
|
+
setSessions(all);
|
|
21
|
+
setSelected(0);
|
|
22
|
+
}, [workdir]);
|
|
23
|
+
// Keyboard navigation — captures everything while the picker is open.
|
|
24
|
+
useInput((_input, key) => {
|
|
25
|
+
if (key.escape) {
|
|
26
|
+
onClose();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
if (key.upArrow) {
|
|
30
|
+
setSelected((s) => Math.max(0, s - 1));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (key.downArrow) {
|
|
34
|
+
setSelected((s) => Math.min(sessions.length - 1, s + 1));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (key.return) {
|
|
38
|
+
if (sessions.length > 0) {
|
|
39
|
+
onSelect(sessions[selected]);
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
// Vim-style j/k navigation
|
|
44
|
+
if (_input === "k" && !key.ctrl && !key.meta) {
|
|
45
|
+
setSelected((s) => Math.max(0, s - 1));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (_input === "j" && !key.ctrl && !key.meta) {
|
|
49
|
+
setSelected((s) => Math.min(sessions.length - 1, s + 1));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// Quick-jump by number (1-9 selects that entry)
|
|
53
|
+
const n = parseInt(_input, 10);
|
|
54
|
+
if (n >= 1 && n <= sessions.length) {
|
|
55
|
+
setSelected(n - 1);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
if (sessions.length === 0) {
|
|
60
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.brand, paddingX: 1, paddingY: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "\u2190 saved sessions" }), _jsx(Text, { dimColor: true, children: "no saved sessions found" }), _jsx(Text, { dimColor: true, children: "esc to close" })] }));
|
|
61
|
+
}
|
|
62
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.brand, paddingX: 1, paddingY: 1, children: [_jsxs(Box, { justifyContent: "space-between", marginBottom: 1, children: [_jsx(Text, { color: theme.brand, bold: true, children: "\u2190 saved sessions" }), _jsxs(Text, { dimColor: true, children: [sessions.length, " session", sessions.length !== 1 ? "s" : ""] })] }), _jsx(Box, { children: _jsx(Text, { dimColor: true, children: "─".repeat(38) }) }), sessions.map((s, i) => {
|
|
63
|
+
const isSelected = i === selected;
|
|
64
|
+
const idPart = s.id.slice(0, 14);
|
|
65
|
+
const dateStr = s.updatedAt ? s.updatedAt.slice(0, 16).replace("T", " ") : "—";
|
|
66
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: isSelected ? 0 : 1, children: [_jsxs(Text, { color: isSelected ? theme.brand : theme.dim, bold: isSelected, dimColor: !isSelected, children: [isSelected ? "▸" : " ", " ", _jsx(Text, { bold: isSelected, color: isSelected ? theme.accent : theme.dim, children: idPart }), " ", _jsx(Text, { dimColor: true, children: dateStr }), " ", _jsxs(Text, { dimColor: true, children: ["\u00B7 ", s.steps, " steps"] })] }), _jsxs(Text, { dimColor: !isSelected, color: isSelected ? theme.user : undefined, bold: isSelected, children: [isSelected ? " " : " ", fit(s.task, 36)] })] }, s.id));
|
|
67
|
+
}), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "─".repeat(38) }) }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 / jk navigate \u00B7 enter select \u00B7 esc close" }), _jsxs(Text, { dimColor: true, children: ["quick-jump: press 1-", Math.min(sessions.length, 9), " to jump"] })] }));
|
|
68
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
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";
|
|
6
|
+
import { fit, theme } from "./theme.js";
|
|
7
|
+
import { fuelGauge, gaugeColor, SIDEBAR_WIDTH } from "./chrome.js";
|
|
8
|
+
const INNER = SIDEBAR_WIDTH - 4; // border + paddingX
|
|
9
|
+
const MAX_PLAN_ROWS = 14; // cap chiều cao — plan dài thì gộp "+N more"
|
|
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] }));
|
|
12
|
+
}
|
|
13
|
+
function SectionLine({ label, value, color }) {
|
|
14
|
+
return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { dimColor: true, children: [" ", fit(label, 8), " "] }), _jsx(Text, { color: color, children: value })] }));
|
|
15
|
+
}
|
|
16
|
+
// ── Session ──────────────────────────────────────────────────────────────────
|
|
17
|
+
function SessionSection({ state, config }) {
|
|
18
|
+
const perm = state.permission || config.permissionMode;
|
|
19
|
+
const permColor = perm === "readonly" ? theme.warn : perm === "ask" ? theme.accent : theme.dim;
|
|
20
|
+
const model = state.model || config.model || "—";
|
|
21
|
+
const dir = config.workdir.split("/").filter(Boolean).pop() ?? config.workdir;
|
|
22
|
+
return (_jsxs(Section, { title: "SESSION", children: [_jsx(SectionLine, { label: "model", value: fit(model, INNER - 9) }), _jsx(SectionLine, { label: "perm", value: perm, color: permColor }), _jsx(SectionLine, { label: "dir", value: fit(dir, INNER - 9) })] }));
|
|
23
|
+
}
|
|
24
|
+
function PlanRow({ t, width }) {
|
|
25
|
+
if (t.status === "completed") {
|
|
26
|
+
return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.success, children: " \u2714 " }), _jsx(Text, { color: theme.success, dimColor: true, children: fit(t.content, width - 4) })] }));
|
|
27
|
+
}
|
|
28
|
+
if (t.status === "in_progress") {
|
|
29
|
+
return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: theme.accent, children: " \u25C6 " }), _jsx(Text, { color: theme.accent, bold: true, children: fit(t.content, width - 4) })] }));
|
|
30
|
+
}
|
|
31
|
+
return (_jsxs(Text, { wrap: "truncate", dimColor: true, children: [" ○ ", fit(t.content, width - 4)] }));
|
|
32
|
+
}
|
|
33
|
+
function PlanSection({ state }) {
|
|
34
|
+
const plan = latestPlan(state.items);
|
|
35
|
+
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: [(() => {
|
|
36
|
+
const done = plan.items.filter((t) => t.status === "completed").length;
|
|
37
|
+
return _jsxs(Text, { children: [" ", _jsx(Text, { bold: true, children: done }), _jsxs(Text, { dimColor: true, children: ["/", plan.items.length, " steps"] })] });
|
|
38
|
+
})(), 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"] }))] })) }));
|
|
39
|
+
}
|
|
40
|
+
// ── Metrics (tok/s · ctx · cost · elapsed · fuel gauge · guardrails) ─────────
|
|
41
|
+
function MetricsSection({ state }) {
|
|
42
|
+
const fmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
43
|
+
const hasUsage = state.usage.calls > 0;
|
|
44
|
+
const elapsedMs = state.startedAt ? (state.endedAt ?? Date.now()) - state.startedAt : null;
|
|
45
|
+
const elapsedSec = elapsedMs != null ? Math.floor(elapsedMs / 1000) : null;
|
|
46
|
+
const gauge = fuelGauge(state.step, state.maxSteps);
|
|
47
|
+
const cost = (state.usage.inputTokens / 1_000_000) * resolveModelPricing(state.model).input +
|
|
48
|
+
(state.usage.outputTokens / 1_000_000) * resolveModelPricing(state.model).output;
|
|
49
|
+
// tok/s: output tokens / giây thô (chưa floor — 0.6s run đầu vẫn ra số).
|
|
50
|
+
const tokPerSec = hasUsage && elapsedMs != null && elapsedMs > 500
|
|
51
|
+
? Math.round(state.usage.outputTokens / (elapsedMs / 1000))
|
|
52
|
+
: null;
|
|
53
|
+
// Live estimate based on accumulated streaming chars (chars / ~4 ≈ tokens).
|
|
54
|
+
const liveTokPerSec = state.streamingOutputChars > 0 && elapsedMs != null && elapsedMs > 500
|
|
55
|
+
? Math.round((state.streamingOutputChars / 4) / (elapsedMs / 1000))
|
|
56
|
+
: null;
|
|
57
|
+
const win = contextWindowFor(state.provider, state.model);
|
|
58
|
+
const ctxPct = hasUsage && win > 0 ? Math.round((state.usage.inputTokens / win) * 100) : null;
|
|
59
|
+
// ctx giờ hiển thị đủ số: `input/ctxWin (pct%)`.
|
|
60
|
+
const ctxFmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n));
|
|
61
|
+
const ctxDisplay = ctxPct != null
|
|
62
|
+
? `${ctxFmt(state.usage.inputTokens)}/${ctxFmt(win)} (${ctxPct}%)`
|
|
63
|
+
: null;
|
|
64
|
+
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)}` })), 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 }))] })) }));
|
|
65
|
+
}
|
|
66
|
+
// ── Phases (plan › explore › edit › build › verify — vertical) ───────────────
|
|
67
|
+
const PHASES = ["plan", "explore", "edit", "build", "verify"];
|
|
68
|
+
function PhaseSection({ state }) {
|
|
69
|
+
if (state.phases.length === 0) {
|
|
70
|
+
return (_jsx(Section, { title: "PHASES", children: _jsx(Text, { dimColor: true, children: " \u2014 idle" }) }));
|
|
71
|
+
}
|
|
72
|
+
const current = state.phases[state.phases.length - 1];
|
|
73
|
+
return (_jsx(Section, { title: "PHASES", children: _jsx(Box, { flexDirection: "column", flexShrink: 0, children: PHASES.map((p) => {
|
|
74
|
+
const visited = state.phases.includes(p);
|
|
75
|
+
const isCurrent = p === current;
|
|
76
|
+
return (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { dimColor: true, children: " " }), isCurrent ? (_jsxs(Text, { color: theme.accent, bold: true, children: ["\u25C6 ", p] })) : visited ? (_jsxs(Text, { color: theme.success, children: ["\u2714 ", p] })) : (_jsxs(Text, { dimColor: true, children: ["\u00B7 ", p] }))] }, p));
|
|
77
|
+
}) }) }));
|
|
78
|
+
}
|
|
79
|
+
// ── Build (lamp + command) ───────────────────────────────────────────────────
|
|
80
|
+
function BuildSection({ state, config }) {
|
|
81
|
+
const lampLabel = state.buildLamp === "ok" ? "build ok" : state.buildLamp === "fail" ? "build fail" : "no build yet";
|
|
82
|
+
const lampColor = state.buildLamp === "ok" ? theme.success : state.buildLamp === "fail" ? theme.error : theme.dim;
|
|
83
|
+
const cmd = state.buildCmd || config.buildCmd || null;
|
|
84
|
+
return (_jsxs(Section, { title: "BUILD", children: [_jsx(Text, { wrap: "truncate", children: _jsxs(Text, { color: lampColor, children: [" \u25CF ", lampLabel] }) }), cmd && _jsxs(Text, { dimColor: true, wrap: "truncate", children: [" ", fit(cmd, INNER)] })] }));
|
|
85
|
+
}
|
|
86
|
+
// ── MCP (trạng thái từng server — từ event mcp_status structured) ────────────
|
|
87
|
+
function McpSection({ state, config }) {
|
|
88
|
+
const names = Object.keys(config.mcpServers ?? {});
|
|
89
|
+
return (_jsx(Section, { title: "MCP", children: names.length === 0 ? (_jsx(Text, { dimColor: true, children: " none configured" })) : (_jsx(Box, { flexDirection: "column", flexShrink: 0, children: names.map((n) => {
|
|
90
|
+
const st = state.mcpServers[n] ?? "waiting";
|
|
91
|
+
const color = st === "ready" ? theme.success : st === "dead" ? theme.error : st === "waiting" ? theme.dim : theme.warn;
|
|
92
|
+
const mark = st === "ready" ? "●" : st === "dead" ? "✘" : "◐";
|
|
93
|
+
const budget = Math.max(6, INNER - 3 - st.length - 1);
|
|
94
|
+
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));
|
|
95
|
+
}) })) }));
|
|
96
|
+
}
|
|
97
|
+
// ── Root ─────────────────────────────────────────────────────────────────────
|
|
98
|
+
/** Sidebar phải — mọi trạng thái sống của run, cập nhật theo AgentEvent. */
|
|
99
|
+
export function Sidebar({ state, config }) {
|
|
100
|
+
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(PlanSection, { state: state }), _jsx(MetricsSection, { state: state }), _jsx(PhaseSection, { state: state }), _jsx(BuildSection, { state: state, config: config }), _jsx(McpSection, { state: state, config: config })] }));
|
|
101
|
+
}
|