s-app-scoder-agent 0.6.0 → 0.8.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.
Files changed (49) hide show
  1. package/.scoderrc.example.json +97 -0
  2. package/LICENSE +21 -21
  3. package/README.md +600 -468
  4. package/dist/agent.js +841 -197
  5. package/dist/agents.js +70 -0
  6. package/dist/compaction.js +114 -0
  7. package/dist/config.js +178 -12
  8. package/dist/context.js +119 -14
  9. package/dist/cost.js +56 -6
  10. package/dist/guardrails.js +423 -0
  11. package/dist/index.js +129 -76
  12. package/dist/json-schema.js +58 -0
  13. package/dist/llm.js +123 -9
  14. package/dist/mcp.js +397 -70
  15. package/dist/model-catalog.js +175 -0
  16. package/dist/permissions.js +328 -35
  17. package/dist/prompt-builder.js +47 -0
  18. package/dist/prompt.js +103 -0
  19. package/dist/provider-anthropic.js +113 -35
  20. package/dist/provider-registry.js +116 -0
  21. package/dist/retry.js +3 -2
  22. package/dist/sandbox.js +96 -0
  23. package/dist/server.js +32 -2
  24. package/dist/session.js +282 -11
  25. package/dist/shell-scanner.js +112 -0
  26. package/dist/skills.js +122 -0
  27. package/dist/snapshot.js +170 -0
  28. package/dist/steering.js +35 -0
  29. package/dist/tools.js +513 -19
  30. package/dist/tui/app.js +505 -198
  31. package/dist/tui/at-complete.js +93 -0
  32. package/dist/tui/chat-views.js +146 -0
  33. package/dist/tui/chrome.js +96 -0
  34. package/dist/tui/command-registry.js +189 -0
  35. package/dist/tui/commands.js +200 -2
  36. package/dist/tui/image.js +149 -0
  37. package/dist/tui/input.js +214 -0
  38. package/dist/tui/markdown.js +229 -0
  39. package/dist/tui/mouse.js +20 -0
  40. package/dist/tui/permission-modal.js +34 -0
  41. package/dist/tui/review.js +64 -0
  42. package/dist/tui/session-picker.js +68 -0
  43. package/dist/tui/sidebar.js +99 -0
  44. package/dist/tui/state.js +192 -6
  45. package/dist/tui/theme.js +90 -0
  46. package/dist/tui/vim.js +60 -0
  47. package/dist/watch.js +52 -0
  48. package/package.json +16 -8
  49. 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,99 @@
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 model = state.model || config.model || "—";
19
+ const dir = config.workdir.split("/").filter(Boolean).pop() ?? config.workdir;
20
+ return (_jsxs(Section, { title: "SESSION", children: [_jsx(SectionLine, { label: "model", value: fit(model, INNER - 9) }), _jsx(SectionLine, { label: "dir", value: fit(dir, INNER - 9) })] }));
21
+ }
22
+ function PlanRow({ t, width }) {
23
+ if (t.status === "completed") {
24
+ 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) })] }));
25
+ }
26
+ if (t.status === "in_progress") {
27
+ 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) })] }));
28
+ }
29
+ return (_jsxs(Text, { wrap: "truncate", dimColor: true, children: [" ○ ", fit(t.content, width - 4)] }));
30
+ }
31
+ function PlanSection({ state }) {
32
+ const plan = latestPlan(state.items);
33
+ return (_jsx(Section, { title: "PLAN", children: !plan || plan.items.length === 0 ? (_jsx(Text, { dimColor: true, children: " no plan yet" })) : (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [(() => {
34
+ const done = plan.items.filter((t) => t.status === "completed").length;
35
+ return _jsxs(Text, { children: [" ", _jsx(Text, { bold: true, children: done }), _jsxs(Text, { dimColor: true, children: ["/", plan.items.length, " steps"] })] });
36
+ })(), plan.items.slice(0, MAX_PLAN_ROWS).map((t, i) => (_jsx(PlanRow, { t: t, width: INNER }, i))), plan.items.length > MAX_PLAN_ROWS && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", plan.items.length - MAX_PLAN_ROWS, " more"] }))] })) }));
37
+ }
38
+ // ── Metrics (tok/s · ctx · cost · elapsed · fuel gauge · guardrails) ─────────
39
+ function MetricsSection({ state }) {
40
+ const fmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
41
+ const hasUsage = state.usage.calls > 0;
42
+ const elapsedMs = state.startedAt ? (state.endedAt ?? Date.now()) - state.startedAt : null;
43
+ const elapsedSec = elapsedMs != null ? Math.floor(elapsedMs / 1000) : null;
44
+ const gauge = fuelGauge(state.step, state.maxSteps);
45
+ const cost = (state.usage.inputTokens / 1_000_000) * resolveModelPricing(state.model).input +
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 > 500
53
+ ? Math.round((state.streamingOutputChars / 4) / (elapsedMs / 1000))
54
+ : null;
55
+ 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
+ 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}%)`
61
+ : null;
62
+ 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 }))] })) }));
63
+ }
64
+ // ── Phases (plan › explore › edit › build › verify — vertical) ───────────────
65
+ const PHASES = ["plan", "explore", "edit", "build", "verify"];
66
+ function PhaseSection({ state }) {
67
+ if (state.phases.length === 0) {
68
+ return (_jsx(Section, { title: "PHASES", children: _jsx(Text, { dimColor: true, children: " \u2014 idle" }) }));
69
+ }
70
+ const current = state.phases[state.phases.length - 1];
71
+ return (_jsx(Section, { title: "PHASES", children: _jsx(Box, { flexDirection: "column", flexShrink: 0, children: PHASES.map((p) => {
72
+ const visited = state.phases.includes(p);
73
+ const isCurrent = p === current;
74
+ 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));
75
+ }) }) }));
76
+ }
77
+ // ── Build (lamp + command) ───────────────────────────────────────────────────
78
+ function BuildSection({ state, config }) {
79
+ const lampLabel = state.buildLamp === "ok" ? "build ok" : state.buildLamp === "fail" ? "build fail" : "no build yet";
80
+ const lampColor = state.buildLamp === "ok" ? theme.success : state.buildLamp === "fail" ? theme.error : theme.dim;
81
+ const cmd = state.buildCmd || config.buildCmd || null;
82
+ 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)] })] }));
83
+ }
84
+ // ── MCP (trạng thái từng server — từ event mcp_status structured) ────────────
85
+ function McpSection({ state, config }) {
86
+ const names = Object.keys(config.mcpServers ?? {});
87
+ 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) => {
88
+ const st = state.mcpServers[n] ?? "waiting";
89
+ const color = st === "ready" ? theme.success : st === "dead" ? theme.error : st === "waiting" ? theme.dim : theme.warn;
90
+ const mark = st === "ready" ? "●" : st === "dead" ? "✘" : "◐";
91
+ const budget = Math.max(6, INNER - 3 - st.length - 1);
92
+ 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
+ }) })) }));
94
+ }
95
+ // ── Root ─────────────────────────────────────────────────────────────────────
96
+ /** Sidebar phải — mọi trạng thái sống của run, cập nhật theo AgentEvent. */
97
+ 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(MetricsSection, { state: state }), _jsx(PhaseSection, { state: state }), _jsx(BuildSection, { state: state, config: config }), _jsx(McpSection, { state: state, config: config }), _jsx(PlanSection, { state: state })] }));
99
+ }