agent-from-zero 0.34.2 → 0.35.1
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 +0 -96
- package/dist/account-status.js +172 -0
- package/dist/agent-progress.js +39 -0
- package/dist/agent.js +159 -34
- 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 +384 -171
- 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 +9 -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/tools.js +13 -1
- package/dist/tui.js +31 -15
- package/dist/ui.js +4 -6
- package/package.json +3 -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,14 +7,16 @@ 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
|
|
@@ -25,12 +28,19 @@ 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,6 +141,17 @@ 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);
|
|
@@ -132,6 +163,7 @@ export function App({ session, runTurn }) {
|
|
|
132
163
|
}, [session.initialTitle]);
|
|
133
164
|
// Open a one-of-N menu and run `onChoose` with the picked index (-1 = cancel).
|
|
134
165
|
const openSelect = (header, options, onChoose) => {
|
|
166
|
+
setDetails(null);
|
|
135
167
|
setMenuSel(0);
|
|
136
168
|
setPending({ kind: "select", header, options, onChoose });
|
|
137
169
|
};
|
|
@@ -139,8 +171,11 @@ export function App({ session, runTurn }) {
|
|
|
139
171
|
// as the prompt (a user highlight bar, or a note for /skill); the loop appends
|
|
140
172
|
// `content` to `messages` itself. The permission menu + ask_user form raise a
|
|
141
173
|
// `pending` prompt that returns a promise the loop awaits.
|
|
142
|
-
const runConversationTurn = (content, display) => {
|
|
143
|
-
|
|
174
|
+
const runConversationTurn = (content, display, attachments = []) => {
|
|
175
|
+
setDetails(null);
|
|
176
|
+
setDetailOffset(0);
|
|
177
|
+
if (display)
|
|
178
|
+
pushItem(display);
|
|
144
179
|
clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
|
|
145
180
|
clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
|
|
146
181
|
setBusy(true);
|
|
@@ -148,12 +183,14 @@ export function App({ session, runTurn }) {
|
|
|
148
183
|
const tstate = { controller, interrupted: false };
|
|
149
184
|
turn.current = tstate; // expose it so Esc can interrupt this turn
|
|
150
185
|
const hooks = {
|
|
186
|
+
images: attachments,
|
|
151
187
|
output: sink,
|
|
152
188
|
confirm: (question, toolName) => new Promise((resolve) => {
|
|
153
189
|
const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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)
|
|
157
194
|
CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
|
|
158
195
|
note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}`);
|
|
159
196
|
resolve(approved);
|
|
@@ -162,16 +199,20 @@ export function App({ session, runTurn }) {
|
|
|
162
199
|
askUser: (questions) => new Promise((resolve) => {
|
|
163
200
|
if (!questions.length)
|
|
164
201
|
return resolve(null);
|
|
202
|
+
setDetails(null);
|
|
165
203
|
setFormState(initFormState(questions));
|
|
166
204
|
setPending({ kind: "form", questions, resolve });
|
|
167
205
|
}),
|
|
168
206
|
signal: controller.signal,
|
|
169
207
|
isInterrupted: () => tstate.interrupted, // polled between steps for a clean stop
|
|
170
208
|
judge,
|
|
209
|
+
autoMode,
|
|
210
|
+
followUps,
|
|
211
|
+
onFollowUp: (text) => pushItem({ kind: "user", text }),
|
|
171
212
|
};
|
|
172
213
|
runTurn(content, hooks)
|
|
173
214
|
.then(async (result) => {
|
|
174
|
-
if (result.reason !== TerminateReason.Done)
|
|
215
|
+
if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
|
|
175
216
|
note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
|
|
176
217
|
saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
|
|
177
218
|
if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
|
|
@@ -193,6 +234,12 @@ export function App({ session, runTurn }) {
|
|
|
193
234
|
setLive(null);
|
|
194
235
|
});
|
|
195
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
|
+
});
|
|
196
243
|
// The cron IDLE PROCESSOR (Day s14): the scheduler fires jobs into a queue, but
|
|
197
244
|
// injectCronMessages only drains it mid-turn — so a job that fires while you're
|
|
198
245
|
// at the prompt would never run on its own. Here, while idle (no turn, no menu),
|
|
@@ -201,8 +248,8 @@ export function App({ session, runTurn }) {
|
|
|
201
248
|
// Effect re-subscribes on busy/pending changes so the closure always sees the
|
|
202
249
|
// current `runConversationTurn`; it only installs the poll when truly idle.
|
|
203
250
|
useEffect(() => {
|
|
204
|
-
if (busy || pending)
|
|
205
|
-
return; //
|
|
251
|
+
if (busy || pending || followUps.size)
|
|
252
|
+
return; // human follow-ups take priority
|
|
206
253
|
const id = setInterval(() => {
|
|
207
254
|
if (!cronItemsPending())
|
|
208
255
|
return;
|
|
@@ -218,11 +265,16 @@ export function App({ session, runTurn }) {
|
|
|
218
265
|
}, [busy, pending]);
|
|
219
266
|
// A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
|
|
220
267
|
// mentions, then start the turn. Mirrors agent.ts's submit path.
|
|
221
|
-
const submitLine = async (
|
|
222
|
-
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"))
|
|
223
274
|
return doExit();
|
|
224
|
-
if (await handleCommand(
|
|
225
|
-
return;
|
|
275
|
+
if (!pending && await handleCommand(draft))
|
|
276
|
+
return;
|
|
277
|
+
const text = pastedTexts.expand(draft);
|
|
226
278
|
const hook = await runHooks("UserPromptSubmit", { prompt: text });
|
|
227
279
|
if (hook.block)
|
|
228
280
|
return note(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${hook.feedback.slice(0, 150)})`));
|
|
@@ -253,7 +305,32 @@ export function App({ session, runTurn }) {
|
|
|
253
305
|
.catch(() => { });
|
|
254
306
|
}
|
|
255
307
|
}
|
|
256
|
-
|
|
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)"));
|
|
257
334
|
};
|
|
258
335
|
// Handle a /slash command. Returns true if the line was a command (handled).
|
|
259
336
|
// /mcp — list configured servers + status, then pick one to manage it.
|
|
@@ -299,6 +376,48 @@ export function App({ session, runTurn }) {
|
|
|
299
376
|
});
|
|
300
377
|
};
|
|
301
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
|
+
}
|
|
302
421
|
const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
|
|
303
422
|
if (info !== null) {
|
|
304
423
|
note(info);
|
|
@@ -361,11 +480,13 @@ export function App({ session, runTurn }) {
|
|
|
361
480
|
note(chalk.yellow(`(no skill named "${name}")`));
|
|
362
481
|
return true;
|
|
363
482
|
}
|
|
483
|
+
autoMode.recordRequest(`Run the "${s.name}" skill.`);
|
|
364
484
|
runConversationTurn(`Run the "${s.name}" skill.\n\n${skillInstructions(s)}`, { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
|
|
365
485
|
return true;
|
|
366
486
|
}
|
|
367
487
|
switch (line) {
|
|
368
488
|
case "/clear":
|
|
489
|
+
autoMode.clearRequests();
|
|
369
490
|
messages.length = 0; // mutate in place — same array ref the loop holds
|
|
370
491
|
messages.push({ role: "system", content: systemMessage });
|
|
371
492
|
forgetFilesExcept([]);
|
|
@@ -425,6 +546,7 @@ export function App({ session, runTurn }) {
|
|
|
425
546
|
if (!chosen)
|
|
426
547
|
return note(chalk.yellow("(could not load that session — it may be corrupt)"));
|
|
427
548
|
messages.length = 0;
|
|
549
|
+
autoMode.clearRequests();
|
|
428
550
|
messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
|
|
429
551
|
setSessionId(chosen.id);
|
|
430
552
|
titleAttempted.current = false; // a resumed session without a title gets one on its next message
|
|
@@ -464,20 +586,80 @@ export function App({ session, runTurn }) {
|
|
|
464
586
|
tuiCleanup();
|
|
465
587
|
return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
|
|
466
588
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (
|
|
471
|
-
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
|
+
}
|
|
472
646
|
// Esc while a sub-agent detail is open: close it, don't interrupt.
|
|
473
|
-
if (key.escape && subAgentDetail && !pending) {
|
|
647
|
+
if (key.escape && (subAgentDetail || subAgentFocus !== null) && !pending) {
|
|
474
648
|
setSubAgentDetail(null);
|
|
475
649
|
setSubAgentFocus(null);
|
|
476
650
|
return;
|
|
477
651
|
}
|
|
478
652
|
// Esc while a turn runs (and no menu is up): interrupt it — like the first
|
|
479
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
|
+
}
|
|
480
658
|
if (key.escape && busy && !pending) {
|
|
659
|
+
if (followUps.size) {
|
|
660
|
+
interruptForFollowUp();
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
481
663
|
const t = turn.current;
|
|
482
664
|
if (t && !t.interrupted) {
|
|
483
665
|
t.interrupted = true; // the loop polls this between steps
|
|
@@ -489,25 +671,33 @@ export function App({ session, runTurn }) {
|
|
|
489
671
|
// A select menu is up (approval / model / resume): ↑↓ move, Enter choose, Esc cancel.
|
|
490
672
|
if (pending?.kind === "select") {
|
|
491
673
|
const n = pending.options.length;
|
|
492
|
-
if (key.upArrow)
|
|
674
|
+
if (key.upArrow) {
|
|
493
675
|
setMenuSel((i) => (i - 1 + n) % n);
|
|
494
|
-
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
else if (key.downArrow) {
|
|
495
679
|
setMenuSel((i) => (i + 1) % n);
|
|
496
|
-
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
else if (((key.return || modifiedReturn) && !input.trim()) || key.escape) {
|
|
497
683
|
const choice = key.escape ? -1 : menuSel;
|
|
498
684
|
const onChoose = pending.onChoose;
|
|
499
685
|
setPending(null);
|
|
500
686
|
onChoose(choice);
|
|
687
|
+
return;
|
|
501
688
|
}
|
|
502
|
-
return;
|
|
503
689
|
}
|
|
504
690
|
// The ask_user form is up: reuse form.ts's pure state machine verbatim.
|
|
505
691
|
if (pending?.kind === "form" && formState) {
|
|
506
|
-
if (key.upArrow)
|
|
692
|
+
if (key.upArrow) {
|
|
507
693
|
setFormState(reduceForm(pending.questions, formState, "up").state);
|
|
508
|
-
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
else if (key.downArrow || key.tab) {
|
|
509
697
|
setFormState(reduceForm(pending.questions, formState, "down").state);
|
|
510
|
-
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
else if ((key.return || modifiedReturn) && !input.trim()) {
|
|
511
701
|
const next = reduceForm(pending.questions, formState, "select");
|
|
512
702
|
setFormState(next.state);
|
|
513
703
|
if (next.done) {
|
|
@@ -515,76 +705,89 @@ export function App({ session, runTurn }) {
|
|
|
515
705
|
setPending(null);
|
|
516
706
|
setFormState(null);
|
|
517
707
|
}
|
|
708
|
+
return;
|
|
518
709
|
}
|
|
519
710
|
else if (key.escape) {
|
|
520
711
|
pending.resolve(null);
|
|
521
712
|
setPending(null);
|
|
522
713
|
setFormState(null);
|
|
714
|
+
return;
|
|
523
715
|
}
|
|
524
|
-
return;
|
|
525
716
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
const next =
|
|
541
|
-
|
|
542
|
-
|
|
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
|
|
543
751
|
}
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
if (back) {
|
|
549
|
-
if (prev === null)
|
|
550
|
-
return n - 1;
|
|
551
|
-
const next = (prev - 1 + n) % n;
|
|
552
|
-
return next === n - 1 ? null : next;
|
|
553
|
-
}
|
|
554
|
-
if (prev === null)
|
|
555
|
-
return 0;
|
|
556
|
-
const next = (prev + 1) % n;
|
|
557
|
-
return next === 0 ? null : next;
|
|
558
|
-
});
|
|
752
|
+
if (key.tab || (key.leftArrow && cursor === 0) || (key.downArrow && !input && histIdx === null)) {
|
|
753
|
+
setSubAgentFocus("main");
|
|
754
|
+
setDetails(null);
|
|
755
|
+
return;
|
|
559
756
|
}
|
|
560
|
-
return;
|
|
561
757
|
}
|
|
562
|
-
//
|
|
563
|
-
if (
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
+
}
|
|
570
767
|
// Dismiss the sub-agent detail/focus on a normal submit.
|
|
768
|
+
setDetails(null);
|
|
571
769
|
setSubAgentDetail(null);
|
|
572
770
|
setSubAgentFocus(null);
|
|
573
|
-
const text = input.trim();
|
|
771
|
+
const text = completionAction.submit ?? input.trim();
|
|
574
772
|
setInput("");
|
|
575
773
|
setCursor(0);
|
|
576
774
|
setHistIdx(null);
|
|
775
|
+
const attachments = imagesInInput(text, images);
|
|
776
|
+
setImages([]);
|
|
577
777
|
if (!text)
|
|
578
778
|
return;
|
|
579
779
|
if (text.trim())
|
|
580
780
|
history.push(text); // remember non-empty entries for ↑/↓
|
|
581
|
-
|
|
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; });
|
|
582
785
|
}
|
|
583
786
|
else if (key.leftArrow) {
|
|
584
|
-
setCursor((c) => Math.max(0, c - 1));
|
|
787
|
+
setCursor((c) => pastedTexts.at(input, c, "left")?.start ?? Math.max(0, c - 1));
|
|
585
788
|
}
|
|
586
789
|
else if (key.rightArrow) {
|
|
587
|
-
setCursor((c) => Math.min(input.length, c + 1));
|
|
790
|
+
setCursor((c) => pastedTexts.at(input, c, "right")?.end ?? Math.min(input.length, c + 1));
|
|
588
791
|
}
|
|
589
792
|
else if (key.ctrl && char === "a") {
|
|
590
793
|
setCursor(0); // Home — jump to the start of the line
|
|
@@ -620,7 +823,19 @@ export function App({ session, runTurn }) {
|
|
|
620
823
|
else if (key.backspace || key.delete) {
|
|
621
824
|
// Delete the char BEFORE the caret. Both Backspace and macOS DEL land here,
|
|
622
825
|
// so this is always a backward delete (forward-delete is rare; we skip it).
|
|
623
|
-
|
|
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) {
|
|
624
839
|
setInput(input.slice(0, cursor - 1) + input.slice(cursor));
|
|
625
840
|
setCursor(cursor - 1);
|
|
626
841
|
}
|
|
@@ -628,87 +843,85 @@ export function App({ session, runTurn }) {
|
|
|
628
843
|
else if (char && !key.ctrl && !key.meta) {
|
|
629
844
|
// Insert at the caret (a bulk chunk — paste / dropped file path — is
|
|
630
845
|
// normalized: a dropped file becomes a clean absolute path; prose unchanged).
|
|
631
|
-
const add = char.length > 1 ? normalizeDroppedPaths(char) : char;
|
|
846
|
+
const add = char.length > 1 ? pastedTexts.fold(normalizeDroppedPaths(char)) : char;
|
|
632
847
|
setHistIdx(null);
|
|
633
848
|
setInput(input.slice(0, cursor) + add + input.slice(cursor));
|
|
634
849
|
setCursor(cursor + add.length);
|
|
635
850
|
}
|
|
636
851
|
});
|
|
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
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
})()] }));
|
|
713
|
-
})(), _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)" })] }))] })] }));
|
|
714
927
|
}
|