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 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 list the reusable skills available in this project
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 = currentSkills();
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 who = s.disableModelInvocation ? chalk.yellow("user-only") : chalk.green("model+user");
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;
@@ -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: "Browse and run available skills", children: true },
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: liveSkills(), models: completionModels, servers: listMcpServers().map((s) => s.name),
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
- return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: at }), _jsx(Text, { children: after })] }, i));
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
  }
@@ -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 list the reusable skills available in this project
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
 
@@ -1,18 +1,19 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
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(8, maxRows));
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 + (item.children ? " " : ""));
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) => (_jsxs(Text, { color: start + i === selected ? "cyan" : undefined, bold: start + i === selected, wrap: "truncate-end", children: [start + i === selected ? "› " : " ", item.value, " ", _jsx(Text, { dimColor: true, children: item.description.replace(/\s+/g, " ") })] }, item.value))), _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" })] })),
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 context window sizes the listing budget
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
- // The live skill set: reloaded from disk only when a SKILL.md was added,
104
- // edited, or removed since the last call. `dirs` is injectable for tests.
105
- export function currentSkills(dirs = skillDirs()) {
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.2",
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",