agent-from-zero 0.35.3 → 0.35.5
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 +20 -2
- package/dist/agent.js +40 -2
- package/dist/completion.js +2 -0
- package/dist/config.js +4 -0
- package/dist/context.js +57 -10
- package/dist/ink/app.js +184 -14
- package/dist/ink/commands.js +3 -0
- package/dist/ink/launch.js +5 -0
- package/dist/ink/setup.js +8 -0
- package/dist/loop.js +52 -6
- package/dist/peers.js +332 -0
- package/dist/permissions.js +4 -0
- package/dist/suggestion.js +55 -0
- package/dist/title.js +41 -1
- package/dist/ui.js +15 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -107,7 +107,7 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
|
|
|
107
107
|
|
|
108
108
|
### 上下文和自动压缩
|
|
109
109
|
|
|
110
|
-
上下文大小用的是 API 每次返回的真实 `prompt_tokens`,再加上之后新增消息的估算值,所以基本是准的。快满时自动压缩:触发点是窗口减去给回复预留的 20k 和 13k 安全余量(约 1M 的窗口大概在 97% 左右)。状态栏的 `ctx N%` 表示离自动压缩还有多远,到 100% 时下一次调用模型前就会先压缩。也可以随时手动 `/compact
|
|
110
|
+
上下文大小用的是 API 每次返回的真实 `prompt_tokens`,再加上之后新增消息的估算值,所以基本是准的。快满时自动压缩:触发点是窗口减去给回复预留的 20k 和 13k 安全余量(约 1M 的窗口大概在 97% 左右)。状态栏的 `ctx N%` 表示离自动压缩还有多远,到 100% 时下一次调用模型前就会先压缩。也可以随时手动 `/compact`。不管是手动还是自动,压缩时都会像 Claude Code 一样显示 `Compacting conversation…` 和进度条。
|
|
111
111
|
|
|
112
112
|
### 配置 MCP
|
|
113
113
|
|
|
@@ -115,6 +115,24 @@ export MINI_AGENT_MODEL=gpt-4.1-mini
|
|
|
115
115
|
|
|
116
116
|
输入 `/mcp` 选一个服务器,会看到和 Claude Code 一样的详情(状态、登录、协议版本、URL、配置文件位置、能力、工具数)和操作:View tools、Re-authenticate、Clear authentication、Reconnect、Disable。也可以直接敲 `/mcp tools|auth|clear-auth|reconnect|disable|enable <名字>`。
|
|
117
117
|
|
|
118
|
+
### 下一句预测
|
|
119
|
+
|
|
120
|
+
和 Claude Code 一样,每轮对话结束后,mini-agent 会猜你下一句大概要说什么(比如 “跑一下测试”、“提交并推送”),灰色显示在空的输入框里。按 Tab(或 →)变成真正的输入,可以改,也可以直接 Enter 发送。开始打字它就消失。
|
|
121
|
+
|
|
122
|
+
生成预测要多调一次模型,但请求内容和刚结束的主调用相同,只在最后加了一条指令,所以基本都能命中 DeepSeek 的前缀缓存,费用很低,也会算进 `/cost`。定时任务和其他实例发来的消息触发的回合不做预测。不想要可以在 `settings.json` 里设 `"promptSuggestions": false`,或者单次运行时设环境变量 `MINI_AGENT_PROMPT_SUGGESTIONS=0`。
|
|
123
|
+
|
|
124
|
+
### 多开实例互相通信
|
|
125
|
+
|
|
126
|
+
在几个终端里各开一个 mini-agent(比如一个在后端仓库、一个在前端仓库),它们会自动互相发现,不用配置。
|
|
127
|
+
|
|
128
|
+
- `/peers` 列出本机其他实例:名字、空闲/忙、目录和分支、在哪个终端(比如 `iTerm2 · ttys003`,tmux 里会显示 pane)、正在做什么。选一个之后可以:
|
|
129
|
+
- **Talk to**:输入框变成 `→ web ❯`,你打的字直接发给那个实例,它处理完会把回答发回你这个窗口。按 Esc 回到当前 agent。
|
|
130
|
+
- **Identify**:让那个实例响铃、刷一条横幅、让标签页标题闪几秒,用来确认它是哪个窗口。
|
|
131
|
+
- 实例名跟着它在做的事走:发出第一条消息后会生成会话标题(比如 “Fix login bug”),实例名随之变成 `fix-login-bug`,标签页标题就是 `✳ Fix login bug`,和 `/peers` 里看到的一致。还没有标题时用目录名。`/rename <名字>` 可以手动指定名字,指定后标签页显示 `✳ 名字 · 标题`,之后不会再跟着标题变。
|
|
132
|
+
- agent 之间也能自己沟通:模型有 `list_peers` 工具,`send_message` 可以直接发给其他实例。对方空闲时会自动开一轮处理;忙的话,在它当前这一步工具调用结束后插进对话。你在 Talk 模式里打的字则会等对方这一轮结束再处理,因为回答要发回给你。另一个 agent 发来的消息不算用户的授权。为了防止两个 agent 互相回复停不下来,连续自动处理 5 轮之后会暂停,等你输入任何内容再继续。
|
|
133
|
+
|
|
134
|
+
所有数据都存在 `~/.config/mini-agent/sessions/` 下,只在本机,不经过网络。
|
|
135
|
+
|
|
118
136
|
### 非交互模式
|
|
119
137
|
|
|
120
138
|
```bash
|
|
@@ -125,7 +143,7 @@ npx agent-from-zero -p "总结当前改动" --output-format json
|
|
|
125
143
|
|
|
126
144
|
## 常用命令
|
|
127
145
|
|
|
128
|
-
在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
|
|
146
|
+
在会话里输入 `/help` 可以看到全部命令,比较常用的有:`/model` `/effort` `/plan` `/auto` `/skills` `/mcp` `/peers` `/memory` `/compact` `/diff` `/undo` `/resume` `/status` `/cost`。
|
|
129
147
|
|
|
130
148
|
## 开发
|
|
131
149
|
|
package/dist/agent.js
CHANGED
|
@@ -10,7 +10,7 @@ import { buildSystemMessage, readProjectInstructions } from "./prompt.js"; // th
|
|
|
10
10
|
import { forgetFilesExcept, registerExternalTool } from "./tools.js"; // file-state reset + tool registration
|
|
11
11
|
import { compactHistory, compactThreshold, contextPercent, contextWindowFor } from "./context.js"; // /compact, /model info, the ctx % in the status line
|
|
12
12
|
import { newSessionId, saveSession, latestSession, listSessions, loadSession, setSessionTitle } from "./session.js"; // conversation persistence (project-local) + the /resume picker
|
|
13
|
-
import { generateSessionTitle, setTerminalTitle } from "./title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
13
|
+
import { generateSessionTitle, setTerminalTitle, refreshTerminalTitle } from "./title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
14
14
|
import { initTelemetry, emit, statsReport } from "./telemetry.js"; // local-only event log + /stats
|
|
15
15
|
import { runHooks } from "./hooks.js"; // SessionStart lifecycle hook
|
|
16
16
|
import { connectMcpServers, listMcpServers, MCP_SUBCOMMANDS, mcpActionsFor, mcpServerDetails, reloadMcpServers, runMcpAction, watchMcpConfig } from "./mcp.js"; // external tool servers (MCP) + /mcp
|
|
@@ -28,6 +28,8 @@ import { renderDiff } from "./diff.js"; // show what /undo put back / what /diff
|
|
|
28
28
|
import path from "node:path"; // shorten paths for the /diff summary
|
|
29
29
|
import { expandMentions } from "./mentions.js"; // @file mentions: pull referenced files into context (secret files refused)
|
|
30
30
|
import { banner, framedPrompt, formatModelChoices, statusLine, sentMessage } from "./ui.js"; // welcome box, prompt, model labels, status line, sent-message echo
|
|
31
|
+
import { SCREEN_KINDS, listPeers, peerInboxPending, peerMessageContent, peersCommand, readPeerInbox, registerSession, setSessionState, unregisterSession } from "./peers.js"; // peer sessions: other mini-agent processes on this machine
|
|
32
|
+
import { STDOUT_OUTPUT } from "./output.js"; // /compact draws its progress bar with the same ora spinner the loop uses
|
|
31
33
|
import { gitBranch, toggleLastCollapsed, revealReasoning, clearReasoning, revealToolCalls, clearToolCalls, cleanup as tuiCleanup } from "./tui.js"; // git branch + collapsible output + collapsed reasoning (Ctrl+R) + folded tool-call trace (Ctrl+T)
|
|
32
34
|
import { promptSelect, promptForm } from "./menu.js"; // arrow-key approval menu + multi-question form
|
|
33
35
|
import { editLine } from "./editor.js"; // our own line editor (keeps the status footer pinned even when input wraps)
|
|
@@ -107,6 +109,8 @@ const SESSION_HELP = `commands:
|
|
|
107
109
|
/team list the agent team (spawn_teammate): each teammate's role, status, and pending inbox
|
|
108
110
|
/tasks show the shared task board (create_task/claim_task): each task's status, owner, and dependencies
|
|
109
111
|
/cron list scheduled cron jobs (schedule_cron) and their expressions
|
|
112
|
+
/peers list other mini-agent sessions on this machine (the agent can message them with send_message)
|
|
113
|
+
/rename <name> rename this session — the name other sessions use to reach it
|
|
110
114
|
/undo revert the most recent file write (write_file / edit_file) this session
|
|
111
115
|
/diff show every file changed this session, as a diff from where it started
|
|
112
116
|
/resume list recent sessions in this project and continue one of them
|
|
@@ -297,6 +301,14 @@ async function main() {
|
|
|
297
301
|
// ---- Interactive session (REPL) -------------------------------------------------
|
|
298
302
|
// The welcome box: who am I, which model and host am I on, and the basics.
|
|
299
303
|
console.log(banner(pkg.version, CONFIG.model, new URL(CONFIG.baseURL).host));
|
|
304
|
+
// Peer sessions: join the machine-wide registry (interactive sessions only —
|
|
305
|
+
// a one-shot -p run is gone before anyone could answer it).
|
|
306
|
+
const me = registerSession({ cwd: process.cwd(), branch: gitBranch(), model: CONFIG.model });
|
|
307
|
+
process.on("exit", unregisterSession);
|
|
308
|
+
const otherSessions = listPeers().length;
|
|
309
|
+
refreshTerminalTitle(); // the tab now carries this session's name
|
|
310
|
+
if (otherSessions)
|
|
311
|
+
console.log(chalk.dim(`(peers: this session is "${me.name}"; ${otherSessions} other session${otherSessions === 1 ? "" : "s"} online — /peers)`));
|
|
300
312
|
// ONE readline interface for the whole session — the task prompt and the
|
|
301
313
|
// permission prompts share it. Two interfaces on one stdin fight each other.
|
|
302
314
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -547,6 +559,12 @@ async function main() {
|
|
|
547
559
|
};
|
|
548
560
|
// Handle a /slash command. Returns true if the line was a command.
|
|
549
561
|
const handleCommand = async (line) => {
|
|
562
|
+
const peers = peersCommand(line); // /peers, /rename <name>
|
|
563
|
+
if (peers !== null) {
|
|
564
|
+
console.log(chalk.dim(peers));
|
|
565
|
+
refreshTerminalTitle();
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
550
568
|
if (line === "/status" || line.startsWith("/status ")) {
|
|
551
569
|
console.log(await statusCommand(line, costMeter));
|
|
552
570
|
return true;
|
|
@@ -826,7 +844,7 @@ async function main() {
|
|
|
826
844
|
return true;
|
|
827
845
|
}
|
|
828
846
|
try {
|
|
829
|
-
await compactHistory(messages, client, CONFIG.model, new AbortController().signal); // same machinery as automatic compaction
|
|
847
|
+
await compactHistory(messages, client, CONFIG.model, new AbortController().signal, undefined, STDOUT_OUTPUT); // same machinery (and progress bar) as automatic compaction
|
|
830
848
|
}
|
|
831
849
|
catch (err) {
|
|
832
850
|
console.log(chalk.yellow(`compaction failed: ${err.message}`)); // report, don't crash
|
|
@@ -925,6 +943,25 @@ async function main() {
|
|
|
925
943
|
.catch(() => { });
|
|
926
944
|
}
|
|
927
945
|
}
|
|
946
|
+
// The readline prompt blocks on your input, so it cannot start a turn by
|
|
947
|
+
// itself: peer messages that arrived while you were typing ride along now.
|
|
948
|
+
for (const m of readPeerInbox(SCREEN_KINDS)) {
|
|
949
|
+
// Replies and "which window?" pings are for you, not the model.
|
|
950
|
+
if (m.kind === "identify") {
|
|
951
|
+
process.stdout.write("\x07");
|
|
952
|
+
console.log(chalk.bgMagenta.white.bold(` 👋 This window is session "${m.text}" — the "${m.from.name}" window was looking for it `));
|
|
953
|
+
}
|
|
954
|
+
else
|
|
955
|
+
console.log(chalk.magenta(`↩ ${m.from.name} replied:\n`) + m.text);
|
|
956
|
+
}
|
|
957
|
+
if (peerInboxPending()) {
|
|
958
|
+
const got = readPeerInbox();
|
|
959
|
+
if (got.length) {
|
|
960
|
+
messages.push({ role: "user", content: peerMessageContent(got) });
|
|
961
|
+
console.log(chalk.magenta(`✉ ${got.length} message${got.length > 1 ? "s" : ""} from ${[...new Set(got.map((m) => m.from.name))].join(", ")}`));
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
setSessionState("busy", CONFIG.model);
|
|
928
965
|
messages.push({ role: "user", content: augmented + injected }); // the new turn (with any attached files + hook context) joins the shared history
|
|
929
966
|
autoMode.recordRequest(line); // original human text, before file/hook attachments
|
|
930
967
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking, not the previous answer's
|
|
@@ -947,6 +984,7 @@ async function main() {
|
|
|
947
984
|
canPrompt: !!process.stdin.isTTY,
|
|
948
985
|
});
|
|
949
986
|
running = false; // back at the prompt — Ctrl+C means "exit" again
|
|
987
|
+
setSessionState("idle"); // other sessions see this one as free again
|
|
950
988
|
saveSession(sessionId, CONFIG.model, messages, pendingTitle); // snapshot after every turn — crash-safe by construction
|
|
951
989
|
// Auto-extract memories (opt-in: settings.memory.autoExtract). A cheap pass
|
|
952
990
|
// that saves durable facts — especially user corrections — so memory
|
package/dist/completion.js
CHANGED
|
@@ -20,6 +20,8 @@ export const COMMANDS = [
|
|
|
20
20
|
{ value: "/team", description: "Show teammates and status" },
|
|
21
21
|
{ value: "/tasks", description: "Show the shared task board" },
|
|
22
22
|
{ value: "/cron", description: "Show scheduled jobs" },
|
|
23
|
+
{ value: "/peers", description: "Talk to another mini-agent session, or find its window" },
|
|
24
|
+
{ value: "/rename", description: "Rename this session for other sessions", children: true },
|
|
23
25
|
{ value: "/undo", description: "Revert the most recent file write" },
|
|
24
26
|
{ value: "/diff", description: "Show file changes this session" },
|
|
25
27
|
{ value: "/resume", description: "Choose a previous session" },
|
package/dist/config.js
CHANGED
|
@@ -136,6 +136,10 @@ export const CONFIG = {
|
|
|
136
136
|
memory: {
|
|
137
137
|
autoExtract: projectSettings.memory?.autoExtract ?? globalSettings.memory?.autoExtract ?? false,
|
|
138
138
|
},
|
|
139
|
+
// Next-prompt suggestions (Claude Code's placeholder you accept with Tab). On
|
|
140
|
+
// by default: the call reuses the main conversation's cached prefix, so it is
|
|
141
|
+
// cheap. MINI_AGENT_PROMPT_SUGGESTIONS=0 turns it off for one run.
|
|
142
|
+
promptSuggestions: process.env.MINI_AGENT_PROMPT_SUGGESTIONS === "0" ? false : (projectSettings.promptSuggestions ?? globalSettings.promptSuggestions ?? true),
|
|
139
143
|
};
|
|
140
144
|
// Persist a single key to the global settings file, preserving all other keys.
|
|
141
145
|
// Used by /model to remember the user's last choice across sessions.
|
package/dist/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { effortMessages } from "./effort.js";
|
|
2
2
|
import fs from "node:fs"; // re-reading files from disk during recovery
|
|
3
3
|
import chalk from "chalk"; // status lines
|
|
4
|
+
import { compactingText } from "./ui.js"; // the progress bar
|
|
4
5
|
import { CONFIG } from "./config.js"; // the resolved context window (env > settings files > default)
|
|
5
6
|
import { recentFiles, forgetFilesExcept, toolDefinitions } from "./tools.js"; // session file state (what was read/edited, and when) + the tool manuals we send
|
|
6
7
|
// ---- The context window ------------------------------------------------------
|
|
@@ -120,6 +121,22 @@ export function recoverFileState() {
|
|
|
120
121
|
return null; // nothing worth recovering
|
|
121
122
|
return `[Recovered file state after compaction — most recently used files, re-read fresh from disk:]\n\n${blocks.join("\n\n")}`;
|
|
122
123
|
}
|
|
124
|
+
// ---- Compaction progress ----------------------------------------------------------
|
|
125
|
+
// A summary call takes tens of seconds, and a frozen screen reads as a hang. We
|
|
126
|
+
// cannot know the true progress (nobody knows the summary's length in advance),
|
|
127
|
+
// so we estimate it from two signals and never let it go backwards:
|
|
128
|
+
// - time: before the first token (the model reading a huge prompt, or
|
|
129
|
+
// thinking) the bar creeps toward 15%, so it visibly moves;
|
|
130
|
+
// - output: streamed tokens against an expected summary size. The curve is
|
|
131
|
+
// asymptotic and capped at 95% — only the finished summary says 100%.
|
|
132
|
+
export function expectedSummaryTokens(historyTokens) {
|
|
133
|
+
return Math.max(800, Math.min(4000, Math.round(historyTokens * 0.05))); // the 6-section summary grows with the history, within bounds
|
|
134
|
+
}
|
|
135
|
+
export function compactProgress(elapsedMs, outTokens, expected) {
|
|
136
|
+
const waiting = 15 * (1 - Math.exp(-elapsedMs / 10_000));
|
|
137
|
+
const writing = 95 * (1 - Math.exp(-(outTokens / expected) * 1.6));
|
|
138
|
+
return Math.min(95, Math.max(waiting, writing));
|
|
139
|
+
}
|
|
123
140
|
// ---- Compaction itself -----------------------------------------------------------
|
|
124
141
|
// Replace the whole history with a structured summary + recovered file state.
|
|
125
142
|
// Throws on failure — the loop decides what a failure means.
|
|
@@ -127,17 +144,47 @@ export async function compactHistory(messages, // the history, mutated in place
|
|
|
127
144
|
client, // the same client the loop uses
|
|
128
145
|
model, // the same model summarizes its own conversation
|
|
129
146
|
signal, // Ctrl+C must abort compaction too
|
|
130
|
-
log = (s) => console.log(s)
|
|
147
|
+
log = (s) => console.log(s), // where the result line goes — stdout by default; the Ink REPL routes it through its sink so it doesn't corrupt the live region
|
|
148
|
+
output) {
|
|
131
149
|
const before = contextTokens(messages); // for the log line — measured, not just estimated
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
150
|
+
const spin = output?.spinner(compactingText(0));
|
|
151
|
+
if (!spin)
|
|
152
|
+
log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
|
|
153
|
+
const expected = expectedSummaryTokens(before);
|
|
154
|
+
const started = Date.now();
|
|
155
|
+
let outChars = 0; // content + reasoning streamed so far (~4 chars a token)
|
|
156
|
+
let shown = 0; // the bar only moves forward
|
|
157
|
+
const paint = () => {
|
|
158
|
+
shown = Math.max(shown, compactProgress(Date.now() - started, outChars / 4, expected));
|
|
159
|
+
spin?.set(compactingText(shown));
|
|
160
|
+
};
|
|
161
|
+
const timer = spin ? setInterval(paint, 200) : null; // time alone moves the bar while no token arrives
|
|
162
|
+
let summary = "";
|
|
163
|
+
try {
|
|
164
|
+
// Streamed only so the bar has something to measure; the result is the same text.
|
|
165
|
+
const res = (await client.chat.completions.create({
|
|
166
|
+
model, // same model — no need for a fancier one to summarize
|
|
167
|
+
messages: [...effortMessages(model, messages), { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
|
|
168
|
+
stream: true,
|
|
169
|
+
// Deliberately NO `tools` parameter: with no tools declared, the API
|
|
170
|
+
// cannot accept a tool call — that is the hard guarantee. The CRITICAL
|
|
171
|
+
// lines in the prompt are the soft second layer of the same defense.
|
|
172
|
+
}, { signal }));
|
|
173
|
+
if ("choices" in res)
|
|
174
|
+
summary = res.choices[0]?.message.content ?? ""; // a provider (or test double) that ignored stream: true
|
|
175
|
+
else {
|
|
176
|
+
for await (const chunk of res) {
|
|
177
|
+
const delta = chunk.choices[0]?.delta;
|
|
178
|
+
summary += delta?.content ?? "";
|
|
179
|
+
outChars += (delta?.content?.length ?? 0) + (delta?.reasoning_content?.length ?? 0); // thinking is work too
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
if (timer)
|
|
185
|
+
clearInterval(timer);
|
|
186
|
+
spin?.stop();
|
|
187
|
+
}
|
|
141
188
|
if (!summary.trim())
|
|
142
189
|
throw new Error("compaction returned an empty summary"); // empty summary = failed compaction
|
|
143
190
|
// The constitution survives compaction: keep the leading system message and
|
package/dist/ink/app.js
CHANGED
|
@@ -4,12 +4,14 @@ 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 { generatePromptSuggestion } from "../suggestion.js"; // the predicted next message (Claude Code's prompt suggestion)
|
|
14
|
+
import { lastRequestTools } from "../loop.js"; // resend the main call's tools so the guess hits the prompt cache
|
|
13
15
|
import { commandNames, commandTokenRanges, inlineCommandGhost } from "../completion.js"; // which /command tokens to paint + the mid-text ghost suggestion
|
|
14
16
|
import { TerminateReason, listAgentViews } from "../loop.js"; // how a turn can end
|
|
15
17
|
import { compactHistory, compactThreshold, contextWindowFor } from "../context.js"; // /compact + /model info
|
|
@@ -21,9 +23,10 @@ import { resetBoard } from "../board.js";
|
|
|
21
23
|
import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, getToolActivity, getToolCallCount, cleanup as tuiCleanup } from "../tui.js";
|
|
22
24
|
import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
|
|
23
25
|
import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
|
|
26
|
+
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
|
|
24
27
|
import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
|
|
25
28
|
import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
|
|
26
|
-
import { generateSessionTitle, setTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
29
|
+
import { generateSessionTitle, setTerminalTitle, refreshTerminalTitle, flashTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
27
30
|
import { isPlanMode, setPlanMode } from "../permissions.js";
|
|
28
31
|
import { findSkill, recordSkillUsage, skillSource, skillUsageScores, userSkillMessages } from "../skills.js";
|
|
29
32
|
import { SkillsPanel } from "./skills-panel.js"; // /skills: Claude Code's on / user-only / off manager
|
|
@@ -49,6 +52,7 @@ const SPINNER_FRAMES = ["·", "✢", "✶", "✻", "✽", "✻", "✶", "✢"];
|
|
|
49
52
|
const SPINNER_COLOR = "#D77757"; // Claude Code's orange
|
|
50
53
|
const SKILL_COLOR = "#B1B9F9"; // Claude Code's lavender for a recognised /skill in the input
|
|
51
54
|
const SHIMMER_PEAK = "#FFD2BD"; // the highlight that sweeps across the verb
|
|
55
|
+
const COMPACT_PEAK = "#E4E8FF"; // the same sweep over the lavender "Compacting conversation…"
|
|
52
56
|
const SHIMMER_TICK_MS = 100; // one animation clock for glyph + shimmer → one repaint per tick
|
|
53
57
|
const SHIMMER_RADIUS = 3; // how many cells the highlight fades over on each side
|
|
54
58
|
// Cap the live streaming preview to a terminal-aware tail. THIS IS LOAD-BEARING:
|
|
@@ -78,8 +82,8 @@ function useTick() {
|
|
|
78
82
|
}
|
|
79
83
|
// The glyph lives in a fixed two-cell box: whatever the frame, the text after it
|
|
80
84
|
// starts in the same column.
|
|
81
|
-
function Glyph({ tick }) {
|
|
82
|
-
return (_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color:
|
|
85
|
+
function Glyph({ tick, color = SPINNER_COLOR }) {
|
|
86
|
+
return (_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: color, children: SPINNER_FRAMES[Math.floor(tick / 2) % SPINNER_FRAMES.length] }) }));
|
|
83
87
|
}
|
|
84
88
|
function Spinner() {
|
|
85
89
|
return _jsx(Glyph, { tick: useTick() });
|
|
@@ -92,10 +96,10 @@ function mix(a, b, t) {
|
|
|
92
96
|
// The light sweep: each character is coloured by its distance from a moving
|
|
93
97
|
// highlight. Only COLOUR changes between frames — never a character, never a
|
|
94
98
|
// width — so the line shimmers without a single cell moving.
|
|
95
|
-
export function shimmer(text, pos) {
|
|
99
|
+
export function shimmer(text, pos, base = SPINNER_COLOR, peak = SHIMMER_PEAK) {
|
|
96
100
|
return [...text].map((c, i) => {
|
|
97
101
|
const glow = Math.max(0, 1 - Math.abs(i - pos) / SHIMMER_RADIUS);
|
|
98
|
-
return chalk.hex(mix(
|
|
102
|
+
return chalk.hex(mix(base, peak, glow))(c);
|
|
99
103
|
}).join("");
|
|
100
104
|
}
|
|
101
105
|
// "✶ Thinking… (3s · ↓ 281 tokens · thought for 1s)": the verb (everything up to
|
|
@@ -103,12 +107,14 @@ export function shimmer(text, pos) {
|
|
|
103
107
|
// between sweeps (the highlight travels past the end) keeps it calm.
|
|
104
108
|
function SpinnerLine({ text }) {
|
|
105
109
|
const tick = useTick();
|
|
106
|
-
const
|
|
110
|
+
const [head, ...below] = text.split("\n"); // a status may carry extra lines (the compaction progress bar)
|
|
111
|
+
const plain = stripVTControlCharacters(head);
|
|
107
112
|
const cut = plain.indexOf("…");
|
|
108
|
-
const verb = cut >= 0 &&
|
|
113
|
+
const verb = cut >= 0 && head.startsWith(plain.slice(0, cut + 1)) ? plain.slice(0, cut + 1) : ""; // shimmer only an unstyled verb
|
|
109
114
|
const period = verb.length + SHIMMER_RADIUS * 2 + 8;
|
|
110
115
|
const pos = (tick % period) - SHIMMER_RADIUS;
|
|
111
|
-
|
|
116
|
+
const [base, peak] = verb === COMPACTING_VERB ? [SKILL_COLOR, COMPACT_PEAK] : [SPINNER_COLOR, SHIMMER_PEAK]; // Claude Code draws compaction in lavender
|
|
117
|
+
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))] }));
|
|
112
118
|
}
|
|
113
119
|
// One committed line of conversation. `user` is a highlight bar; `answer` is a
|
|
114
120
|
// finished reply rendered as markdown; `note` is already-formatted narration.
|
|
@@ -174,6 +180,10 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
174
180
|
const [detailOffset, setDetailOffset] = useState(0);
|
|
175
181
|
const [live, setLive] = useState(null); // the streaming answer
|
|
176
182
|
const [input, setInput] = useState(""); // the current input buffer
|
|
183
|
+
const [suggestion, setSuggestion] = useState(null); // the predicted next message: a dim placeholder, Tab accepts it
|
|
184
|
+
const suggestionSeq = useRef(0); // bumped by every new turn, so a late guess for an old turn is dropped
|
|
185
|
+
const [talkTo, setTalkTo] = useState(null); // /peers → talk: the input box sends to another session instead of this agent
|
|
186
|
+
const replyTo = useRef([]); // who gets this turn's final answer (the senders of "user" peer messages)
|
|
177
187
|
const [images, setImages] = useState([]);
|
|
178
188
|
const pasting = useRef(false);
|
|
179
189
|
const imageSeq = useRef(0);
|
|
@@ -219,6 +229,8 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
219
229
|
}, rows - 10, columns);
|
|
220
230
|
// A "/" later in the text: no list, just the rest of the best match as dim ghost text (Tab accepts).
|
|
221
231
|
const ghost = completing && cursor === input.length ? inlineCommandGhost(input, { skills: typeaheadSkills }) : null;
|
|
232
|
+
// The predicted next message, shown only in an empty box while nothing else wants attention.
|
|
233
|
+
const placeholder = suggestion && !input && !busy && !pending && !talkTo && !details && subAgentFocus === null ? suggestion : null;
|
|
222
234
|
useEffect(() => {
|
|
223
235
|
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
224
236
|
return () => clearInterval(id);
|
|
@@ -245,7 +257,15 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
245
257
|
pushItem(display);
|
|
246
258
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
|
|
247
259
|
clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
|
|
260
|
+
const senders = replyTo.current; // taken now, so a failed turn can't leak them into the next one
|
|
261
|
+
replyTo.current = [];
|
|
262
|
+
// Guess the next message only after turns YOU started — not cron, not
|
|
263
|
+
// another agent's message, not your message relayed from another window.
|
|
264
|
+
const suggestAfter = CONFIG.promptSuggestions && !senders.length && (content === null || display?.kind === "user");
|
|
265
|
+
const seq = ++suggestionSeq.current;
|
|
266
|
+
setSuggestion(null);
|
|
248
267
|
setBusy(true);
|
|
268
|
+
setSessionState("busy", CONFIG.model); // other sessions see "busy" in list_peers
|
|
249
269
|
const controller = new AbortController();
|
|
250
270
|
const tstate = { controller, interrupted: false };
|
|
251
271
|
turn.current = tstate; // expose it so Esc can interrupt this turn
|
|
@@ -279,9 +299,18 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
279
299
|
};
|
|
280
300
|
runTurn(content, hooks)
|
|
281
301
|
.then(async (result) => {
|
|
302
|
+
// A turn started by the user from another window: show them the answer there.
|
|
303
|
+
for (const to of senders)
|
|
304
|
+
sendPeerMessage(to, result.finalText?.trim() || `(finished: ${result.reason})`, "reply");
|
|
282
305
|
if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
|
|
283
306
|
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
284
307
|
saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
|
|
308
|
+
if (suggestAfter && result.reason === TerminateReason.Done) {
|
|
309
|
+
void generatePromptSuggestion(client, CONFIG.model, messages, lastRequestTools()).then((guess) => {
|
|
310
|
+
if (guess && seq === suggestionSeq.current)
|
|
311
|
+
setSuggestion(guess); // no newer turn started meanwhile (the box shows it only while idle)
|
|
312
|
+
});
|
|
313
|
+
}
|
|
285
314
|
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
286
315
|
try {
|
|
287
316
|
const got = await extractMemories(client, CONFIG.subAgentModel || model, messages);
|
|
@@ -296,6 +325,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
296
325
|
.catch((e) => note(chalk.red(`[error] ${e.message}`)))
|
|
297
326
|
.finally(() => {
|
|
298
327
|
turn.current = null;
|
|
328
|
+
setSessionState("idle");
|
|
299
329
|
setBusy(false);
|
|
300
330
|
setStatus(null);
|
|
301
331
|
setLive(null);
|
|
@@ -330,13 +360,131 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
330
360
|
return () => clearInterval(id);
|
|
331
361
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
332
362
|
}, [busy, pending]);
|
|
363
|
+
// The PEER idle processor: another mini-agent session messaged this one while
|
|
364
|
+
// it sat at the prompt. Like cron, start a turn so the agent answers on its
|
|
365
|
+
// own. Two agents answering each other could run forever (and bill for every
|
|
366
|
+
// hop), so after MAX_PEER_TURNS auto-started turns with no human input in
|
|
367
|
+
// between, messages wait in the inbox until you type something.
|
|
368
|
+
const peerTurns = useRef(0);
|
|
369
|
+
const peerHoldNoted = useRef(false);
|
|
370
|
+
useEffect(() => {
|
|
371
|
+
if (busy || pending || followUps.size)
|
|
372
|
+
return; // human follow-ups take priority
|
|
373
|
+
const id = setInterval(() => {
|
|
374
|
+
const waiting = peerInboxPending();
|
|
375
|
+
if (!waiting || turn.current)
|
|
376
|
+
return;
|
|
377
|
+
const fromUser = peerInboxPending(["user"]);
|
|
378
|
+
if (fromUser) {
|
|
379
|
+
// You, typing in another window: never held by the agent-to-agent cap.
|
|
380
|
+
const got = readPeerInbox(["user"]);
|
|
381
|
+
peerTurns.current = 0;
|
|
382
|
+
peerHoldNoted.current = false;
|
|
383
|
+
replyTo.current = [...new Set(got.map((m) => m.from.id))];
|
|
384
|
+
runConversationTurn(peerMessageContent(got), { kind: "user", text: `${got.map((m) => m.text).join("\n")} ${chalk.magenta(`(from your "${got[0].from.name}" window)`)}` });
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
if (peerTurns.current >= MAX_PEER_TURNS) {
|
|
388
|
+
if (!peerHoldNoted.current)
|
|
389
|
+
note(chalk.yellow(`✉ ${waiting} peer message${waiting > 1 ? "s" : ""} waiting — paused after ${MAX_PEER_TURNS} automatic turns in a row; send anything to continue`));
|
|
390
|
+
peerHoldNoted.current = true;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const got = readPeerInbox();
|
|
394
|
+
if (!got.length)
|
|
395
|
+
return;
|
|
396
|
+
peerTurns.current++;
|
|
397
|
+
const from = [...new Set(got.map((m) => `${m.from.name} (${m.from.cwd})`))].join(", ");
|
|
398
|
+
runConversationTurn(peerMessageContent(got), { kind: "note", text: chalk.magenta(`✉ message from ${from}`) });
|
|
399
|
+
}, 1000);
|
|
400
|
+
return () => clearInterval(id);
|
|
401
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
402
|
+
}, [busy, pending]);
|
|
403
|
+
// Replies and "which window are you?" pings are for the SCREEN, not the model:
|
|
404
|
+
// handled even mid-turn, never starting a model call.
|
|
405
|
+
useEffect(() => {
|
|
406
|
+
const id = setInterval(() => {
|
|
407
|
+
if (!peerInboxPending(SCREEN_KINDS))
|
|
408
|
+
return;
|
|
409
|
+
for (const m of readPeerInbox(SCREEN_KINDS)) {
|
|
410
|
+
if (m.kind === "identify") {
|
|
411
|
+
flashTerminalTitle();
|
|
412
|
+
note(chalk.bgMagenta.white.bold(` 👋 This window is session "${m.text}" — the "${m.from.name}" window is looking for it `));
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
note(chalk.magenta(`↩ ${m.from.name} replied:`));
|
|
416
|
+
pushItem({ kind: "answer", text: m.text });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}, 1000);
|
|
420
|
+
return () => clearInterval(id);
|
|
421
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
422
|
+
}, []);
|
|
423
|
+
// /peers: pick another session, then talk to it (the input box addresses it)
|
|
424
|
+
// or make it identify itself (bell + flashing tab) so you know which window it is.
|
|
425
|
+
const openPeers = () => {
|
|
426
|
+
const peers = listPeers();
|
|
427
|
+
if (!peers.length)
|
|
428
|
+
return note(chalk.dim(peersCommand("/peers")));
|
|
429
|
+
openSelect(`Other mini-agent sessions — pick one · this one is "${currentSession()?.name ?? "?"}" (/rename <name> to change)`, [...peers.map(peerRow), "Cancel"], (i) => {
|
|
430
|
+
const p = peers[i];
|
|
431
|
+
if (!p)
|
|
432
|
+
return;
|
|
433
|
+
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) => {
|
|
434
|
+
if (j === 0) {
|
|
435
|
+
setTalkTo(p);
|
|
436
|
+
note(chalk.magenta(`→ talking to "${p.name}" (${p.terminal}). What you type goes there; its answer comes back here. Esc returns to this agent.`));
|
|
437
|
+
}
|
|
438
|
+
else if (j === 1) {
|
|
439
|
+
const sent = sendPeerMessage(p.id, p.name, "identify");
|
|
440
|
+
note(sent.startsWith("[error]") ? chalk.yellow(sent) : chalk.dim(`(asked "${p.name}" to flash its tab — look for 👋 ${p.name} — HERE)`));
|
|
441
|
+
}
|
|
442
|
+
}, peerRow(p));
|
|
443
|
+
});
|
|
444
|
+
};
|
|
333
445
|
// A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
|
|
334
446
|
// mentions, then start the turn. Mirrors agent.ts's submit path.
|
|
335
447
|
const submitLine = async (draft, attachments = [], immediate = false) => {
|
|
448
|
+
peerTurns.current = 0; // a human is here: peers may start turns again
|
|
449
|
+
peerHoldNoted.current = false;
|
|
450
|
+
// Talk mode: plain text goes to the chosen session, even while this agent works.
|
|
451
|
+
if (talkTo && !pending && !draft.startsWith("/")) {
|
|
452
|
+
const text = pastedTexts.expand(draft);
|
|
453
|
+
const alive = findPeer(talkTo.id);
|
|
454
|
+
if (!alive) {
|
|
455
|
+
setTalkTo(null);
|
|
456
|
+
return note(chalk.yellow(`"${talkTo.name}" is no longer running — back to this agent`));
|
|
457
|
+
}
|
|
458
|
+
const sent = sendPeerMessage(alive.id, text, "user");
|
|
459
|
+
if (sent.startsWith("[error]"))
|
|
460
|
+
return note(chalk.yellow(sent));
|
|
461
|
+
pushItem({ kind: "user", text: `→ ${alive.name}: ${draft}` });
|
|
462
|
+
if (alive.state === "busy")
|
|
463
|
+
note(chalk.dim(`("${alive.name}" is busy — it answers when its current turn ends)`));
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (!draft.startsWith("/"))
|
|
467
|
+
setSessionInfo({ lastPrompt: draft });
|
|
336
468
|
if (!pending && turn.current && (draft.startsWith("/") || draft === "exit" || draft === "quit")) {
|
|
337
469
|
note(chalk.dim("Commands are available when the agent is idle; use Esc to interrupt."));
|
|
338
470
|
return;
|
|
339
471
|
}
|
|
472
|
+
// A /command typed while a menu is up is a command, never a message for the
|
|
473
|
+
// model (a second "/peers" into the /peers picker used to become the
|
|
474
|
+
// conversation's first prompt). Close the menu, then run it — when idle.
|
|
475
|
+
if (pending && /^\/[\w:-]+(\s|$)/.test(draft)) {
|
|
476
|
+
if (turn.current)
|
|
477
|
+
return note(chalk.dim("Commands are available when the agent is idle; use Esc to interrupt."));
|
|
478
|
+
setPending(null);
|
|
479
|
+
setFormState(null);
|
|
480
|
+
if (pending.kind === "form")
|
|
481
|
+
pending.resolve(null);
|
|
482
|
+
else if (pending.kind === "select")
|
|
483
|
+
pending.onChoose(-1);
|
|
484
|
+
if (await handleCommand(draft))
|
|
485
|
+
return;
|
|
486
|
+
return note(chalk.dim(`unknown command: ${draft} — try /help`));
|
|
487
|
+
}
|
|
340
488
|
if (!pending && (draft === "exit" || draft === "quit"))
|
|
341
489
|
return doExit();
|
|
342
490
|
if (!pending && await handleCommand(draft))
|
|
@@ -502,6 +650,16 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
502
650
|
setAutoEnabled(autoMode.enabled);
|
|
503
651
|
return true;
|
|
504
652
|
}
|
|
653
|
+
if (line === "/peers") {
|
|
654
|
+
openPeers();
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
const peers = peersCommand(line); // /rename <name>
|
|
658
|
+
if (peers !== null) {
|
|
659
|
+
note(chalk.dim(peers));
|
|
660
|
+
refreshTerminalTitle();
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
505
663
|
const info = runInfoCommand(line, { skills: liveSkills(), costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
|
|
506
664
|
if (info !== null) {
|
|
507
665
|
note(info);
|
|
@@ -610,7 +768,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
610
768
|
}
|
|
611
769
|
setBusy(true);
|
|
612
770
|
try {
|
|
613
|
-
await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note);
|
|
771
|
+
await compactHistory(messages, client, CONFIG.model, new AbortController().signal, note, sink); // the same progress bar as automatic compaction
|
|
614
772
|
}
|
|
615
773
|
catch (err) {
|
|
616
774
|
note(chalk.yellow(`compaction failed: ${err.message}`));
|
|
@@ -747,6 +905,11 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
747
905
|
statusRequest.current.abort();
|
|
748
906
|
return;
|
|
749
907
|
}
|
|
908
|
+
if (key.escape && talkTo && !pending && !input) {
|
|
909
|
+
setTalkTo(null);
|
|
910
|
+
note(chalk.dim(`(back to this agent — no longer talking to "${talkTo.name}")`));
|
|
911
|
+
return;
|
|
912
|
+
}
|
|
750
913
|
if (key.escape && busy && !pending) {
|
|
751
914
|
if (followUps.size) {
|
|
752
915
|
interruptForFollowUp();
|
|
@@ -806,6 +969,13 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
806
969
|
return;
|
|
807
970
|
}
|
|
808
971
|
}
|
|
972
|
+
if ((key.tab || (key.rightArrow && !input)) && placeholder) { // Tab (or →) turns the predicted next message into real text; Enter then sends it
|
|
973
|
+
setInput(placeholder);
|
|
974
|
+
setCursor(placeholder.length);
|
|
975
|
+
setHistIdx(null);
|
|
976
|
+
setSuggestion(null);
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
809
979
|
if (key.tab && ghost) { // accept the inline suggestion as "/name "
|
|
810
980
|
const next = input.slice(0, ghost.start) + `/${ghost.full} `;
|
|
811
981
|
setInput(next);
|
|
@@ -953,9 +1123,9 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
953
1123
|
const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
|
|
954
1124
|
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));
|
|
955
1125
|
const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
|
|
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: (() => {
|
|
957
|
-
const prompt = "❯ ";
|
|
958
|
-
const promptColor = planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
|
|
1126
|
+
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: (() => {
|
|
1127
|
+
const prompt = talkTo ? `→ ${talkTo.name} ❯ ` : "❯ ";
|
|
1128
|
+
const promptColor = talkTo ? "magenta" : planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
|
|
959
1129
|
const cols = columns;
|
|
960
1130
|
const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
|
|
961
1131
|
const promptW = displayWidth(prompt);
|
|
@@ -1047,7 +1217,7 @@ export function App({ session, runTurn, clipboard }) {
|
|
|
1047
1217
|
const at = line.slice(cuOff, cuOff + 1) || " ";
|
|
1048
1218
|
const after = line.slice(cuOff + 1);
|
|
1049
1219
|
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));
|
|
1220
|
+
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] : placeholder && i === 0 ? [...placeholder][0] : at }), _jsx(Text, { children: paint(after, base + cuOff + at.length, "a") }), ghost && _jsx(Text, { dimColor: true, children: ghost.suffix.slice(1) }), placeholder && i === 0 && _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [[...placeholder].slice(1).join(""), _jsx(Text, { dimColor: true, children: " ⇥ tab" })] })] }, i));
|
|
1051
1221
|
}
|
|
1052
1222
|
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: paint(line, lineStarts[i] ?? 0, "l") })] }, i));
|
|
1053
1223
|
});
|