s-app-scoder-agent 0.5.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 -435
- package/dist/agent.js +829 -168
- 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 +216 -45
- package/dist/json-schema.js +58 -0
- package/dist/llm.js +148 -11
- 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 +537 -24
- package/dist/tui/app.js +615 -0
- 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 +222 -0
- 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 +342 -0
- package/dist/tui/theme.js +90 -0
- package/dist/tui/vim.js +60 -0
- package/dist/ui.js +43 -3
- package/dist/watch.js +52 -0
- package/package.json +18 -5
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash commands shared by the classic REPL (src/index.ts) and the Ink TUI
|
|
3
|
+
* (src/tui/app.tsx). Pure parsing/formatting only — each frontend executes
|
|
4
|
+
* the command against its own context, so both stay in sync on the surface.
|
|
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)" },
|
|
21
|
+
];
|
|
22
|
+
/** Parse "/cmd arg…"; returns null when the input is not a slash command. */
|
|
23
|
+
export function parseSlashCommand(input) {
|
|
24
|
+
const m = /^\/([a-zA-Z][a-zA-Z0-9]*)(?:\s+(.*\S))?\s*$/.exec(input.trim());
|
|
25
|
+
if (!m)
|
|
26
|
+
return null;
|
|
27
|
+
return { cmd: "/" + m[1].toLowerCase(), arg: m[2] ?? "" };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Tab completion for a partial input starting with "/".
|
|
31
|
+
* Returns the completed command (with trailing space) plus all candidates,
|
|
32
|
+
* or null when the input is not completable.
|
|
33
|
+
*/
|
|
34
|
+
export function completeSlashCommand(input) {
|
|
35
|
+
const trimmed = input.trim();
|
|
36
|
+
if (!trimmed.startsWith("/"))
|
|
37
|
+
return null;
|
|
38
|
+
const partial = trimmed.toLowerCase();
|
|
39
|
+
const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(partial) && partial.length >= 1);
|
|
40
|
+
if (matches.length === 0)
|
|
41
|
+
return null;
|
|
42
|
+
// Exact match — complete to it (e.g. typing "/help" + Tab still completes).
|
|
43
|
+
if (matches.length === 1)
|
|
44
|
+
return { completed: matches[0].cmd + " ", candidates: matches };
|
|
45
|
+
// Common prefix of all matches (e.g. "/sk" → "/skill" prefix of /skills+/skill).
|
|
46
|
+
let prefix = matches[0].cmd;
|
|
47
|
+
for (const m of matches.slice(1)) {
|
|
48
|
+
let i = 0;
|
|
49
|
+
while (i < prefix.length && i < m.cmd.length && prefix[i] === m.cmd[i])
|
|
50
|
+
i++;
|
|
51
|
+
prefix = prefix.slice(0, i);
|
|
52
|
+
}
|
|
53
|
+
return { completed: prefix + (matches.length === 1 ? " " : ""), candidates: matches };
|
|
54
|
+
}
|
|
55
|
+
import { suggest } from "./at-complete.js";
|
|
56
|
+
/** Build providers from runtime context (called once per keystroke burst). */
|
|
57
|
+
export function completionProviders(sources) {
|
|
58
|
+
return [
|
|
59
|
+
{
|
|
60
|
+
id: "commands",
|
|
61
|
+
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 + " " })),
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "files",
|
|
67
|
+
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
|
+
{
|
|
71
|
+
id: "skills",
|
|
72
|
+
match: (t) => t.startsWith("@") && t.length > 1,
|
|
73
|
+
candidates: (t) => {
|
|
74
|
+
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} ` }));
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
id: "agents",
|
|
83
|
+
match: (t) => t.startsWith("@") && t.length > 1,
|
|
84
|
+
candidates: (t) => {
|
|
85
|
+
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} ` }));
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Ghost text: when the current input has exactly ONE candidate across all
|
|
96
|
+
* providers and it extends the typed token, return the remainder to render
|
|
97
|
+
* dim after the cursor (Tab accepts). Returns null otherwise.
|
|
98
|
+
*/
|
|
99
|
+
export function ghostText(value, providers) {
|
|
100
|
+
const tokens = value.split(/\s/);
|
|
101
|
+
const token = tokens[tokens.length - 1] ?? "";
|
|
102
|
+
if (!token || value.endsWith(" "))
|
|
103
|
+
return null;
|
|
104
|
+
const all = [];
|
|
105
|
+
for (const p of providers) {
|
|
106
|
+
if (!p.match(token))
|
|
107
|
+
continue;
|
|
108
|
+
all.push(...p.candidates(token));
|
|
109
|
+
if (all.length > 1)
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (all.length !== 1)
|
|
113
|
+
return null;
|
|
114
|
+
const insert = all[0].insert;
|
|
115
|
+
// Ghost shows only the unread part of a plain extension (no replacement).
|
|
116
|
+
if (!insert.toLowerCase().startsWith(token.toLowerCase()))
|
|
117
|
+
return null;
|
|
118
|
+
const rest = insert.slice(token.length);
|
|
119
|
+
if (!rest)
|
|
120
|
+
return null;
|
|
121
|
+
return { rest, insert };
|
|
122
|
+
}
|
|
123
|
+
/** Format the /help listing (shared so both frontends render identically). */
|
|
124
|
+
export function formatSlashHelp(commandName) {
|
|
125
|
+
const rows = SLASH_COMMANDS.map((c) => ` ${c.cmd.padEnd(12)} ${c.desc}`).join("\n");
|
|
126
|
+
return `Commands (${commandName}):\n${rows}\n Anything else is sent to the agent as a task.\n Tab completes commands; ↑/↓ recalls history.`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Tab completion for `/mcp` arguments: server name, then tool name.
|
|
130
|
+
* Input forms: "/mcp " → servers; "/mcp srv " → srv's tools; "/mcp srv par" → tools starting "par".
|
|
131
|
+
* Returns the completed text or null.
|
|
132
|
+
*/
|
|
133
|
+
export function completeMcpArg(input, servers) {
|
|
134
|
+
const m = /^\/mcp\s+(\S*)$/.exec(input);
|
|
135
|
+
if (m) {
|
|
136
|
+
// Đang gõ tên server.
|
|
137
|
+
const partial = m[1].toLowerCase();
|
|
138
|
+
const hits = servers.filter((s) => s.name.toLowerCase().startsWith(partial));
|
|
139
|
+
if (hits.length === 1)
|
|
140
|
+
return `/mcp ${hits[0].name} `;
|
|
141
|
+
if (hits.length > 1) {
|
|
142
|
+
let prefix = hits[0].name;
|
|
143
|
+
for (const h of hits.slice(1)) {
|
|
144
|
+
let i = 0;
|
|
145
|
+
while (i < prefix.length && i < h.name.length && prefix[i] === h.name[i])
|
|
146
|
+
i++;
|
|
147
|
+
prefix = prefix.slice(0, i);
|
|
148
|
+
}
|
|
149
|
+
return `/mcp ${prefix}`;
|
|
150
|
+
}
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
const tm = /^\/mcp\s+(\S+)\s+(\S*)$/.exec(input);
|
|
154
|
+
if (tm) {
|
|
155
|
+
// Đang gõ tên tool (bỏ prefix mcp__srv__ khi gõ).
|
|
156
|
+
const srv = servers.find((s) => s.name.toLowerCase() === tm[1].toLowerCase());
|
|
157
|
+
if (!srv)
|
|
158
|
+
return null;
|
|
159
|
+
const partial = tm[2].toLowerCase().replace(/^mcp__\S+?__/, "");
|
|
160
|
+
const hits = srv.tools.filter((t) => t.name.toLowerCase().startsWith(partial));
|
|
161
|
+
if (hits.length === 1)
|
|
162
|
+
return `/mcp ${srv.name} ${hits[0].name} `;
|
|
163
|
+
if (hits.length > 1) {
|
|
164
|
+
let prefix = hits[0].name;
|
|
165
|
+
for (const h of hits.slice(1)) {
|
|
166
|
+
let i = 0;
|
|
167
|
+
while (i < prefix.length && i < h.name.length && prefix[i] === h.name[i])
|
|
168
|
+
i++;
|
|
169
|
+
prefix = prefix.slice(0, i);
|
|
170
|
+
}
|
|
171
|
+
return `/mcp ${srv.name} ${prefix}`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Parse a `/mcp` exec invocation into (fullName, args).
|
|
178
|
+
*
|
|
179
|
+
* Forms:
|
|
180
|
+
* /mcp → list (no exec)
|
|
181
|
+
* /mcp server tool {"k":"v"} → mcp__server__tool
|
|
182
|
+
* /mcp mcp__server__tool {"k":"v"} → as-is (full name)
|
|
183
|
+
* /mcp server tool free text → args resolved from the tool's
|
|
184
|
+
* first string parameter (see
|
|
185
|
+
* Agent.callMcpTool `__text`)
|
|
186
|
+
* The JSON part is optional; non-JSON text is passed as the `__text` shorthand.
|
|
187
|
+
*/
|
|
188
|
+
export function parseMcpExec(arg) {
|
|
189
|
+
const trimmed = arg.trim();
|
|
190
|
+
if (!trimmed)
|
|
191
|
+
return null; // list mode
|
|
192
|
+
const tokens = trimmed.split(/\s+/);
|
|
193
|
+
let fullName;
|
|
194
|
+
let rest;
|
|
195
|
+
if (tokens[0].startsWith("mcp__")) {
|
|
196
|
+
fullName = tokens[0];
|
|
197
|
+
rest = trimmed.slice(tokens[0].length).trim();
|
|
198
|
+
}
|
|
199
|
+
else {
|
|
200
|
+
if (tokens.length < 2)
|
|
201
|
+
return { error: "usage: /mcp <server> <tool> [json-args] — list servers with bare /mcp" };
|
|
202
|
+
fullName = `mcp__${tokens[0]}__${tokens[1]}`;
|
|
203
|
+
// Preserve the JSON part verbatim (it may contain spaces).
|
|
204
|
+
const m = /^(\S+)\s+(\S+)(?:\s+([\s\S]*))?$/.exec(trimmed);
|
|
205
|
+
rest = m?.[3] ?? "";
|
|
206
|
+
}
|
|
207
|
+
if (!rest)
|
|
208
|
+
return { fullName, args: {} };
|
|
209
|
+
try {
|
|
210
|
+
const parsed = JSON.parse(rest);
|
|
211
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
212
|
+
return { error: "json args must be an object, e.g. {\"prompt\": \"...\"}" };
|
|
213
|
+
}
|
|
214
|
+
return { fullName, args: parsed };
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Not JSON — treat the rest as the value of the tool's first required
|
|
218
|
+
// string parameter (discoverable from its inputSchema via /mcp listing),
|
|
219
|
+
// e.g. `/mcp exec mcp__srv__tool phân tích project`.
|
|
220
|
+
return { fullName, args: { __text: rest } };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T7: image render trong chat — detect terminal protocol + halfblock preview.
|
|
3
|
+
* Không thêm dependency: halfblock qua unicode upper-half ▀ với truecolor
|
|
4
|
+
* (iTerm2/kitty hỗ trợ COLORTERM=truecolor). Terminal không hỗ trợ → fallback
|
|
5
|
+
* link text. ponytail: 40px width cố định, không đo cell size thật — nâng
|
|
6
|
+
* cấp khi cần aspect ratio chuẩn.
|
|
7
|
+
*/
|
|
8
|
+
import { inflateSync } from "node:zlib";
|
|
9
|
+
export function detectImageCaps(env = process.env) {
|
|
10
|
+
const truecolor = env.COLORTERM === "truecolor" || env.COLORTERM === "24bit";
|
|
11
|
+
const iterm = env.TERM_PROGRAM === "iTerm.app";
|
|
12
|
+
const kitty = env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID !== undefined;
|
|
13
|
+
return { truecolor, iterm, kitty };
|
|
14
|
+
}
|
|
15
|
+
const IMG_MAGIC = [
|
|
16
|
+
{ ext: "png", test: (b) => b.length > 8 && b[0] === 0x89 && b[1] === 0x50 },
|
|
17
|
+
{ ext: "jpg", test: (b) => b.length > 3 && b[0] === 0xff && b[1] === 0xd8 },
|
|
18
|
+
{ ext: "gif", test: (b) => b.length > 6 && b.toString("ascii", 0, 3) === "GIF" },
|
|
19
|
+
{ ext: "bmp", test: (b) => b.length > 2 && b[0] === 0x42 && b[1] === 0x4d },
|
|
20
|
+
];
|
|
21
|
+
/** Detect image từ magic bytes (đuôi file không đáng tin). */
|
|
22
|
+
export function detectImageBytes(buf) {
|
|
23
|
+
return IMG_MAGIC.find((m) => m.test(buf))?.ext ?? null;
|
|
24
|
+
}
|
|
25
|
+
/** Decode PNG/JPG size (width/height) — chỉ PNG (IHDR) và JPEG (SOF markers). */
|
|
26
|
+
export function imageSize(buf) {
|
|
27
|
+
if (detectImageBytes(buf) === "png" && buf.length > 24) {
|
|
28
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
29
|
+
}
|
|
30
|
+
if (detectImageBytes(buf) === "jpg") {
|
|
31
|
+
let i = 2;
|
|
32
|
+
while (i + 9 < buf.length) {
|
|
33
|
+
if (buf[i] !== 0xff)
|
|
34
|
+
break;
|
|
35
|
+
const marker = buf[i + 1];
|
|
36
|
+
const len = buf.readUInt16BE(i + 2);
|
|
37
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
38
|
+
return { height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
|
39
|
+
}
|
|
40
|
+
i += 2 + len;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Render buffer ảnh thành mảng dòng "▀" (2 pixel ảnh / 1 dòng terminal).
|
|
47
|
+
* Trả về null khi terminal không truecolor (caller fallback link).
|
|
48
|
+
*/
|
|
49
|
+
export function halfblockRows(buf, maxCols = 40) {
|
|
50
|
+
const png = detectImageBytes(buf) === "png";
|
|
51
|
+
if (!png)
|
|
52
|
+
return null; // ponytail: chỉ decode PNG thuần (zlib builtin); JPG → null
|
|
53
|
+
const size = imageSize(buf);
|
|
54
|
+
if (!size || size.width === 0 || size.height === 0)
|
|
55
|
+
return null;
|
|
56
|
+
// Decode IDAT: zlib inflate + unfilter scanlines.
|
|
57
|
+
let rows;
|
|
58
|
+
try {
|
|
59
|
+
rows = decodePngPixels(buf, size.width, size.height);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const w = size.width;
|
|
65
|
+
const cols = Math.min(maxCols, w);
|
|
66
|
+
const step = w / cols; // downsample theo cột
|
|
67
|
+
const out = [];
|
|
68
|
+
for (let py = 0; py < size.height; py += 2) {
|
|
69
|
+
let line = "";
|
|
70
|
+
for (let cx = 0; cx < cols; cx++) {
|
|
71
|
+
const px = Math.min(w - 1, Math.floor(cx * step));
|
|
72
|
+
const top = pixelAt(rows, w, px, py);
|
|
73
|
+
const bot = py + 1 < size.height ? pixelAt(rows, w, px, py + 1) : [0, 0, 0];
|
|
74
|
+
line += `\x1b[38;2;${top[0]};${top[1]};${top[2]};48;2;${bot[0]};${bot[1]};${bot[2]}m▀`;
|
|
75
|
+
}
|
|
76
|
+
out.push({ text: line + "\x1b[0m" });
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
function pixelAt(rows, w, x, y) {
|
|
81
|
+
const o = (y * w + x) * 3;
|
|
82
|
+
return [rows[o], rows[o + 1], rows[o + 2]];
|
|
83
|
+
}
|
|
84
|
+
/** PNG IDAT decode (zlib builtin) + unfilter — RGB/RGBA 8-bit không interlace. */
|
|
85
|
+
function decodePngPixels(buf, w, h) {
|
|
86
|
+
// Scan chunks: gather IDAT data.
|
|
87
|
+
let off = 8;
|
|
88
|
+
const idat = [];
|
|
89
|
+
let bitDepth = 8;
|
|
90
|
+
let colorType = 2;
|
|
91
|
+
while (off + 8 <= buf.length) {
|
|
92
|
+
const len = buf.readUInt32BE(off);
|
|
93
|
+
const type = buf.toString("ascii", off + 4, off + 8);
|
|
94
|
+
if (type === "IHDR") {
|
|
95
|
+
bitDepth = buf[off + 16];
|
|
96
|
+
colorType = buf[off + 17];
|
|
97
|
+
}
|
|
98
|
+
if (type === "IDAT")
|
|
99
|
+
idat.push(buf.subarray(off + 8, off + 8 + len));
|
|
100
|
+
if (type === "IEND")
|
|
101
|
+
break;
|
|
102
|
+
off += 12 + len;
|
|
103
|
+
}
|
|
104
|
+
if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6))
|
|
105
|
+
throw new Error("unsupported png");
|
|
106
|
+
const bpp = colorType === 6 ? 4 : 3;
|
|
107
|
+
const raw = inflateSync(Buffer.concat(idat));
|
|
108
|
+
const stride = w * bpp;
|
|
109
|
+
const px = new Uint8Array(w * h * 3);
|
|
110
|
+
let prev = new Uint8Array(stride);
|
|
111
|
+
for (let y = 0; y < h; y++) {
|
|
112
|
+
const f = raw[y * (stride + 1)];
|
|
113
|
+
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
|
|
114
|
+
const cur = new Uint8Array(stride);
|
|
115
|
+
for (let x = 0; x < stride; x++) {
|
|
116
|
+
const a = x >= bpp ? cur[x - bpp] : 0;
|
|
117
|
+
const b = prev[x];
|
|
118
|
+
const c = x >= bpp ? prev[x - bpp] : 0;
|
|
119
|
+
let v = line[x];
|
|
120
|
+
switch (f) {
|
|
121
|
+
case 1:
|
|
122
|
+
v += a;
|
|
123
|
+
break;
|
|
124
|
+
case 2:
|
|
125
|
+
v += b;
|
|
126
|
+
break;
|
|
127
|
+
case 3:
|
|
128
|
+
v += (a + b) >> 1;
|
|
129
|
+
break;
|
|
130
|
+
case 4: {
|
|
131
|
+
const p = a + b - c;
|
|
132
|
+
const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c);
|
|
133
|
+
v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
cur[x] = v & 0xff;
|
|
138
|
+
}
|
|
139
|
+
for (let x = 0; x < w; x++) {
|
|
140
|
+
const so = x * bpp;
|
|
141
|
+
const do_ = (y * w + x) * 3;
|
|
142
|
+
px[do_] = cur[so];
|
|
143
|
+
px[do_ + 1] = cur[so + 1];
|
|
144
|
+
px[do_ + 2] = cur[so + 2];
|
|
145
|
+
}
|
|
146
|
+
prev = cur;
|
|
147
|
+
}
|
|
148
|
+
return px;
|
|
149
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* T2: Input component — ↑/↓ history, Tab slash-command completion, @ fuzzy
|
|
4
|
+
* file autocomplete, ghost text. Tách khỏi app.tsx.
|
|
5
|
+
*/
|
|
6
|
+
import { useCallback, useMemo, useRef, useState } from "react";
|
|
7
|
+
import { Box, Text, useInput } from "ink";
|
|
8
|
+
import { applySuggestion, atQuery, fileIndex, suggest } from "./at-complete.js";
|
|
9
|
+
import { completeSlashCommand, completionProviders, ghostText, completeMcpArg } from "./commands.js";
|
|
10
|
+
import { applyNormalKey } from "./vim.js"; // T5
|
|
11
|
+
import { theme } from "./theme.js";
|
|
12
|
+
export function Input({ valueRef, value, onChange, onSubmit, historyRef, skillNames, agentRef, config, atSources, modalRef }) {
|
|
13
|
+
const histIdx = useRef(null);
|
|
14
|
+
const draft = useRef("");
|
|
15
|
+
// ── T5: vim normal/insert mode ──
|
|
16
|
+
// Insert là mode mặc định (chat input); Esc → normal, i/a/A → insert.
|
|
17
|
+
// Cursor position chỉ có ý nghĩa khi normal mode; insert luôn ở cuối.
|
|
18
|
+
const [vimMode, setVimMode] = useState("insert");
|
|
19
|
+
const [cursor, setCursor] = useState(null); // null = insert (cuối)
|
|
20
|
+
const vimRef = useRef({ mode: "insert", cursor: 0 });
|
|
21
|
+
vimRef.current = { mode: vimMode, cursor: cursor ?? value.length };
|
|
22
|
+
// ── T-01: `@` fuzzy file autocomplete ──────────────────────────────────────
|
|
23
|
+
// Overlay state kept out of React re-render hot path: suggestions are sync
|
|
24
|
+
// (fuzzysort over cached index ≤5s TTL) so we can compute inside useInput.
|
|
25
|
+
const [atState, setAtState] = useState(null);
|
|
26
|
+
const atStateRef = useRef(atState);
|
|
27
|
+
const workdir = config?.workdir ?? process.cwd();
|
|
28
|
+
const refreshAt = useCallback((v) => {
|
|
29
|
+
const at = atQuery(v);
|
|
30
|
+
if (!at) {
|
|
31
|
+
if (atStateRef.current) {
|
|
32
|
+
atStateRef.current = null;
|
|
33
|
+
setAtState(null);
|
|
34
|
+
}
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const suggestions = suggest(fileIndex(workdir), at.query);
|
|
38
|
+
const prev = atStateRef.current;
|
|
39
|
+
// Keep selection when the query narrows (still points at same entry).
|
|
40
|
+
const idx = prev && prev.at.start === at.start && suggestions[prev.idx] === prev.suggestions[prev.idx] ? prev.idx : 0;
|
|
41
|
+
const next = suggestions.length > 0 ? { at, suggestions, idx } : null;
|
|
42
|
+
atStateRef.current = next;
|
|
43
|
+
setAtState(next);
|
|
44
|
+
}, [workdir]);
|
|
45
|
+
const acceptAt = useCallback((sel) => {
|
|
46
|
+
const st = atStateRef.current;
|
|
47
|
+
if (!st || sel === undefined)
|
|
48
|
+
return;
|
|
49
|
+
const v = applySuggestion(valueRef.current, st.at, sel);
|
|
50
|
+
valueRef.current = v;
|
|
51
|
+
onChange(v);
|
|
52
|
+
atStateRef.current = null;
|
|
53
|
+
setAtState(null);
|
|
54
|
+
}, [onChange, valueRef]);
|
|
55
|
+
useInput((input, key) => {
|
|
56
|
+
if (modalRef.current)
|
|
57
|
+
return; // T-03: modal owns the keyboard
|
|
58
|
+
const current = valueRef.current;
|
|
59
|
+
// T5: vim mode — Esc → normal (nếu không có @ overlay), normal keys qua keymap.
|
|
60
|
+
if (key.escape && !atStateRef.current) {
|
|
61
|
+
setCursor(vimRef.current.cursor);
|
|
62
|
+
setVimMode("normal");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (vimRef.current.mode === "normal" && input.length === 1 && !key.ctrl && !key.meta) {
|
|
66
|
+
const r = applyNormalKey(current, vimRef.current.cursor, input);
|
|
67
|
+
if (!r.unhandled) {
|
|
68
|
+
valueRef.current = r.value;
|
|
69
|
+
onChange(r.value);
|
|
70
|
+
setCursor(r.cursor);
|
|
71
|
+
setVimMode(r.mode);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (key.return) {
|
|
75
|
+
setVimMode("insert");
|
|
76
|
+
setCursor(null);
|
|
77
|
+
histIdx.current = null;
|
|
78
|
+
onSubmit(current);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
// Phím lạ trong normal mode (mũi tên, ctrl...) — fall through với
|
|
82
|
+
// cursor giữ nguyên, xử lý như insert bên dưới.
|
|
83
|
+
}
|
|
84
|
+
else if (vimRef.current.mode === "normal") {
|
|
85
|
+
return; // normal mode bỏ qua paste chunks
|
|
86
|
+
}
|
|
87
|
+
// T-01: Esc closes the @ overlay (before submit semantics).
|
|
88
|
+
if (key.escape) {
|
|
89
|
+
if (atStateRef.current) {
|
|
90
|
+
atStateRef.current = null;
|
|
91
|
+
setAtState(null);
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (key.return) {
|
|
96
|
+
histIdx.current = null;
|
|
97
|
+
onSubmit(current);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
// ↑/↓ message history recall — but T-01 @ overlay takes precedence.
|
|
101
|
+
if (key.upArrow || key.downArrow) {
|
|
102
|
+
if (atStateRef.current) {
|
|
103
|
+
const st = atStateRef.current;
|
|
104
|
+
const idx = key.upArrow ? Math.max(0, st.idx - 1) : Math.min(st.suggestions.length - 1, st.idx + 1);
|
|
105
|
+
const next = { ...st, idx };
|
|
106
|
+
atStateRef.current = next;
|
|
107
|
+
setAtState(next);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const hist = historyRef.current;
|
|
111
|
+
if (hist.length === 0)
|
|
112
|
+
return;
|
|
113
|
+
if (histIdx.current === null) {
|
|
114
|
+
if (key.downArrow)
|
|
115
|
+
return;
|
|
116
|
+
draft.current = current;
|
|
117
|
+
histIdx.current = hist.length - 1;
|
|
118
|
+
}
|
|
119
|
+
else if (key.upArrow) {
|
|
120
|
+
histIdx.current = Math.max(0, histIdx.current - 1);
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
histIdx.current += 1;
|
|
124
|
+
if (histIdx.current >= hist.length) {
|
|
125
|
+
histIdx.current = null;
|
|
126
|
+
onChange(draft.current);
|
|
127
|
+
valueRef.current = draft.current;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const v = hist[histIdx.current];
|
|
132
|
+
valueRef.current = v;
|
|
133
|
+
onChange(v);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// Tab — complete slash command (and /skill, /mcp arguments)
|
|
137
|
+
if (key.tab) {
|
|
138
|
+
const argMatch = /^\/skill\s+(\S*)$/.exec(current);
|
|
139
|
+
if (argMatch) {
|
|
140
|
+
const partial = argMatch[1].toLowerCase();
|
|
141
|
+
const hits = skillNames.filter((n) => n.toLowerCase().startsWith(partial));
|
|
142
|
+
if (hits.length > 0) {
|
|
143
|
+
const v = "/skill " + hits[0] + (hits.length === 1 ? " " : "");
|
|
144
|
+
valueRef.current = v;
|
|
145
|
+
onChange(v);
|
|
146
|
+
}
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
if (/^\/mcp\s/.test(current)) {
|
|
150
|
+
// /mcp completion cần danh sách server/tools — async qua mcpStatus().
|
|
151
|
+
const agent = agentRef.current;
|
|
152
|
+
if (agent) {
|
|
153
|
+
void agent.mcpStatus().then((servers) => {
|
|
154
|
+
const v = completeMcpArg(current, servers);
|
|
155
|
+
if (v) {
|
|
156
|
+
valueRef.current = v;
|
|
157
|
+
onChange(v);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
// T-02: Tab accepts ghost text (unique candidate extension).
|
|
164
|
+
if (!atStateRef.current && ghostRef.current) {
|
|
165
|
+
const v = ghostRef.current.insert;
|
|
166
|
+
valueRef.current = v;
|
|
167
|
+
onChange(v);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
// T-01: Tab accepts the active @ suggestion (dirs keep trailing slash).
|
|
171
|
+
if (atStateRef.current) {
|
|
172
|
+
acceptAt(atStateRef.current.suggestions[atStateRef.current.idx]);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const comp = completeSlashCommand(current);
|
|
176
|
+
if (comp) {
|
|
177
|
+
valueRef.current = comp.completed;
|
|
178
|
+
onChange(comp.completed);
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (key.backspace || key.delete) {
|
|
183
|
+
const next = current.slice(0, -1);
|
|
184
|
+
valueRef.current = next;
|
|
185
|
+
onChange(next);
|
|
186
|
+
refreshAt(next);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (input.length > 0 && !key.ctrl && !key.meta && !key.escape && !key.upArrow && !key.downArrow && !key.tab) {
|
|
190
|
+
// A chunk may contain several pasted chars — even a trailing newline
|
|
191
|
+
// (terminal pastes / PTY writes deliver "exit\r" as one chunk).
|
|
192
|
+
const parts = input.split(/[\r\n]+/);
|
|
193
|
+
const next = current + parts[0];
|
|
194
|
+
valueRef.current = next;
|
|
195
|
+
onChange(next);
|
|
196
|
+
refreshAt(next);
|
|
197
|
+
if (parts.length > 1)
|
|
198
|
+
onSubmit(next); // newline in the chunk = submit
|
|
199
|
+
}
|
|
200
|
+
// T5: bất kỳ phím gõ nào trong insert mode cũng đưa cursor về cuối.
|
|
201
|
+
if (vimRef.current.mode === "insert")
|
|
202
|
+
setCursor(null);
|
|
203
|
+
});
|
|
204
|
+
// Live candidates hint while a slash command is partially typed.
|
|
205
|
+
const hint = value.startsWith("/") && !value.includes(" ") ? completeSlashCommand(value) : null;
|
|
206
|
+
const candidates = hint && hint.candidates.length > 1 ? hint.candidates.map((c) => c.cmd).join(" ") : null;
|
|
207
|
+
const at = atState;
|
|
208
|
+
// T-02 ghost text: unique extension of the last token, dim after cursor.
|
|
209
|
+
const providers = useMemo(() => completionProviders(atSources), [atSources]);
|
|
210
|
+
const ghost = useMemo(() => (atState ? null : ghostText(value, providers)), [value, providers, atState]);
|
|
211
|
+
const ghostRef = useRef(ghost);
|
|
212
|
+
ghostRef.current = ghost;
|
|
213
|
+
return (_jsxs(Box, { flexDirection: "column", children: [at && (_jsxs(Box, { flexDirection: "column", paddingLeft: 2, children: [at.suggestions.map((s, i) => (_jsxs(Text, { color: i === at.idx ? theme.brand : undefined, bold: i === at.idx, children: [i === at.idx ? "▸ " : " ", s] }, s))), _jsx(Text, { dimColor: true, children: " \u2191/\u2193 select \u00B7 tab/enter accept \u00B7 esc close" })] })), _jsxs(Box, { children: [_jsxs(Text, { color: theme.brand, bold: true, children: ["\u276F", " "] }), vimMode === "normal" && cursor !== null ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: value.slice(0, cursor) }), _jsx(Text, { backgroundColor: "gray", color: "black", children: value[cursor] ?? " " }), _jsx(Text, { children: value.slice(cursor + 1) })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { children: value }), ghost && _jsx(Text, { dimColor: true, children: ghost.rest }), _jsx(Text, { dimColor: true, children: "\u258C" })] }))] }), candidates && _jsxs(Text, { dimColor: true, children: [" ", candidates, " (tab completes)"] }), vimMode === "normal" && _jsx(Text, { dimColor: true, children: " -- NORMAL -- h/l/w/b/e \u00B7 x \u00B7 i/a/A insert \u00B7 \u23CE send" })] }));
|
|
214
|
+
}
|