agent-from-zero 0.35.1 → 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/ink/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { stripVTControlCharacters } from "node:util"; // plain text of a styled status line, to find the verb
2
3
  import { statusCommand } from "../status.js";
3
4
  import React, { useState, useEffect, useRef } from "react";
4
5
  import { Box, Text, Static, useInput, useApp } from "ink";
@@ -9,8 +10,9 @@ import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.j
9
10
  import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
10
11
  import { effortMenu, setEffort } from "../effort.js";
11
12
  import { useSlashCompletion } from "./completion.js";
13
+ import { commandNames, commandTokenRanges, inlineCommandGhost } from "../completion.js"; // which /command tokens to paint + the mid-text ghost suggestion
12
14
  import { TerminateReason, listAgentViews } from "../loop.js"; // how a turn can end
13
- import { compactHistory, COMPACT_AT } from "../context.js"; // /compact
15
+ import { compactHistory, compactThreshold, contextWindowFor } from "../context.js"; // /compact + /model info
14
16
  import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
15
17
  import { clearUndo } from "../undo.js";
16
18
  import { clearTodos } from "../todos.js";
@@ -23,7 +25,8 @@ import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.
23
25
  import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
24
26
  import { generateSessionTitle, setTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
25
27
  import { isPlanMode, setPlanMode } from "../permissions.js";
26
- import { findSkill, skillInstructions } 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
27
30
  import { extractMemories } from "../memory.js";
28
31
  import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
29
32
  import { runHooks } from "../hooks.js";
@@ -37,10 +40,17 @@ import { detailPage, summarizeActivity } from "./activity.js";
37
40
  import { useTerminalSize } from "./viewport.js";
38
41
  import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
39
42
  import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
40
- import { listMcpServers, mcpActionsFor, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
43
+ import { listMcpServers, MCP_SUBCOMMANDS, mcpActionsFor, mcpProblems, mcpServerDetails, reloadMcpServers, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
41
44
  // Injected when plan mode turns on, so the model knows the rules it now lives in.
42
45
  const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
43
- const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
46
+ // No ✳ (U+2733): it has an emoji presentation, and terminals that honour it draw
47
+ // it two cells wide — the text after the glyph then jumps on every other frame.
48
+ const SPINNER_FRAMES = ["·", "✢", "✶", "✻", "✽", "✻", "✶", "✢"];
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
51
+ const SHIMMER_PEAK = "#FFD2BD"; // the highlight that sweeps across the verb
52
+ const SHIMMER_TICK_MS = 100; // one animation clock for glyph + shimmer → one repaint per tick
53
+ const SHIMMER_RADIUS = 3; // how many cells the highlight fades over on each side
44
54
  // Cap the live streaming preview to a terminal-aware tail. THIS IS LOAD-BEARING:
45
55
  // Ink redraws the whole dynamic region (everything below <Static>) on every
46
56
  // token, and when that region grows TALLER than the terminal, Ink can no longer
@@ -56,13 +66,49 @@ function liveTail(s, rows, columns, queued) {
56
66
  const page = detailPage(s, 0, maxLines, Math.max(1, columns - 2));
57
67
  return (page.pages > 1 ? "… " : "") + page.text;
58
68
  }
59
- function Spinner() {
60
- const [f, setF] = useState(0);
69
+ // A shared animation clock. One interval drives both the glyph and the shimmer,
70
+ // so the live region repaints once per tick instead of once per effect.
71
+ function useTick() {
72
+ const [tick, setTick] = useState(0);
61
73
  useEffect(() => {
62
- const id = setInterval(() => setF((x) => (x + 1) % SPINNER_FRAMES.length), 120);
74
+ const id = setInterval(() => setTick((t) => t + 1), SHIMMER_TICK_MS);
63
75
  return () => clearInterval(id);
64
76
  }, []);
65
- return _jsx(Text, { color: "#D77757", children: SPINNER_FRAMES[f] });
77
+ return tick;
78
+ }
79
+ // The glyph lives in a fixed two-cell box: whatever the frame, the text after it
80
+ // starts in the same column.
81
+ function Glyph({ tick }) {
82
+ return (_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: SPINNER_COLOR, children: SPINNER_FRAMES[Math.floor(tick / 2) % SPINNER_FRAMES.length] }) }));
83
+ }
84
+ function Spinner() {
85
+ return _jsx(Glyph, { tick: useTick() });
86
+ }
87
+ // Blend two #rrggbb colours; t = 0 → a, 1 → b.
88
+ function mix(a, b, t) {
89
+ const ch = (hex, i) => parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16);
90
+ return "#" + [0, 1, 2].map((i) => Math.round(ch(a, i) + (ch(b, i) - ch(a, i)) * t).toString(16).padStart(2, "0")).join("");
91
+ }
92
+ // The light sweep: each character is coloured by its distance from a moving
93
+ // highlight. Only COLOUR changes between frames — never a character, never a
94
+ // width — so the line shimmers without a single cell moving.
95
+ export function shimmer(text, pos) {
96
+ return [...text].map((c, i) => {
97
+ const glow = Math.max(0, 1 - Math.abs(i - pos) / SHIMMER_RADIUS);
98
+ return chalk.hex(mix(SPINNER_COLOR, SHIMMER_PEAK, glow))(c);
99
+ }).join("");
100
+ }
101
+ // "✶ Thinking… (3s · ↓ 281 tokens · thought for 1s)": the verb (everything up to
102
+ // and including the first "…") shimmers; the stats after it stay put. A pause
103
+ // between sweeps (the highlight travels past the end) keeps it calm.
104
+ function SpinnerLine({ text }) {
105
+ const tick = useTick();
106
+ const plain = stripVTControlCharacters(text);
107
+ const cut = plain.indexOf("…");
108
+ const verb = cut >= 0 && text.startsWith(plain.slice(0, cut + 1)) ? plain.slice(0, cut + 1) : ""; // shimmer only an unstyled verb
109
+ const period = verb.length + SHIMMER_RADIUS * 2 + 8;
110
+ const pos = (tick % period) - SHIMMER_RADIUS;
111
+ return (_jsxs(Box, { children: [_jsx(Glyph, { tick: tick }), _jsxs(Text, { color: SPINNER_COLOR, wrap: "truncate-end", children: [verb ? shimmer(verb, pos) : "", text.slice(verb.length)] })] }));
66
112
  }
67
113
  // One committed line of conversation. `user` is a highlight bar; `answer` is a
68
114
  // finished reply rendered as markdown; `note` is already-formatted narration.
@@ -88,6 +134,24 @@ function StatusBar({ model, dir, branch, status }) {
88
134
  ];
89
135
  return (_jsx(Text, { wrap: "truncate-end", children: parts.map((p, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i))) }));
90
136
  }
137
+ // Bottom-right MCP health, like Claude Code's footer notice: red when a
138
+ // server failed, yellow when one needs sign-in, dim while servers are still
139
+ // connecting in the background; nothing at all when every server is fine.
140
+ // Re-read on every render (the app ticks once a second), so it follows
141
+ // background connects, /mcp actions and settings reloads with no wiring.
142
+ function McpBadge() {
143
+ const { failed, needsAuth, pending } = mcpProblems();
144
+ const parts = [];
145
+ if (failed.length)
146
+ parts.push(_jsxs(Text, { color: "red", children: ["\u2717 MCP failed: ", failed.join(", ")] }, "f"));
147
+ if (needsAuth.length)
148
+ parts.push(_jsxs(Text, { color: "yellow", children: ["\u25B3 ", needsAuth.join(", "), " needs auth"] }, "a"));
149
+ if (pending)
150
+ parts.push(_jsxs(Text, { dimColor: true, children: ["\u25CC connecting ", pending, " MCP server", pending === 1 ? "" : "s", "\u2026"] }, "p"));
151
+ if (!parts.length)
152
+ return null;
153
+ return (_jsx(Box, { flexShrink: 1, marginLeft: 2, children: _jsxs(Text, { wrap: "truncate-end", children: [parts.map((p, i) => _jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i)), failed.length || needsAuth.length ? _jsx(Text, { dimColor: true, children: " · /mcp" }) : null] }) }));
154
+ }
91
155
  // One human note per non-Done ending, so the user always learns why a turn stopped.
92
156
  const EXIT_NOTES = {
93
157
  [TerminateReason.ReviewLimit]: "Repeated automatic denials — stopped. Tell the agent how to proceed; prohibited actions were not executed.",
@@ -102,7 +166,7 @@ const EXIT_NOTES = {
102
166
  };
103
167
  export function App({ session, runTurn, clipboard }) {
104
168
  const { rows, columns } = useTerminalSize();
105
- const { client, messages, systemMessage, costMeter, skills, judge, autoMode, model, dir, branch, getStatus } = session;
169
+ const { client, messages, systemMessage, costMeter, skills: liveSkills, allSkills: everySkill, judge, autoMode, model, dir, branch, getStatus } = session;
106
170
  // Seed the scrollback with the welcome banner + the dim startup notices.
107
171
  const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
108
172
  const [status, setStatus] = useState(null); // the live spinner line
@@ -149,9 +213,12 @@ export function App({ session, runTurn, clipboard }) {
149
213
  modelListEndpoint.current = CONFIG.baseURL;
150
214
  void client.models.list().then((page) => setCompletionModels(page.data.map((m) => m.id))).catch(() => { });
151
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) }));
152
217
  const completion = useSlashCompletion(input, completing, {
153
- model: CONFIG.model, skills, models: completionModels, servers: listMcpServers().map((s) => s.name),
154
- }, 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;
155
222
  useEffect(() => {
156
223
  const id = setInterval(() => setTick((t) => t + 1), 1000);
157
224
  return () => clearInterval(id);
@@ -162,10 +229,10 @@ export function App({ session, runTurn, clipboard }) {
162
229
  setTerminalTitle(session.initialTitle);
163
230
  }, [session.initialTitle]);
164
231
  // Open a one-of-N menu and run `onChoose` with the picked index (-1 = cancel).
165
- const openSelect = (header, options, onChoose) => {
232
+ const openSelect = (header, options, onChoose, body) => {
166
233
  setDetails(null);
167
234
  setMenuSel(0);
168
- setPending({ kind: "select", header, options, onChoose });
235
+ setPending({ kind: "select", header, body, options, onChoose });
169
236
  };
170
237
  // Run one conversation turn through the real loop. `display` is what scrolls up
171
238
  // as the prompt (a user highlight bar, or a note for /skill); the loop appends
@@ -314,7 +381,7 @@ export function App({ session, runTurn, clipboard }) {
314
381
  setFormState(null);
315
382
  if (pending.kind === "form")
316
383
  pending.resolve(null);
317
- else
384
+ else if (pending.kind === "select")
318
385
  pending.onChoose(-1);
319
386
  }
320
387
  if (immediate)
@@ -336,12 +403,24 @@ export function App({ session, runTurn, clipboard }) {
336
403
  // /mcp — list configured servers + status, then pick one to manage it.
337
404
  const handleMcpCommand = async (arg) => {
338
405
  const parts = arg.trim().split(/\s+/).filter(Boolean);
406
+ // /mcp reload — re-read settings.json now (the file watcher normally does this on save).
407
+ if (parts[0] === "reload") {
408
+ setBusy(true);
409
+ try {
410
+ const changes = await reloadMcpServers();
411
+ note(chalk.dim(changes.length ? `(mcp: ${changes.join(", ")})` : "(mcp: no changes in settings.json)"));
412
+ }
413
+ finally {
414
+ setBusy(false);
415
+ }
416
+ return;
417
+ }
339
418
  // Direct subcommands (scriptable, matching Claude Code): /mcp reconnect|auth|enable|disable <name>
340
- if (parts.length >= 2 && ["reconnect", "auth", "authenticate", "enable", "disable"].includes(parts[0])) {
419
+ if (parts.length >= 2 && MCP_SUBCOMMANDS[parts[0]]) {
341
420
  const name = parts.slice(1).join(" ");
342
421
  setBusy(true);
343
422
  try {
344
- const action = parts[0] === "auth" || parts[0] === "authenticate" ? "authenticate" : parts[0];
423
+ const action = MCP_SUBCOMMANDS[parts[0]];
345
424
  const msg = await runMcpAction(name, action, (url) => note(chalk.dim(`authenticate in your browser: ${url}`)));
346
425
  note(chalk.dim(msg));
347
426
  }
@@ -364,7 +443,7 @@ export function App({ session, runTurn, clipboard }) {
364
443
  return note(chalk.dim("(cancelled)"));
365
444
  const server = servers[i];
366
445
  const actions = mcpActionsFor(server);
367
- openSelect(`${server.name} — ${mcpStatusText(server)}`, actions.map((a) => a.label), (j) => {
446
+ openSelect(`${server.name} MCP Server`, actions.map((a) => a.label), (j) => {
368
447
  if (j < 0)
369
448
  return note(chalk.dim("(cancelled)"));
370
449
  setBusy(true);
@@ -372,7 +451,7 @@ export function App({ session, runTurn, clipboard }) {
372
451
  .then((msg) => note(chalk.dim(msg)))
373
452
  .catch((err) => note(chalk.yellow(err.message)))
374
453
  .finally(() => setBusy(false));
375
- });
454
+ }, mcpServerDetails(server)); // Claude Code's server screen: status, auth, protocol, URL, config, capabilities, tools
376
455
  });
377
456
  };
378
457
  const handleCommand = async (line) => {
@@ -393,6 +472,11 @@ export function App({ session, runTurn, clipboard }) {
393
472
  }
394
473
  if (line.startsWith("/skills "))
395
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
396
480
  if (line === "/effort" || line.startsWith("/effort ")) {
397
481
  const target = CONFIG.model;
398
482
  const value = line.slice("/effort".length).trim();
@@ -418,12 +502,12 @@ export function App({ session, runTurn, clipboard }) {
418
502
  setAutoEnabled(autoMode.enabled);
419
503
  return true;
420
504
  }
421
- const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
505
+ const info = runInfoCommand(line, { skills: liveSkills(), costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
422
506
  if (info !== null) {
423
507
  note(info);
424
508
  return true;
425
509
  }
426
- // /mcp [reconnect|auth|enable|disable <name>]
510
+ // /mcp [reload | reconnect|auth|enable|disable <name>]
427
511
  if (line === "/mcp" || line.startsWith("/mcp ")) {
428
512
  await handleMcpCommand(line.slice("/mcp".length).trim());
429
513
  return true;
@@ -448,7 +532,7 @@ export function App({ session, runTurn, clipboard }) {
448
532
  apply(name);
449
533
  return true;
450
534
  }
451
- note(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${CONFIG.contextWindow} tokens (compaction at ~${COMPACT_AT})`));
535
+ note(chalk.dim(`model: ${CONFIG.model}\nendpoint: ${CONFIG.baseURL}\ncontext window: ${contextWindowFor()} tokens (auto-compact at ~${compactThreshold()})`));
452
536
  let models = [];
453
537
  try {
454
538
  const page = await client.models.list();
@@ -468,9 +552,12 @@ export function App({ session, runTurn, clipboard }) {
468
552
  });
469
553
  return true;
470
554
  }
471
- // /skill <name> — run a skill on the user's behalf (works for user-only skills)
555
+ // /skill <name> [args] — run a skill on the user's behalf (works for user-only
556
+ // skills). Claude Code's pair: a <command-name> marker, then the body.
472
557
  if (line === "/skill" || line.startsWith("/skill ")) {
473
- const name = line.slice("/skill".length).trim();
558
+ const skills = liveSkills(); // live — a SKILL.md added mid-session is found
559
+ const [name = "", ...rest] = line.slice("/skill".length).trim().split(/\s+/);
560
+ const args = rest.join(" ");
474
561
  if (!name) {
475
562
  note(chalk.dim(skills.length ? `usage: /skill <name> — available: ${skills.map((s) => s.name).join(", ")}` : "(no skills in this project — add one at .mini-agent/skills/<name>/SKILL.md)"));
476
563
  return true;
@@ -480,8 +567,9 @@ export function App({ session, runTurn, clipboard }) {
480
567
  note(chalk.yellow(`(no skill named "${name}")`));
481
568
  return true;
482
569
  }
483
- autoMode.recordRequest(`Run the "${s.name}" skill.`);
484
- runConversationTurn(`Run the "${s.name}" skill.\n\n${skillInstructions(s)}`, { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
570
+ autoMode.recordRequest(`/${s.name}${args ? ` ${args}` : ""}`);
571
+ recordSkillUsage(s.name); // feeds the "/" typeahead's most-used ranking
572
+ runConversationTurn(userSkillMessages(s, args), { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
485
573
  return true;
486
574
  }
487
575
  switch (line) {
@@ -562,6 +650,8 @@ export function App({ session, runTurn, clipboard }) {
562
650
  return true;
563
651
  }
564
652
  default:
653
+ if (line.startsWith("/") && findSkill(liveSkills(), line.slice(1).split(/\s+/)[0]))
654
+ return handleCommand(`/skill ${line.slice(1)}`); // /<skill-name> [args], like Claude Code
565
655
  if (line.startsWith("/")) {
566
656
  note(chalk.dim(`unknown command: ${line} — try /help`));
567
657
  return true; // consumed — don't send typos to the model
@@ -586,6 +676,8 @@ export function App({ session, runTurn, clipboard }) {
586
676
  tuiCleanup();
587
677
  return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
588
678
  }
679
+ if (pending?.kind === "skills")
680
+ return; // the /skills panel owns the keyboard while it is open
589
681
  const modifiedReturn = char === "[13;5u" || char === "[27;5;13~";
590
682
  // A submitted follow-up may still be passing hooks. A second Enter must
591
683
  // not approve the old action while that asynchronous preparation runs.
@@ -714,6 +806,13 @@ export function App({ session, runTurn, clipboard }) {
714
806
  return;
715
807
  }
716
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
+ }
717
816
  const completionAction = completion.handleKey(key, (value) => { setInput(value); setCursor(value.length); setHistIdx(null); });
718
817
  if (completionAction.handled && !completionAction.submit)
719
818
  return;
@@ -854,7 +953,7 @@ export function App({ session, runTurn, clipboard }) {
854
953
  const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
855
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));
856
955
  const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
857
- 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) && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { color: "#D77757", wrap: "truncate-end", children: [" ", 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 }), _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: (() => {
858
957
  const prompt = "❯ ";
859
958
  const promptColor = planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
860
959
  const cols = columns;
@@ -863,6 +962,7 @@ export function App({ session, runTurn, clipboard }) {
863
962
  const firstW = Math.max(1, innerWidth - promptW);
864
963
  // Build visual lines display-width-aware (CJK, emoji = 2 cols).
865
964
  const lines = [""];
965
+ const lineStarts = [0]; // code-unit offset where each visual line begins (for skill highlighting)
866
966
  let row = 0;
867
967
  let col = 0;
868
968
  // Cursor tracking: map code-unit offset → (line, display-column).
@@ -882,6 +982,7 @@ export function App({ session, runTurn, clipboard }) {
882
982
  row++;
883
983
  col = 0;
884
984
  lines[row] = "";
985
+ lineStarts[row] = cu;
885
986
  }
886
987
  lines[row] += ch;
887
988
  col += cw;
@@ -896,9 +997,35 @@ export function App({ session, runTurn, clipboard }) {
896
997
  if (cursorCol >= edge) {
897
998
  cursorLine++;
898
999
  cursorCol = 0;
899
- if (lines.length <= cursorLine)
1000
+ if (lines.length <= cursorLine) {
900
1001
  lines.push("");
1002
+ lineStarts.push(cu);
1003
+ }
901
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
+ };
902
1029
  // Map display-column back to code-unit offset within the target line.
903
1030
  const cursorLineText = lines[cursorLine] ?? "";
904
1031
  let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
@@ -919,9 +1046,10 @@ export function App({ session, runTurn, clipboard }) {
919
1046
  const before = line.slice(0, cuOff);
920
1047
  const at = line.slice(cuOff, cuOff + 1) || " ";
921
1048
  const after = line.slice(cuOff + 1);
922
- 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));
923
1051
  }
924
- 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));
925
1053
  });
926
- })() }), 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)) }), _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }), (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)" })] }))] })] }));
927
1055
  }
package/dist/ink/chat.js CHANGED
@@ -8,11 +8,13 @@ import { userContent } from "../images.js";
8
8
  // rest. All screen output flows through hooks.output (the Ink sink).
9
9
  export function makeRunTurn(client, messages) {
10
10
  let reviewBudget = new AutoReviewBudget();
11
+ // input: one user message, or several in order (a /skill run sends the
12
+ // <command-name> marker, then the skill body); images ride on the first.
11
13
  return async (input, hooks) => {
12
14
  if (input !== null)
13
15
  reviewBudget = new AutoReviewBudget();
14
16
  if (input !== null)
15
- messages.push({ role: "user", content: userContent(input, hooks.images ?? []) });
17
+ [input].flat().forEach((text, i) => messages.push({ role: "user", content: i === 0 ? userContent(text, hooks.images ?? []) : text }));
16
18
  return runLoop(messages, {
17
19
  client,
18
20
  model: CONFIG.model,
@@ -25,7 +25,8 @@ export const SESSION_HELP = `commands:
25
25
  /memory show the durable facts the agent remembers about this project
26
26
  /status session usage and vendor account balance/cost
27
27
  /cost tokens, cache hit rate and estimated spend this session (local)
28
- /mcp list configured MCP servers + status; select one to authenticate / reconnect / disable
28
+ /mcp list configured MCP servers + status; select one to view tools / authenticate / clear auth / reconnect / disable
29
+ /mcp reload re-read mcpServers from settings.json (saves are also picked up automatically)
29
30
  /plan toggle plan mode — research-only; the agent presents a plan you approve before any change
30
31
  /effort [level] list/select the current model’s supported reasoning effort
31
32
  /auto toggle auto mode — risky or uncertain actions still require approval
@@ -38,8 +39,8 @@ export const SESSION_HELP = `commands:
38
39
  /undo revert the most recent file write (write_file / edit_file) this session
39
40
  /diff show every file changed this session, as a diff from where it started
40
41
  /resume list recent sessions in this project and continue one of them
41
- /skills list the reusable skills available in this project
42
- /skill <name> run a skill yourself (works even for user-only skills)
42
+ /skills manage skills: turn each on, off, or user-only (search with /, sort with t)
43
+ /skill <name> [args] run a skill yourself (works even for user-only skills); /<name> [args] works too
43
44
  exit leave (Ctrl+C does the same)
44
45
 
45
46
  keys (during or after a turn):
@@ -57,6 +58,8 @@ keys (during or after a turn):
57
58
  // Colored status word for an MCP server (shared by the Ink + readline /mcp UIs).
58
59
  export function mcpStatusText(info) {
59
60
  switch (info.status) {
61
+ case "pending":
62
+ return chalk.dim("connecting…");
60
63
  case "connected":
61
64
  return chalk.green("connected");
62
65
  case "needs-auth":
@@ -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
@@ -3,18 +3,18 @@ import path from "node:path";
3
3
  import { createRequire } from "node:module";
4
4
  import { CONFIG, requireApiKey } from "../config.js";
5
5
  import { buildSystemMessage, readProjectInstructions } from "../prompt.js";
6
- import { estimateHistoryTokens } from "../context.js";
6
+ import { contextPercent } from "../context.js";
7
7
  import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
8
8
  import { gitBranch } from "../tui.js";
9
9
  import { newSessionId, latestSession } from "../session.js";
10
10
  import { initTelemetry, emit } from "../telemetry.js";
11
11
  import { runHooks } from "../hooks.js";
12
- import { connectMcpServers } from "../mcp.js";
12
+ import { connectMcpServers, watchMcpConfig } from "../mcp.js";
13
13
  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 { loadSkills, buildSkillTool } 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)
@@ -52,10 +52,12 @@ export async function buildInkSession(opts = {}) {
52
52
  }
53
53
  initTelemetry(sessionId);
54
54
  emit("agent_session_start", { mode: "repl" });
55
- // MCP servers — must finish before the first model call so their tools appear
56
- // in the manual. A server that fails to start is skipped, never fatal.
57
- const disconnectMcp = await connectMcpServers();
55
+ // MCP servers connect in the BACKGROUND, like Claude Code: the prompt is
56
+ // ready at once, each server's tools join the next model call the moment it
57
+ // connects, and a failure is flagged in the status bar (red, bottom right).
58
+ const disconnectMcp = await connectMcpServers({ background: true });
58
59
  process.on("exit", disconnectMcp);
60
+ process.on("exit", watchMcpConfig()); // hot reload: a settings.json save adds/removes/reconnects servers, no restart
59
61
  process.on("exit", killAllBackground); // SIGKILL any background job so it never outlives the agent
60
62
  process.on("exit", killAllSubAgents); // mark any still-running sub-agents as killed
61
63
  process.on("exit", stopCronScheduler); // Day s14: stop the setInterval on exit
@@ -75,13 +77,11 @@ export async function buildInkSession(opts = {}) {
75
77
  const memCount = readMemory().length;
76
78
  if (memCount)
77
79
  notices.push(`(long-term memory: ${memCount} facts loaded)`);
78
- // Skills: model-invocable ones get the single `skill` tool; user-only ones are
79
- // reachable via /skill <name>.
80
- const skills = loadSkills();
81
- if (skills.some((s) => !s.disableModelInvocation))
82
- registerExternalTool(buildSkillTool(skills));
83
- if (skills.length)
84
- notices.push(`(skills: ${skills.length} loaded — ${skills.map((s) => s.name).join(", ")})`);
80
+ // Skills are re-read before every model call (no restart needed); the loop
81
+ // registers the `skill` tool and sends the listing. Just announce them here.
82
+ const startupSkills = currentSkills();
83
+ if (startupSkills.length)
84
+ notices.push(`(skills: ${startupSkills.length} loaded — ${startupSkills.map((s) => s.name).join(", ")})`);
85
85
  const costMeter = initCostMeter({
86
86
  inputPerM: CONFIG.pricing.inputPerM ?? DEFAULT_PRICING.inputPerM,
87
87
  cachedInputPerM: CONFIG.pricing.cachedInputPerM ?? DEFAULT_PRICING.cachedInputPerM,
@@ -94,7 +94,7 @@ export async function buildInkSession(opts = {}) {
94
94
  const dir = path.basename(process.cwd());
95
95
  const branch = gitBranch();
96
96
  const getStatus = () => ({
97
- ctxPct: Math.min(100, Math.round((estimateHistoryTokens(messages) / CONFIG.contextWindow) * 100)),
97
+ ctxPct: contextPercent(messages), // % of the way to auto-compaction
98
98
  cost: costMeter.cost(),
99
99
  elapsedMs: Date.now() - startedAt,
100
100
  });
@@ -106,7 +106,8 @@ export async function buildInkSession(opts = {}) {
106
106
  initialTitle,
107
107
  startedAt,
108
108
  costMeter,
109
- skills,
109
+ skills: () => currentSkills(),
110
+ allSkills: () => allSkills(),
110
111
  judge,
111
112
  autoMode,
112
113
  model: CONFIG.model,