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/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 { TerminateReason, listSubAgents } from "../loop.js"; // how a turn can end
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 = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // the braille spinner ora uses, hand-rolled for Ink
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(3, Math.min(10, rows - 14)); // reserve rows for the input box + status bar + agent bar + margins
47
- const cols = process.stdout.columns || 80;
48
- const maxChars = maxLines * cols; // rough bound so one very long line can't blow past either
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), 80);
62
+ const id = setInterval(() => setF((x) => (x + 1) % SPINNER_FRAMES.length), 120);
60
63
  return () => clearInterval(id);
61
64
  }, []);
62
- return _jsx(Text, { color: "cyan", children: SPINNER_FRAMES[f] });
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(Box, { children: parts.map((p, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx(Text, { dimColor: true, children: " · " }), p] }, i))) }));
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 { client, messages, systemMessage, costMeter, skills, judge, model, dir, branch, getStatus } = session;
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 blocking input
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
- pushItem(display);
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
- openSelect(`⚠ approval needed — ${question}`, ["Yes", allowLabel, "No — let me tell the agent what to do instead"], (i) => {
155
- const approved = i === 0 || i === 1;
156
- if (approved && i === 1 && toolName)
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; // only fire scheduled work when the agent is idle
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 (text) => {
222
- if (text === "exit" || text === "quit")
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(text))
225
- return; // slash commands never reach the model
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
- runConversationTurn(augmented + injected, { kind: "user", text }); // show the original line; send the augmented content
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
- // Ctrl+R / Ctrl+T: reveal the LAST answer's collapsed thinking / tool calls.
468
- if (key.ctrl && char === "r")
469
- return note(getReasoning() ?? chalk.dim("(no thinking recorded for the last answer)"));
470
- if (key.ctrl && char === "t")
471
- return note(getToolCalls() ?? chalk.dim("(no tool calls recorded for the last answer)"));
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
- else if (key.downArrow)
676
+ return;
677
+ }
678
+ else if (key.downArrow) {
495
679
  setMenuSel((i) => (i + 1) % n);
496
- else if (key.return || key.escape) {
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
- else if (key.downArrow || key.tab)
694
+ return;
695
+ }
696
+ else if (key.downArrow || key.tab) {
509
697
  setFormState(reduceForm(pending.questions, formState, "down").state);
510
- else if (key.return || char === " ") {
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
- // Tab / Shift+Tab: cycle through the sub-agent list below the input box.
527
- // Enter on a highlighted sub-agent toggles its detail view.
528
- const agents = listSubAgents();
529
- if (agents.length > 0 && !key.return && key.tab) {
530
- const back = key.shift; // Shift+Tab = go backwards
531
- if (subAgentDetail) {
532
- // Detail open: Tab moves to next + closes detail.
533
- setSubAgentDetail(null);
534
- setSubAgentFocus((prev) => {
535
- const n = agents.length;
536
- if (back) {
537
- const next = prev === null ? n - 1 : (prev - 1 + n) % n;
538
- return next;
539
- }
540
- const next = prev === null ? 0 : (prev + 1) % n;
541
- return next;
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
- else {
545
- setSubAgentDetail(null);
546
- setSubAgentFocus((prev) => {
547
- const n = agents.length;
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
- // Enter with a sub-agent highlighted: show its detail.
563
- if (agents.length > 0 && subAgentFocus !== null && key.return) {
564
- setSubAgentDetail(agents[subAgentFocus].id === subAgentDetail ? null : agents[subAgentFocus].id);
565
- return;
566
- }
567
- if (busy)
568
- return; // a turn in flight with no prompt — ignore typing for now (type-ahead is later)
569
- if (key.return) {
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
- void submitLine(text);
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)); // move the caret left within the line
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)); // ...and right
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
- if (cursor > 0) {
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
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), live !== null && (_jsxs(Box, { marginTop: 1, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live) }) })] })), (status !== null || (busy && live === null)) && (_jsxs(Box, { marginTop: live === null ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { children: [" ", status ?? chalk.dim("working…")] })] })), 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) }) })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : "cyan", paddingX: 1, marginTop: 1, flexDirection: "column", children: (() => {
638
- const prompt = planMode ? "plan ❯ " : "❯ ";
639
- const promptColor = planMode ? "magenta" : "cyan";
640
- const cols = process.stdout.columns || 80;
641
- const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
642
- const promptW = displayWidth(prompt);
643
- const firstW = Math.max(1, innerWidth - promptW);
644
- // Build visual lines display-width-aware (CJK, emoji = 2 cols).
645
- const lines = [""];
646
- let row = 0;
647
- let col = 0;
648
- // Cursor tracking: map code-unit offset → (line, display-column).
649
- let cursorLine = 0;
650
- let cursorCol = 0;
651
- let set = false;
652
- let cu = 0; // code-unit offset, for matching `cursor`
653
- for (const ch of input) {
654
- if (!set && cu === cursor) {
655
- cursorLine = row;
656
- cursorCol = col;
657
- set = true;
658
- }
659
- const cw = displayWidth(ch);
660
- const limit = row === 0 ? firstW : innerWidth;
661
- if (col + cw > limit) {
662
- row++;
663
- col = 0;
664
- lines[row] = "";
665
- }
666
- lines[row] += ch;
667
- col += cw;
668
- cu += ch.length;
669
- }
670
- if (!set) {
671
- cursorLine = row;
672
- cursorCol = col;
673
- } // cursor at end
674
- // Cursor exactly at edge → wrap to start of next line for visibility
675
- const edge = cursorLine === 0 ? firstW : innerWidth;
676
- if (cursorCol >= edge) {
677
- cursorLine++;
678
- cursorCol = 0;
679
- if (lines.length <= cursorLine)
680
- lines.push("");
681
- }
682
- // Map display-column back to code-unit offset within the target line.
683
- const cursorLineText = lines[cursorLine] ?? "";
684
- let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
685
- let dcol = 0;
686
- for (const ch of cursorLineText) {
687
- if (dcol >= cursorCol)
688
- break;
689
- dcol += displayWidth(ch);
690
- cuOff += ch.length;
691
- }
692
- return lines.map((line, i) => {
693
- if (i === cursorLine) {
694
- const before = line.slice(0, cuOff);
695
- const at = line.slice(cuOff, cuOff + 1) || " ";
696
- const after = line.slice(cuOff + 1);
697
- 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));
698
- }
699
- return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
700
- });
701
- })() }), (() => {
702
- const agents = listSubAgents();
703
- if (!agents.length)
704
- return null;
705
- return (_jsxs(Box, { flexDirection: "column", marginTop: 0, children: [_jsxs(Box, { children: [_jsx(Text, { dimColor: true, children: "agents \u00B7 " }), agents.map((sa, i) => (_jsxs(React.Fragment, { children: [_jsx(Text, { color: i === subAgentFocus ? "cyan" : "blue", bold: i === subAgentFocus, children: sa.id }), _jsx(Text, { dimColor: true, children: sa.status === "running" ? " ●" : sa.status === "done" ? " ✓" : " ✗" }), i < agents.length - 1 && _jsx(Text, { dimColor: true, children: " \u2502 " })] }, sa.id))), _jsx(Text, { dimColor: true, children: " \u00B7 Tab" })] }), subAgentDetail && (() => {
706
- const detail = agents.find((sa) => sa.id === subAgentDetail);
707
- if (!detail)
708
- return null;
709
- const head = `${detail.id} · ${detail.status === "running" ? "running" : detail.status === "done" ? "done" : "failed"}`;
710
- const body = detail.result ?? "(still working…)";
711
- return (_jsxs(Box, { flexDirection: "column", marginTop: 0, borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: head }), _jsx(Text, { dimColor: true, children: detail.description }), _jsx(Box, { marginTop: 0, children: _jsx(Text, { children: body.length > 500 ? body.slice(0, 500) + "\n…" : body }) })] }));
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
  }