s-app-scoder-agent 0.8.20 → 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.
@@ -3,28 +3,45 @@
3
3
  * (src/tui/app.tsx). Pure parsing/formatting only — each frontend executes
4
4
  * the command against its own context, so both stay in sync on the surface.
5
5
  */
6
- export const SLASH_COMMANDS = [
7
- { cmd: "/help", desc: "show this command list" },
8
- { cmd: "/sessions", desc: "list saved sessions — /sessions resume <id|last>" },
9
- { cmd: "/diff", desc: "show diffs: /diff turn | session | branch" },
10
- { cmd: "/theme", desc: "switch palette: /theme <name> — /theme list (mission|paper|mono|light|high-contrast|solarized)" },
11
- { cmd: "/clear", desc: "clear the conversation and reset agent context" },
12
- { cmd: "/cost", desc: "token usage of the current session" },
13
- { cmd: "/model", desc: "show the active model & provider" },
14
- { cmd: "/skills", desc: "list discovered skills (SKILL.md)" },
15
- { cmd: "/skill", desc: "load a skill into context: /skill <name>" },
16
- { cmd: "/mcp", desc: "MCP servers & tools: /mcp [server] [tool] [json|text]" },
17
- { cmd: "/review", desc: "prompt gate: rewrite + user approval — /review <prompt>" },
18
- { cmd: "/permission", desc: "view or switch permission mode: /permission <auto|ask|readonly>" },
19
- { cmd: "/undo", desc: "revert files: /undo [files|edit] — edit also trims the turn back for re-send" },
20
- { cmd: "/exit", desc: "quit (aliases: /quit, exit, quit)" },
6
+ /** Built-in commands — the single catalog both help and completion read. */
7
+ export const BUILTIN_COMMANDS = [
8
+ { name: "/help", aliases: [], description: "show this command list", usage: "/help", visibility: "public", source: "builtin" },
9
+ { name: "/plan", aliases: [], description: "show the current plan — /plan done|start <id|n> to mark steps", usage: "/plan [done|start <id|n|text>]", visibility: "public", source: "builtin" },
10
+ { name: "/agent", aliases: [], description: "list selectable agents — /agent <name> to switch (idle only)", usage: "/agent [name]", visibility: "public", source: "builtin" },
11
+ { name: "/init", aliases: [], description: "create an AGENTS.md template in this project", usage: "/init", visibility: "public", source: "builtin" },
12
+ { name: "/sessions", aliases: [], description: "list saved sessions — /sessions resume <id|last>", usage: "/sessions [resume <id|last>]", visibility: "public", source: "builtin" },
13
+ { name: "/diff", aliases: [], description: "show diffs: /diff turn | session | branch", usage: "/diff [turn|session|branch]", visibility: "tui-only", source: "builtin" },
14
+ { name: "/theme", aliases: [], description: "switch palette: /theme <name> — /theme list (mission|paper|mono|light|high-contrast|solarized)", usage: "/theme [name|list]", visibility: "tui-only", source: "builtin" },
15
+ { name: "/clear", aliases: [], description: "clear the conversation and reset agent context", usage: "/clear", visibility: "public", source: "builtin" },
16
+ { name: "/compact", aliases: [], description: "summarize older history now (manual compaction)", usage: "/compact", visibility: "public", source: "builtin" },
17
+ { name: "/cost", aliases: [], description: "token usage of the current session", usage: "/cost", visibility: "public", source: "builtin" },
18
+ { name: "/model", aliases: [], description: "show the active model & provider", usage: "/model [provider/model]", visibility: "public", source: "builtin" },
19
+ { name: "/skills", aliases: [], description: "list discovered skills (SKILL.md)", usage: "/skills", visibility: "public", source: "builtin" },
20
+ { name: "/skill", aliases: [], description: "load a skill into context: /skill <name>", usage: "/skill <name>", visibility: "public", source: "builtin" },
21
+ { name: "/mcp", aliases: [], description: "MCP servers & tools: /mcp [server] [tool] [json|text]", usage: "/mcp [server [tool [json]]]", visibility: "public", source: "builtin" },
22
+ { name: "/review", aliases: [], description: "prompt gate: rewrite + user approval — /review <prompt>", usage: "/review <prompt>", visibility: "tui-only", source: "builtin" },
23
+ { name: "/permission", aliases: [], description: "view or switch permission mode: /permission <auto|ask|readonly>", usage: "/permission [auto|ask|readonly]", visibility: "public", source: "builtin" },
24
+ { name: "/undo", aliases: [], description: "revert files: /undo [files|edit] — edit also trims the turn back for re-send", usage: "/undo [files|edit]", visibility: "tui-only", source: "builtin" },
25
+ { name: "/commands", aliases: [], description: "list all commands (builtin + custom) — /commands <name> to inspect", usage: "/commands [name]", visibility: "public", source: "builtin" },
26
+ { name: "/export", aliases: [], description: "export session transcript: /export [md|json]", usage: "/export [md|json]", visibility: "public", source: "builtin" },
27
+ { name: "/search", aliases: [], description: "search transcript: /search <query>", usage: "/search <query>", visibility: "public", source: "builtin" },
28
+ { name: "/exit", aliases: ["/quit"], description: "quit (aliases: /quit, exit, quit)", usage: "/exit", visibility: "public", source: "builtin" },
21
29
  ];
30
+ /** Back-compat view used by completion + /help — derived from BUILTIN_COMMANDS. */
31
+ export const SLASH_COMMANDS = BUILTIN_COMMANDS
32
+ .filter((c) => c.visibility !== "hidden")
33
+ .map((c) => ({ cmd: c.name, desc: c.description }));
34
+ /** Resolve a command name or alias → canonical CommandDef (case-insensitive). */
35
+ export function resolveCommandDef(name) {
36
+ const n = name.toLowerCase();
37
+ return BUILTIN_COMMANDS.find((c) => c.name === n || c.aliases.includes(n));
38
+ }
22
39
  /** Parse "/cmd arg…"; returns null when the input is not a slash command. */
23
40
  export function parseSlashCommand(input) {
24
- const m = /^\/([a-zA-Z][a-zA-Z0-9]*)(?:\s+(.*\S))?\s*$/.exec(input.trim());
41
+ const m = /^\/([a-zA-Z][a-zA-Z0-9-]*)(?:\s+([\s\S]*\S))?\s*$/.exec(input.trim());
25
42
  if (!m)
26
43
  return null;
27
- return { cmd: "/" + m[1].toLowerCase(), arg: m[2] ?? "" };
44
+ return { cmd: resolveCommandDef("/" + m[1].toLowerCase())?.name ?? "/" + m[1].toLowerCase(), arg: m[2] ?? "" };
28
45
  }
29
46
  /**
30
47
  * Tab completion for a partial input starting with "/".
@@ -52,44 +69,215 @@ export function completeSlashCommand(input) {
52
69
  }
53
70
  return { completed: prefix + (matches.length === 1 ? " " : ""), candidates: matches };
54
71
  }
72
+ import { readdirSync, existsSync, readFileSync, statSync } from "node:fs";
73
+ import { homedir } from "node:os";
74
+ import { join, resolve, basename, extname } from "node:path";
55
75
  import { suggest } from "./at-complete.js";
76
+ /** Substitute $ARGUMENTS, $1..$n, @file references in the body. */
77
+ function substituteTemplate(body, argStr) {
78
+ const args = argStr.trim().split(/\s+/).filter(Boolean);
79
+ let out = body;
80
+ out = out.replace(/\$ARGUMENTS/g, argStr.trim());
81
+ for (let i = 0; i < args.length; i++) {
82
+ out = out.replace(new RegExp(`\\$${i + 1}`, "g"), args[i]);
83
+ }
84
+ out = out.replace(/\$ARGUMENTS/g, argStr.trim()); // repeat in case $ARGUMENTS contains $1
85
+ // @file references — leave as-is (agent interprets them).
86
+ return out;
87
+ }
88
+ /** Discover custom commands from ~/.scoder/commands/*.md + .scoder/commands/*.md. */
89
+ export function discoverCustomCommands(workdir) {
90
+ const dirs = [resolve(workdir, ".scoder", "commands"), join(homedir(), ".scoder", "commands")].filter((d) => existsSync(d));
91
+ const results = [];
92
+ for (const dir of dirs) {
93
+ let files;
94
+ try {
95
+ files = readdirSync(dir);
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ for (const file of files) {
101
+ if (extname(file) !== ".md")
102
+ continue;
103
+ const filePath = join(dir, file);
104
+ let stat;
105
+ try {
106
+ stat = statSync(filePath);
107
+ }
108
+ catch {
109
+ continue;
110
+ }
111
+ if (!stat.isFile())
112
+ continue;
113
+ const content = readFileSync(filePath, "utf8");
114
+ const parsed = parseCustomCommandFile(basename(file, ".md"), filePath, content, dir === resolve(workdir, ".scoder", "commands") ? "project" : "global");
115
+ if (parsed)
116
+ results.push(parsed);
117
+ }
118
+ }
119
+ return results;
120
+ }
121
+ /** Parse a custom command .md file. Returns null on error. */
122
+ function parseCustomCommandFile(defaultName, filePath, content, source) {
123
+ const frontMatch = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(content.trim());
124
+ if (!frontMatch) {
125
+ // No frontmatter: body is the entire file, name is filename.
126
+ return {
127
+ name: defaultName,
128
+ description: content.trim().slice(0, 80),
129
+ visibility: "public",
130
+ aliases: [],
131
+ body: content.trim(),
132
+ source,
133
+ filePath,
134
+ };
135
+ }
136
+ const frontRaw = frontMatch[1];
137
+ const body = frontMatch[2].trim();
138
+ // Parse YAML frontmatter manually (no external dependency).
139
+ const front = {};
140
+ for (const line of frontRaw.split("\n")) {
141
+ const m = /^(\w+):\s*(.*)$/.exec(line.trim());
142
+ if (m) {
143
+ const [, key, rawVal] = m;
144
+ front[key] = rawVal.startsWith('"') && rawVal.endsWith('"')
145
+ ? rawVal.slice(1, -1)
146
+ : rawVal;
147
+ }
148
+ }
149
+ return {
150
+ name: String(front.name ?? defaultName),
151
+ description: String(front.description ?? body.split("\n")[0].slice(0, 80)),
152
+ visibility: front.visibility ?? "public",
153
+ aliases: parseFrontList(front.aliases),
154
+ exec: front.exec ?? "prompt",
155
+ agent: front.agent ? String(front.agent) : undefined,
156
+ model: front.model ? String(front.model) : undefined,
157
+ shell: front.shell ? String(front.shell) : undefined,
158
+ args: front.args
159
+ ? (Array.isArray(front.args)
160
+ ? front.args.map(String)
161
+ : String(front.args)
162
+ // YAML flow array "[a, b]" → strip brackets, split on comma.
163
+ .replace(/^\[|\]$/g, "")
164
+ .split(",")
165
+ .map((s) => s.trim())
166
+ .filter(Boolean))
167
+ : undefined,
168
+ body,
169
+ source,
170
+ filePath,
171
+ };
172
+ }
173
+ /** Parse the small YAML list subset used by command aliases/args metadata. */
174
+ function parseFrontList(value) {
175
+ if (Array.isArray(value))
176
+ return value.map(String).map((s) => s.trim()).filter(Boolean);
177
+ if (typeof value !== "string")
178
+ return [];
179
+ const raw = value.trim().replace(/^\[|\]$/g, "");
180
+ return raw.split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
181
+ }
182
+ /** Substitute + execute a custom command; returns the prompt text to inject. */
183
+ export function runCustomCommand(cmd, argStr) {
184
+ return substituteTemplate(cmd.body, argStr);
185
+ }
56
186
  /** Build providers from runtime context (called once per keystroke burst). */
57
187
  export function completionProviders(sources) {
58
- return [
188
+ const limit = sources.limit ?? 8;
189
+ const skills = sources.skills ?? [];
190
+ const agents = sources.agents ?? [];
191
+ const files = sources.files ?? [];
192
+ const commands = sources.commands ?? SLASH_COMMANDS;
193
+ const out = [
59
194
  {
60
195
  id: "commands",
61
196
  match: (t) => t.startsWith("/") && !t.slice(1).includes(" "),
62
- candidates: (t) => SLASH_COMMANDS.filter((c) => c.cmd.startsWith(t.toLowerCase()))
63
- .map((c) => ({ label: c.cmd, desc: c.desc, insert: c.cmd + " " })),
197
+ candidates: (t) => {
198
+ const q = t.toLowerCase();
199
+ return commands
200
+ .filter((c) => c.cmd.toLowerCase().startsWith(q) || c.cmd.toLowerCase().includes(q.slice(1)))
201
+ .slice(0, limit)
202
+ .map((c) => ({ label: c.cmd, desc: c.desc, insert: c.cmd + " " }));
203
+ },
64
204
  },
65
- {
205
+ ];
206
+ if (files.length > 0) {
207
+ out.push({
66
208
  id: "files",
67
209
  match: (t) => t.startsWith("@") && t.length > 1,
68
- candidates: (t) => suggest(sources.files, t.slice(1)).map((f) => ({ label: f, insert: "@" + f + (f.endsWith("/") ? "" : " ") })),
69
- },
70
- {
210
+ candidates: (t) => suggest(files, t.slice(1), limit).map((f) => ({ label: f, insert: "@" + f + (f.endsWith("/") ? "" : " ") })),
211
+ });
212
+ }
213
+ if (skills.length > 0) {
214
+ out.push({
71
215
  id: "skills",
72
216
  match: (t) => t.startsWith("@") && t.length > 1,
73
217
  candidates: (t) => {
74
218
  const q = t.slice(1).toLowerCase();
75
- return sources.skills
76
- .filter((s) => s.toLowerCase().includes(q))
77
- .slice(0, 4)
78
- .map((s) => ({ label: `@${s}`, desc: "skill", insert: `@${s} ` }));
219
+ return skills.filter((s) => s.toLowerCase().includes(q)).slice(0, limit).map((s) => ({ label: `@${s}`, desc: "skill", insert: `@${s} ` }));
79
220
  },
80
- },
81
- {
221
+ });
222
+ }
223
+ if (agents.length > 0) {
224
+ out.push({
82
225
  id: "agents",
83
226
  match: (t) => t.startsWith("@") && t.length > 1,
84
227
  candidates: (t) => {
85
228
  const q = t.slice(1).toLowerCase();
86
- return sources.agents
87
- .filter((a) => a.toLowerCase().includes(q))
88
- .slice(0, 4)
89
- .map((a) => ({ label: `@${a}`, desc: "agent", insert: `@${a} ` }));
229
+ return agents.filter((a) => a.toLowerCase().includes(q)).slice(0, limit).map((a) => ({ label: `@${a}`, desc: "agent", insert: `@${a} ` }));
90
230
  },
91
- },
92
- ];
231
+ });
232
+ }
233
+ if (sources.mcpServers && sources.mcpServers.length > 0) {
234
+ out.push({
235
+ id: "mcp",
236
+ match: (t) => t.startsWith("@") && t.length > 1,
237
+ candidates: (t) => {
238
+ const q = t.slice(1).toLowerCase();
239
+ const res = [];
240
+ for (const s of sources.mcpServers ?? []) {
241
+ if (s.name.toLowerCase().includes(q))
242
+ res.push({ label: `@${s.name}`, desc: "mcp server", insert: `@${s.name} ` });
243
+ for (const tool of s.tools ?? []) {
244
+ if (tool.toLowerCase().includes(q))
245
+ res.push({ label: `@${tool}`, desc: `mcp:${s.name}`, insert: `@${tool} ` });
246
+ }
247
+ }
248
+ return res.slice(0, limit);
249
+ },
250
+ });
251
+ }
252
+ if (sources.sessions && sources.sessions.length > 0) {
253
+ out.push({
254
+ id: "sessions",
255
+ match: (t) => t.startsWith("@") && t.length > 1,
256
+ candidates: (t) => {
257
+ const q = t.slice(1).toLowerCase();
258
+ return (sources.sessions ?? []).filter((s) => s.toLowerCase().includes(q)).slice(0, limit).map((s) => ({ label: `@${s}`, desc: "session", insert: `@${s} ` }));
259
+ },
260
+ });
261
+ }
262
+ return out;
263
+ }
264
+ /** Fuzzy score helper (Completion v2 ranking). Higher = better. */
265
+ export function fuzzyScore(query, target) {
266
+ const q = query.toLowerCase();
267
+ const t = target.toLowerCase();
268
+ if (!q)
269
+ return 0;
270
+ if (t.startsWith(q))
271
+ return 1000 - (t.length - q.length);
272
+ const idx = t.indexOf(q);
273
+ if (idx >= 0)
274
+ return 500 - idx;
275
+ // subsequence
276
+ let qi = 0;
277
+ for (let i = 0; i < t.length && qi < q.length; i++)
278
+ if (t[i] === q[qi])
279
+ qi++;
280
+ return qi === q.length ? 100 : 0;
93
281
  }
94
282
  /**
95
283
  * Ghost text: when the current input has exactly ONE candidate across all
@@ -1,14 +1,121 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { theme } from "./theme.js";
4
- const MAX_LINE = 160;
4
+ const DEFAULT_WIDTH = 100;
5
5
  const MAX_CODE_LINES = 60;
6
- function clip(line) {
7
- return line.length > MAX_LINE ? line.slice(0, MAX_LINE - 1) + "…" : line;
6
+ function clip(line, width = DEFAULT_WIDTH) {
7
+ const safeWidth = Math.max(8, width);
8
+ return line.length > safeWidth ? line.slice(0, safeWidth - 1) + "…" : line;
9
+ }
10
+ function contentWidth(width) {
11
+ return Math.max(32, Math.floor(width ?? DEFAULT_WIDTH));
12
+ }
13
+ function scalar(value) {
14
+ if (value == null)
15
+ return "";
16
+ return typeof value === "string" ? value.trim() : String(value);
17
+ }
18
+ /** Parse JSON or the intentionally small line format accepted by chart fences. */
19
+ export function parseChartSpec(source, language = "chart") {
20
+ const raw = source.trim();
21
+ if (!raw)
22
+ return null;
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(raw);
26
+ }
27
+ catch {
28
+ parsed = null;
29
+ }
30
+ const object = parsed && typeof parsed === "object" && !Array.isArray(parsed)
31
+ ? parsed
32
+ : {};
33
+ const typeRaw = scalar(object.type) || language.split(/[:\s]+/)[1] || "bar";
34
+ const type = typeRaw.toLowerCase();
35
+ const rows = Array.isArray(parsed)
36
+ ? parsed
37
+ : Array.isArray(object.data) ? object.data : [];
38
+ const data = rows.flatMap((row, index) => {
39
+ if (Array.isArray(row)) {
40
+ const value = Number(row[1]);
41
+ return Number.isFinite(value) ? [{ label: scalar(row[0]) || String(index + 1), value }] : [];
42
+ }
43
+ if (row && typeof row === "object") {
44
+ const r = row;
45
+ const value = Number(r.value ?? r.y ?? r.amount);
46
+ return Number.isFinite(value) ? [{ label: scalar(r.label ?? r.name ?? r.x) || String(index + 1), value }] : [];
47
+ }
48
+ return [];
49
+ });
50
+ // Friendly fallback: `label | value` or `label, value` per line.
51
+ if (data.length === 0) {
52
+ for (const [index, line] of raw.split("\n").entries()) {
53
+ if (!line.trim() || /^(type|title|data)\s*:/i.test(line))
54
+ continue;
55
+ const parts = line.split(/\s*[|,]\s*/);
56
+ const value = Number(parts.at(-1));
57
+ if (parts.length >= 2 && Number.isFinite(value))
58
+ data.push({ label: parts.slice(0, -1).join(" ").trim() || String(index + 1), value });
59
+ }
60
+ }
61
+ if (data.length === 0 || !["bar", "line", "pie"].includes(type))
62
+ return null;
63
+ const title = scalar(object.title);
64
+ return {
65
+ type: type,
66
+ ...(title ? { title } : {}),
67
+ data: data.slice(0, 40),
68
+ };
69
+ }
70
+ /** Parse JSON or `time | title | detail | status` timeline rows. */
71
+ export function parseTimelineSpec(source) {
72
+ const raw = source.trim();
73
+ if (!raw)
74
+ return [];
75
+ let parsed;
76
+ try {
77
+ parsed = JSON.parse(raw);
78
+ }
79
+ catch {
80
+ parsed = null;
81
+ }
82
+ const rows = Array.isArray(parsed)
83
+ ? parsed
84
+ : parsed && typeof parsed === "object" && Array.isArray(parsed.events)
85
+ ? parsed.events
86
+ : [];
87
+ const events = rows.flatMap((row) => {
88
+ if (!row || typeof row !== "object")
89
+ return [];
90
+ const r = row;
91
+ const title = scalar(r.title ?? r.name ?? r.event);
92
+ if (!title)
93
+ return [];
94
+ const status = scalar(r.status).toLowerCase();
95
+ return [{
96
+ time: scalar(r.time ?? r.date ?? r.timestamp),
97
+ title,
98
+ detail: scalar(r.detail ?? r.description ?? r.body) || undefined,
99
+ status: ["done", "active", "pending", "error"].includes(status) ? status : undefined,
100
+ }];
101
+ });
102
+ if (events.length > 0)
103
+ return events.slice(0, 40);
104
+ return raw.split("\n").flatMap((line) => {
105
+ const trimmed = line.replace(/^[-*]\s*/, "").trim();
106
+ if (!trimmed)
107
+ return [];
108
+ const parts = trimmed.split(/\s*\|\s*/);
109
+ if (parts.length < 2)
110
+ return [];
111
+ const status = parts[3]?.toLowerCase();
112
+ return [{ time: parts[0], title: parts[1], detail: parts[2] || undefined,
113
+ status: ["done", "active", "pending", "error"].includes(status) ? status : undefined }];
114
+ }).slice(0, 40);
8
115
  }
9
116
  // ── Syntax highlighting ───────────────────────────────────────────────────────
10
117
  /** T-15: token-based syntax highlighter for TypeScript/JavaScript/JSON/shell. */
11
- function highlightCode(code) {
118
+ function highlightCode(code, width = DEFAULT_WIDTH) {
12
119
  const kw = new Set(["const", "let", "var", "function", "async", "await", "return",
13
120
  "if", "else", "for", "while", "do", "switch", "case", "break", "continue",
14
121
  "try", "catch", "finally", "throw", "class", "extends", "import", "export",
@@ -18,7 +125,9 @@ function highlightCode(code) {
18
125
  const typeKw = new Set(["string", "number", "boolean", "any", "unknown", "never",
19
126
  "null", "undefined", "void", "object", "true", "false", "Promise", "Array",
20
127
  "Record", "Partial", "Required", "Omit", "Pick", "keyof", "infer"]);
21
- return code.slice(0, MAX_CODE_LINES).map((line, i) => {
128
+ const codeWidth = Math.max(8, width - 6);
129
+ return code.slice(0, MAX_CODE_LINES).map((sourceLine, i) => {
130
+ const line = clip(sourceLine, codeWidth);
22
131
  const parts = [];
23
132
  const re = /("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)|(\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(\b0x[\da-fA-F]+|\d+\.?\d*(?:[eE][+-]?\d+)?\b)|(\w+)/g;
24
133
  let last = 0;
@@ -105,6 +214,37 @@ function mermaidPreview(code) {
105
214
  result.push("└─────────────────────────────────────────────────────┘");
106
215
  return (_jsx(Box, { flexDirection: "column", marginTop: 1, children: result.map((l, i) => (_jsx(Text, { color: theme.mermaid, children: i === 0 || i === result.length - 1 ? _jsx(Text, { bold: true, children: l }) : l }, i))) }));
107
216
  }
217
+ function chartPreview(spec, width) {
218
+ const chartWidth = Math.max(24, Math.min(72, width - 8));
219
+ const max = Math.max(...spec.data.map((p) => Math.abs(p.value)), 1);
220
+ const min = Math.min(...spec.data.map((p) => p.value), 0);
221
+ const range = Math.max(1, max - min);
222
+ const labelWidth = Math.min(14, Math.max(4, ...spec.data.map((p) => p.label.length)));
223
+ const plotWidth = Math.max(12, chartWidth - labelWidth - 10);
224
+ const title = spec.title ? ` ${spec.title} ` : ` ${spec.type} chart `;
225
+ const rows = spec.data.map((point, index) => {
226
+ const ratio = (point.value - min) / range;
227
+ const filled = Math.max(0, Math.min(plotWidth, Math.round(ratio * plotWidth)));
228
+ const bar = "█".repeat(filled) + "░".repeat(plotWidth - filled);
229
+ const marker = spec.type === "line" ? (index === 0 ? "╭" : index === spec.data.length - 1 ? "╰" : "│") : "▌";
230
+ return ` ${point.label.slice(0, labelWidth).padEnd(labelWidth)} ${marker}${bar} ${String(point.value)}`;
231
+ });
232
+ if (spec.type === "pie") {
233
+ const total = spec.data.reduce((sum, p) => sum + Math.max(0, p.value), 0) || 1;
234
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: ["\u250C\u2500", title, "─".repeat(Math.max(0, chartWidth - title.length - 2)), "\u2510"] }), spec.data.map((p, i) => {
235
+ const pct = Math.round((Math.max(0, p.value) / total) * 100);
236
+ const pieBarWidth = Math.max(1, chartWidth - labelWidth - 9);
237
+ const pieBar = "█".repeat(Math.min(pieBarWidth, Math.max(1, Math.round((pct / 100) * pieBarWidth))));
238
+ return _jsxs(Text, { color: theme.code, children: ["\u2502 ", p.label.slice(0, labelWidth).padEnd(labelWidth), " ", String(pct).padStart(3), "% ", pieBar.padEnd(pieBarWidth, " ")] }, i);
239
+ }), _jsxs(Text, { color: theme.table, bold: true, children: ["\u2514", "─".repeat(chartWidth), "\u2518"] })] }));
240
+ }
241
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: ["\u250C\u2500", title, "─".repeat(Math.max(0, chartWidth - title.length - 2)), "\u2510"] }), rows.map((row, i) => _jsxs(Text, { color: theme.code, children: ["\u2502", row] }, i)), _jsxs(Text, { color: theme.table, bold: true, children: ["\u2514", "─".repeat(chartWidth), "\u2518"] })] }));
242
+ }
243
+ function timelinePreview(events, width) {
244
+ const detailWidth = Math.max(16, width - 18);
245
+ const icon = (status) => status === "done" ? "✓" : status === "error" ? "✗" : status === "active" ? "◆" : "○";
246
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.table, bold: true, children: [" timeline \u00B7 ", events.length, " event", events.length === 1 ? "" : "s"] }), events.map((event, i) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: event.status === "error" ? theme.error : event.status === "done" ? theme.success : theme.accent, bold: true, children: icon(event.status) }), _jsxs(Text, { dimColor: true, children: [" ", clip(event.time || "—", 16).padEnd(16), " "] }), _jsx(Text, { bold: true, children: clip(event.title, detailWidth) })] }), event.detail && _jsxs(Text, { dimColor: true, children: [" ", i === events.length - 1 ? " " : "│", " ", clip(event.detail, detailWidth)] }), i < events.length - 1 && _jsx(Text, { dimColor: true, children: " \u2502" })] }, i)))] }));
247
+ }
108
248
  /** Render inline markdown tokens as nested Ink <Text> elements. */
109
249
  function renderInline(text, keyPrefix) {
110
250
  const parts = [];
@@ -134,7 +274,8 @@ function renderInline(text, keyPrefix) {
134
274
  parts.push(text.slice(last));
135
275
  return parts;
136
276
  }
137
- export function Markdown({ text }) {
277
+ export function Markdown({ text, width }) {
278
+ const paneWidth = contentWidth(width);
138
279
  const lines = text.split("\n");
139
280
  const blocks = [];
140
281
  let i = 0;
@@ -154,8 +295,18 @@ export function Markdown({ text }) {
154
295
  blocks.push(_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { dimColor: true, children: " \u25C9 mermaid diagram" }), mermaidPreview(code)] }, key++));
155
296
  continue;
156
297
  }
298
+ if ((lang === "chart" || lang.startsWith("chart:")) && code.length > 0) {
299
+ const chart = parseChartSpec(code.join("\n"), lang);
300
+ blocks.push(chart ? _jsx(Box, { children: chartPreview(chart, paneWidth) }, key++) : _jsx(Text, { dimColor: true, children: " (invalid chart data)" }, key++));
301
+ continue;
302
+ }
303
+ if ((lang === "timeline" || lang === "events") && code.length > 0) {
304
+ const events = parseTimelineSpec(code.join("\n"));
305
+ blocks.push(events.length > 0 ? _jsx(Box, { children: timelinePreview(events, paneWidth) }, key++) : _jsx(Text, { dimColor: true, children: " (empty timeline)" }, key++));
306
+ continue;
307
+ }
157
308
  // T-15: syntax-highlighted code block
158
- blocks.push(_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop: 1, children: [lang && _jsx(Text, { dimColor: true, bold: true, children: lang }), code.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty)" })) : (highlightCode(code)), code.length > MAX_CODE_LINES && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", code.length - MAX_CODE_LINES, " more lines"] }))] }, key++));
309
+ blocks.push(_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop: 1, children: [lang && _jsx(Text, { dimColor: true, bold: true, children: lang }), code.length === 0 ? (_jsx(Text, { dimColor: true, children: "(empty)" })) : (highlightCode(code, paneWidth)), code.length > MAX_CODE_LINES && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", code.length - MAX_CODE_LINES, " more lines"] }))] }, key++));
159
310
  continue;
160
311
  }
161
312
  // Headers
@@ -175,12 +326,19 @@ export function Markdown({ text }) {
175
326
  while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i]))
176
327
  rows.push(parseRow(lines[i++]));
177
328
  // Column widths (bounded — wide cells clip via clip()).
178
- const widths = header.map((h, c) => Math.min(MAX_CELL, Math.max(h.length, ...rows.map((r) => (r[c] ?? "").length))));
329
+ const available = Math.max(20, paneWidth - 4);
330
+ const naturalWidths = header.map((h, c) => Math.min(MAX_CELL, Math.max(h.length, ...rows.map((r) => (r[c] ?? "").length))));
331
+ const separators = Math.max(0, header.length - 1) * 3 + 4;
332
+ const totalNatural = naturalWidths.reduce((sum, n) => sum + n, 0);
333
+ const widths = naturalWidths.map((n) => totalNatural + separators <= available ? n : Math.max(3, Math.floor((n / Math.max(1, totalNatural)) * (available - separators))));
179
334
  const renderRow = (cells, chars, color, bold, rowKey) => {
180
- const line = chars.l + cells.map((c, ci) => (c.length > widths[ci] ? c.slice(0, widths[ci] - 1) + "…" : c).padEnd(widths[ci], " ")).join(chars.m) + chars.r;
181
- return _jsx(Text, { color: color, bold: bold, children: clip(line) }, rowKey);
335
+ const line = chars.l + widths.map((width, ci) => {
336
+ const cell = cells[ci] ?? "";
337
+ return (cell.length > width ? cell.slice(0, Math.max(1, width - 1)) + "…" : cell).padEnd(width, " ");
338
+ }).join(chars.m) + chars.r;
339
+ return _jsx(Text, { color: color, bold: bold, children: clip(line, paneWidth) }, rowKey);
182
340
  };
183
- blocks.push(_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [renderRow(header, { l: "┌ ", m: " ┬ ", r: " ┐" }, undefined, true, `th${key}`), renderRow(header.map(() => ""), { l: "├", m: "┼", r: "┤" }, "gray", false, `ts${key}`), rows.map((r, ri) => renderRow(r, { l: "│ ", m: " │ ", r: " │" }, undefined, false, `tr${key}-${ri}`))] }, key++));
341
+ blocks.push(_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [renderRow(header, { l: "┌ ", m: " ┬ ", r: " ┐" }, theme.table, true, `th${key}`), renderRow(header.map(() => ""), { l: "├", m: "┼", r: "┤" }, "gray", false, `ts${key}`), rows.map((r, ri) => renderRow(r, { l: "│ ", m: " │ ", r: " │" }, undefined, false, `tr${key}-${ri}`))] }, key++));
184
342
  continue;
185
343
  }
186
344
  // Bullets + task lists: - [ ] pending · - [x] completed
@@ -222,7 +380,7 @@ export function Markdown({ text }) {
222
380
  i++;
223
381
  continue;
224
382
  }
225
- blocks.push(_jsx(Text, { children: renderInline(clip(line), `t${key}`) }, key++));
383
+ blocks.push(_jsx(Text, { children: renderInline(clip(line, paneWidth), `t${key}`) }, key++));
226
384
  i++;
227
385
  }
228
386
  return _jsx(Box, { flexDirection: "column", children: blocks });
@@ -1,4 +1,4 @@
1
- import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { contextWindowFor } from "../model-catalog.js";
4
4
  import { resolveModelPricing } from "../constants.js";
@@ -44,20 +44,30 @@ function MetricsSection({ state }) {
44
44
  const gauge = fuelGauge(state.step, state.maxSteps);
45
45
  const cost = (state.usage.inputTokens / 1_000_000) * resolveModelPricing(state.model).input +
46
46
  (state.usage.outputTokens / 1_000_000) * resolveModelPricing(state.model).output;
47
- // tok/s: output tokens / giây thô (chưa floor — 0.6s run đầu vẫn ra số).
48
- const tokPerSec = hasUsage && elapsedMs != null && elapsedMs > 500
49
- ? Math.round(state.usage.outputTokens / (elapsedMs / 1000))
50
- : null;
51
- // Live estimate based on accumulated streaming chars (chars / ~4 ≈ tokens).
52
- const liveTokPerSec = state.streamingOutputChars > 0 && elapsedMs != null && elapsedMs > 250
53
- ? Math.round((state.streamingOutputChars / 4) / (elapsedMs / 1000))
47
+ // tok/s of the most recently completed LLM call (output tokens / call duration).
48
+ // This is NOT averaged over the whole run (which includes tool/build time and
49
+ // dilutes the number); it reflects the model's actual generation speed.
50
+ const tokPerSec = state.lastLlmTokPerSec;
51
+ // Live estimate during an active stream: chars so far / elapsed since the
52
+ // first delta of this stream.
53
+ const streamElapsedMs = state.streamStartedAt != null ? Date.now() - state.streamStartedAt : null;
54
+ const liveTokPerSec = state.streamingOutputChars > 0 && streamElapsedMs != null && streamElapsedMs > 250
55
+ ? Math.round((state.streamingOutputChars / 4) / (streamElapsedMs / 1000))
54
56
  : null;
55
57
  const win = contextWindowFor(state.provider, state.model);
56
- const ctxPct = hasUsage && win > 0 ? Math.round((state.usage.inputTokens / win) * 100) : null;
57
- // ctx giờ hiển thị đủ số: `input/ctxWin (pct%)`.
58
+ // `ctx` should reflect the CURRENT context window pressure (estimateTokens of
59
+ // the live message history), NOT the cumulative input tokens spent across the
60
+ // whole session (state.usage.inputTokens grows forever and can exceed 100%).
61
+ // state.context is emitted by the agent after every LLM turn; fall back to the
62
+ // cumulative counter only before the first turn has reported.
63
+ const ctxTokens = state.context?.tokens ?? (hasUsage ? state.usage.inputTokens : null);
64
+ const ctxWindow = state.context?.window ?? win;
65
+ const ctxPct = state.context?.pct ??
66
+ (ctxTokens != null && ctxWindow > 0 ? Math.min(100, Math.round((ctxTokens / ctxWindow) * 100)) : null);
67
+ // ctx giờ hiển thị đủ số: `current/ctxWin (pct%)`.
58
68
  const ctxFmt = (n) => (n >= 1000 ? `${(n / 1000).toFixed(0)}k` : String(n));
59
- const ctxDisplay = ctxPct != null
60
- ? `${ctxFmt(state.usage.inputTokens)}/${ctxFmt(win)} (${ctxPct}%)`
69
+ const ctxDisplay = ctxTokens != null && ctxPct != null
70
+ ? `${ctxFmt(ctxTokens)}/${ctxFmt(ctxWindow)} (${ctxPct}%)`
61
71
  : null;
62
72
  return (_jsx(Section, { title: "METRICS", children: !hasUsage && elapsedSec == null ? (_jsx(Text, { dimColor: true, children: " starting\u2026" })) : (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [gauge && (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { dimColor: true, children: " steps " }), _jsx(Text, { color: gaugeColor(state.step, state.maxSteps), children: gauge }), _jsxs(Text, { dimColor: true, children: [" ", state.step, "/", state.maxSteps] })] })), hasUsage && (_jsx(SectionLine, { label: "toks", value: `${fmt(state.usage.inputTokens)} → ${fmt(state.usage.outputTokens)}` })), !hasUsage && state.streamingOutputChars > 0 && (_jsx(SectionLine, { label: "toks", value: `~${fmt(Math.round(state.streamingOutputChars / 4))} (streaming)`, color: theme.accent })), tokPerSec != null && (_jsx(SectionLine, { label: "speed", value: `${fmt(tokPerSec)} tok/s`, color: theme.accent })), liveTokPerSec != null && tokPerSec == null && (_jsx(SectionLine, { label: "speed", value: `~${fmt(liveTokPerSec)} tok/s`, color: theme.accent })), ctxDisplay != null && _jsx(SectionLine, { label: "ctx", value: ctxDisplay }), cost > 0 && _jsx(SectionLine, { label: "cost", value: `$${cost < 0.01 ? cost.toFixed(4) : cost.toFixed(2)}` }), elapsedSec != null && (_jsx(SectionLine, { label: "elapsed", value: `${Math.floor(elapsedSec / 60)}:${String(elapsedSec % 60).padStart(2, "0")}` })), state.usage.cachedTokens > 0 && (_jsx(SectionLine, { label: "cached", value: fmt(state.usage.cachedTokens) })), state.guardrailCount > 0 && (_jsx(SectionLine, { label: "guard", value: String(state.guardrailCount), color: theme.warn }))] })) }));
63
73
  }
@@ -92,8 +102,21 @@ function McpSection({ state, config }) {
92
102
  return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: color, children: [" ", mark, " "] }), _jsx(Text, { color: color, children: fit(n, budget) }), _jsxs(Text, { dimColor: true, children: [" ", st] })] }, n));
93
103
  }) })) }));
94
104
  }
105
+ function SubagentSection({ state }) {
106
+ if (state.subagents.length === 0)
107
+ return _jsx(_Fragment, {});
108
+ return (_jsx(Section, { title: "SUBAGENTS", children: state.subagents.map((s) => {
109
+ const color = s.status === "running" ? theme.accent :
110
+ s.status === "ok" ? theme.success :
111
+ theme.error;
112
+ const mark = s.status === "running" ? "◐" : s.status === "ok" ? "✔" : "✘";
113
+ const summary = s.summary ? ` — ${s.summary.slice(0, 50)}${s.summary.length > 50 ? "…" : ""}` : "";
114
+ const steps = s.steps != null ? ` (${s.steps} steps)` : "";
115
+ return (_jsxs(Text, { wrap: "truncate", dimColor: true, children: [_jsxs(Text, { color: color, children: [" ", mark, " "] }), _jsx(Text, { color: color, children: s.name }), _jsxs(Text, { dimColor: true, children: [summary, steps] })] }, s.id));
116
+ }) }));
117
+ }
95
118
  // ── Root ─────────────────────────────────────────────────────────────────────
96
119
  /** Sidebar phải — mọi trạng thái sống của run, cập nhật theo AgentEvent. */
97
120
  export function Sidebar({ state, config }) {
98
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.border, width: SIDEBAR_WIDTH, paddingX: 1, flexShrink: 0, overflow: "hidden", children: [_jsx(SessionSection, { state: state, config: config }), _jsx(McpSection, { state: state, config: config }), _jsx(MetricsSection, { state: state }), _jsx(PhaseSection, { state: state }), _jsx(PlanSection, { state: state }), _jsx(BuildSection, { state: state, config: config })] }));
121
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: theme.border, width: SIDEBAR_WIDTH, paddingX: 1, flexShrink: 0, overflow: "hidden", children: [_jsx(SessionSection, { state: state, config: config }), _jsx(McpSection, { state: state, config: config }), _jsx(MetricsSection, { state: state }), _jsx(PhaseSection, { state: state }), _jsx(PlanSection, { state: state }), _jsx(SubagentSection, { state: state }), _jsx(BuildSection, { state: state, config: config })] }));
99
122
  }