agent-from-zero 0.34.1 → 0.35.0
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 +147 -0
- package/dist/account-status.js +172 -0
- package/dist/agent-progress.js +39 -0
- package/dist/agent.js +167 -35
- package/dist/auto-budget.js +15 -0
- package/dist/auto-context.js +125 -0
- package/dist/auto-debug.js +87 -0
- package/dist/auto-providers.js +154 -0
- package/dist/auto-review.js +113 -0
- package/dist/auto.js +295 -0
- package/dist/background.js +2 -1
- package/dist/cli.js +83 -0
- package/dist/clipboard.js +88 -0
- package/dist/completion.js +65 -0
- package/dist/config.js +8 -0
- package/dist/context.js +9 -3
- package/dist/cost.js +4 -0
- package/dist/effort.js +81 -0
- package/dist/follow-up.js +22 -0
- package/dist/form.js +8 -6
- package/dist/images.js +43 -0
- package/dist/ink/activity.js +62 -0
- package/dist/ink/agents.js +24 -0
- package/dist/ink/app.js +393 -172
- package/dist/ink/chat.js +12 -1
- package/dist/ink/commands.js +16 -3
- package/dist/ink/completion.js +43 -0
- package/dist/ink/setup.js +12 -4
- package/dist/ink/sink.js +16 -6
- package/dist/ink/viewport.js +15 -0
- package/dist/loop.js +251 -106
- package/dist/mcp.js +1 -0
- package/dist/menu.js +50 -8
- package/dist/output.js +1 -1
- package/dist/pasted-text.js +32 -0
- package/dist/permissions.js +467 -12
- package/dist/print.js +16 -0
- package/dist/prompt.js +7 -4
- package/dist/status.js +80 -0
- package/dist/team.js +9 -3
- package/dist/title.js +20 -0
- package/dist/tools.js +13 -1
- package/dist/tui.js +31 -15
- package/dist/ui.js +4 -6
- package/package.json +4 -3
package/dist/ink/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { statusCommand } from "../status.js";
|
|
2
3
|
import React, { useState, useEffect, useRef } from "react";
|
|
3
4
|
import { Box, Text, Static, useInput, useApp } from "ink";
|
|
4
5
|
import chalk from "chalk";
|
|
@@ -6,31 +7,40 @@ import { formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.
|
|
|
6
7
|
import { renderMarkdown } from "../markdown.js"; // model speaks markdown → ANSI, same as the non-Ink REPL
|
|
7
8
|
import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.js"; // the ask_user form: pure state machine + renderer
|
|
8
9
|
import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
|
|
9
|
-
import {
|
|
10
|
+
import { effortMenu, setEffort } from "../effort.js";
|
|
11
|
+
import { useSlashCompletion } from "./completion.js";
|
|
12
|
+
import { TerminateReason, listAgentViews } from "../loop.js"; // how a turn can end
|
|
10
13
|
import { compactHistory, COMPACT_AT } from "../context.js"; // /compact
|
|
11
14
|
import { forgetFilesExcept } from "../tools.js"; // /clear resets the file read-state
|
|
12
15
|
import { clearUndo } from "../undo.js";
|
|
13
16
|
import { clearTodos } from "../todos.js";
|
|
14
17
|
import { resetTeam } from "../team.js";
|
|
15
18
|
import { resetBoard } from "../board.js";
|
|
16
|
-
import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, cleanup as tuiCleanup } from "../tui.js";
|
|
19
|
+
import { clearReasoning, clearToolCalls, getReasoning, getToolCalls, getToolActivity, getToolCallCount, cleanup as tuiCleanup } from "../tui.js";
|
|
17
20
|
import { expandMentions } from "../mentions.js"; // @file mentions → attach file contents
|
|
18
21
|
import { normalizeDroppedPaths } from "../drop.js"; // drag-and-drop a file → its absolute path in the input
|
|
19
22
|
import { cronItemsPending, consumeCronQueue, cronTriggerContent } from "../cron.js"; // cron scheduler (Day s14): fire scheduled jobs autonomously while idle
|
|
20
23
|
import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
|
|
21
|
-
import { generateSessionTitle } from "../title.js"; // concise session name, generated after the first message
|
|
24
|
+
import { generateSessionTitle, setTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
|
|
22
25
|
import { isPlanMode, setPlanMode } from "../permissions.js";
|
|
23
26
|
import { findSkill, skillInstructions } from "../skills.js";
|
|
24
27
|
import { extractMemories } from "../memory.js";
|
|
25
28
|
import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
|
|
26
29
|
import { runHooks } from "../hooks.js";
|
|
27
30
|
import { emit } from "../telemetry.js";
|
|
31
|
+
import { reviewDebugCommand } from "../auto-debug.js";
|
|
32
|
+
import { readClipboard, imageLabel, imagesInInput, userContent, MAX_IMAGES } from "../images.js";
|
|
33
|
+
import { FollowUpQueue } from "../follow-up.js";
|
|
34
|
+
import { PastedTextStore } from "../pasted-text.js";
|
|
35
|
+
import { AgentList, moveAgentFocus } from "./agents.js";
|
|
36
|
+
import { detailPage, summarizeActivity } from "./activity.js";
|
|
37
|
+
import { useTerminalSize } from "./viewport.js";
|
|
28
38
|
import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
|
|
29
39
|
import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
|
|
30
40
|
import { listMcpServers, mcpActionsFor, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
|
|
31
41
|
// Injected when plan mode turns on, so the model knows the rules it now lives in.
|
|
32
42
|
const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
|
|
33
|
-
const SPINNER_FRAMES = ["
|
|
43
|
+
const SPINNER_FRAMES = ["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"];
|
|
34
44
|
// Cap the live streaming preview to a terminal-aware tail. THIS IS LOAD-BEARING:
|
|
35
45
|
// Ink redraws the whole dynamic region (everything below <Static>) on every
|
|
36
46
|
// token, and when that region grows TALLER than the terminal, Ink can no longer
|
|
@@ -38,28 +48,21 @@ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧",
|
|
|
38
48
|
// and the cursor math drifts (sudden blank gaps). So we never let the in-flight
|
|
39
49
|
// answer render more than the rows we can spare; the FULL reply still commits to
|
|
40
50
|
// <Static> (formatted as markdown) the moment it finishes.
|
|
41
|
-
function liveTail(s) {
|
|
42
|
-
const rows = process.stdout.rows || 24;
|
|
51
|
+
function liveTail(s, rows, columns, queued) {
|
|
43
52
|
// Keep the preview SMALL (≤10 lines) even on a tall terminal: Ink repaints this
|
|
44
53
|
// whole region on every (throttled) update, and a smaller block repaints with
|
|
45
54
|
// far less flicker. The full reply still lands in <Static> on commit.
|
|
46
|
-
const maxLines = Math.max(
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
let t = s.length > maxChars ? s.slice(-maxChars) : s;
|
|
50
|
-
const lines = t.split("\n");
|
|
51
|
-
const clipped = s.length > maxChars || lines.length > maxLines;
|
|
52
|
-
if (lines.length > maxLines)
|
|
53
|
-
t = lines.slice(-maxLines).join("\n");
|
|
54
|
-
return clipped ? "… " + t : t; // a leading ellipsis signals there's more above (it's all in the scrollback on commit)
|
|
55
|
+
const maxLines = Math.max(1, Math.min(10, rows - (queued ? 26 : 20)));
|
|
56
|
+
const page = detailPage(s, 0, maxLines, Math.max(1, columns - 2));
|
|
57
|
+
return (page.pages > 1 ? "… " : "") + page.text;
|
|
55
58
|
}
|
|
56
59
|
function Spinner() {
|
|
57
60
|
const [f, setF] = useState(0);
|
|
58
61
|
useEffect(() => {
|
|
59
|
-
const id = setInterval(() => setF((x) => (x + 1) % SPINNER_FRAMES.length),
|
|
62
|
+
const id = setInterval(() => setF((x) => (x + 1) % SPINNER_FRAMES.length), 120);
|
|
60
63
|
return () => clearInterval(id);
|
|
61
64
|
}, []);
|
|
62
|
-
return _jsx(Text, { color: "
|
|
65
|
+
return _jsx(Text, { color: "#D77757", children: SPINNER_FRAMES[f] });
|
|
63
66
|
}
|
|
64
67
|
// One committed line of conversation. `user` is a highlight bar; `answer` is a
|
|
65
68
|
// finished reply rendered as markdown; `note` is already-formatted narration.
|
|
@@ -83,36 +86,53 @@ function StatusBar({ model, dir, branch, status }) {
|
|
|
83
86
|
_jsxs(Text, { color: "yellow", children: ["$", status.cost.toFixed(status.cost < 1 ? 4 : 2)] }, "$"),
|
|
84
87
|
_jsxs(Text, { dimColor: true, children: ["\u23F1 ", formatElapsed(status.elapsedMs)] }, "t"),
|
|
85
88
|
];
|
|
86
|
-
return (_jsx(
|
|
89
|
+
return (_jsx(Text, { wrap: "truncate-end", children: parts.map((p, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i))) }));
|
|
87
90
|
}
|
|
88
91
|
// One human note per non-Done ending, so the user always learns why a turn stopped.
|
|
89
92
|
const EXIT_NOTES = {
|
|
93
|
+
[TerminateReason.ReviewLimit]: "Repeated automatic denials — stopped. Tell the agent how to proceed; prohibited actions were not executed.",
|
|
90
94
|
[TerminateReason.CircuitBreaker]: "3 API failures in a row — stopping here.",
|
|
91
95
|
[TerminateReason.RetryBudgetExhausted]: "Too many failed API calls — giving up. Check your network.",
|
|
92
96
|
[TerminateReason.RateLimitBudgetExhausted]: "The provider keeps rate-limiting us. Wait a minute, then retry.",
|
|
93
97
|
[TerminateReason.ContextTooLong]: "The conversation no longer fits the context window. Use /clear.",
|
|
94
98
|
[TerminateReason.CompactionFailed]: "Automatic compaction kept failing. Use /clear to start fresh.",
|
|
99
|
+
[TerminateReason.ImageInputRejected]: "The provider rejected this image request. Check image limits and use /model to select a vision-capable model at your current vendor, then retry (images remain in the conversation).",
|
|
95
100
|
[TerminateReason.FatalApiError]: "Unrecoverable API error — check your API key and request.",
|
|
96
101
|
[TerminateReason.UserInterrupt]: "Interrupted — back at the prompt.",
|
|
97
102
|
};
|
|
98
|
-
export function App({ session, runTurn }) {
|
|
99
|
-
const {
|
|
103
|
+
export function App({ session, runTurn, clipboard }) {
|
|
104
|
+
const { rows, columns } = useTerminalSize();
|
|
105
|
+
const { client, messages, systemMessage, costMeter, skills, judge, autoMode, model, dir, branch, getStatus } = session;
|
|
100
106
|
// Seed the scrollback with the welcome banner + the dim startup notices.
|
|
101
107
|
const [items, setItems] = useState(() => [{ kind: "note", text: session.bannerText }, ...session.notices.map((n) => ({ kind: "note", text: chalk.dim(n) }))]);
|
|
102
108
|
const [status, setStatus] = useState(null); // the live spinner line
|
|
109
|
+
const [details, setDetails] = useState(null);
|
|
110
|
+
const [detailOffset, setDetailOffset] = useState(0);
|
|
103
111
|
const [live, setLive] = useState(null); // the streaming answer
|
|
104
112
|
const [input, setInput] = useState(""); // the current input buffer
|
|
113
|
+
const [images, setImages] = useState([]);
|
|
114
|
+
const pasting = useRef(false);
|
|
115
|
+
const imageSeq = useRef(0);
|
|
116
|
+
const pastedTexts = useRef(new PastedTextStore()).current;
|
|
105
117
|
const [cursor, setCursor] = useState(0); // caret position WITHIN `input` (0..input.length), for ←/→ editing
|
|
106
118
|
const [busy, setBusy] = useState(false); // a turn is in flight
|
|
107
|
-
const [pending, setPending] = useState(null); // a prompt/menu
|
|
119
|
+
const [pending, setPending] = useState(null); // a prompt/menu awaiting a choice or follow-up
|
|
120
|
+
const [completionModels, setCompletionModels] = useState([]);
|
|
121
|
+
const modelListEndpoint = useRef(null);
|
|
108
122
|
const [menuSel, setMenuSel] = useState(0); // the select menu's cursor
|
|
109
123
|
const [formState, setFormState] = useState(null); // the ask_user form's state
|
|
110
124
|
const [sessionId, setSessionId] = useState(session.initialSessionId); // changes on /clear, /resume
|
|
111
125
|
const [planMode, setPlan] = useState(isPlanMode()); // mirrored into the prompt frame
|
|
126
|
+
const [autoEnabled, setAutoEnabled] = useState(autoMode.enabled);
|
|
112
127
|
const [histIdx, setHistIdx] = useState(null); // ↑/↓ recall position (null = editing a fresh line)
|
|
113
128
|
const [subAgentFocus, setSubAgentFocus] = useState(null); // which sub-agent is highlighted (null = none)
|
|
129
|
+
const [agentPage, setAgentPage] = useState(0);
|
|
114
130
|
const [subAgentDetail, setSubAgentDetail] = useState(null); // the id of the sub-agent whose output is shown in detail
|
|
115
131
|
const [, setTick] = useState(0); // forces a re-render once a second so the clock / cost tick
|
|
132
|
+
const followUps = useRef(new FollowUpQueue(() => setTick((t) => t + 1))).current;
|
|
133
|
+
const submitting = useRef(false);
|
|
134
|
+
const statusRequest = useRef(null);
|
|
135
|
+
useEffect(() => () => statusRequest.current?.abort(), []);
|
|
116
136
|
const { exit } = useApp();
|
|
117
137
|
const pushItem = useRef((it) => setItems((xs) => [...xs, it])).current;
|
|
118
138
|
const note = (text) => pushItem({ kind: "note", text });
|
|
@@ -121,12 +141,29 @@ export function App({ session, runTurn }) {
|
|
|
121
141
|
const turn = useRef(null); // the in-flight turn, for Esc-interrupt
|
|
122
142
|
const titleAttempted = useRef(false); // one-shot: name the session once, after the first real message
|
|
123
143
|
const pendingTitle = useRef(undefined); // the generated title, passed into the next save
|
|
144
|
+
const completing = !busy && !pending && !details && subAgentFocus === null && !subAgentDetail;
|
|
145
|
+
const wantsModels = completing && /^\/model(?:\s|$)/.test(input);
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
if (!wantsModels || modelListEndpoint.current === CONFIG.baseURL)
|
|
148
|
+
return;
|
|
149
|
+
modelListEndpoint.current = CONFIG.baseURL;
|
|
150
|
+
void client.models.list().then((page) => setCompletionModels(page.data.map((m) => m.id))).catch(() => { });
|
|
151
|
+
}, [wantsModels, client]);
|
|
152
|
+
const completion = useSlashCompletion(input, completing, {
|
|
153
|
+
model: CONFIG.model, skills, models: completionModels, servers: listMcpServers().map((s) => s.name),
|
|
154
|
+
}, rows - 10);
|
|
124
155
|
useEffect(() => {
|
|
125
156
|
const id = setInterval(() => setTick((t) => t + 1), 1000);
|
|
126
157
|
return () => clearInterval(id);
|
|
127
158
|
}, []);
|
|
159
|
+
// Name the terminal tab from the first frame: the resumed session's title,
|
|
160
|
+
// or just "mini-agent" until the first message earns a real one.
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
setTerminalTitle(session.initialTitle);
|
|
163
|
+
}, [session.initialTitle]);
|
|
128
164
|
// Open a one-of-N menu and run `onChoose` with the picked index (-1 = cancel).
|
|
129
165
|
const openSelect = (header, options, onChoose) => {
|
|
166
|
+
setDetails(null);
|
|
130
167
|
setMenuSel(0);
|
|
131
168
|
setPending({ kind: "select", header, options, onChoose });
|
|
132
169
|
};
|
|
@@ -134,8 +171,11 @@ export function App({ session, runTurn }) {
|
|
|
134
171
|
// as the prompt (a user highlight bar, or a note for /skill); the loop appends
|
|
135
172
|
// `content` to `messages` itself. The permission menu + ask_user form raise a
|
|
136
173
|
// `pending` prompt that returns a promise the loop awaits.
|
|
137
|
-
const runConversationTurn = (content, display) => {
|
|
138
|
-
|
|
174
|
+
const runConversationTurn = (content, display, attachments = []) => {
|
|
175
|
+
setDetails(null);
|
|
176
|
+
setDetailOffset(0);
|
|
177
|
+
if (display)
|
|
178
|
+
pushItem(display);
|
|
139
179
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
|
|
140
180
|
clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
|
|
141
181
|
setBusy(true);
|
|
@@ -143,12 +183,14 @@ export function App({ session, runTurn }) {
|
|
|
143
183
|
const tstate = { controller, interrupted: false };
|
|
144
184
|
turn.current = tstate; // expose it so Esc can interrupt this turn
|
|
145
185
|
const hooks = {
|
|
186
|
+
images: attachments,
|
|
146
187
|
output: sink,
|
|
147
188
|
confirm: (question, toolName) => new Promise((resolve) => {
|
|
148
189
|
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
190
|
+
const options = autoMode.enabled ? ["Yes, once", "No — let me tell the agent what to do instead"] : ["Yes", allowLabel, "No — let me tell the agent what to do instead"];
|
|
191
|
+
openSelect(`⚠ approval needed — ${question}`, options, (i) => {
|
|
192
|
+
const approved = i === 0 || (!autoMode.enabled && i === 1);
|
|
193
|
+
if (!autoMode.enabled && approved && i === 1 && toolName)
|
|
152
194
|
CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
|
|
153
195
|
note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}`);
|
|
154
196
|
resolve(approved);
|
|
@@ -157,16 +199,20 @@ export function App({ session, runTurn }) {
|
|
|
157
199
|
askUser: (questions) => new Promise((resolve) => {
|
|
158
200
|
if (!questions.length)
|
|
159
201
|
return resolve(null);
|
|
202
|
+
setDetails(null);
|
|
160
203
|
setFormState(initFormState(questions));
|
|
161
204
|
setPending({ kind: "form", questions, resolve });
|
|
162
205
|
}),
|
|
163
206
|
signal: controller.signal,
|
|
164
207
|
isInterrupted: () => tstate.interrupted, // polled between steps for a clean stop
|
|
165
208
|
judge,
|
|
209
|
+
autoMode,
|
|
210
|
+
followUps,
|
|
211
|
+
onFollowUp: (text) => pushItem({ kind: "user", text }),
|
|
166
212
|
};
|
|
167
213
|
runTurn(content, hooks)
|
|
168
214
|
.then(async (result) => {
|
|
169
|
-
if (result.reason !== TerminateReason.Done)
|
|
215
|
+
if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
|
|
170
216
|
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
171
217
|
saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
|
|
172
218
|
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
@@ -188,6 +234,12 @@ export function App({ session, runTurn }) {
|
|
|
188
234
|
setLive(null);
|
|
189
235
|
});
|
|
190
236
|
};
|
|
237
|
+
// Covers immediate interruption and a message arriving as the turn finishes.
|
|
238
|
+
// The queue survives the old turn; the new controller has a fresh abort signal.
|
|
239
|
+
useEffect(() => {
|
|
240
|
+
if (!busy && !pending && !turn.current && followUps.size)
|
|
241
|
+
runConversationTurn(null);
|
|
242
|
+
});
|
|
191
243
|
// The cron IDLE PROCESSOR (Day s14): the scheduler fires jobs into a queue, but
|
|
192
244
|
// injectCronMessages only drains it mid-turn — so a job that fires while you're
|
|
193
245
|
// at the prompt would never run on its own. Here, while idle (no turn, no menu),
|
|
@@ -196,8 +248,8 @@ export function App({ session, runTurn }) {
|
|
|
196
248
|
// Effect re-subscribes on busy/pending changes so the closure always sees the
|
|
197
249
|
// current `runConversationTurn`; it only installs the poll when truly idle.
|
|
198
250
|
useEffect(() => {
|
|
199
|
-
if (busy || pending)
|
|
200
|
-
return; //
|
|
251
|
+
if (busy || pending || followUps.size)
|
|
252
|
+
return; // human follow-ups take priority
|
|
201
253
|
const id = setInterval(() => {
|
|
202
254
|
if (!cronItemsPending())
|
|
203
255
|
return;
|
|
@@ -213,11 +265,16 @@ export function App({ session, runTurn }) {
|
|
|
213
265
|
}, [busy, pending]);
|
|
214
266
|
// A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
|
|
215
267
|
// mentions, then start the turn. Mirrors agent.ts's submit path.
|
|
216
|
-
const submitLine = async (
|
|
217
|
-
if (
|
|
268
|
+
const submitLine = async (draft, attachments = [], immediate = false) => {
|
|
269
|
+
if (!pending && turn.current && (draft.startsWith("/") || draft === "exit" || draft === "quit")) {
|
|
270
|
+
note(chalk.dim("Commands are available when the agent is idle; use Esc to interrupt."));
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!pending && (draft === "exit" || draft === "quit"))
|
|
218
274
|
return doExit();
|
|
219
|
-
if (await handleCommand(
|
|
220
|
-
return;
|
|
275
|
+
if (!pending && await handleCommand(draft))
|
|
276
|
+
return;
|
|
277
|
+
const text = pastedTexts.expand(draft);
|
|
221
278
|
const hook = await runHooks("UserPromptSubmit", { prompt: text });
|
|
222
279
|
if (hook.block)
|
|
223
280
|
return note(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${hook.feedback.slice(0, 150)})`));
|
|
@@ -242,12 +299,38 @@ export function App({ session, runTurn }) {
|
|
|
242
299
|
if (title) {
|
|
243
300
|
pendingTitle.current = title;
|
|
244
301
|
setSessionTitle(sessionId, title);
|
|
302
|
+
setTerminalTitle(title); // and rename the terminal tab, like Claude Code does
|
|
245
303
|
}
|
|
246
304
|
})
|
|
247
305
|
.catch(() => { });
|
|
248
306
|
}
|
|
249
307
|
}
|
|
250
|
-
|
|
308
|
+
if (pending || turn.current || followUps.size) {
|
|
309
|
+
followUps.enqueue({ text, displayText: draft, content: userContent(augmented + injected, attachments) });
|
|
310
|
+
// Queue FIRST, then release the blocked tool without approving it. The
|
|
311
|
+
// loop consumes the new instruction at its next tool boundary.
|
|
312
|
+
if (pending) {
|
|
313
|
+
setPending(null);
|
|
314
|
+
setFormState(null);
|
|
315
|
+
if (pending.kind === "form")
|
|
316
|
+
pending.resolve(null);
|
|
317
|
+
else
|
|
318
|
+
pending.onChoose(-1);
|
|
319
|
+
}
|
|
320
|
+
if (immediate)
|
|
321
|
+
interruptForFollowUp();
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
autoMode.recordRequest(text); // before attached files or hook output can masquerade as user intent
|
|
325
|
+
runConversationTurn(augmented + injected, { kind: "user", text: draft }, attachments);
|
|
326
|
+
};
|
|
327
|
+
const interruptForFollowUp = () => {
|
|
328
|
+
const current = turn.current;
|
|
329
|
+
if (!current || current.interrupted || !followUps.size)
|
|
330
|
+
return;
|
|
331
|
+
current.interrupted = true;
|
|
332
|
+
current.controller.abort();
|
|
333
|
+
note(chalk.dim("(interrupting the current step to process your follow-up)"));
|
|
251
334
|
};
|
|
252
335
|
// Handle a /slash command. Returns true if the line was a command (handled).
|
|
253
336
|
// /mcp — list configured servers + status, then pick one to manage it.
|
|
@@ -293,6 +376,48 @@ export function App({ session, runTurn }) {
|
|
|
293
376
|
});
|
|
294
377
|
};
|
|
295
378
|
const handleCommand = async (line) => {
|
|
379
|
+
if (line === "/status" || line.startsWith("/status ")) {
|
|
380
|
+
const controller = new AbortController();
|
|
381
|
+
statusRequest.current = controller;
|
|
382
|
+
setBusy(true);
|
|
383
|
+
setStatus("Checking account usage… (Esc to cancel)");
|
|
384
|
+
try {
|
|
385
|
+
note(await statusCommand(line, costMeter, { signal: controller.signal, columns }));
|
|
386
|
+
}
|
|
387
|
+
finally {
|
|
388
|
+
statusRequest.current = null;
|
|
389
|
+
setBusy(false);
|
|
390
|
+
setStatus(null);
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
if (line.startsWith("/skills "))
|
|
395
|
+
line = `/skill ${line.slice(8).trim()}`;
|
|
396
|
+
if (line === "/effort" || line.startsWith("/effort ")) {
|
|
397
|
+
const target = CONFIG.model;
|
|
398
|
+
const value = line.slice("/effort".length).trim();
|
|
399
|
+
if (value)
|
|
400
|
+
note(setEffort(target, value));
|
|
401
|
+
else {
|
|
402
|
+
const menu = effortMenu(target);
|
|
403
|
+
if (!menu.values.length)
|
|
404
|
+
note(menu.header);
|
|
405
|
+
else
|
|
406
|
+
openSelect(menu.header, menu.labels, (i) => { if (i >= 0)
|
|
407
|
+
note(setEffort(target, menu.values[i])); });
|
|
408
|
+
}
|
|
409
|
+
return true;
|
|
410
|
+
}
|
|
411
|
+
const debug = reviewDebugCommand(line);
|
|
412
|
+
if (debug !== null) {
|
|
413
|
+
note(chalk.dim(debug));
|
|
414
|
+
return true;
|
|
415
|
+
}
|
|
416
|
+
if (line === "/auto") {
|
|
417
|
+
note(chalk.dim(autoMode.toggle()));
|
|
418
|
+
setAutoEnabled(autoMode.enabled);
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
296
421
|
const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
|
|
297
422
|
if (info !== null) {
|
|
298
423
|
note(info);
|
|
@@ -355,11 +480,13 @@ export function App({ session, runTurn }) {
|
|
|
355
480
|
note(chalk.yellow(`(no skill named "${name}")`));
|
|
356
481
|
return true;
|
|
357
482
|
}
|
|
483
|
+
autoMode.recordRequest(`Run the "${s.name}" skill.`);
|
|
358
484
|
runConversationTurn(`Run the "${s.name}" skill.\n\n${skillInstructions(s)}`, { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
|
|
359
485
|
return true;
|
|
360
486
|
}
|
|
361
487
|
switch (line) {
|
|
362
488
|
case "/clear":
|
|
489
|
+
autoMode.clearRequests();
|
|
363
490
|
messages.length = 0; // mutate in place — same array ref the loop holds
|
|
364
491
|
messages.push({ role: "system", content: systemMessage });
|
|
365
492
|
forgetFilesExcept([]);
|
|
@@ -372,6 +499,7 @@ export function App({ session, runTurn }) {
|
|
|
372
499
|
setSessionId(newSessionId());
|
|
373
500
|
titleAttempted.current = false; // a fresh session gets its own title on its first message
|
|
374
501
|
pendingTitle.current = undefined;
|
|
502
|
+
setTerminalTitle(undefined); // the tab no longer describes the old conversation
|
|
375
503
|
note(chalk.dim("(history cleared)"));
|
|
376
504
|
return true;
|
|
377
505
|
case "/plan":
|
|
@@ -418,10 +546,12 @@ export function App({ session, runTurn }) {
|
|
|
418
546
|
if (!chosen)
|
|
419
547
|
return note(chalk.yellow("(could not load that session — it may be corrupt)"));
|
|
420
548
|
messages.length = 0;
|
|
549
|
+
autoMode.clearRequests();
|
|
421
550
|
messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
|
|
422
551
|
setSessionId(chosen.id);
|
|
423
552
|
titleAttempted.current = false; // a resumed session without a title gets one on its next message
|
|
424
553
|
pendingTitle.current = chosen.title;
|
|
554
|
+
setTerminalTitle(chosen.title ?? sessions[i].title.slice(0, 40)); // the tab follows the resumed session (raw prompt if never titled)
|
|
425
555
|
forgetFilesExcept([]);
|
|
426
556
|
clearUndo();
|
|
427
557
|
clearTodos();
|
|
@@ -456,20 +586,80 @@ export function App({ session, runTurn }) {
|
|
|
456
586
|
tuiCleanup();
|
|
457
587
|
return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
|
|
458
588
|
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
if (
|
|
463
|
-
return
|
|
589
|
+
const modifiedReturn = char === "[13;5u" || char === "[27;5;13~";
|
|
590
|
+
// A submitted follow-up may still be passing hooks. A second Enter must
|
|
591
|
+
// not approve the old action while that asynchronous preparation runs.
|
|
592
|
+
if (pending && submitting.current && (key.return || modifiedReturn || key.escape || key.upArrow || key.downArrow))
|
|
593
|
+
return;
|
|
594
|
+
if (key.ctrl && char === "v" && subAgentFocus === null) {
|
|
595
|
+
if (pasting.current)
|
|
596
|
+
return;
|
|
597
|
+
pasting.current = true;
|
|
598
|
+
const id = ++imageSeq.current;
|
|
599
|
+
void readClipboard(id, clipboard).then((clipboard) => {
|
|
600
|
+
if (clipboard.image) {
|
|
601
|
+
if (imagesInInput(input, images).length >= MAX_IMAGES) {
|
|
602
|
+
note(chalk.yellow(`Attach at most ${MAX_IMAGES} images per message.`));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const label = imageLabel(id);
|
|
606
|
+
setImages((current) => [...imagesInInput(input, current), clipboard.image]);
|
|
607
|
+
setInput((current) => current.slice(0, cursor) + label + current.slice(cursor));
|
|
608
|
+
setCursor(cursor + label.length);
|
|
609
|
+
}
|
|
610
|
+
else if (clipboard.text) {
|
|
611
|
+
const text = pastedTexts.fold(clipboard.text);
|
|
612
|
+
setInput((current) => current.slice(0, cursor) + text + current.slice(cursor));
|
|
613
|
+
setCursor(cursor + text.length);
|
|
614
|
+
}
|
|
615
|
+
else
|
|
616
|
+
note(chalk.dim("Clipboard has no image or text."));
|
|
617
|
+
}).catch(() => note(chalk.yellow("Could not read the clipboard. Check clipboard access and image size (5 MB max). Linux requires wl-paste or xclip.")))
|
|
618
|
+
.finally(() => { pasting.current = false; });
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (pasting.current)
|
|
622
|
+
return; // keep the insertion position stable during native clipboard access
|
|
623
|
+
// Details stay in the transient viewport: toggling never appends history.
|
|
624
|
+
if (!pending && key.ctrl && (char === "r" || char === "t")) {
|
|
625
|
+
const next = char === "t" ? "tools" : "reasoning";
|
|
626
|
+
setDetails((current) => current === next ? null : next);
|
|
627
|
+
setDetailOffset(0);
|
|
628
|
+
setSubAgentDetail(null);
|
|
629
|
+
setSubAgentFocus(null);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (details && !pending) {
|
|
633
|
+
if (key.escape) {
|
|
634
|
+
setDetails(null);
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
if (key.pageUp || key.upArrow) {
|
|
638
|
+
setDetailOffset((n) => Math.min(n + 1, detailView.pages - 1));
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
if (key.pageDown || key.downArrow) {
|
|
642
|
+
setDetailOffset((n) => Math.max(0, n - 1));
|
|
643
|
+
return;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
464
646
|
// Esc while a sub-agent detail is open: close it, don't interrupt.
|
|
465
|
-
if (key.escape && subAgentDetail && !pending) {
|
|
647
|
+
if (key.escape && (subAgentDetail || subAgentFocus !== null) && !pending) {
|
|
466
648
|
setSubAgentDetail(null);
|
|
467
649
|
setSubAgentFocus(null);
|
|
468
650
|
return;
|
|
469
651
|
}
|
|
470
652
|
// Esc while a turn runs (and no menu is up): interrupt it — like the first
|
|
471
653
|
// Ctrl+C of the readline REPL, without the force-quit escalation.
|
|
654
|
+
if (key.escape && statusRequest.current) {
|
|
655
|
+
statusRequest.current.abort();
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
472
658
|
if (key.escape && busy && !pending) {
|
|
659
|
+
if (followUps.size) {
|
|
660
|
+
interruptForFollowUp();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
473
663
|
const t = turn.current;
|
|
474
664
|
if (t && !t.interrupted) {
|
|
475
665
|
t.interrupted = true; // the loop polls this between steps
|
|
@@ -481,25 +671,33 @@ export function App({ session, runTurn }) {
|
|
|
481
671
|
// A select menu is up (approval / model / resume): ↑↓ move, Enter choose, Esc cancel.
|
|
482
672
|
if (pending?.kind === "select") {
|
|
483
673
|
const n = pending.options.length;
|
|
484
|
-
if (key.upArrow)
|
|
674
|
+
if (key.upArrow) {
|
|
485
675
|
setMenuSel((i) => (i - 1 + n) % n);
|
|
486
|
-
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
else if (key.downArrow) {
|
|
487
679
|
setMenuSel((i) => (i + 1) % n);
|
|
488
|
-
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
else if (((key.return || modifiedReturn) && !input.trim()) || key.escape) {
|
|
489
683
|
const choice = key.escape ? -1 : menuSel;
|
|
490
684
|
const onChoose = pending.onChoose;
|
|
491
685
|
setPending(null);
|
|
492
686
|
onChoose(choice);
|
|
687
|
+
return;
|
|
493
688
|
}
|
|
494
|
-
return;
|
|
495
689
|
}
|
|
496
690
|
// The ask_user form is up: reuse form.ts's pure state machine verbatim.
|
|
497
691
|
if (pending?.kind === "form" && formState) {
|
|
498
|
-
if (key.upArrow)
|
|
692
|
+
if (key.upArrow) {
|
|
499
693
|
setFormState(reduceForm(pending.questions, formState, "up").state);
|
|
500
|
-
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
else if (key.downArrow || key.tab) {
|
|
501
697
|
setFormState(reduceForm(pending.questions, formState, "down").state);
|
|
502
|
-
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
else if ((key.return || modifiedReturn) && !input.trim()) {
|
|
503
701
|
const next = reduceForm(pending.questions, formState, "select");
|
|
504
702
|
setFormState(next.state);
|
|
505
703
|
if (next.done) {
|
|
@@ -507,76 +705,89 @@ export function App({ session, runTurn }) {
|
|
|
507
705
|
setPending(null);
|
|
508
706
|
setFormState(null);
|
|
509
707
|
}
|
|
708
|
+
return;
|
|
510
709
|
}
|
|
511
710
|
else if (key.escape) {
|
|
512
711
|
pending.resolve(null);
|
|
513
712
|
setPending(null);
|
|
514
713
|
setFormState(null);
|
|
714
|
+
return;
|
|
515
715
|
}
|
|
516
|
-
return;
|
|
517
716
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const next =
|
|
533
|
-
|
|
534
|
-
|
|
717
|
+
const completionAction = completion.handleKey(key, (value) => { setInput(value); setCursor(value.length); setHistIdx(null); });
|
|
718
|
+
if (completionAction.handled && !completionAction.submit)
|
|
719
|
+
return;
|
|
720
|
+
// Agent navigation has its own focus. Typing/history keeps arrow keys
|
|
721
|
+
// until Tab, left at the input boundary, or down from an empty input.
|
|
722
|
+
const ids = ["main", ...listAgentViews().map((a) => a.id)];
|
|
723
|
+
if (!pending && ids.length > 1) {
|
|
724
|
+
if (subAgentFocus !== null) {
|
|
725
|
+
if (key.escape || key.rightArrow) {
|
|
726
|
+
setSubAgentFocus(null);
|
|
727
|
+
setSubAgentDetail(null);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (key.upArrow || key.downArrow || key.tab) {
|
|
731
|
+
const next = moveAgentFocus(ids, subAgentFocus, key.upArrow || (key.tab && key.shift) ? -1 : 1);
|
|
732
|
+
setSubAgentFocus(next);
|
|
733
|
+
if (subAgentDetail)
|
|
734
|
+
setSubAgentDetail(next === "main" ? null : next);
|
|
735
|
+
setAgentPage(0);
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (key.pageUp || key.pageDown) {
|
|
739
|
+
setAgentPage((n) => Math.max(0, Math.min(agentDetailView.pages - 1, n + (key.pageUp ? 1 : -1))));
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (key.return) {
|
|
743
|
+
setSubAgentDetail(subAgentFocus === "main" ? null : subAgentFocus);
|
|
744
|
+
if (subAgentFocus === "main")
|
|
745
|
+
setSubAgentFocus(null);
|
|
746
|
+
setDetails(null);
|
|
747
|
+
setAgentPage(0);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
return; // the input buffer stays untouched while selecting an agent
|
|
535
751
|
}
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
if (back) {
|
|
541
|
-
if (prev === null)
|
|
542
|
-
return n - 1;
|
|
543
|
-
const next = (prev - 1 + n) % n;
|
|
544
|
-
return next === n - 1 ? null : next;
|
|
545
|
-
}
|
|
546
|
-
if (prev === null)
|
|
547
|
-
return 0;
|
|
548
|
-
const next = (prev + 1) % n;
|
|
549
|
-
return next === 0 ? null : next;
|
|
550
|
-
});
|
|
752
|
+
if (key.tab || (key.leftArrow && cursor === 0) || (key.downArrow && !input && histIdx === null)) {
|
|
753
|
+
setSubAgentFocus("main");
|
|
754
|
+
setDetails(null);
|
|
755
|
+
return;
|
|
551
756
|
}
|
|
552
|
-
return;
|
|
553
|
-
}
|
|
554
|
-
// Enter with a sub-agent highlighted: show its detail.
|
|
555
|
-
if (agents.length > 0 && subAgentFocus !== null && key.return) {
|
|
556
|
-
setSubAgentDetail(agents[subAgentFocus].id === subAgentDetail ? null : agents[subAgentFocus].id);
|
|
557
|
-
return;
|
|
558
757
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
758
|
+
// Ink 5 strips the leading ESC from CSI-u / modifyOtherKeys sequences.
|
|
759
|
+
if (key.return || modifiedReturn) {
|
|
760
|
+
if (submitting.current || (busy && !turn.current))
|
|
761
|
+
return;
|
|
762
|
+
const immediate = key.ctrl || modifiedReturn;
|
|
763
|
+
if (!input.trim() && immediate && followUps.size) {
|
|
764
|
+
interruptForFollowUp();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
562
767
|
// Dismiss the sub-agent detail/focus on a normal submit.
|
|
768
|
+
setDetails(null);
|
|
563
769
|
setSubAgentDetail(null);
|
|
564
770
|
setSubAgentFocus(null);
|
|
565
|
-
const text = input.trim();
|
|
771
|
+
const text = completionAction.submit ?? input.trim();
|
|
566
772
|
setInput("");
|
|
567
773
|
setCursor(0);
|
|
568
774
|
setHistIdx(null);
|
|
775
|
+
const attachments = imagesInInput(text, images);
|
|
776
|
+
setImages([]);
|
|
569
777
|
if (!text)
|
|
570
778
|
return;
|
|
571
779
|
if (text.trim())
|
|
572
780
|
history.push(text); // remember non-empty entries for ↑/↓
|
|
573
|
-
|
|
781
|
+
submitting.current = true;
|
|
782
|
+
void submitLine(text, attachments, immediate)
|
|
783
|
+
.catch((error) => note(chalk.yellow(`Could not submit message: ${error instanceof Error ? error.message : String(error)}`)))
|
|
784
|
+
.finally(() => { submitting.current = false; });
|
|
574
785
|
}
|
|
575
786
|
else if (key.leftArrow) {
|
|
576
|
-
setCursor((c) => Math.max(0, c - 1));
|
|
787
|
+
setCursor((c) => pastedTexts.at(input, c, "left")?.start ?? Math.max(0, c - 1));
|
|
577
788
|
}
|
|
578
789
|
else if (key.rightArrow) {
|
|
579
|
-
setCursor((c) => Math.min(input.length, c + 1));
|
|
790
|
+
setCursor((c) => pastedTexts.at(input, c, "right")?.end ?? Math.min(input.length, c + 1));
|
|
580
791
|
}
|
|
581
792
|
else if (key.ctrl && char === "a") {
|
|
582
793
|
setCursor(0); // Home — jump to the start of the line
|
|
@@ -612,7 +823,19 @@ export function App({ session, runTurn }) {
|
|
|
612
823
|
else if (key.backspace || key.delete) {
|
|
613
824
|
// Delete the char BEFORE the caret. Both Backspace and macOS DEL land here,
|
|
614
825
|
// so this is always a backward delete (forward-delete is rare; we skip it).
|
|
615
|
-
|
|
826
|
+
const attachment = images.find((image) => input.slice(0, cursor).endsWith(imageLabel(image.id)));
|
|
827
|
+
const paste = pastedTexts.at(input, cursor, "left");
|
|
828
|
+
if (paste) {
|
|
829
|
+
setInput(input.slice(0, paste.start) + input.slice(paste.end));
|
|
830
|
+
setCursor(paste.start);
|
|
831
|
+
}
|
|
832
|
+
else if (attachment) {
|
|
833
|
+
const length = imageLabel(attachment.id).length;
|
|
834
|
+
setInput(input.slice(0, cursor - length) + input.slice(cursor));
|
|
835
|
+
setCursor(cursor - length);
|
|
836
|
+
setImages(images.filter((image) => image.id !== attachment.id));
|
|
837
|
+
}
|
|
838
|
+
else if (cursor > 0) {
|
|
616
839
|
setInput(input.slice(0, cursor - 1) + input.slice(cursor));
|
|
617
840
|
setCursor(cursor - 1);
|
|
618
841
|
}
|
|
@@ -620,87 +843,85 @@ export function App({ session, runTurn }) {
|
|
|
620
843
|
else if (char && !key.ctrl && !key.meta) {
|
|
621
844
|
// Insert at the caret (a bulk chunk — paste / dropped file path — is
|
|
622
845
|
// normalized: a dropped file becomes a clean absolute path; prose unchanged).
|
|
623
|
-
const add = char.length > 1 ? normalizeDroppedPaths(char) : char;
|
|
846
|
+
const add = char.length > 1 ? pastedTexts.fold(normalizeDroppedPaths(char)) : char;
|
|
624
847
|
setHistIdx(null);
|
|
625
848
|
setInput(input.slice(0, cursor) + add + input.slice(cursor));
|
|
626
849
|
setCursor(cursor + add.length);
|
|
627
850
|
}
|
|
628
851
|
});
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
})()] }));
|
|
705
|
-
})(), _jsx(StatusBar, { model: model, dir: dir, branch: branch, status: getStatus() })] }));
|
|
852
|
+
const agents = listAgentViews();
|
|
853
|
+
const selectedAgent = agents.find((a) => a.id === subAgentDetail);
|
|
854
|
+
const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
|
|
855
|
+
const detailView = detailPage(details === "tools" ? getToolCalls() ?? "No tool calls this turn." : details === "reasoning" ? getReasoning() ?? "No thinking recorded this turn." : "", detailOffset, Math.max(1, Math.min(12, rows - 9)), Math.max(1, columns - 4));
|
|
856
|
+
const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
|
|
857
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { color: "#D77757", wrap: "truncate-end", children: [" ", status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…")] })] })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
|
|
858
|
+
const prompt = "❯ ";
|
|
859
|
+
const promptColor = planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
|
|
860
|
+
const cols = columns;
|
|
861
|
+
const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
|
|
862
|
+
const promptW = displayWidth(prompt);
|
|
863
|
+
const firstW = Math.max(1, innerWidth - promptW);
|
|
864
|
+
// Build visual lines display-width-aware (CJK, emoji = 2 cols).
|
|
865
|
+
const lines = [""];
|
|
866
|
+
let row = 0;
|
|
867
|
+
let col = 0;
|
|
868
|
+
// Cursor tracking: map code-unit offset → (line, display-column).
|
|
869
|
+
let cursorLine = 0;
|
|
870
|
+
let cursorCol = 0;
|
|
871
|
+
let set = false;
|
|
872
|
+
let cu = 0; // code-unit offset, for matching `cursor`
|
|
873
|
+
for (const ch of input) {
|
|
874
|
+
if (!set && cu === cursor) {
|
|
875
|
+
cursorLine = row;
|
|
876
|
+
cursorCol = col;
|
|
877
|
+
set = true;
|
|
878
|
+
}
|
|
879
|
+
const cw = displayWidth(ch);
|
|
880
|
+
const limit = row === 0 ? firstW : innerWidth;
|
|
881
|
+
if (col + cw > limit) {
|
|
882
|
+
row++;
|
|
883
|
+
col = 0;
|
|
884
|
+
lines[row] = "";
|
|
885
|
+
}
|
|
886
|
+
lines[row] += ch;
|
|
887
|
+
col += cw;
|
|
888
|
+
cu += ch.length;
|
|
889
|
+
}
|
|
890
|
+
if (!set) {
|
|
891
|
+
cursorLine = row;
|
|
892
|
+
cursorCol = col;
|
|
893
|
+
} // cursor at end
|
|
894
|
+
// Cursor exactly at edge → wrap to start of next line for visibility
|
|
895
|
+
const edge = cursorLine === 0 ? firstW : innerWidth;
|
|
896
|
+
if (cursorCol >= edge) {
|
|
897
|
+
cursorLine++;
|
|
898
|
+
cursorCol = 0;
|
|
899
|
+
if (lines.length <= cursorLine)
|
|
900
|
+
lines.push("");
|
|
901
|
+
}
|
|
902
|
+
// Map display-column back to code-unit offset within the target line.
|
|
903
|
+
const cursorLineText = lines[cursorLine] ?? "";
|
|
904
|
+
let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
|
|
905
|
+
let dcol = 0;
|
|
906
|
+
for (const ch of cursorLineText) {
|
|
907
|
+
if (dcol >= cursorCol)
|
|
908
|
+
break;
|
|
909
|
+
dcol += displayWidth(ch);
|
|
910
|
+
cuOff += ch.length;
|
|
911
|
+
}
|
|
912
|
+
// Long drafts must not grow the redraw region. Keep the cursor's
|
|
913
|
+
// neighborhood visible; the complete draft remains in input state.
|
|
914
|
+
const visibleRows = details || selectedAgent || followUps.size || completion.visible || rows < 20 ? 1 : 3;
|
|
915
|
+
const startLine = Math.max(0, cursorLine - visibleRows + 1);
|
|
916
|
+
return lines.slice(startLine, startLine + visibleRows).map((line, offset) => {
|
|
917
|
+
const i = startLine + offset;
|
|
918
|
+
if (i === cursorLine) {
|
|
919
|
+
const before = line.slice(0, cuOff);
|
|
920
|
+
const at = line.slice(cuOff, cuOff + 1) || " ";
|
|
921
|
+
const after = line.slice(cuOff + 1);
|
|
922
|
+
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: at }), _jsx(Text, { children: after })] }, i));
|
|
923
|
+
}
|
|
924
|
+
return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
|
|
925
|
+
});
|
|
926
|
+
})() }), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
|
|
706
927
|
}
|