agent-from-zero 0.35.2 → 0.35.3
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/dist/agent.js +6 -4
- package/dist/completion.js +92 -2
- package/dist/config.js +2 -0
- package/dist/ink/app.js +60 -10
- package/dist/ink/commands.js +1 -1
- package/dist/ink/completion.js +13 -5
- package/dist/ink/setup.js +2 -1
- package/dist/ink/skills-panel.js +86 -0
- package/dist/skills.js +97 -4
- package/package.json +2 -1
package/dist/agent.js
CHANGED
|
@@ -33,7 +33,7 @@ import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu
|
|
|
33
33
|
import { editLine } from "./editor.js"; // our own line editor (keeps the status footer pinned even when input wraps)
|
|
34
34
|
import { normalizeDroppedPaths } from "./drop.js"; // drag-and-drop: a dropped file's path → a clean absolute path in the input
|
|
35
35
|
import { rememberTool, readMemory, readMemoryTyped, extractMemories, MEMORY_PATH } from "./memory.js"; // long-term project memory + auto-extract
|
|
36
|
-
import { currentSkills, findSkill, userSkillMessages } from "./skills.js"; // Markdown-as-plugin skills
|
|
36
|
+
import { allSkills, currentSkills, findSkill, recordSkillUsage, skillLocked, skillMode, userSkillMessages } from "./skills.js"; // Markdown-as-plugin skills
|
|
37
37
|
import { initCostMeter, DEFAULT_PRICING } from "./cost.js"; // token & cost accounting for /cost
|
|
38
38
|
import { launchInk } from "./ink/launch.js"; // the Ink REPL — the default front-end for interactive sessions
|
|
39
39
|
import { listBackground, hasRunningBackground, killAllBackground } from "./background.js"; // background tasks: /bg view + kill-on-exit (Day 37)
|
|
@@ -110,7 +110,7 @@ const SESSION_HELP = `commands:
|
|
|
110
110
|
/undo revert the most recent file write (write_file / edit_file) this session
|
|
111
111
|
/diff show every file changed this session, as a diff from where it started
|
|
112
112
|
/resume list recent sessions in this project and continue one of them
|
|
113
|
-
/skills
|
|
113
|
+
/skills manage skills: turn each on, off, or user-only (search with /, sort with t)
|
|
114
114
|
/skill <name> [args] run a skill yourself (works even for user-only skills); /<name> [args] works too
|
|
115
115
|
exit leave (Ctrl+C at the prompt does the same)
|
|
116
116
|
|
|
@@ -607,6 +607,7 @@ async function main() {
|
|
|
607
607
|
for (const content of userSkillMessages(s, args))
|
|
608
608
|
messages.push({ role: "user", content });
|
|
609
609
|
autoMode.recordRequest(`/${s.name}${args ? ` ${args}` : ""}`);
|
|
610
|
+
recordSkillUsage(s.name); // feeds the "/" typeahead's most-used ranking
|
|
610
611
|
console.log(chalk.dim(`(running skill: ${s.name})`));
|
|
611
612
|
running = true;
|
|
612
613
|
interrupted = false;
|
|
@@ -625,14 +626,15 @@ async function main() {
|
|
|
625
626
|
}
|
|
626
627
|
switch (line) {
|
|
627
628
|
case "/skills": {
|
|
628
|
-
const skills =
|
|
629
|
+
const skills = allSkills(); // every skill, with its on / user-only / off state (toggle them in the Ink UI's /skills)
|
|
629
630
|
if (!skills.length) {
|
|
630
631
|
console.log(chalk.dim("(no skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/)"));
|
|
631
632
|
return true;
|
|
632
633
|
}
|
|
633
634
|
console.log(chalk.dim(`skills in this project:`));
|
|
634
635
|
for (const s of skills) {
|
|
635
|
-
const
|
|
636
|
+
const mode = skillMode(s);
|
|
637
|
+
const who = mode === "on" ? chalk.green("on") : mode === "off" ? chalk.dim("off") : chalk.yellow(skillLocked(s) ? "user-only (locked)" : "user-only");
|
|
636
638
|
console.log(chalk.dim(` ${s.name} [${who}${chalk.dim("]")} — ${(s.whenToUse || s.description).slice(0, 70)}`));
|
|
637
639
|
}
|
|
638
640
|
return true;
|
package/dist/completion.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import Fuse from "fuse.js"; // the fuzzy matcher Claude Code's "/" typeahead uses
|
|
1
2
|
import { effortMenu } from "./effort.js";
|
|
2
3
|
export const COMMANDS = [
|
|
3
4
|
{ value: "/model", description: "Switch the model for this session", children: true },
|
|
4
5
|
{ value: "/effort", description: "Choose this model's reasoning effort", children: true },
|
|
5
|
-
{ value: "/skills", description: "
|
|
6
|
+
{ value: "/skills", description: "Turn skills on, off, or user-only" },
|
|
6
7
|
{ value: "/skill", description: "Run a skill by name", children: true },
|
|
7
8
|
{ value: "/help", description: "Show commands and keyboard shortcuts" },
|
|
8
9
|
{ value: "/clear", description: "Start a fresh conversation" },
|
|
@@ -23,6 +24,86 @@ export const COMMANDS = [
|
|
|
23
24
|
{ value: "/diff", description: "Show file changes this session" },
|
|
24
25
|
{ value: "/resume", description: "Choose a previous session" },
|
|
25
26
|
];
|
|
27
|
+
// Every "/<command>" token in the text that names a real command or skill, as
|
|
28
|
+
// [start, end) code-unit ranges — the input box paints these. Claude Code's
|
|
29
|
+
// rule (findSlashCommandPositions): a "/" at the start or after whitespace,
|
|
30
|
+
// then a letter and [A-Za-z0-9:_-]*, anywhere in the text; it lights up only
|
|
31
|
+
// if that name exists. So "/grill-me," lights up, "/grill" and "a/grill-me" don't.
|
|
32
|
+
export function commandTokenRanges(text, names) {
|
|
33
|
+
const known = new Set(names.map((n) => n.toLowerCase()));
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const m of text.matchAll(/(^|\s)(\/[a-zA-Z][a-zA-Z0-9:\-_]*)/g)) {
|
|
36
|
+
const start = (m.index ?? 0) + m[1].length;
|
|
37
|
+
if (known.has(m[2].slice(1).toLowerCase()))
|
|
38
|
+
out.push([start, start + m[2].length]);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
// Names a "/" can reach: built-in commands, then skills (a built-in wins a clash — it is what runs).
|
|
43
|
+
export function commandNames(ctx) {
|
|
44
|
+
const builtins = COMMANDS.map((c) => c.value.slice(1));
|
|
45
|
+
return [...builtins, ...ctx.skills.map((s) => s.name).filter((n) => !builtins.includes(n))];
|
|
46
|
+
}
|
|
47
|
+
function typeaheadItems(ctx) {
|
|
48
|
+
const builtins = new Set(COMMANDS.map((c) => c.value));
|
|
49
|
+
const skills = ctx.skills
|
|
50
|
+
.filter((s) => !builtins.has(`/${s.name}`))
|
|
51
|
+
.map((s) => ({
|
|
52
|
+
value: `/${s.name}`,
|
|
53
|
+
name: s.name,
|
|
54
|
+
// Claude Code's formatDescriptionWithSource: "desc (User)" / "desc (Project)", plus the argument names.
|
|
55
|
+
description: `${s.description || s.whenToUse}${s.source ? ` (${s.source === "project" ? "Project" : "User"})` : ""}${s.argNames?.length ? ` (arguments: ${s.argNames.join(", ")})` : ""}`,
|
|
56
|
+
children: Boolean(s.argNames?.length), // takes arguments → Enter fills it in; none → Enter runs it
|
|
57
|
+
group: s.source ?? "user",
|
|
58
|
+
usage: ctx.usage?.(s.name) ?? 0,
|
|
59
|
+
}));
|
|
60
|
+
return [...COMMANDS.map((c) => ({ ...c, name: c.value.slice(1), group: "builtin", usage: 0 })), ...skills];
|
|
61
|
+
}
|
|
62
|
+
// The "/" typeahead, Claude Code's generateCommandSuggestions:
|
|
63
|
+
// bare "/" → your 5 most-used skills, then built-ins, then user skills, then
|
|
64
|
+
// project skills, each group alphabetical
|
|
65
|
+
// "/query" → Fuse.js over name (weight 3), name parts split on :_- (2) and
|
|
66
|
+
// description words (0.5), threshold 0.3; then re-sorted: exact
|
|
67
|
+
// name, then name prefix (shorter first), then fuzzy score (when
|
|
68
|
+
// it differs by > 0.1), then usage
|
|
69
|
+
function commandTypeahead(input, ctx) {
|
|
70
|
+
const items = typeaheadItems(ctx);
|
|
71
|
+
const q = input.slice(1).toLowerCase();
|
|
72
|
+
const alpha = (a, b) => a.name.localeCompare(b.name);
|
|
73
|
+
if (!q) {
|
|
74
|
+
const top = items.filter((i) => i.usage > 0).sort((a, b) => b.usage - a.usage).slice(0, 5);
|
|
75
|
+
const rest = (group) => items.filter((i) => i.group === group && !top.includes(i)).sort(alpha);
|
|
76
|
+
return [...top, ...rest("builtin"), ...rest("user"), ...rest("project")];
|
|
77
|
+
}
|
|
78
|
+
const docs = items.map((item) => {
|
|
79
|
+
const parts = item.name.split(/[:_-]/);
|
|
80
|
+
return { item, commandName: item.name.toLowerCase(), partKey: parts.length > 1 ? parts.map((p) => p.toLowerCase()) : undefined, descriptionKey: item.description.toLowerCase().split(/\s+/).map((w) => w.replace(/[^a-z0-9]/g, "")).filter(Boolean) };
|
|
81
|
+
});
|
|
82
|
+
const fuse = new Fuse(docs, { includeScore: true, threshold: 0.3, location: 0, distance: 100, keys: [{ name: "commandName", weight: 3 }, { name: "partKey", weight: 2 }, { name: "descriptionKey", weight: 0.5 }] });
|
|
83
|
+
const rank = (name) => (name === q ? 0 : name.startsWith(q) ? 1 : 2);
|
|
84
|
+
return fuse.search(q).sort((a, b) => {
|
|
85
|
+
const an = a.item.commandName, bn = b.item.commandName;
|
|
86
|
+
if (rank(an) !== rank(bn))
|
|
87
|
+
return rank(an) - rank(bn);
|
|
88
|
+
if (rank(an) === 1 && an.length !== bn.length)
|
|
89
|
+
return an.length - bn.length;
|
|
90
|
+
const ds = (a.score ?? 0) - (b.score ?? 0);
|
|
91
|
+
if (Math.abs(ds) > 0.1)
|
|
92
|
+
return ds;
|
|
93
|
+
return b.item.item.usage - a.item.item.usage;
|
|
94
|
+
}).map((r) => r.item.item);
|
|
95
|
+
}
|
|
96
|
+
// A "/" in the middle of the text gets no list — just ghost text: the rest of
|
|
97
|
+
// the best prefix match, dim, after the cursor; Tab accepts it as "/name ".
|
|
98
|
+
// (Claude Code: getBestCommandMatch on /\s\/([a-zA-Z0-9_:-]*)$/ before the cursor.)
|
|
99
|
+
export function inlineCommandGhost(beforeCursor, ctx) {
|
|
100
|
+
const m = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]+)$/);
|
|
101
|
+
if (!m)
|
|
102
|
+
return null;
|
|
103
|
+
const partial = m[1].toLowerCase();
|
|
104
|
+
const best = commandNames(ctx).filter((n) => n.toLowerCase().startsWith(partial) && n.length > partial.length).sort((a, b) => a.length - b.length || a.localeCompare(b))[0];
|
|
105
|
+
return best ? { start: beforeCursor.length - m[1].length - 1, suffix: best.slice(partial.length), full: best } : null;
|
|
106
|
+
}
|
|
26
107
|
export function slashCompletions(input, ctx) {
|
|
27
108
|
if (!input.startsWith("/") || /[\r\n]/.test(input))
|
|
28
109
|
return null;
|
|
@@ -33,7 +114,7 @@ export function slashCompletions(input, ctx) {
|
|
|
33
114
|
const menu = effortMenu(ctx.model);
|
|
34
115
|
return { items: filter(menu.values.map((value, i) => ({ value: `/effort ${value}`, description: menu.labels[i] }))), hint: menu.header };
|
|
35
116
|
}
|
|
36
|
-
if (command === "/skills" || command === "/skill") {
|
|
117
|
+
if ((command === "/skills" && parts.length) || command === "/skill") { // bare "/skills" opens the manager; "/skills <name>" still runs one
|
|
37
118
|
return { items: ctx.skills.filter((s) => s.name.toLowerCase().startsWith(arg)).map((s) => ({ value: `/skill ${s.name}`, description: s.whenToUse || s.description })), hint: ctx.skills.length ? "Choose a skill to run" : "No skills found in project or user skill directories" };
|
|
38
119
|
}
|
|
39
120
|
if (command === "/model") {
|
|
@@ -61,5 +142,14 @@ export function slashCompletions(input, ctx) {
|
|
|
61
142
|
const items = [{ value: "/mcp", description: "Open MCP server menu" }, { value: "/mcp reload", description: "Re-read MCP servers from settings.json" }, ...["tools", "auth", "clear-auth", "reconnect", "enable", "disable"].flatMap((action) => ctx.servers.map((name) => ({ value: `/mcp ${action} ${name}`, description: `${action} ${name}` })))];
|
|
62
143
|
return { items: filter(items, input.trimEnd()) };
|
|
63
144
|
}
|
|
145
|
+
if (!/\s/.test(input))
|
|
146
|
+
return { items: commandTypeahead(input, ctx), hint: "No matching commands or skills" };
|
|
147
|
+
// "/<skill> args…": the user is typing arguments now — no list, just the
|
|
148
|
+
// skill's argument hint while it has one (Claude Code's argumentHint).
|
|
149
|
+
const skill = ctx.skills.find((s) => `/${s.name.toLowerCase()}` === command.toLowerCase());
|
|
150
|
+
if (skill) {
|
|
151
|
+
const hint = skill.argumentHint || (skill.argNames?.length ? skill.argNames.map((a) => `<${a}>`).join(" ") : "");
|
|
152
|
+
return hint && !arg ? { items: [], hint } : null;
|
|
153
|
+
}
|
|
64
154
|
return { items: filter(COMMANDS), hint: "Commands" };
|
|
65
155
|
}
|
package/dist/config.js
CHANGED
|
@@ -90,6 +90,8 @@ export const CONFIG = {
|
|
|
90
90
|
// a cheap/fast one for grunt work (reading many files, broad search), or a
|
|
91
91
|
// strong one as an "advisor" to double-check. undefined → use the main model.
|
|
92
92
|
subAgentModel: process.env.MINI_AGENT_SUBAGENT_MODEL || projectSettings.subAgentModel || globalSettings.subAgentModel || undefined,
|
|
93
|
+
// /skills on/off/user-only choices. Project entries override global ones.
|
|
94
|
+
skillOverrides: { ...(globalSettings.skillOverrides ?? {}), ...(projectSettings.skillOverrides ?? {}) },
|
|
93
95
|
// Per-model windows (context.ts contextWindowFor): project entries override global ones.
|
|
94
96
|
contextWindows: { ...(globalSettings.contextWindows ?? {}), ...(projectSettings.contextWindows ?? {}) },
|
|
95
97
|
contextWindow: Number(process.env.MINI_AGENT_CONTEXT_WINDOW) || // env override first
|
package/dist/ink/app.js
CHANGED
|
@@ -10,6 +10,7 @@ import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.j
|
|
|
10
10
|
import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
|
|
11
11
|
import { effortMenu, setEffort } from "../effort.js";
|
|
12
12
|
import { useSlashCompletion } from "./completion.js";
|
|
13
|
+
import { commandNames, commandTokenRanges, inlineCommandGhost } from "../completion.js"; // which /command tokens to paint + the mid-text ghost suggestion
|
|
13
14
|
import { TerminateReason, listAgentViews } from "../loop.js"; // how a turn can end
|
|
14
15
|
import { compactHistory, compactThreshold, contextWindowFor } from "../context.js"; // /compact + /model info
|
|
15
16
|
import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
|
|
@@ -24,7 +25,8 @@ import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.
|
|
|
24
25
|
import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
|
|
25
26
|
import { generateSessionTitle, setTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
26
27
|
import { isPlanMode, setPlanMode } from "../permissions.js";
|
|
27
|
-
import { findSkill, userSkillMessages } from "../skills.js";
|
|
28
|
+
import { findSkill, recordSkillUsage, skillSource, skillUsageScores, userSkillMessages } from "../skills.js";
|
|
29
|
+
import { SkillsPanel } from "./skills-panel.js"; // /skills: Claude Code's on / user-only / off manager
|
|
28
30
|
import { extractMemories } from "../memory.js";
|
|
29
31
|
import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
|
|
30
32
|
import { runHooks } from "../hooks.js";
|
|
@@ -45,6 +47,7 @@ const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only rea
|
|
|
45
47
|
// it two cells wide — the text after the glyph then jumps on every other frame.
|
|
46
48
|
const SPINNER_FRAMES = ["·", "✢", "✶", "✻", "✽", "✻", "✶", "✢"];
|
|
47
49
|
const SPINNER_COLOR = "#D77757"; // Claude Code's orange
|
|
50
|
+
const SKILL_COLOR = "#B1B9F9"; // Claude Code's lavender for a recognised /skill in the input
|
|
48
51
|
const SHIMMER_PEAK = "#FFD2BD"; // the highlight that sweeps across the verb
|
|
49
52
|
const SHIMMER_TICK_MS = 100; // one animation clock for glyph + shimmer → one repaint per tick
|
|
50
53
|
const SHIMMER_RADIUS = 3; // how many cells the highlight fades over on each side
|
|
@@ -163,7 +166,7 @@ const EXIT_NOTES = {
|
|
|
163
166
|
};
|
|
164
167
|
export function App({ session, runTurn, clipboard }) {
|
|
165
168
|
const { rows, columns } = useTerminalSize();
|
|
166
|
-
const { client, messages, systemMessage, costMeter, skills: liveSkills, judge, autoMode, model, dir, branch, getStatus } = session;
|
|
169
|
+
const { client, messages, systemMessage, costMeter, skills: liveSkills, allSkills: everySkill, judge, autoMode, model, dir, branch, getStatus } = session;
|
|
167
170
|
// Seed the scrollback with the welcome banner + the dim startup notices.
|
|
168
171
|
const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
|
|
169
172
|
const [status, setStatus] = useState(null); // the live spinner line
|
|
@@ -210,9 +213,12 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
210
213
|
modelListEndpoint.current = CONFIG.baseURL;
|
|
211
214
|
void client.models.list().then((page) => setCompletionModels(page.data.map((m) => m.id))).catch(() => { });
|
|
212
215
|
}, [wantsModels, client]);
|
|
216
|
+
const typeaheadSkills = liveSkills().map((s) => ({ name: s.name, description: s.description, whenToUse: s.whenToUse, argNames: s.argNames, argumentHint: s.argumentHint, source: skillSource(s) }));
|
|
213
217
|
const completion = useSlashCompletion(input, completing, {
|
|
214
|
-
model: CONFIG.model, skills:
|
|
215
|
-
}, rows - 10);
|
|
218
|
+
model: CONFIG.model, skills: typeaheadSkills, models: completionModels, servers: listMcpServers().map((s) => s.name), usage: completing && input.startsWith("/") ? skillUsageScores() : undefined,
|
|
219
|
+
}, rows - 10, columns);
|
|
220
|
+
// A "/" later in the text: no list, just the rest of the best match as dim ghost text (Tab accepts).
|
|
221
|
+
const ghost = completing && cursor === input.length ? inlineCommandGhost(input, { skills: typeaheadSkills }) : null;
|
|
216
222
|
useEffect(() => {
|
|
217
223
|
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
218
224
|
return () => clearInterval(id);
|
|
@@ -375,7 +381,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
375
381
|
setFormState(null);
|
|
376
382
|
if (pending.kind === "form")
|
|
377
383
|
pending.resolve(null);
|
|
378
|
-
else
|
|
384
|
+
else if (pending.kind === "select")
|
|
379
385
|
pending.onChoose(-1);
|
|
380
386
|
}
|
|
381
387
|
if (immediate)
|
|
@@ -466,6 +472,11 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
466
472
|
}
|
|
467
473
|
if (line.startsWith("/skills "))
|
|
468
474
|
line = `/skill ${line.slice(8).trim()}`;
|
|
475
|
+
if (line === "/skills") {
|
|
476
|
+
setDetails(null);
|
|
477
|
+
setPending({ kind: "skills" });
|
|
478
|
+
return true;
|
|
479
|
+
} // Claude Code's manager: on / user-only / off per skill
|
|
469
480
|
if (line === "/effort" || line.startsWith("/effort ")) {
|
|
470
481
|
const target = CONFIG.model;
|
|
471
482
|
const value = line.slice("/effort".length).trim();
|
|
@@ -557,6 +568,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
557
568
|
return true;
|
|
558
569
|
}
|
|
559
570
|
autoMode.recordRequest(`/${s.name}${args ? ` ${args}` : ""}`);
|
|
571
|
+
recordSkillUsage(s.name); // feeds the "/" typeahead's most-used ranking
|
|
560
572
|
runConversationTurn(userSkillMessages(s, args), { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
|
|
561
573
|
return true;
|
|
562
574
|
}
|
|
@@ -664,6 +676,8 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
664
676
|
tuiCleanup();
|
|
665
677
|
return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
|
|
666
678
|
}
|
|
679
|
+
if (pending?.kind === "skills")
|
|
680
|
+
return; // the /skills panel owns the keyboard while it is open
|
|
667
681
|
const modifiedReturn = char === "[13;5u" || char === "[27;5;13~";
|
|
668
682
|
// A submitted follow-up may still be passing hooks. A second Enter must
|
|
669
683
|
// not approve the old action while that asynchronous preparation runs.
|
|
@@ -792,6 +806,13 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
792
806
|
return;
|
|
793
807
|
}
|
|
794
808
|
}
|
|
809
|
+
if (key.tab && ghost) { // accept the inline suggestion as "/name "
|
|
810
|
+
const next = input.slice(0, ghost.start) + `/${ghost.full} `;
|
|
811
|
+
setInput(next);
|
|
812
|
+
setCursor(next.length);
|
|
813
|
+
setHistIdx(null);
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
795
816
|
const completionAction = completion.handleKey(key, (value) => { setInput(value); setCursor(value.length); setHistIdx(null); });
|
|
796
817
|
if (completionAction.handled && !completionAction.submit)
|
|
797
818
|
return;
|
|
@@ -932,7 +953,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
932
953
|
const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
|
|
933
954
|
const detailView = detailPage(details === "tools" ? getToolCalls() ?? "No tool calls this turn." : details === "reasoning" ? getReasoning() ?? "No thinking recorded this turn." : "", detailOffset, Math.max(1, Math.min(12, rows - 9)), Math.max(1, columns - 4));
|
|
934
955
|
const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
|
|
935
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
|
|
956
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsx(Box, { marginTop: rows >= 20 ? 1 : 0, children: _jsx(SpinnerLine, { text: status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…") }) })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), pending.body && _jsx(Box, { marginY: 1, children: _jsx(Text, { children: pending.body }) }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "skills" && (_jsx(SkillsPanel, { skills: everySkill(), maxRows: Math.max(3, rows - 14), onClose: (summary) => { setPending(null); note(chalk.dim(summary)); } })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), pending && pending.kind !== "skills" && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), pending?.kind !== "skills" && (_jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
|
|
936
957
|
const prompt = "❯ ";
|
|
937
958
|
const promptColor = planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
|
|
938
959
|
const cols = columns;
|
|
@@ -941,6 +962,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
941
962
|
const firstW = Math.max(1, innerWidth - promptW);
|
|
942
963
|
// Build visual lines display-width-aware (CJK, emoji = 2 cols).
|
|
943
964
|
const lines = [""];
|
|
965
|
+
const lineStarts = [0]; // code-unit offset where each visual line begins (for skill highlighting)
|
|
944
966
|
let row = 0;
|
|
945
967
|
let col = 0;
|
|
946
968
|
// Cursor tracking: map code-unit offset → (line, display-column).
|
|
@@ -960,6 +982,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
960
982
|
row++;
|
|
961
983
|
col = 0;
|
|
962
984
|
lines[row] = "";
|
|
985
|
+
lineStarts[row] = cu;
|
|
963
986
|
}
|
|
964
987
|
lines[row] += ch;
|
|
965
988
|
col += cw;
|
|
@@ -974,9 +997,35 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
974
997
|
if (cursorCol >= edge) {
|
|
975
998
|
cursorLine++;
|
|
976
999
|
cursorCol = 0;
|
|
977
|
-
if (lines.length <= cursorLine)
|
|
1000
|
+
if (lines.length <= cursorLine) {
|
|
978
1001
|
lines.push("");
|
|
1002
|
+
lineStarts.push(cu);
|
|
1003
|
+
}
|
|
979
1004
|
}
|
|
1005
|
+
// "/<skill>" tokens anywhere in the draft are painted — the typeahead
|
|
1006
|
+
// only opens at the start, but a real skill name lights up wherever
|
|
1007
|
+
// it is. paint() splits a slice of a line into plain and lit runs.
|
|
1008
|
+
const lit = commandTokenRanges(input, commandNames({ skills: liveSkills() }));
|
|
1009
|
+
const isLit = (at) => lit.some(([a, b]) => at >= a && at < b);
|
|
1010
|
+
const paint = (text, from, keyBase) => {
|
|
1011
|
+
const runs = [];
|
|
1012
|
+
let run = "";
|
|
1013
|
+
let runLit = false;
|
|
1014
|
+
let at = from;
|
|
1015
|
+
const flush = () => { if (run)
|
|
1016
|
+
runs.push(_jsx(Text, { color: runLit ? SKILL_COLOR : undefined, children: run }, `${keyBase}${runs.length}`)); run = ""; };
|
|
1017
|
+
for (const ch of text) {
|
|
1018
|
+
const l = isLit(at);
|
|
1019
|
+
if (l !== runLit) {
|
|
1020
|
+
flush();
|
|
1021
|
+
runLit = l;
|
|
1022
|
+
}
|
|
1023
|
+
run += ch;
|
|
1024
|
+
at += ch.length;
|
|
1025
|
+
}
|
|
1026
|
+
flush();
|
|
1027
|
+
return runs;
|
|
1028
|
+
};
|
|
980
1029
|
// Map display-column back to code-unit offset within the target line.
|
|
981
1030
|
const cursorLineText = lines[cursorLine] ?? "";
|
|
982
1031
|
let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
|
|
@@ -997,9 +1046,10 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
997
1046
|
const before = line.slice(0, cuOff);
|
|
998
1047
|
const at = line.slice(cuOff, cuOff + 1) || " ";
|
|
999
1048
|
const after = line.slice(cuOff + 1);
|
|
1000
|
-
|
|
1049
|
+
const base = lineStarts[i] ?? 0;
|
|
1050
|
+
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(before, base, "b") }), _jsx(Text, { inverse: true, color: isLit(base + cuOff) ? SKILL_COLOR : undefined, children: ghost ? ghost.suffix[0] : at }), _jsx(Text, { children: paint(after, base + cuOff + at.length, "a") }), ghost && _jsx(Text, { dimColor: true, children: ghost.suffix.slice(1) })] }, i));
|
|
1001
1051
|
}
|
|
1002
|
-
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
|
|
1052
|
+
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(line, lineStarts[i] ?? 0, "l") })] }, i));
|
|
1003
1053
|
});
|
|
1004
|
-
})() }), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, children: _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }) }), _jsx(McpBadge, {})] }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
|
|
1054
|
+
})() })), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsxs(Box, { justifyContent: "space-between", children: [_jsx(Box, { flexShrink: 1, children: _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }) }), _jsx(McpBadge, {})] }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
|
|
1005
1055
|
}
|
package/dist/ink/commands.js
CHANGED
|
@@ -39,7 +39,7 @@ export const SESSION_HELP = `commands:
|
|
|
39
39
|
/undo revert the most recent file write (write_file / edit_file) this session
|
|
40
40
|
/diff show every file changed this session, as a diff from where it started
|
|
41
41
|
/resume list recent sessions in this project and continue one of them
|
|
42
|
-
/skills
|
|
42
|
+
/skills manage skills: turn each on, off, or user-only (search with /, sort with t)
|
|
43
43
|
/skill <name> [args] run a skill yourself (works even for user-only skills); /<name> [args] works too
|
|
44
44
|
exit leave (Ctrl+C does the same)
|
|
45
45
|
|
package/dist/ink/completion.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
4
|
import { slashCompletions } from "../completion.js";
|
|
5
5
|
// Keep the full list in state, but render only a window around the selection.
|
|
6
6
|
// Long lists must never grow Ink's redraw region beyond the terminal height.
|
|
7
|
-
export function useSlashCompletion(input, enabled, context, maxRows) {
|
|
7
|
+
export function useSlashCompletion(input, enabled, context, maxRows, columns = 80) {
|
|
8
8
|
const [selection, setSelection] = useState({ input: "", index: 0 });
|
|
9
9
|
const [dismissed, setDismissed] = useState(null);
|
|
10
10
|
useEffect(() => { setDismissed(null); }, [input]);
|
|
11
11
|
const menu = enabled && dismissed !== input ? slashCompletions(input, context) : null;
|
|
12
12
|
const items = menu?.items ?? [];
|
|
13
13
|
const selected = Math.min(selection.input === input ? selection.index : 0, Math.max(0, items.length - 1));
|
|
14
|
-
const count = Math.max(1, Math.min(
|
|
14
|
+
const count = Math.max(1, Math.min(6, maxRows)); // Claude Code shows at most 6, centred on the selection
|
|
15
15
|
const start = Math.max(0, Math.min(selected - Math.floor(count / 2), items.length - count));
|
|
16
|
+
const nameWidth = Math.min(Math.max(0, ...items.map((item) => item.value.length)) + 2, Math.floor(columns * 0.4));
|
|
16
17
|
return {
|
|
17
18
|
visible: menu !== null,
|
|
18
19
|
handleKey: (key, replace) => {
|
|
@@ -30,14 +31,21 @@ export function useSlashCompletion(input, enabled, context, maxRows) {
|
|
|
30
31
|
}
|
|
31
32
|
if (key.tab || (key.return && !key.ctrl && !key.meta)) {
|
|
32
33
|
const item = items[selected];
|
|
34
|
+
// Tab always just fills it in. Enter runs it — unless it takes
|
|
35
|
+
// arguments (a skill with `arguments`, or a command with sub-choices),
|
|
36
|
+
// then it fills "/name " so you can type them. (Claude Code's rule.)
|
|
33
37
|
if (key.tab || item.children)
|
|
34
|
-
replace(item.value +
|
|
38
|
+
replace(item.value + " ");
|
|
35
39
|
else
|
|
36
40
|
return { handled: true, submit: item.value };
|
|
37
41
|
return { handled: true };
|
|
38
42
|
}
|
|
39
43
|
return { handled: false };
|
|
40
44
|
},
|
|
41
|
-
view: menu && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [items.slice(start, start + count).map((item, i) =>
|
|
45
|
+
view: menu && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [items.slice(start, start + count).map((item, i) => {
|
|
46
|
+
const on = start + i === selected;
|
|
47
|
+
// Names in one column (at most 40% of the width); the description is one line, cut to fit.
|
|
48
|
+
return (_jsxs(Text, { color: on ? "#B1B9F9" : undefined, dimColor: !on, wrap: "truncate-end", children: [on ? "› " : " ", item.value.padEnd(nameWidth), item.description.replace(/\s+/g, " ")] }, item.value));
|
|
49
|
+
}), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: items.length ? `↑↓ choose · Tab complete · Enter select · Esc close · ${selected + 1}/${items.length}` : menu.hint || "No matching commands" })] })),
|
|
42
50
|
};
|
|
43
51
|
}
|
package/dist/ink/setup.js
CHANGED
|
@@ -14,7 +14,7 @@ import { Judge } from "../judge.js";
|
|
|
14
14
|
import { AutoMode } from "../auto.js";
|
|
15
15
|
import { registerExternalTool } from "../tools.js";
|
|
16
16
|
import { rememberTool, readMemory } from "../memory.js";
|
|
17
|
-
import { currentSkills } from "../skills.js";
|
|
17
|
+
import { allSkills, currentSkills } from "../skills.js";
|
|
18
18
|
import { killAllBackground } from "../background.js";
|
|
19
19
|
import { killAllSubAgents } from "../loop.js";
|
|
20
20
|
import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "../cron.js"; // cron scheduler (Day s14)
|
|
@@ -107,6 +107,7 @@ export async function buildInkSession(opts = {}) {
|
|
|
107
107
|
startedAt,
|
|
108
108
|
costMeter,
|
|
109
109
|
skills: () => currentSkills(),
|
|
110
|
+
allSkills: () => allSkills(),
|
|
110
111
|
judge,
|
|
111
112
|
autoMode,
|
|
112
113
|
model: CONFIG.model,
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from "react";
|
|
3
|
+
import { Box, Text, useInput } from "ink";
|
|
4
|
+
import { nextSkillMode, nextSkillSort, setSkillMode, skillListingTokens, skillLocked, skillMode, skillPanelRows, skillSource } from "../skills.js";
|
|
5
|
+
// /skills — Claude Code's skill manager. One row per skill on disk:
|
|
6
|
+
// ❯ ✔ on grill-me · user · ~20 tok
|
|
7
|
+
// 🔒 user-only deploy · project · ~30 tok · locked by author
|
|
8
|
+
// Enter/space cycles on → user-only → off (saved at once, live on the next
|
|
9
|
+
// model call), "/" searches, "t" changes the sort, Esc closes. The panel owns
|
|
10
|
+
// its keys while it is open; the app's main handler stands aside.
|
|
11
|
+
const LAVENDER = "#B1B9F9";
|
|
12
|
+
const SORT_LABEL = { name: "name", tokens: "tokens", source: "source" };
|
|
13
|
+
function ModeCell({ mode, locked }) {
|
|
14
|
+
if (locked)
|
|
15
|
+
return _jsx(Text, { dimColor: true, children: "🔒 user-only " });
|
|
16
|
+
if (mode === "on")
|
|
17
|
+
return _jsx(Text, { color: "green", children: "✔ on " });
|
|
18
|
+
if (mode === "user-only")
|
|
19
|
+
return _jsx(Text, { color: "yellow", children: "◐ user-only " });
|
|
20
|
+
return _jsx(Text, { dimColor: true, children: "○ off " });
|
|
21
|
+
}
|
|
22
|
+
export function SkillsPanel({ skills, maxRows, onClose }) {
|
|
23
|
+
const [cursor, setCursor] = useState(0);
|
|
24
|
+
const [query, setQuery] = useState("");
|
|
25
|
+
const [searching, setSearching] = useState(false);
|
|
26
|
+
const [sort, setSort] = useState("name");
|
|
27
|
+
const [, setVersion] = useState(0); // re-render after a mode change (modes live in CONFIG)
|
|
28
|
+
const initial = useRef(new Map(skills.map((s) => [s.name, skillMode(s)]))).current; // for the summary on close
|
|
29
|
+
const rows = skillPanelRows(skills, query, sort);
|
|
30
|
+
const at = Math.min(cursor, Math.max(0, rows.length - 1));
|
|
31
|
+
useInput((char, key) => {
|
|
32
|
+
if (searching) {
|
|
33
|
+
if (key.escape || key.return) {
|
|
34
|
+
setSearching(false);
|
|
35
|
+
if (key.escape)
|
|
36
|
+
setQuery("");
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (key.backspace || key.delete) {
|
|
40
|
+
setQuery((q) => q.slice(0, -1));
|
|
41
|
+
setCursor(0);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (key.upArrow || key.downArrow) {
|
|
45
|
+
setSearching(false);
|
|
46
|
+
} // fall through to navigation
|
|
47
|
+
else if (char === "/")
|
|
48
|
+
return; // skill names never contain "/": a second "/" press must not become the query
|
|
49
|
+
else if (char && !key.ctrl && !key.meta) {
|
|
50
|
+
setQuery((q) => q + char);
|
|
51
|
+
setCursor(0);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
else
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (key.escape) {
|
|
58
|
+
const changes = skills.filter((s) => initial.get(s.name) !== skillMode(s)).map((s) => `${s.name} → ${skillMode(s)}`);
|
|
59
|
+
return onClose(changes.length ? `Skills updated: ${changes.join(", ")}` : "No changes");
|
|
60
|
+
}
|
|
61
|
+
if (key.upArrow)
|
|
62
|
+
return setCursor((c) => (rows.length ? (Math.min(c, rows.length - 1) - 1 + rows.length) % rows.length : 0));
|
|
63
|
+
if (key.downArrow)
|
|
64
|
+
return setCursor((c) => (rows.length ? (Math.min(c, rows.length - 1) + 1) % rows.length : 0));
|
|
65
|
+
if (char === "/")
|
|
66
|
+
return setSearching(true);
|
|
67
|
+
if (char === "t")
|
|
68
|
+
return setSort((s) => nextSkillSort(s));
|
|
69
|
+
if (key.return || char === " ") {
|
|
70
|
+
const s = rows[at];
|
|
71
|
+
if (!s || skillLocked(s))
|
|
72
|
+
return; // the author pinned it — nothing to cycle
|
|
73
|
+
setSkillMode(s.name, nextSkillMode(skillMode(s)));
|
|
74
|
+
setVersion((v) => v + 1);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
// A window of rows around the cursor, so a long list never outgrows the terminal.
|
|
78
|
+
const count = Math.max(3, Math.min(rows.length, maxRows));
|
|
79
|
+
const start = Math.max(0, Math.min(at - Math.floor(count / 2), rows.length - count));
|
|
80
|
+
const shown = rows.slice(start, start + count);
|
|
81
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, color: LAVENDER, children: "Skills" }), _jsxs(Text, { dimColor: true, children: [skills.length, " skill", skills.length === 1 ? "" : "s", " \u00B7 ", searching ? "type to filter, ↑↓ to pick, Enter/Esc when done" : `enter/space to cycle, / to search, t to sort (${SORT_LABEL[sort]}), Esc to close`] }), _jsxs(Box, { borderStyle: "round", borderColor: searching ? LAVENDER : "gray", paddingX: 1, marginTop: 1, children: [_jsx(Text, { color: searching ? LAVENDER : undefined, dimColor: !searching, children: "Search: " }), _jsx(Text, { dimColor: !query && !searching, children: query || (searching ? "" : "press / to search skills…") }), searching && _jsx(Text, { inverse: true, children: " " })] }), start > 0 && _jsxs(Text, { dimColor: true, children: [" \u2191 ", start, " more above"] }), shown.map((s, i) => {
|
|
82
|
+
const selected = start + i === at;
|
|
83
|
+
const locked = skillLocked(s);
|
|
84
|
+
return (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: LAVENDER, children: selected ? "❯ " : " " }), _jsx(ModeCell, { mode: skillMode(s), locked: locked }), _jsxs(Text, { bold: !selected, color: selected ? LAVENDER : undefined, children: [" ", s.name] }), _jsxs(Text, { dimColor: true, children: [" \u00B7 ", skillSource(s), " \u00B7 ~", skillListingTokens(s), " tok", locked ? " · locked by author" : ""] })] }, s.path || s.name));
|
|
85
|
+
}), !rows.length && _jsxs(Text, { dimColor: true, children: [" ", skills.length ? `No skills match "${query}"` : "No skills — add one at .mini-agent/skills/<name>/SKILL.md or ~/.config/mini-agent/skills/"] }), start + count < rows.length && _jsxs(Text, { dimColor: true, children: [" \u2193 ", rows.length - start - count, " more below"] })] }));
|
|
86
|
+
}
|
package/dist/skills.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs"; // discover and read SKILL.md files
|
|
2
2
|
import os from "node:os"; // the global skills dir
|
|
3
3
|
import path from "node:path"; // path resolution
|
|
4
|
-
import { CONFIG } from "./config.js"; // the
|
|
4
|
+
import { CONFIG, saveGlobalSetting } from "./config.js"; // the listing budget + /skills on/off choices
|
|
5
5
|
import { registerExternalTool, tools, unregisterExternalTool } from "./tools.js"; // a skill is exposed to the model as a tool
|
|
6
6
|
// Project skills win over global ones with the same name.
|
|
7
7
|
function skillDirs() {
|
|
@@ -37,6 +37,7 @@ export function parseSkill(raw, fallbackName, filePath = "") {
|
|
|
37
37
|
.filter(Boolean),
|
|
38
38
|
disableModelInvocation: /^true$/i.test(fm.disableModelInvocation || fm["disable-model-invocation"] || ""),
|
|
39
39
|
argumentHint: fm["argument-hint"] || "",
|
|
40
|
+
argNames: (fm.arguments || "").replace(/^\[|\]$/g, "").split(/[\s,]+/).map((a) => a.replace(/^["']|["']$/g, "")).filter(Boolean), // "a, b" or "[a, b]"
|
|
40
41
|
body,
|
|
41
42
|
path: filePath,
|
|
42
43
|
dir: filePath ? path.dirname(filePath) : "",
|
|
@@ -100,19 +101,111 @@ function fingerprint(dirs) {
|
|
|
100
101
|
}
|
|
101
102
|
return parts.join("|");
|
|
102
103
|
}
|
|
103
|
-
//
|
|
104
|
-
// edited, or removed since the last call. `dirs` is
|
|
105
|
-
|
|
104
|
+
// Every skill on disk, whatever its /skills setting: reloaded only when a
|
|
105
|
+
// SKILL.md was added, edited, or removed since the last call. `dirs` is
|
|
106
|
+
// injectable for tests. The /skills panel lists these.
|
|
107
|
+
export function allSkills(dirs = skillDirs()) {
|
|
106
108
|
const key = dirs.join("\n");
|
|
107
109
|
const fp = fingerprint(dirs);
|
|
108
110
|
if (!cache || cache.key !== key || cache.fingerprint !== fp)
|
|
109
111
|
cache = { key, fingerprint: fp, skills: loadSkills(dirs) };
|
|
110
112
|
return cache.skills;
|
|
111
113
|
}
|
|
114
|
+
// The skills in effect — what the listing, the skill tool and /skill see.
|
|
115
|
+
// The /skills choices apply here: "off" skills vanish entirely, "user-only"
|
|
116
|
+
// ones stay runnable by the user but are hidden from the model.
|
|
117
|
+
export function currentSkills(dirs = skillDirs()) {
|
|
118
|
+
return allSkills(dirs)
|
|
119
|
+
.filter((s) => skillMode(s) !== "off")
|
|
120
|
+
.map((s) => (skillMode(s) === "user-only" && !s.disableModelInvocation ? { ...s, disableModelInvocation: true } : s));
|
|
121
|
+
}
|
|
122
|
+
export const skillLocked = (s) => s.disableModelInvocation;
|
|
123
|
+
export function skillMode(s) {
|
|
124
|
+
if (skillLocked(s))
|
|
125
|
+
return "user-only";
|
|
126
|
+
return CONFIG.skillOverrides[s.name] ?? "on";
|
|
127
|
+
}
|
|
128
|
+
// Enter/space in the panel: on → user-only → off → on.
|
|
129
|
+
export const nextSkillMode = (m) => (m === "on" ? "user-only" : m === "user-only" ? "off" : "on");
|
|
130
|
+
// Apply and persist a choice. "on" is the default, so it removes the entry
|
|
131
|
+
// rather than storing it — the settings file only lists exceptions.
|
|
132
|
+
export function setSkillMode(name, mode, persist = true) {
|
|
133
|
+
const next = { ...CONFIG.skillOverrides };
|
|
134
|
+
if (mode === "on")
|
|
135
|
+
delete next[name];
|
|
136
|
+
else
|
|
137
|
+
next[name] = mode;
|
|
138
|
+
CONFIG.skillOverrides = next;
|
|
139
|
+
if (persist)
|
|
140
|
+
saveGlobalSetting("skillOverrides", next);
|
|
141
|
+
}
|
|
142
|
+
// One panel row's facts: where it lives and what it costs the model to know
|
|
143
|
+
// about it (its listing line, ~4 bytes per token — the same estimate as context.ts).
|
|
144
|
+
export function skillSource(s) {
|
|
145
|
+
return s.path.startsWith(path.resolve(".mini-agent", "skills")) ? "project" : "user";
|
|
146
|
+
}
|
|
147
|
+
export const skillListingTokens = (s) => Math.ceil(Buffer.byteLength(listingEntry(s)) / 4);
|
|
148
|
+
export const nextSkillSort = (s) => (s === "name" ? "tokens" : s === "tokens" ? "source" : "name");
|
|
149
|
+
// The panel's list: filtered by the search box, sorted.
|
|
150
|
+
// Matching is about the NAME: any part of it ("pul" finds co_dev_pulse). The
|
|
151
|
+
// description only counts for a real word — 3+ characters that start one of
|
|
152
|
+
// its words ("standup", "pulse") — and those rows come after the name matches.
|
|
153
|
+
// A plain substring over descriptions matched everything: a single "a" is in
|
|
154
|
+
// almost every sentence.
|
|
155
|
+
export function skillPanelRows(skills, query, sort) {
|
|
156
|
+
const q = query.trim().toLowerCase();
|
|
157
|
+
const byName = (a, b) => a.name.localeCompare(b.name);
|
|
158
|
+
const order = (rows) => sort === "tokens" ? rows.sort((a, b) => skillListingTokens(b) - skillListingTokens(a) || byName(a, b))
|
|
159
|
+
: sort === "source" ? rows.sort((a, b) => skillSource(a).localeCompare(skillSource(b)) || byName(a, b))
|
|
160
|
+
: rows.sort(byName);
|
|
161
|
+
if (!q)
|
|
162
|
+
return order([...skills]);
|
|
163
|
+
const inName = skills.filter((s) => s.name.toLowerCase().includes(q));
|
|
164
|
+
const inWords = q.length >= 3 ? skills.filter((s) => !inName.includes(s) && s.description.toLowerCase().split(/[^a-z0-9]+/).some((w) => w.startsWith(q))) : [];
|
|
165
|
+
return [...order(inName), ...order(inWords)];
|
|
166
|
+
}
|
|
112
167
|
export function findSkill(skills, name) {
|
|
113
168
|
const n = name.trim().replace(/^\//, "").toLowerCase(); // "/deploy" and "deploy" both work, like Claude Code
|
|
114
169
|
return skills.find((s) => s.name.toLowerCase() === n);
|
|
115
170
|
}
|
|
171
|
+
// ---- usage: what the "/" typeahead puts first -----------------------------------------
|
|
172
|
+
// Claude Code ranks a bare "/" by how much you use each skill: every run you
|
|
173
|
+
// start yourself (/name or /skill name) counts, and old use fades — the score
|
|
174
|
+
// halves every 7 days, never below a tenth. Kept in its own small file (not in
|
|
175
|
+
// settings.json, which is for choices, not counters).
|
|
176
|
+
// MINI_AGENT_SKILL_USAGE relocates it (tests point it at a scratch file).
|
|
177
|
+
const usagePath = () => process.env.MINI_AGENT_SKILL_USAGE || path.join(os.homedir(), ".config", "mini-agent", "skill-usage.json");
|
|
178
|
+
function readUsage() {
|
|
179
|
+
try {
|
|
180
|
+
return JSON.parse(fs.readFileSync(usagePath(), "utf8"));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return {}; // none yet, or unreadable — ranking just falls back to groups + names
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
export function recordSkillUsage(name, now = Date.now()) {
|
|
187
|
+
const usage = readUsage();
|
|
188
|
+
usage[name] = { usageCount: (usage[name]?.usageCount ?? 0) + 1, lastUsedAt: now };
|
|
189
|
+
try {
|
|
190
|
+
fs.mkdirSync(path.dirname(usagePath()), { recursive: true });
|
|
191
|
+
fs.writeFileSync(usagePath(), JSON.stringify(usage, null, 2) + "\n");
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
/* a counter is never worth failing a skill run over */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// One read of the usage file, scored per name — for the typeahead, which asks about every skill per keystroke.
|
|
198
|
+
export function skillUsageScores(now = Date.now()) {
|
|
199
|
+
const usage = readUsage();
|
|
200
|
+
return (name) => skillUsageScore(name, now, usage);
|
|
201
|
+
}
|
|
202
|
+
export function skillUsageScore(name, now = Date.now(), usage = readUsage()) {
|
|
203
|
+
const u = usage[name];
|
|
204
|
+
if (!u)
|
|
205
|
+
return 0;
|
|
206
|
+
const days = (now - u.lastUsedAt) / 86_400_000;
|
|
207
|
+
return u.usageCount * Math.max(0.5 ** (days / 7), 0.1);
|
|
208
|
+
}
|
|
116
209
|
// ---- the listing, as a <system-reminder> ------------------------------------------
|
|
117
210
|
export const wrapInSystemReminder = (content) => `<system-reminder>\n${content}\n</system-reminder>`;
|
|
118
211
|
const LISTING_HEADER = "The following skills are available for use with the skill tool:"; // Claude Code's wording, our tool name
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-from-zero",
|
|
3
|
-
"version": "0.35.
|
|
3
|
+
"version": "0.35.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"mini-agent": "dist/agent.js"
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"dependencies": {
|
|
27
27
|
"chalk": "^5.6.2",
|
|
28
28
|
"cli-table3": "^0.6.5",
|
|
29
|
+
"fuse.js": "^7.5.0",
|
|
29
30
|
"ink": "^5.2.1",
|
|
30
31
|
"marked": "^15.0.12",
|
|
31
32
|
"marked-terminal": "^7.3.0",
|