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,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T-01: `@` fuzzy autocomplete engine — pure logic, TUI-agnostic (unit-testable).
|
|
3
|
+
* Sources: file index (.gitignore-aware via `rg --files`, fallback walk) +
|
|
4
|
+
* directory prefixes so `@src/` opens the subtree. Engine: fuzzysort (MIT).
|
|
5
|
+
*/
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { readdirSync } from "node:fs";
|
|
8
|
+
import { join, relative } from "node:path";
|
|
9
|
+
import fuzzysort from "fuzzysort";
|
|
10
|
+
const SKIP = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
11
|
+
let cache = null;
|
|
12
|
+
/** File index for `workdir` (cached, TTL 5s; invalidateFileIndex on tool writes). */
|
|
13
|
+
export function fileIndex(workdir) {
|
|
14
|
+
if (cache && Date.now() - cache.built < 5_000)
|
|
15
|
+
return cache.files;
|
|
16
|
+
const files = [];
|
|
17
|
+
// Fast path: ripgrep --files (gitignore-aware). Bare-name globs match at any
|
|
18
|
+
// depth; `!**/x/**` does NOT match absolute paths (I-07 lesson).
|
|
19
|
+
try {
|
|
20
|
+
const r = spawnSync("rg", ["--files", "--hidden", "-g", "!.git", "-g", "!node_modules", "-g", "!dist", "-g", "!build", "-g", "!.venv", "-g", "!__pycache__", "-g", "!.next", "--no-messages", workdir], { maxBuffer: 16e6, timeout: 3_000 });
|
|
21
|
+
if (r.status === 0) {
|
|
22
|
+
for (const line of r.stdout.toString().split("\n")) {
|
|
23
|
+
if (line)
|
|
24
|
+
files.push(relative(workdir, line) || line);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// no rg on PATH → walker fallback
|
|
30
|
+
}
|
|
31
|
+
if (files.length === 0)
|
|
32
|
+
walkFiles(workdir, "", files, 0);
|
|
33
|
+
files.sort();
|
|
34
|
+
cache = { files, built: Date.now() };
|
|
35
|
+
return files;
|
|
36
|
+
}
|
|
37
|
+
/** Cache invalidate — call after write/edit tool_result (scope: whole index). */
|
|
38
|
+
export function invalidateFileIndex() {
|
|
39
|
+
cache = null;
|
|
40
|
+
}
|
|
41
|
+
function walkFiles(root, prefix, out, depth) {
|
|
42
|
+
if (depth > 6)
|
|
43
|
+
return;
|
|
44
|
+
let entries;
|
|
45
|
+
try {
|
|
46
|
+
entries = readdirSync(root, { withFileTypes: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
for (const e of entries) {
|
|
52
|
+
if (SKIP.has(e.name))
|
|
53
|
+
continue;
|
|
54
|
+
const rel = prefix ? `${prefix}/${e.name}` : e.name;
|
|
55
|
+
if (e.isDirectory())
|
|
56
|
+
walkFiles(join(root, e.name), rel, out, depth + 1);
|
|
57
|
+
else if (e.isFile())
|
|
58
|
+
out.push(rel);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Active `@` token in `value`, or null. Returns start index (of "@") + query. */
|
|
62
|
+
export function atQuery(value) {
|
|
63
|
+
const m = /(^|\s)@([^\s@]*)$/.exec(value);
|
|
64
|
+
if (!m)
|
|
65
|
+
return null;
|
|
66
|
+
return { start: m.index + m[1].length, query: m[2] };
|
|
67
|
+
}
|
|
68
|
+
/** Candidate suggestions for a query: fuzzy files + dir prefixes, max 8. */
|
|
69
|
+
export function suggest(files, query, limit = 8) {
|
|
70
|
+
const dirs = new Set();
|
|
71
|
+
for (const f of files) {
|
|
72
|
+
const parts = f.split("/");
|
|
73
|
+
for (let i = 1; i < parts.length; i++)
|
|
74
|
+
dirs.add(parts.slice(0, i).join("/"));
|
|
75
|
+
}
|
|
76
|
+
const all = [...files, ...[...dirs].map((d) => `${d}/`)];
|
|
77
|
+
let hits;
|
|
78
|
+
if (query.includes("/") || query.endsWith("/")) {
|
|
79
|
+
// Subtree mode: prefix match on directories (`@src/tui/…`).
|
|
80
|
+
hits = all.filter((t) => t.startsWith(query));
|
|
81
|
+
}
|
|
82
|
+
else if (query === "") {
|
|
83
|
+
hits = all.slice(0, limit * 4);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
hits = fuzzysort.go(query, all, { limit: limit * 4 }).map((r) => r.target);
|
|
87
|
+
}
|
|
88
|
+
return hits.slice(0, limit);
|
|
89
|
+
}
|
|
90
|
+
/** Replace the active @token with `sel` (dirs keep the trailing slash). */
|
|
91
|
+
export function applySuggestion(value, at, sel) {
|
|
92
|
+
return value.slice(0, at.start) + "@" + sel + (sel.endsWith("/") ? "" : " ");
|
|
93
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* T2: chat item views — Spinner, ToolItemView, DiffItemView, TodoItemView,
|
|
4
|
+
* ChatItemView. Tách khỏi app.tsx (component-based, pattern từ opencode
|
|
5
|
+
* packages/tui).
|
|
6
|
+
*/
|
|
7
|
+
import { useEffect, useMemo, useState } from "react";
|
|
8
|
+
import { Box, Text } from "ink";
|
|
9
|
+
import { Markdown } from "./markdown.js";
|
|
10
|
+
import { detectImageCaps, halfblockRows } from "./image.js"; // T7
|
|
11
|
+
import { AgentMark, fit, theme, toolIcon } from "./theme.js";
|
|
12
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
13
|
+
const SPINNER_MS = 80;
|
|
14
|
+
function useSpinner(active) {
|
|
15
|
+
const [frame, setFrame] = useState(0);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!active)
|
|
18
|
+
return;
|
|
19
|
+
const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), SPINNER_MS);
|
|
20
|
+
return () => clearInterval(timer);
|
|
21
|
+
}, [active]);
|
|
22
|
+
return SPINNER_FRAMES[frame];
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Isolated spinner: owns its own 80ms timer so animation frames re-render
|
|
26
|
+
* ONLY this component — not the whole tree (the previous design lifted the
|
|
27
|
+
* frame state into TuiApp, re-rendering every chat item 12x/second → flicker).
|
|
28
|
+
*/
|
|
29
|
+
export function Spinner({ active }) {
|
|
30
|
+
const frame = useSpinner(active);
|
|
31
|
+
if (!active)
|
|
32
|
+
return null;
|
|
33
|
+
return _jsx(Text, { color: "cyan", children: frame });
|
|
34
|
+
}
|
|
35
|
+
const OUTPUT_PREVIEW_LINES = 5;
|
|
36
|
+
/** T7: decode data URL → halfblock rows (null khi không PNG/không decode được). */
|
|
37
|
+
function halfblockFromDataUrl(dataUrl) {
|
|
38
|
+
const m = /^data:image\/(\w+);base64,(.+)$/.exec(dataUrl);
|
|
39
|
+
if (!m)
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
return halfblockRows(Buffer.from(m[2], "base64"));
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/** Collapse run_command scaffolding ("--- stdout ---" etc.) to real content. */
|
|
49
|
+
function previewLines(output) {
|
|
50
|
+
const lines = output
|
|
51
|
+
.replace(/\n+$/, "")
|
|
52
|
+
.split("\n")
|
|
53
|
+
.filter((l) => !/^--- (stdout|stderr) ---$/.test(l.trim()));
|
|
54
|
+
return lines.slice(-OUTPUT_PREVIEW_LINES);
|
|
55
|
+
}
|
|
56
|
+
function ToolItemView({ item }) {
|
|
57
|
+
const running = item.ok === null;
|
|
58
|
+
// One meaningful arg per tool type keeps the line scannable.
|
|
59
|
+
const keyArg = typeof item.args.command === "string"
|
|
60
|
+
? fit(item.args.command, 60)
|
|
61
|
+
: typeof item.args.path === "string"
|
|
62
|
+
? fit(item.args.path, 40)
|
|
63
|
+
: Object.keys(item.args).length > 0
|
|
64
|
+
? fit(Object.keys(item.args).join(","), 24)
|
|
65
|
+
: "";
|
|
66
|
+
const preview = previewLines(item.output);
|
|
67
|
+
// T7: image result — halfblock preview khi terminal truecolor, fallback link.
|
|
68
|
+
const caps = useMemo(() => detectImageCaps(), []);
|
|
69
|
+
const halfblocks = useMemo(() => (item.image && caps.truecolor ? halfblockFromDataUrl(item.image) : null), [item.image, caps.truecolor]);
|
|
70
|
+
// T6: live elapsed cho tool đang chạy
|
|
71
|
+
const liveElapsed = item.ok === null && item.startedAt != null ? Date.now() - item.startedAt : null;
|
|
72
|
+
const elapsedMs = item.durationMs ?? liveElapsed;
|
|
73
|
+
const elapsed = elapsedMs != null
|
|
74
|
+
? elapsedMs >= 10_000
|
|
75
|
+
? `${(elapsedMs / 1000).toFixed(0)}s`
|
|
76
|
+
: `${(elapsedMs / 1000).toFixed(1)}s`
|
|
77
|
+
: null;
|
|
78
|
+
return (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8 "] }), _jsxs(Text, { color: theme.accent, children: [toolIcon(item.name), " "] }), _jsx(Text, { color: item.ok === false ? theme.error : theme.accent, bold: true, children: item.name }), keyArg && _jsxs(Text, { dimColor: true, children: [" ", keyArg] }), running && _jsx(Text, { dimColor: true, children: " \u00B7 running" }), !running && elapsed && _jsxs(Text, { dimColor: true, children: [" \u00B7 ", elapsed] })] }), preview.map((line, i) => (_jsxs(Text, { dimColor: true, children: [" └ ", line.length > 140 ? line.slice(0, 140) + "…" : line] }, i))), halfblocks && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [halfblocks.map((r, i) => (_jsx(Text, { children: r.text }, i))), _jsx(Text, { dimColor: true, children: " \u2514 image preview" })] })), item.image && !halfblocks && (_jsxs(Text, { dimColor: true, children: [" \u2514 image \u2014 ", item.output] }))] }));
|
|
79
|
+
}
|
|
80
|
+
/** One colored line of a unified diff (+ green, - red, @@ cyan, header bold). */
|
|
81
|
+
export function DiffLine({ line }) {
|
|
82
|
+
if (line.startsWith("+++") || line.startsWith("---"))
|
|
83
|
+
return _jsx(Text, { bold: true, children: line });
|
|
84
|
+
if (line.startsWith("@@"))
|
|
85
|
+
return _jsx(Text, { color: "cyan", children: line });
|
|
86
|
+
if (line.startsWith("+"))
|
|
87
|
+
return _jsx(Text, { color: "green", children: line });
|
|
88
|
+
if (line.startsWith("-"))
|
|
89
|
+
return _jsx(Text, { color: "red", children: line });
|
|
90
|
+
return _jsx(Text, { children: line });
|
|
91
|
+
}
|
|
92
|
+
const MAX_DIFF_RENDER_LINES = 20;
|
|
93
|
+
/**
|
|
94
|
+
* Polished diff card — rounded border, step badge, color-coded + and - lines.
|
|
95
|
+
* Header row: [step] ✎ [+/− stats] [path]
|
|
96
|
+
* Body: up to MAX_DIFF_RENDER_LINES of colored diff lines.
|
|
97
|
+
*/
|
|
98
|
+
function DiffItemView({ item }) {
|
|
99
|
+
const lines = item.text.length > 0 ? item.text.split("\n") : [];
|
|
100
|
+
const shown = lines.slice(0, MAX_DIFF_RENDER_LINES);
|
|
101
|
+
const hidden = lines.length - shown.length;
|
|
102
|
+
const addedColor = item.added > 0 ? theme.success : undefined;
|
|
103
|
+
const removedColor = item.removed > 0 ? theme.error : undefined;
|
|
104
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: theme.border, paddingX: 1, marginTop: 1, marginLeft: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { dimColor: true, children: [String(item.step).padStart(2, "0"), "\u25B8 "] }), _jsx(Text, { color: theme.brand, bold: true, children: "\u270E" }), _jsx(Text, { children: " " }), _jsxs(Text, { color: addedColor, children: ["+", item.added] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: removedColor, children: ["-", item.removed] }), _jsx(Text, { children: " " }), _jsx(Text, { bold: true, color: theme.accent, children: item.path }), item.truncated && _jsx(Text, { dimColor: true, children: " \u00B7 diff truncated" })] }), shown.map((line, i) => (_jsx(DiffLine, { line: line.length > 160 ? line.slice(0, 160) + "…" : line }, i))), hidden > 0 && (_jsxs(Text, { dimColor: true, children: [" \u2026 +", hidden, " more diff lines"] }))] }));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Plan KHÔNG còn render trong chat — checklist live hiển thị ở Sidebar bên
|
|
108
|
+
* phải (sidebar.tsx, PlanSection) để transcript sạch; todo items vẫn được
|
|
109
|
+
* giữ trong state (latestPlan) làm nguồn dữ liệu cho sidebar.
|
|
110
|
+
*/
|
|
111
|
+
function TodoItemView() {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
export function ChatItemView({ item }) {
|
|
115
|
+
switch (item.kind) {
|
|
116
|
+
case "user":
|
|
117
|
+
// Clean user bubble: accent arrow + bold text
|
|
118
|
+
return (_jsxs(Box, { marginTop: 1, paddingLeft: 1, children: [_jsx(Text, { color: theme.user, bold: true, children: "\u276F " }), _jsx(Text, { bold: true, children: item.text })] }));
|
|
119
|
+
case "assistant": {
|
|
120
|
+
// Streaming: dim with pulsing cursor; finalized: markdown with agent mark.
|
|
121
|
+
if (item.streaming || !item.text) {
|
|
122
|
+
return (_jsxs(Box, { marginTop: item.text ? 1 : 0, paddingLeft: 1, children: [_jsx(AgentMark, {}), _jsx(Text, { dimColor: item.streaming, children: item.text || (item.streaming ? "" : "(no text)") }), item.streaming && _jsx(Text, { color: theme.accent, bold: true, children: "\u258B" })] }));
|
|
123
|
+
}
|
|
124
|
+
return (_jsxs(Box, { marginTop: 1, paddingLeft: 1, children: [_jsx(AgentMark, {}), _jsx(Markdown, { text: item.text })] }));
|
|
125
|
+
}
|
|
126
|
+
case "thinking":
|
|
127
|
+
// T-10: reasoning stream — dim color, ⊙ prefix, collapse to last 4 lines.
|
|
128
|
+
return (_jsx(Box, { paddingLeft: 1, marginTop: 1, children: _jsxs(Text, { color: theme.thinking, italic: true, children: [_jsx(Text, { dimColor: true, children: "\u2299" }), " ", item.text.split("\n").slice(-4).join("\n ")] }) }));
|
|
129
|
+
case "tool":
|
|
130
|
+
return _jsx(ToolItemView, { item: item });
|
|
131
|
+
case "diff":
|
|
132
|
+
return _jsx(DiffItemView, { item: item });
|
|
133
|
+
case "todo":
|
|
134
|
+
return _jsx(TodoItemView, {});
|
|
135
|
+
case "build":
|
|
136
|
+
// Rich build status card: large icon + bold label + dim command
|
|
137
|
+
return (_jsx(Box, { paddingLeft: 1, marginTop: 1, children: _jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: item.success
|
|
138
|
+
? _jsx(Text, { color: theme.success, bold: true, children: "\u2713 build passed" })
|
|
139
|
+
: _jsx(Text, { color: theme.error, bold: true, children: "\u2717 build FAILED" }) }), _jsxs(Text, { dimColor: true, children: [" ", item.command] })] }) }));
|
|
140
|
+
case "notice":
|
|
141
|
+
return (_jsx(Box, { paddingLeft: 2, marginTop: 1, children: _jsxs(Text, { color: item.level === "error" ? theme.error :
|
|
142
|
+
item.level === "warn" ? theme.warn :
|
|
143
|
+
theme.dim, children: [item.level === "error" ? "✗ " : item.level === "warn" ? "⚠ " : " ", item.text.length > 2000 ? item.text.slice(0, 2000) + "…" : item.text] }) }));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text, useWindowSize } from "ink";
|
|
3
|
+
import { fit, theme } from "./theme.js";
|
|
4
|
+
import { Spinner } from "./chat-views.js";
|
|
5
|
+
/** Fuel gauge is scaled to this many cells regardless of maxSteps. */
|
|
6
|
+
export const GAUGE_CELLS = 12;
|
|
7
|
+
/** Sidebar phải: tổng rộng (kể cả border + padding) — pattern opencode. */
|
|
8
|
+
export const SIDEBAR_WIDTH = 40;
|
|
9
|
+
/** Dưới ngưỡng cột này ẩn sidebar — BuildRail fallback sang header đầy đủ. */
|
|
10
|
+
export const SIDEBAR_MIN_COLS = 92;
|
|
11
|
+
/** Scaled step fuel gauge: ▰ filled / ▱ remaining, e.g. ▰▰▰▰▱▱▱▱ 4/40. */
|
|
12
|
+
export function fuelGauge(step, maxSteps) {
|
|
13
|
+
if (maxSteps <= 0)
|
|
14
|
+
return null;
|
|
15
|
+
const filled = Math.min(GAUGE_CELLS, Math.round((Math.min(step, maxSteps) / maxSteps) * GAUGE_CELLS));
|
|
16
|
+
return "▰".repeat(filled) + "▱".repeat(GAUGE_CELLS - filled);
|
|
17
|
+
}
|
|
18
|
+
/** T-11: gauge segment color — green (headroom) → yellow → red (nearly spent). */
|
|
19
|
+
export function gaugeColor(step, maxSteps) {
|
|
20
|
+
const ratio = maxSteps > 0 ? Math.min(step, maxSteps) / maxSteps : 0;
|
|
21
|
+
return ratio < 0.6 ? theme.success : ratio < 0.85 ? theme.warn : theme.error;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* BuildRail — polished top bar (SINGLE row).
|
|
25
|
+
*
|
|
26
|
+
* Layout: [⚑ scoder] ── [phase breadcrumb / guardrail warning] ── [perm] [build] [steps]
|
|
27
|
+
*
|
|
28
|
+
* Terminal ≥ SIDEBAR_MIN_COLS: sidebar handles METRICS + PHASES.
|
|
29
|
+
* Terminal < SIDEBAR_MIN_COLS: phase breadcrumb + metrics move into this row.
|
|
30
|
+
*/
|
|
31
|
+
export function BuildRail({ state, config }) {
|
|
32
|
+
const { columns } = useWindowSize();
|
|
33
|
+
const wide = (columns ?? 100) >= SIDEBAR_MIN_COLS;
|
|
34
|
+
const perm = state.permission || config.permissionMode;
|
|
35
|
+
const permColor = perm === "readonly" ? theme.warn : perm === "ask" ? theme.accent : theme.dim;
|
|
36
|
+
// Build lamp
|
|
37
|
+
const lampColor = state.buildLamp === "ok" ? theme.success : state.buildLamp === "fail" ? theme.error : theme.dim;
|
|
38
|
+
const lampLabel = state.buildLamp === "ok" ? "✓" : state.buildLamp === "fail" ? "✗" : "○";
|
|
39
|
+
const lampText = state.buildLamp === "ok" ? "ok" : state.buildLamp === "fail" ? "fail" : "—";
|
|
40
|
+
// Step badge
|
|
41
|
+
const stepLabel = state.step > 0
|
|
42
|
+
? `${state.step}${state.maxSteps > 0 ? `/${state.maxSteps}` : ""}`
|
|
43
|
+
: "idle";
|
|
44
|
+
// Guardrail: shown inline if it fired (small warning before the phase breadcrumb)
|
|
45
|
+
const hasGuardrail = Boolean(state.lastGuardrail);
|
|
46
|
+
// Phase breadcrumb or narrow-terminal metrics (right of the center)
|
|
47
|
+
const showNarrowMetrics = !wide && (state.status === "running" || state.phases.length > 0);
|
|
48
|
+
return (_jsxs(Box, { borderStyle: "bold", borderColor: theme.border, paddingX: 1, justifyContent: "space-between", alignItems: "center", flexShrink: 0, children: [_jsxs(Text, { bold: true, color: theme.brand, children: ["\u2691 ", _jsx(Text, { bold: true, children: "scoder" })] }), _jsx(Box, { flexGrow: 1, justifyContent: "center", children: hasGuardrail ? (_jsxs(Text, { color: theme.warn, bold: true, children: ["\u26A0 ", fit(state.lastGuardrail, 40)] })) : state.status === "running" ? (showNarrowMetrics ? (_jsx(PhaseBreadcrumb, { phases: state.phases })) : (_jsx(Text, { dimColor: true, children: "plan \u203A explore \u203A edit \u203A build \u203A verify" }))) : (_jsx(Text, { dimColor: true, children: "ready" })) }), _jsxs(Text, { children: [_jsxs(Text, { color: permColor, children: ["[", perm, "]"] }), _jsx(Text, { children: " " }), _jsx(Text, { color: lampColor, children: lampLabel }), _jsxs(Text, { dimColor: true, children: [" ", lampText] }), _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: stepLabel })] })] }));
|
|
49
|
+
}
|
|
50
|
+
/** Breadcrumb tiến trình: plan › explore › edit › build › verify. */
|
|
51
|
+
const PHASE_SEQUENCE = ["plan", "explore", "edit", "build", "verify"];
|
|
52
|
+
/** Clean horizontal phase trail with separator dots (● visited, ○ pending, ◆ current). */
|
|
53
|
+
function PhaseBreadcrumb({ phases }) {
|
|
54
|
+
const current = phases[phases.length - 1] ?? null;
|
|
55
|
+
return (_jsx(Text, { children: PHASE_SEQUENCE.map((p, i) => {
|
|
56
|
+
const visited = phases.includes(p);
|
|
57
|
+
const isCurrent = p === current;
|
|
58
|
+
const dot = isCurrent ? "◆" : visited ? "●" : "○";
|
|
59
|
+
return (_jsxs(Text, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " \u00B7 " }), _jsxs(Text, { color: isCurrent ? theme.accent : visited ? theme.success : theme.dim, bold: isCurrent, children: [dot, " ", p] })] }, p));
|
|
60
|
+
}) }));
|
|
61
|
+
}
|
|
62
|
+
/** Metrics cho narrow-terminal — không còn dùng (sidebar đã thay thế), giữ để tham khảo. */
|
|
63
|
+
/**
|
|
64
|
+
* Status bar — bottom row. Left: spinner · status · elapsed · model.
|
|
65
|
+
* Right: scroll position OR keyboard hints.
|
|
66
|
+
*/
|
|
67
|
+
export function StatusBar({ state, scroll }) {
|
|
68
|
+
const { columns } = useWindowSize();
|
|
69
|
+
const width = Math.max(40, (columns ?? 100) - 6);
|
|
70
|
+
const statusColor = state.status === "running" ? theme.accent :
|
|
71
|
+
state.status === "success" ? theme.success :
|
|
72
|
+
state.status === "failed" ? theme.error : theme.dim;
|
|
73
|
+
const statusLabel = state.status === "running"
|
|
74
|
+
? `running ${state.step}${state.maxSteps > 0 ? `/${state.maxSteps}` : ""}`
|
|
75
|
+
: state.status === "idle" ? "idle" : state.status;
|
|
76
|
+
const model = state.model || "—";
|
|
77
|
+
const elapsed = state.startedAt
|
|
78
|
+
? `${Math.floor((Date.now() - state.startedAt) / 60000)}:${String(Math.floor((Date.now() - state.startedAt) / 1000) % 60).padStart(2, "0")}`
|
|
79
|
+
: null;
|
|
80
|
+
// Right side: scroll position OR helpful keybinds
|
|
81
|
+
const rightText = scroll
|
|
82
|
+
? `scroll ${scroll} · esc→tail`
|
|
83
|
+
: `⏎ send · ctrl+s sessions · ctrl+t ${state.thinkingVisible ? "◉" : "○"} · esc abort · ctrl+c quit`;
|
|
84
|
+
const leftBudget = Math.max(24, width - rightText.length - 3);
|
|
85
|
+
let left = statusLabel;
|
|
86
|
+
if (elapsed && state.status === "running")
|
|
87
|
+
left += ` · ${elapsed}`;
|
|
88
|
+
if (model && model !== "—") {
|
|
89
|
+
const part = ` · ${model}`;
|
|
90
|
+
if (left.length + part.length <= leftBudget)
|
|
91
|
+
left += part;
|
|
92
|
+
else
|
|
93
|
+
left = fit(left, leftBudget);
|
|
94
|
+
}
|
|
95
|
+
return (_jsxs(Box, { borderStyle: "bold", borderColor: theme.border, paddingX: 1, justifyContent: "space-between", alignItems: "center", flexShrink: 0, children: [_jsxs(Text, { children: [state.status === "running" && _jsx(Spinner, { active: true }), state.status === "running" ? " " : " ", _jsx(Text, { color: statusColor, bold: true, children: left })] }), _jsx(Text, { dimColor: true, children: fit(rightText, Math.floor(width * 0.5)) })] }));
|
|
96
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { listSessions, resolveSessionId } from "../session.js";
|
|
2
|
+
import { discoverSkills } from "../skills.js";
|
|
3
|
+
import { formatSlashHelp, parseMcpExec } from "./commands.js";
|
|
4
|
+
/** Format usage/cost block (dùng chung — REPL console.log, TUI notice). */
|
|
5
|
+
export function formatUsage(u) {
|
|
6
|
+
return u.calls > 0
|
|
7
|
+
? `session usage: ${u.calls} LLM calls · tokens in ${u.inputTokens} / out ${u.outputTokens}` +
|
|
8
|
+
(u.cachedTokens > 0 ? ` (+${u.cachedTokens} cached)` : "")
|
|
9
|
+
: "no LLM calls yet in this session";
|
|
10
|
+
}
|
|
11
|
+
/** Format skills listing (dùng chung). */
|
|
12
|
+
export function formatSkills(skills) {
|
|
13
|
+
if (skills.length === 0) {
|
|
14
|
+
return "no skills discovered — add .scoder/skills/<name>/SKILL.md (name + description frontmatter)";
|
|
15
|
+
}
|
|
16
|
+
return ("Skills:\n" +
|
|
17
|
+
skills
|
|
18
|
+
.map((s) => ` ${s.name.padEnd(20)} ${s.description || "(no description)"} [${s.scope}]`)
|
|
19
|
+
.join("\n"));
|
|
20
|
+
}
|
|
21
|
+
/** Format sessions listing (dùng chung). */
|
|
22
|
+
export function formatSessions(all) {
|
|
23
|
+
return all.length === 0
|
|
24
|
+
? "no saved sessions — completed runs auto-save to .scoder/sessions/"
|
|
25
|
+
: all.slice(0, 10).map((x) => `${x.id.slice(0, 8)} ${x.updatedAt.slice(0, 16).replace("T", " ")} ${x.steps} steps ${x.task}`).join("\n") +
|
|
26
|
+
"\nresume: /sessions resume <id|last>";
|
|
27
|
+
}
|
|
28
|
+
/** Format MCP status listing (dùng chung). */
|
|
29
|
+
export function formatMcpStatus(status) {
|
|
30
|
+
if (status.length === 0)
|
|
31
|
+
return "no MCP servers configured — add mcpServers to .scoderrc.json";
|
|
32
|
+
return status
|
|
33
|
+
.map((srv) => `${srv.connected ? "✓" : "·"} ${srv.name} — ${srv.command}` +
|
|
34
|
+
(srv.connected ? ` (${srv.tools.length} tools)\n` + srv.tools.map((t) => ` mcp__${srv.name}__${t.name} ${t.description ?? ""}`).join("\n") : " (not connected)"))
|
|
35
|
+
.join("\n");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Handlers dùng chung. Command đặc thù frontend (/review, /undo, /theme — chỉ
|
|
39
|
+
* TUI; /diff TUI có turn-scope riêng) KHÔNG nằm đây — frontend xử lý trước
|
|
40
|
+
* khi dispatch, rồi mới gọi dispatchCommand() cho phần còn lại.
|
|
41
|
+
*/
|
|
42
|
+
export function coreCommands() {
|
|
43
|
+
return [
|
|
44
|
+
{
|
|
45
|
+
name: "/help",
|
|
46
|
+
description: "show this command list",
|
|
47
|
+
run(_slash, _host, notice) {
|
|
48
|
+
notice(formatSlashHelp("scoder tui"));
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "/cost",
|
|
53
|
+
description: "token usage of the current session",
|
|
54
|
+
run(_slash, host, notice) {
|
|
55
|
+
notice(formatUsage(host.agent()?.getUsage() ?? { calls: 0, inputTokens: 0, outputTokens: 0, cachedTokens: 0 }));
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "/skills",
|
|
60
|
+
description: "list discovered skills (SKILL.md)",
|
|
61
|
+
run(_slash, host, notice) {
|
|
62
|
+
notice(formatSkills(host.agent()?.getSkills() ?? discoverSkills(host.config.workdir)));
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "/skill",
|
|
67
|
+
description: "load a skill into context: /skill <name>",
|
|
68
|
+
run(slash, host, notice) {
|
|
69
|
+
if (!slash.arg)
|
|
70
|
+
return notice("usage: /skill <name>", "warn");
|
|
71
|
+
const r = host.agent()?.stageSkill(slash.arg);
|
|
72
|
+
if (!r)
|
|
73
|
+
return notice("agent not ready", "warn");
|
|
74
|
+
notice(r.message, r.ok ? "info" : "error");
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
name: "/mcp",
|
|
79
|
+
description: "MCP servers & tools: /mcp [server] [tool] [json|text]",
|
|
80
|
+
async run(slash, host, notice) {
|
|
81
|
+
const agent = host.agent();
|
|
82
|
+
if (!agent)
|
|
83
|
+
return notice("agent not ready", "warn");
|
|
84
|
+
const exec = parseMcpExec(slash.arg);
|
|
85
|
+
if (exec === null) {
|
|
86
|
+
notice(formatMcpStatus(await agent.mcpStatus()));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if ("error" in exec)
|
|
90
|
+
return notice(exec.error, "warn");
|
|
91
|
+
notice(`→ ${exec.fullName}`);
|
|
92
|
+
const r = await agent.callMcpTool(exec.fullName, exec.args);
|
|
93
|
+
if (r.ok)
|
|
94
|
+
notice(r.output.length > 4000 ? r.output.slice(0, 4000) + "\n… (truncated)" : r.output);
|
|
95
|
+
else
|
|
96
|
+
notice(r.output, "error");
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: "/model",
|
|
101
|
+
description: "show the active model & provider — /model <provider/model> to hot-swap",
|
|
102
|
+
run(slash, host, notice) {
|
|
103
|
+
if (slash.arg) {
|
|
104
|
+
const agent = host.agent();
|
|
105
|
+
if (!agent)
|
|
106
|
+
return notice("agent not ready", "warn");
|
|
107
|
+
const msg = agent.setModel(slash.arg.trim());
|
|
108
|
+
notice(msg, msg.startsWith("ERROR") ? "error" : "info");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const cfg = host.config;
|
|
112
|
+
notice(`model: ${cfg.model} · provider: ${cfg.provider} · build: ${cfg.buildCmd ?? "auto-detect"}`);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "/permission",
|
|
117
|
+
description: "view or switch permission mode: /permission <auto|ask|readonly>",
|
|
118
|
+
run(slash, host, notice) {
|
|
119
|
+
const mode = slash.arg.toLowerCase();
|
|
120
|
+
if (!["auto", "ask", "readonly"].includes(mode)) {
|
|
121
|
+
notice(`permission mode: ${host.config.permissionMode} — usage: /permission <auto|ask|readonly>`, "warn");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
host.agent()?.setPermissionMode(mode);
|
|
125
|
+
host.config = withPermissionMode(host.config, mode);
|
|
126
|
+
notice(`permission mode → ${mode}`);
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "/sessions",
|
|
131
|
+
description: "list saved sessions — /sessions resume <id|last>",
|
|
132
|
+
async run(slash, host, notice) {
|
|
133
|
+
if (slash.arg.startsWith("resume")) {
|
|
134
|
+
const ref = slash.arg.replace(/^resume\s*/, "").trim() || "last";
|
|
135
|
+
const s = resolveSessionId(host.config.workdir, ref);
|
|
136
|
+
if (!s)
|
|
137
|
+
return notice(`session not found: ${ref} — bare /sessions lists saved ones`, "error");
|
|
138
|
+
if (host.isRunning?.())
|
|
139
|
+
return notice("agent is running — wait or Esc first", "warn");
|
|
140
|
+
if (host.resumeAndRun) {
|
|
141
|
+
await host.resumeAndRun(s);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
notice(`resumed session ${s.id.slice(0, 8)}`);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
notice(formatSessions(listSessions(host.config.workdir)));
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
];
|
|
151
|
+
}
|
|
152
|
+
/** Clone config với permissionMode mới (host.config thay thế được). */
|
|
153
|
+
function withPermissionMode(cfg, mode) {
|
|
154
|
+
return { ...cfg, permissionMode: mode };
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Dispatch một input qua registry. Trả về {handled:false} khi không phải
|
|
158
|
+
* slash command. Frontend xử lý command riêng (TUI: /review /undo /theme
|
|
159
|
+
* /diff; REPL: không có) TRƯỚC khi gọi hàm này.
|
|
160
|
+
*/
|
|
161
|
+
export async function dispatchCommand(input, host, notice, extra = []) {
|
|
162
|
+
const m = /^\/([a-zA-Z][a-zA-Z0-9]*)(?:\s+(.*\S))?\s*$/.exec(input.trim());
|
|
163
|
+
if (!m)
|
|
164
|
+
return { handled: false, outcome: null };
|
|
165
|
+
const cmd = "/" + m[1].toLowerCase();
|
|
166
|
+
const arg = m[2] ?? "";
|
|
167
|
+
const slash = { cmd, arg };
|
|
168
|
+
if (cmd === "/exit" || cmd === "/quit")
|
|
169
|
+
return { handled: true, outcome: { quit: true } };
|
|
170
|
+
if (cmd === "/clear") {
|
|
171
|
+
host.agent()?.abort();
|
|
172
|
+
host.replaceAgent(null);
|
|
173
|
+
return { handled: true, outcome: { notice: "conversation cleared — context reset" } };
|
|
174
|
+
}
|
|
175
|
+
const entry = [...extra, ...coreCommands()].find((c) => c.name === cmd);
|
|
176
|
+
if (!entry) {
|
|
177
|
+
notice(`unknown command ${cmd} — try /help`, "error");
|
|
178
|
+
return { handled: true, outcome: {} };
|
|
179
|
+
}
|
|
180
|
+
let outcome;
|
|
181
|
+
try {
|
|
182
|
+
outcome = await entry.run(slash, host, notice);
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
notice(err.message, "error");
|
|
186
|
+
return { handled: true, outcome: {} };
|
|
187
|
+
}
|
|
188
|
+
return { handled: true, outcome: outcome ?? {} };
|
|
189
|
+
}
|