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 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
@@ -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
- log(chalk.magenta(`📦 compacting context (~${before} tokens)...`)); // automatic behavior must be visible
133
- const res = await client.chat.completions.create({
134
- model, // same model — no need for a fancier one to summarize
135
- messages: [...effortMessages(model, messages), { role: "user", content: SUMMARY_PROMPT }], // full history + the summary instruction
136
- // Deliberately NO `tools` parameter: with no tools declared, the API
137
- // cannot accept a tool call — that is the hard guarantee. The CRITICAL
138
- // lines in the prompt are the soft second layer of the same defense.
139
- }, { signal });
140
- const summary = res.choices[0].message.content ?? ""; // the structured summary text
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: SPINNER_COLOR, children: SPINNER_FRAMES[Math.floor(tick / 2) % SPINNER_FRAMES.length] }) }));
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(SPINNER_COLOR, SHIMMER_PEAK, glow))(c);
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 plain = stripVTControlCharacters(text);
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 && text.startsWith(plain.slice(0, cut + 1)) ? plain.slice(0, cut + 1) : ""; // shimmer only an unstyled verb
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
- return (_jsxs(Box, { children: [_jsx(Glyph, { tick: tick }), _jsxs(Text, { color: SPINNER_COLOR, wrap: "truncate-end", children: [verb ? shimmer(verb, pos) : "", text.slice(verb.length)] })] }));
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
  });