agent-from-zero 0.35.2 → 0.35.4
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/README.md +14 -2
- package/dist/agent.js +46 -6
- package/dist/completion.js +94 -2
- package/dist/config.js +2 -0
- package/dist/context.js +57 -10
- package/dist/ink/app.js +218 -22
- package/dist/ink/commands.js +3 -1
- package/dist/ink/completion.js +13 -5
- package/dist/ink/launch.js +5 -0
- package/dist/ink/setup.js +10 -1
- package/dist/ink/skills-panel.js +86 -0
- package/dist/loop.js +41 -5
- package/dist/peers.js +332 -0
- package/dist/permissions.js +4 -0
- package/dist/skills.js +97 -4
- package/dist/title.js +41 -1
- package/dist/ui.js +15 -0
- package/package.json +3 -2
package/dist/ink/app.js
CHANGED
|
@@ -4,12 +4,13 @@ import { statusCommand } from "../status.js";
|
|
|
4
4
|
import React, { useState, useEffect, useRef } from "react";
|
|
5
5
|
import { Box, Text, Static, useInput, useApp } from "ink";
|
|
6
6
|
import chalk from "chalk";
|
|
7
|
-
import { formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.js"; // status format + the SAME pure menu renderer the readline REPL uses
|
|
7
|
+
import { COMPACTING_VERB, formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.js"; // status format + the SAME pure menu renderer the readline REPL uses
|
|
8
8
|
import { renderMarkdown } from "../markdown.js"; // model speaks markdown → ANSI, same as the non-Ink REPL
|
|
9
9
|
import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.js"; // the ask_user form: pure state machine + renderer
|
|
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
|
|
@@ -20,11 +21,13 @@ import { resetBoard } from "../board.js";
|
|
|
20
21
|
import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, getToolActivity, getToolCallCount, cleanup as tuiCleanup } from "../tui.js";
|
|
21
22
|
import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
|
|
22
23
|
import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
|
|
24
|
+
import { currentSession, peerInboxPending, readPeerInbox, peerMessageContent, peersCommand, setSessionState, setSessionInfo, listPeers, findPeer, peerRow, sendPeerMessage, SCREEN_KINDS, MAX_PEER_TURNS } from "../peers.js"; // peer sessions: messages from other mini-agent windows start a turn while idle
|
|
23
25
|
import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
|
|
24
26
|
import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
|
|
25
|
-
import { generateSessionTitle, setTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
27
|
+
import { generateSessionTitle, setTerminalTitle, refreshTerminalTitle, flashTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
26
28
|
import { isPlanMode, setPlanMode } from "../permissions.js";
|
|
27
|
-
import { findSkill, userSkillMessages } from "../skills.js";
|
|
29
|
+
import { findSkill, recordSkillUsage, skillSource, skillUsageScores, userSkillMessages } from "../skills.js";
|
|
30
|
+
import { SkillsPanel } from "./skills-panel.js"; // /skills: Claude Code's on / user-only / off manager
|
|
28
31
|
import { extractMemories } from "../memory.js";
|
|
29
32
|
import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
|
|
30
33
|
import { runHooks } from "../hooks.js";
|
|
@@ -45,7 +48,9 @@ const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only rea
|
|
|
45
48
|
// it two cells wide — the text after the glyph then jumps on every other frame.
|
|
46
49
|
const SPINNER_FRAMES = ["·", "✢", "✶", "✻", "✽", "✻", "✶", "✢"];
|
|
47
50
|
const SPINNER_COLOR = "#D77757"; // Claude Code's orange
|
|
51
|
+
const SKILL_COLOR = "#B1B9F9"; // Claude Code's lavender for a recognised /skill in the input
|
|
48
52
|
const SHIMMER_PEAK = "#FFD2BD"; // the highlight that sweeps across the verb
|
|
53
|
+
const COMPACT_PEAK = "#E4E8FF"; // the same sweep over the lavender "Compacting conversation…"
|
|
49
54
|
const SHIMMER_TICK_MS = 100; // one animation clock for glyph + shimmer → one repaint per tick
|
|
50
55
|
const SHIMMER_RADIUS = 3; // how many cells the highlight fades over on each side
|
|
51
56
|
// Cap the live streaming preview to a terminal-aware tail. THIS IS LOAD-BEARING:
|
|
@@ -75,8 +80,8 @@ function useTick() {
|
|
|
75
80
|
}
|
|
76
81
|
// The glyph lives in a fixed two-cell box: whatever the frame, the text after it
|
|
77
82
|
// starts in the same column.
|
|
78
|
-
function Glyph({ tick }) {
|
|
79
|
-
return (_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color:
|
|
83
|
+
function Glyph({ tick, color = SPINNER_COLOR }) {
|
|
84
|
+
return (_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: color, children: SPINNER_FRAMES[Math.floor(tick / 2) % SPINNER_FRAMES.length] }) }));
|
|
80
85
|
}
|
|
81
86
|
function Spinner() {
|
|
82
87
|
return _jsx(Glyph, { tick: useTick() });
|
|
@@ -89,10 +94,10 @@ function mix(a, b, t) {
|
|
|
89
94
|
// The light sweep: each character is coloured by its distance from a moving
|
|
90
95
|
// highlight. Only COLOUR changes between frames — never a character, never a
|
|
91
96
|
// width — so the line shimmers without a single cell moving.
|
|
92
|
-
export function shimmer(text, pos) {
|
|
97
|
+
export function shimmer(text, pos, base = SPINNER_COLOR, peak = SHIMMER_PEAK) {
|
|
93
98
|
return [...text].map((c, i) => {
|
|
94
99
|
const glow = Math.max(0, 1 - Math.abs(i - pos) / SHIMMER_RADIUS);
|
|
95
|
-
return chalk.hex(mix(
|
|
100
|
+
return chalk.hex(mix(base, peak, glow))(c);
|
|
96
101
|
}).join("");
|
|
97
102
|
}
|
|
98
103
|
// "✶ Thinking… (3s · ↓ 281 tokens · thought for 1s)": the verb (everything up to
|
|
@@ -100,12 +105,14 @@ export function shimmer(text, pos) {
|
|
|
100
105
|
// between sweeps (the highlight travels past the end) keeps it calm.
|
|
101
106
|
function SpinnerLine({ text }) {
|
|
102
107
|
const tick = useTick();
|
|
103
|
-
const
|
|
108
|
+
const [head, ...below] = text.split("\n"); // a status may carry extra lines (the compaction progress bar)
|
|
109
|
+
const plain = stripVTControlCharacters(head);
|
|
104
110
|
const cut = plain.indexOf("…");
|
|
105
|
-
const verb = cut >= 0 &&
|
|
111
|
+
const verb = cut >= 0 && head.startsWith(plain.slice(0, cut + 1)) ? plain.slice(0, cut + 1) : ""; // shimmer only an unstyled verb
|
|
106
112
|
const period = verb.length + SHIMMER_RADIUS * 2 + 8;
|
|
107
113
|
const pos = (tick % period) - SHIMMER_RADIUS;
|
|
108
|
-
|
|
114
|
+
const [base, peak] = verb === COMPACTING_VERB ? [SKILL_COLOR, COMPACT_PEAK] : [SPINNER_COLOR, SHIMMER_PEAK]; // Claude Code draws compaction in lavender
|
|
115
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Glyph, { tick: tick, color: base }), _jsxs(Text, { color: base, wrap: "truncate-end", children: [verb ? shimmer(verb, pos, base, peak) : "", head.slice(verb.length)] })] }), below.map((line, i) => _jsxs(Text, { wrap: "truncate-end", children: [" ", line] }, i))] }));
|
|
109
116
|
}
|
|
110
117
|
// One committed line of conversation. `user` is a highlight bar; `answer` is a
|
|
111
118
|
// finished reply rendered as markdown; `note` is already-formatted narration.
|
|
@@ -163,7 +170,7 @@ const EXIT_NOTES = {
|
|
|
163
170
|
};
|
|
164
171
|
export function App({ session, runTurn, clipboard }) {
|
|
165
172
|
const { rows, columns } = useTerminalSize();
|
|
166
|
-
const { client, messages, systemMessage, costMeter, skills: liveSkills, judge, autoMode, model, dir, branch, getStatus } = session;
|
|
173
|
+
const { client, messages, systemMessage, costMeter, skills: liveSkills, allSkills: everySkill, judge, autoMode, model, dir, branch, getStatus } = session;
|
|
167
174
|
// Seed the scrollback with the welcome banner + the dim startup notices.
|
|
168
175
|
const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
|
|
169
176
|
const [status, setStatus] = useState(null); // the live spinner line
|
|
@@ -171,6 +178,8 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
171
178
|
const [detailOffset, setDetailOffset] = useState(0);
|
|
172
179
|
const [live, setLive] = useState(null); // the streaming answer
|
|
173
180
|
const [input, setInput] = useState(""); // the current input buffer
|
|
181
|
+
const [talkTo, setTalkTo] = useState(null); // /peers → talk: the input box sends to another session instead of this agent
|
|
182
|
+
const replyTo = useRef([]); // who gets this turn's final answer (the senders of "user" peer messages)
|
|
174
183
|
const [images, setImages] = useState([]);
|
|
175
184
|
const pasting = useRef(false);
|
|
176
185
|
const imageSeq = useRef(0);
|
|
@@ -210,9 +219,12 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
210
219
|
modelListEndpoint.current = CONFIG.baseURL;
|
|
211
220
|
void client.models.list().then((page) => setCompletionModels(page.data.map((m) => m.id))).catch(() => { });
|
|
212
221
|
}, [wantsModels, client]);
|
|
222
|
+
const typeaheadSkills = liveSkills().map((s) => ({ name: s.name, description: s.description, whenToUse: s.whenToUse, argNames: s.argNames, argumentHint: s.argumentHint, source: skillSource(s) }));
|
|
213
223
|
const completion = useSlashCompletion(input, completing, {
|
|
214
|
-
model: CONFIG.model, skills:
|
|
215
|
-
}, rows - 10);
|
|
224
|
+
model: CONFIG.model, skills: typeaheadSkills, models: completionModels, servers: listMcpServers().map((s) => s.name), usage: completing && input.startsWith("/") ? skillUsageScores() : undefined,
|
|
225
|
+
}, rows - 10, columns);
|
|
226
|
+
// A "/" later in the text: no list, just the rest of the best match as dim ghost text (Tab accepts).
|
|
227
|
+
const ghost = completing && cursor === input.length ? inlineCommandGhost(input, { skills: typeaheadSkills }) : null;
|
|
216
228
|
useEffect(() => {
|
|
217
229
|
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
218
230
|
return () => clearInterval(id);
|
|
@@ -239,7 +251,10 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
239
251
|
pushItem(display);
|
|
240
252
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
|
|
241
253
|
clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
|
|
254
|
+
const senders = replyTo.current; // taken now, so a failed turn can't leak them into the next one
|
|
255
|
+
replyTo.current = [];
|
|
242
256
|
setBusy(true);
|
|
257
|
+
setSessionState("busy", CONFIG.model); // other sessions see "busy" in list_peers
|
|
243
258
|
const controller = new AbortController();
|
|
244
259
|
const tstate = { controller, interrupted: false };
|
|
245
260
|
turn.current = tstate; // expose it so Esc can interrupt this turn
|
|
@@ -273,6 +288,9 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
273
288
|
};
|
|
274
289
|
runTurn(content, hooks)
|
|
275
290
|
.then(async (result) => {
|
|
291
|
+
// A turn started by the user from another window: show them the answer there.
|
|
292
|
+
for (const to of senders)
|
|
293
|
+
sendPeerMessage(to, result.finalText?.trim() || `(finished: ${result.reason})`, "reply");
|
|
276
294
|
if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
|
|
277
295
|
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
278
296
|
saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
|
|
@@ -290,6 +308,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
290
308
|
.catch((e) => note(chalk.red(`[error] ${e.message}`)))
|
|
291
309
|
.finally(() => {
|
|
292
310
|
turn.current = null;
|
|
311
|
+
setSessionState("idle");
|
|
293
312
|
setBusy(false);
|
|
294
313
|
setStatus(null);
|
|
295
314
|
setLive(null);
|
|
@@ -324,13 +343,131 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
324
343
|
return () => clearInterval(id);
|
|
325
344
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
326
345
|
}, [busy, pending]);
|
|
346
|
+
// The PEER idle processor: another mini-agent session messaged this one while
|
|
347
|
+
// it sat at the prompt. Like cron, start a turn so the agent answers on its
|
|
348
|
+
// own. Two agents answering each other could run forever (and bill for every
|
|
349
|
+
// hop), so after MAX_PEER_TURNS auto-started turns with no human input in
|
|
350
|
+
// between, messages wait in the inbox until you type something.
|
|
351
|
+
const peerTurns = useRef(0);
|
|
352
|
+
const peerHoldNoted = useRef(false);
|
|
353
|
+
useEffect(() => {
|
|
354
|
+
if (busy || pending || followUps.size)
|
|
355
|
+
return; // human follow-ups take priority
|
|
356
|
+
const id = setInterval(() => {
|
|
357
|
+
const waiting = peerInboxPending();
|
|
358
|
+
if (!waiting || turn.current)
|
|
359
|
+
return;
|
|
360
|
+
const fromUser = peerInboxPending(["user"]);
|
|
361
|
+
if (fromUser) {
|
|
362
|
+
// You, typing in another window: never held by the agent-to-agent cap.
|
|
363
|
+
const got = readPeerInbox(["user"]);
|
|
364
|
+
peerTurns.current = 0;
|
|
365
|
+
peerHoldNoted.current = false;
|
|
366
|
+
replyTo.current = [...new Set(got.map((m) => m.from.id))];
|
|
367
|
+
runConversationTurn(peerMessageContent(got), { kind: "user", text: `${got.map((m) => m.text).join("\n")} ${chalk.magenta(`(from your "${got[0].from.name}" window)`)}` });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (peerTurns.current >= MAX_PEER_TURNS) {
|
|
371
|
+
if (!peerHoldNoted.current)
|
|
372
|
+
note(chalk.yellow(`✉ ${waiting} peer message${waiting > 1 ? "s" : ""} waiting — paused after ${MAX_PEER_TURNS} automatic turns in a row; send anything to continue`));
|
|
373
|
+
peerHoldNoted.current = true;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
const got = readPeerInbox();
|
|
377
|
+
if (!got.length)
|
|
378
|
+
return;
|
|
379
|
+
peerTurns.current++;
|
|
380
|
+
const from = [...new Set(got.map((m) => `${m.from.name} (${m.from.cwd})`))].join(", ");
|
|
381
|
+
runConversationTurn(peerMessageContent(got), { kind: "note", text: chalk.magenta(`✉ message from ${from}`) });
|
|
382
|
+
}, 1000);
|
|
383
|
+
return () => clearInterval(id);
|
|
384
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
385
|
+
}, [busy, pending]);
|
|
386
|
+
// Replies and "which window are you?" pings are for the SCREEN, not the model:
|
|
387
|
+
// handled even mid-turn, never starting a model call.
|
|
388
|
+
useEffect(() => {
|
|
389
|
+
const id = setInterval(() => {
|
|
390
|
+
if (!peerInboxPending(SCREEN_KINDS))
|
|
391
|
+
return;
|
|
392
|
+
for (const m of readPeerInbox(SCREEN_KINDS)) {
|
|
393
|
+
if (m.kind === "identify") {
|
|
394
|
+
flashTerminalTitle();
|
|
395
|
+
note(chalk.bgMagenta.white.bold(` 👋 This window is session "${m.text}" — the "${m.from.name}" window is looking for it `));
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
note(chalk.magenta(`↩ ${m.from.name} replied:`));
|
|
399
|
+
pushItem({ kind: "answer", text: m.text });
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}, 1000);
|
|
403
|
+
return () => clearInterval(id);
|
|
404
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
405
|
+
}, []);
|
|
406
|
+
// /peers: pick another session, then talk to it (the input box addresses it)
|
|
407
|
+
// or make it identify itself (bell + flashing tab) so you know which window it is.
|
|
408
|
+
const openPeers = () => {
|
|
409
|
+
const peers = listPeers();
|
|
410
|
+
if (!peers.length)
|
|
411
|
+
return note(chalk.dim(peersCommand("/peers")));
|
|
412
|
+
openSelect(`Other mini-agent sessions — pick one · this one is "${currentSession()?.name ?? "?"}" (/rename <name> to change)`, [...peers.map(peerRow), "Cancel"], (i) => {
|
|
413
|
+
const p = peers[i];
|
|
414
|
+
if (!p)
|
|
415
|
+
return;
|
|
416
|
+
openSelect(`${p.name} · ${p.terminal}`, [`Talk to ${p.name} — type in this box, Esc to come back`, "Identify it — ring its bell and flash its tab title", "Cancel"], (j) => {
|
|
417
|
+
if (j === 0) {
|
|
418
|
+
setTalkTo(p);
|
|
419
|
+
note(chalk.magenta(`→ talking to "${p.name}" (${p.terminal}). What you type goes there; its answer comes back here. Esc returns to this agent.`));
|
|
420
|
+
}
|
|
421
|
+
else if (j === 1) {
|
|
422
|
+
const sent = sendPeerMessage(p.id, p.name, "identify");
|
|
423
|
+
note(sent.startsWith("[error]") ? chalk.yellow(sent) : chalk.dim(`(asked "${p.name}" to flash its tab — look for 👋 ${p.name} — HERE)`));
|
|
424
|
+
}
|
|
425
|
+
}, peerRow(p));
|
|
426
|
+
});
|
|
427
|
+
};
|
|
327
428
|
// A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
|
|
328
429
|
// mentions, then start the turn. Mirrors agent.ts's submit path.
|
|
329
430
|
const submitLine = async (draft, attachments = [], immediate = false) => {
|
|
431
|
+
peerTurns.current = 0; // a human is here: peers may start turns again
|
|
432
|
+
peerHoldNoted.current = false;
|
|
433
|
+
// Talk mode: plain text goes to the chosen session, even while this agent works.
|
|
434
|
+
if (talkTo && !pending && !draft.startsWith("/")) {
|
|
435
|
+
const text = pastedTexts.expand(draft);
|
|
436
|
+
const alive = findPeer(talkTo.id);
|
|
437
|
+
if (!alive) {
|
|
438
|
+
setTalkTo(null);
|
|
439
|
+
return note(chalk.yellow(`"${talkTo.name}" is no longer running — back to this agent`));
|
|
440
|
+
}
|
|
441
|
+
const sent = sendPeerMessage(alive.id, text, "user");
|
|
442
|
+
if (sent.startsWith("[error]"))
|
|
443
|
+
return note(chalk.yellow(sent));
|
|
444
|
+
pushItem({ kind: "user", text: `→ ${alive.name}: ${draft}` });
|
|
445
|
+
if (alive.state === "busy")
|
|
446
|
+
note(chalk.dim(`("${alive.name}" is busy — it answers when its current turn ends)`));
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (!draft.startsWith("/"))
|
|
450
|
+
setSessionInfo({ lastPrompt: draft });
|
|
330
451
|
if (!pending && turn.current && (draft.startsWith("/") || draft === "exit" || draft === "quit")) {
|
|
331
452
|
note(chalk.dim("Commands are available when the agent is idle; use Esc to interrupt."));
|
|
332
453
|
return;
|
|
333
454
|
}
|
|
455
|
+
// A /command typed while a menu is up is a command, never a message for the
|
|
456
|
+
// model (a second "/peers" into the /peers picker used to become the
|
|
457
|
+
// conversation's first prompt). Close the menu, then run it — when idle.
|
|
458
|
+
if (pending && /^\/[\w:-]+(\s|$)/.test(draft)) {
|
|
459
|
+
if (turn.current)
|
|
460
|
+
return note(chalk.dim("Commands are available when the agent is idle; use Esc to interrupt."));
|
|
461
|
+
setPending(null);
|
|
462
|
+
setFormState(null);
|
|
463
|
+
if (pending.kind === "form")
|
|
464
|
+
pending.resolve(null);
|
|
465
|
+
else if (pending.kind === "select")
|
|
466
|
+
pending.onChoose(-1);
|
|
467
|
+
if (await handleCommand(draft))
|
|
468
|
+
return;
|
|
469
|
+
return note(chalk.dim(`unknown command: ${draft} — try /help`));
|
|
470
|
+
}
|
|
334
471
|
if (!pending && (draft === "exit" || draft === "quit"))
|
|
335
472
|
return doExit();
|
|
336
473
|
if (!pending && await handleCommand(draft))
|
|
@@ -375,7 +512,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
375
512
|
setFormState(null);
|
|
376
513
|
if (pending.kind === "form")
|
|
377
514
|
pending.resolve(null);
|
|
378
|
-
else
|
|
515
|
+
else if (pending.kind === "select")
|
|
379
516
|
pending.onChoose(-1);
|
|
380
517
|
}
|
|
381
518
|
if (immediate)
|
|
@@ -466,6 +603,11 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
466
603
|
}
|
|
467
604
|
if (line.startsWith("/skills "))
|
|
468
605
|
line = `/skill ${line.slice(8).trim()}`;
|
|
606
|
+
if (line === "/skills") {
|
|
607
|
+
setDetails(null);
|
|
608
|
+
setPending({ kind: "skills" });
|
|
609
|
+
return true;
|
|
610
|
+
} // Claude Code's manager: on / user-only / off per skill
|
|
469
611
|
if (line === "/effort" || line.startsWith("/effort ")) {
|
|
470
612
|
const target = CONFIG.model;
|
|
471
613
|
const value = line.slice("/effort".length).trim();
|
|
@@ -491,6 +633,16 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
491
633
|
setAutoEnabled(autoMode.enabled);
|
|
492
634
|
return true;
|
|
493
635
|
}
|
|
636
|
+
if (line === "/peers") {
|
|
637
|
+
openPeers();
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
const peers = peersCommand(line); // /rename <name>
|
|
641
|
+
if (peers !== null) {
|
|
642
|
+
note(chalk.dim(peers));
|
|
643
|
+
refreshTerminalTitle();
|
|
644
|
+
return true;
|
|
645
|
+
}
|
|
494
646
|
const info = runInfoCommand(line, { skills: liveSkills(), costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
|
|
495
647
|
if (info !== null) {
|
|
496
648
|
note(info);
|
|
@@ -557,6 +709,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
557
709
|
return true;
|
|
558
710
|
}
|
|
559
711
|
autoMode.recordRequest(`/${s.name}${args ? ` ${args}` : ""}`);
|
|
712
|
+
recordSkillUsage(s.name); // feeds the "/" typeahead's most-used ranking
|
|
560
713
|
runConversationTurn(userSkillMessages(s, args), { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
|
|
561
714
|
return true;
|
|
562
715
|
}
|
|
@@ -598,7 +751,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
598
751
|
}
|
|
599
752
|
setBusy(true);
|
|
600
753
|
try {
|
|
601
|
-
await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note);
|
|
754
|
+
await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note, sink); // the same progress bar as automatic compaction
|
|
602
755
|
}
|
|
603
756
|
catch (err) {
|
|
604
757
|
note(chalk.yellow(`compaction failed: ${err.message}`));
|
|
@@ -664,6 +817,8 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
664
817
|
tuiCleanup();
|
|
665
818
|
return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
|
|
666
819
|
}
|
|
820
|
+
if (pending?.kind === "skills")
|
|
821
|
+
return; // the /skills panel owns the keyboard while it is open
|
|
667
822
|
const modifiedReturn = char === "[13;5u" || char === "[27;5;13~";
|
|
668
823
|
// A submitted follow-up may still be passing hooks. A second Enter must
|
|
669
824
|
// not approve the old action while that asynchronous preparation runs.
|
|
@@ -733,6 +888,11 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
733
888
|
statusRequest.current.abort();
|
|
734
889
|
return;
|
|
735
890
|
}
|
|
891
|
+
if (key.escape && talkTo && !pending && !input) {
|
|
892
|
+
setTalkTo(null);
|
|
893
|
+
note(chalk.dim(`(back to this agent — no longer talking to "${talkTo.name}")`));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
736
896
|
if (key.escape && busy && !pending) {
|
|
737
897
|
if (followUps.size) {
|
|
738
898
|
interruptForFollowUp();
|
|
@@ -792,6 +952,13 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
792
952
|
return;
|
|
793
953
|
}
|
|
794
954
|
}
|
|
955
|
+
if (key.tab && ghost) { // accept the inline suggestion as "/name "
|
|
956
|
+
const next = input.slice(0, ghost.start) + `/${ghost.full} `;
|
|
957
|
+
setInput(next);
|
|
958
|
+
setCursor(next.length);
|
|
959
|
+
setHistIdx(null);
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
795
962
|
const completionAction = completion.handleKey(key, (value) => { setInput(value); setCursor(value.length); setHistIdx(null); });
|
|
796
963
|
if (completionAction.handled && !completionAction.submit)
|
|
797
964
|
return;
|
|
@@ -932,15 +1099,16 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
932
1099
|
const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
|
|
933
1100
|
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
1101
|
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: (() => {
|
|
936
|
-
const prompt = "❯ ";
|
|
937
|
-
const promptColor = planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
|
|
1102
|
+
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: talkTo || planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
|
|
1103
|
+
const prompt = talkTo ? `→ ${talkTo.name} ❯ ` : "❯ ";
|
|
1104
|
+
const promptColor = talkTo ? "magenta" : planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
|
|
938
1105
|
const cols = columns;
|
|
939
1106
|
const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
|
|
940
1107
|
const promptW = displayWidth(prompt);
|
|
941
1108
|
const firstW = Math.max(1, innerWidth - promptW);
|
|
942
1109
|
// Build visual lines display-width-aware (CJK, emoji = 2 cols).
|
|
943
1110
|
const lines = [""];
|
|
1111
|
+
const lineStarts = [0]; // code-unit offset where each visual line begins (for skill highlighting)
|
|
944
1112
|
let row = 0;
|
|
945
1113
|
let col = 0;
|
|
946
1114
|
// Cursor tracking: map code-unit offset → (line, display-column).
|
|
@@ -960,6 +1128,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
960
1128
|
row++;
|
|
961
1129
|
col = 0;
|
|
962
1130
|
lines[row] = "";
|
|
1131
|
+
lineStarts[row] = cu;
|
|
963
1132
|
}
|
|
964
1133
|
lines[row] += ch;
|
|
965
1134
|
col += cw;
|
|
@@ -974,9 +1143,35 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
974
1143
|
if (cursorCol >= edge) {
|
|
975
1144
|
cursorLine++;
|
|
976
1145
|
cursorCol = 0;
|
|
977
|
-
if (lines.length <= cursorLine)
|
|
1146
|
+
if (lines.length <= cursorLine) {
|
|
978
1147
|
lines.push("");
|
|
1148
|
+
lineStarts.push(cu);
|
|
1149
|
+
}
|
|
979
1150
|
}
|
|
1151
|
+
// "/<skill>" tokens anywhere in the draft are painted — the typeahead
|
|
1152
|
+
// only opens at the start, but a real skill name lights up wherever
|
|
1153
|
+
// it is. paint() splits a slice of a line into plain and lit runs.
|
|
1154
|
+
const lit = commandTokenRanges(input, commandNames({ skills: liveSkills() }));
|
|
1155
|
+
const isLit = (at) => lit.some(([a, b]) => at >= a && at < b);
|
|
1156
|
+
const paint = (text, from, keyBase) => {
|
|
1157
|
+
const runs = [];
|
|
1158
|
+
let run = "";
|
|
1159
|
+
let runLit = false;
|
|
1160
|
+
let at = from;
|
|
1161
|
+
const flush = () => { if (run)
|
|
1162
|
+
runs.push(_jsx(Text, { color: runLit ? SKILL_COLOR : undefined, children: run }, `${keyBase}${runs.length}`)); run = ""; };
|
|
1163
|
+
for (const ch of text) {
|
|
1164
|
+
const l = isLit(at);
|
|
1165
|
+
if (l !== runLit) {
|
|
1166
|
+
flush();
|
|
1167
|
+
runLit = l;
|
|
1168
|
+
}
|
|
1169
|
+
run += ch;
|
|
1170
|
+
at += ch.length;
|
|
1171
|
+
}
|
|
1172
|
+
flush();
|
|
1173
|
+
return runs;
|
|
1174
|
+
};
|
|
980
1175
|
// Map display-column back to code-unit offset within the target line.
|
|
981
1176
|
const cursorLineText = lines[cursorLine] ?? "";
|
|
982
1177
|
let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
|
|
@@ -997,9 +1192,10 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
997
1192
|
const before = line.slice(0, cuOff);
|
|
998
1193
|
const at = line.slice(cuOff, cuOff + 1) || " ";
|
|
999
1194
|
const after = line.slice(cuOff + 1);
|
|
1000
|
-
|
|
1195
|
+
const base = lineStarts[i] ?? 0;
|
|
1196
|
+
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
1197
|
}
|
|
1002
|
-
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
|
|
1198
|
+
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(line, lineStarts[i] ?? 0, "l") })] }, i));
|
|
1003
1199
|
});
|
|
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)" })] }))] })] }));
|
|
1200
|
+
})() })), 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
1201
|
}
|
package/dist/ink/commands.js
CHANGED
|
@@ -36,10 +36,12 @@ export const SESSION_HELP = `commands:
|
|
|
36
36
|
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
37
37
|
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
38
38
|
/cron list scheduled cron jobs (schedule_cron) and their expressions
|
|
39
|
+
/peers pick another mini-agent session on this machine: talk to it from this box (Esc to come back), or make it flash its tab so you know which window it is
|
|
40
|
+
/rename <name> rename this session — the name other sessions use to reach it
|
|
39
41
|
/undo revert the most recent file write (write_file / edit_file) this session
|
|
40
42
|
/diff show every file changed this session, as a diff from where it started
|
|
41
43
|
/resume list recent sessions in this project and continue one of them
|
|
42
|
-
/skills
|
|
44
|
+
/skills manage skills: turn each on, off, or user-only (search with /, sort with t)
|
|
43
45
|
/skill <name> [args] run a skill yourself (works even for user-only skills); /<name> [args] works too
|
|
44
46
|
exit leave (Ctrl+C does the same)
|
|
45
47
|
|
package/dist/ink/completion.js
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
3
|
import { Box, Text } from "ink";
|
|
4
4
|
import { slashCompletions } from "../completion.js";
|
|
5
5
|
// Keep the full list in state, but render only a window around the selection.
|
|
6
6
|
// Long lists must never grow Ink's redraw region beyond the terminal height.
|
|
7
|
-
export function useSlashCompletion(input, enabled, context, maxRows) {
|
|
7
|
+
export function useSlashCompletion(input, enabled, context, maxRows, columns = 80) {
|
|
8
8
|
const [selection, setSelection] = useState({ input: "", index: 0 });
|
|
9
9
|
const [dismissed, setDismissed] = useState(null);
|
|
10
10
|
useEffect(() => { setDismissed(null); }, [input]);
|
|
11
11
|
const menu = enabled && dismissed !== input ? slashCompletions(input, context) : null;
|
|
12
12
|
const items = menu?.items ?? [];
|
|
13
13
|
const selected = Math.min(selection.input === input ? selection.index : 0, Math.max(0, items.length - 1));
|
|
14
|
-
const count = Math.max(1, Math.min(
|
|
14
|
+
const count = Math.max(1, Math.min(6, maxRows)); // Claude Code shows at most 6, centred on the selection
|
|
15
15
|
const start = Math.max(0, Math.min(selected - Math.floor(count / 2), items.length - count));
|
|
16
|
+
const nameWidth = Math.min(Math.max(0, ...items.map((item) => item.value.length)) + 2, Math.floor(columns * 0.4));
|
|
16
17
|
return {
|
|
17
18
|
visible: menu !== null,
|
|
18
19
|
handleKey: (key, replace) => {
|
|
@@ -30,14 +31,21 @@ export function useSlashCompletion(input, enabled, context, maxRows) {
|
|
|
30
31
|
}
|
|
31
32
|
if (key.tab || (key.return && !key.ctrl && !key.meta)) {
|
|
32
33
|
const item = items[selected];
|
|
34
|
+
// Tab always just fills it in. Enter runs it — unless it takes
|
|
35
|
+
// arguments (a skill with `arguments`, or a command with sub-choices),
|
|
36
|
+
// then it fills "/name " so you can type them. (Claude Code's rule.)
|
|
33
37
|
if (key.tab || item.children)
|
|
34
|
-
replace(item.value +
|
|
38
|
+
replace(item.value + " ");
|
|
35
39
|
else
|
|
36
40
|
return { handled: true, submit: item.value };
|
|
37
41
|
return { handled: true };
|
|
38
42
|
}
|
|
39
43
|
return { handled: false };
|
|
40
44
|
},
|
|
41
|
-
view: menu && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [items.slice(start, start + count).map((item, i) =>
|
|
45
|
+
view: menu && (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [items.slice(start, start + count).map((item, i) => {
|
|
46
|
+
const on = start + i === selected;
|
|
47
|
+
// Names in one column (at most 40% of the width); the description is one line, cut to fit.
|
|
48
|
+
return (_jsxs(Text, { color: on ? "#B1B9F9" : undefined, dimColor: !on, wrap: "truncate-end", children: [on ? "› " : " ", item.value.padEnd(nameWidth), item.description.replace(/\s+/g, " ")] }, item.value));
|
|
49
|
+
}), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: items.length ? `↑↓ choose · Tab complete · Enter select · Esc close · ${selected + 1}/${items.length}` : menu.hint || "No matching commands" })] })),
|
|
42
50
|
};
|
|
43
51
|
}
|
package/dist/ink/launch.js
CHANGED
|
@@ -12,4 +12,9 @@ export async function launchInk(opts = {}) {
|
|
|
12
12
|
const session = await buildInkSession({ resume: opts.resume });
|
|
13
13
|
const { waitUntilExit } = render(_jsx(App, { session: session, runTurn: makeRunTurn(session.client, session.messages) }));
|
|
14
14
|
await waitUntilExit(); // keep the process alive until the user quits
|
|
15
|
+
// Then leave for real. The cron poll, the settings watcher and friends keep
|
|
16
|
+
// the event loop busy, so without this the process lingers after the UI is
|
|
17
|
+
// gone — and the process.on("exit") cleanups (MCP servers, background jobs,
|
|
18
|
+
// this session's peer record) never run.
|
|
19
|
+
process.exit(0);
|
|
15
20
|
}
|
package/dist/ink/setup.js
CHANGED
|
@@ -6,6 +6,7 @@ import { buildSystemMessage, readProjectInstructions } from "../prompt.js";
|
|
|
6
6
|
import { contextPercent } from "../context.js";
|
|
7
7
|
import { initCostMeter, DEFAULT_PRICING } from "../cost.js";
|
|
8
8
|
import { gitBranch } from "../tui.js";
|
|
9
|
+
import { listPeers, registerSession, unregisterSession } from "../peers.js"; // peer sessions: other mini-agent processes on this machine
|
|
9
10
|
import { newSessionId, latestSession } from "../session.js";
|
|
10
11
|
import { initTelemetry, emit } from "../telemetry.js";
|
|
11
12
|
import { runHooks } from "../hooks.js";
|
|
@@ -14,7 +15,7 @@ import { Judge } from "../judge.js";
|
|
|
14
15
|
import { AutoMode } from "../auto.js";
|
|
15
16
|
import { registerExternalTool } from "../tools.js";
|
|
16
17
|
import { rememberTool, readMemory } from "../memory.js";
|
|
17
|
-
import { currentSkills } from "../skills.js";
|
|
18
|
+
import { allSkills, currentSkills } from "../skills.js";
|
|
18
19
|
import { killAllBackground } from "../background.js";
|
|
19
20
|
import { killAllSubAgents } from "../loop.js";
|
|
20
21
|
import { loadDurableJobs, startCronScheduler, stopCronScheduler, listJobs } from "../cron.js"; // cron scheduler (Day s14)
|
|
@@ -93,6 +94,13 @@ export async function buildInkSession(opts = {}) {
|
|
|
93
94
|
messages.push({ role: "user", content: `[SessionStart hook]\n${sessionStart.stdout}` });
|
|
94
95
|
const dir = path.basename(process.cwd());
|
|
95
96
|
const branch = gitBranch();
|
|
97
|
+
// Peer sessions: join the machine-wide registry so other mini-agent windows
|
|
98
|
+
// can find and message this one (and this one them).
|
|
99
|
+
const me = registerSession({ cwd: process.cwd(), branch, model: CONFIG.model });
|
|
100
|
+
process.on("exit", unregisterSession);
|
|
101
|
+
const others = listPeers().length;
|
|
102
|
+
if (others)
|
|
103
|
+
notices.push(`(peers: this session is "${me.name}"; ${others} other session${others === 1 ? "" : "s"} online — /peers)`);
|
|
96
104
|
const getStatus = () => ({
|
|
97
105
|
ctxPct: contextPercent(messages), // % of the way to auto-compaction
|
|
98
106
|
cost: costMeter.cost(),
|
|
@@ -107,6 +115,7 @@ export async function buildInkSession(opts = {}) {
|
|
|
107
115
|
startedAt,
|
|
108
116
|
costMeter,
|
|
109
117
|
skills: () => currentSkills(),
|
|
118
|
+
allSkills: () => allSkills(),
|
|
110
119
|
judge,
|
|
111
120
|
autoMode,
|
|
112
121
|
model: CONFIG.model,
|