agent-from-zero 0.34.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ink/app.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { statusCommand } from "../status.js";
2
3
  import React, { useState, useEffect, useRef } from "react";
3
4
  import { Box, Text, Static, useInput, useApp } from "ink";
4
5
  import chalk from "chalk";
@@ -6,31 +7,40 @@ import { formatElapsed, renderMenu, MENU_HINT, formatModelChoices } from "../ui.
6
7
  import { renderMarkdown } from "../markdown.js"; // model speaks markdown → ANSI, same as the non-Ink REPL
7
8
  import { initFormState, reduceForm, renderForm, collectAnswers } from "../form.js"; // the ask_user form: pure state machine + renderer
8
9
  import { CONFIG, saveGlobalSetting } from "../config.js"; // session allowlist + /model save
9
- import { 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
20
23
  import { newSessionId, saveSession, listSessions, loadSession, setSessionTitle } from "../session.js";
21
- import { generateSessionTitle } from "../title.js"; // concise session name, generated after the first message
24
+ import { generateSessionTitle, setTerminalTitle } from "../title.js"; // concise session name, generated after the first message + the terminal tab that shows it
22
25
  import { isPlanMode, setPlanMode } from "../permissions.js";
23
26
  import { findSkill, skillInstructions } from "../skills.js";
24
27
  import { extractMemories } from "../memory.js";
25
28
  import { displayWidth } from "../editor.js"; // display-width measurement (CJK-aware)
26
29
  import { runHooks } from "../hooks.js";
27
30
  import { emit } from "../telemetry.js";
31
+ import { reviewDebugCommand } from "../auto-debug.js";
32
+ import { readClipboard, imageLabel, imagesInInput, userContent, MAX_IMAGES } from "../images.js";
33
+ import { FollowUpQueue } from "../follow-up.js";
34
+ import { PastedTextStore } from "../pasted-text.js";
35
+ import { AgentList, moveAgentFocus } from "./agents.js";
36
+ import { detailPage, summarizeActivity } from "./activity.js";
37
+ import { useTerminalSize } from "./viewport.js";
28
38
  import { makeInkSink } from "./sink.js"; // turns the loop's output into React state
29
39
  import { runInfoCommand, mcpStatusText } from "./commands.js"; // the non-interactive slash commands + /mcp status
30
40
  import { listMcpServers, mcpActionsFor, runMcpAction } from "../mcp.js"; // /mcp: list + per-server actions
31
41
  // Injected when plan mode turns on, so the model knows the rules it now lives in.
32
42
  const PLAN_MODE_NOTICE = `[plan mode ON] Investigate this request using only read-only tools — read_file, search, and safe read-only shell (ls, cat, git status). Do NOT write files, edit, or run mutating commands; the permission gate will block them. When you have a concrete, ordered plan, call the exit_plan_mode tool with that plan. The user reviews and approves it before you make any change.`;
33
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; // 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,12 +141,29 @@ export function App({ session, runTurn }) {
121
141
  const turn = useRef(null); // the in-flight turn, for Esc-interrupt
122
142
  const titleAttempted = useRef(false); // one-shot: name the session once, after the first real message
123
143
  const pendingTitle = useRef(undefined); // the generated title, passed into the next save
144
+ const completing = !busy && !pending && !details && subAgentFocus === null && !subAgentDetail;
145
+ const wantsModels = completing && /^\/model(?:\s|$)/.test(input);
146
+ useEffect(() => {
147
+ if (!wantsModels || modelListEndpoint.current === CONFIG.baseURL)
148
+ return;
149
+ modelListEndpoint.current = CONFIG.baseURL;
150
+ void client.models.list().then((page) => setCompletionModels(page.data.map((m) => m.id))).catch(() => { });
151
+ }, [wantsModels, client]);
152
+ const completion = useSlashCompletion(input, completing, {
153
+ model: CONFIG.model, skills, models: completionModels, servers: listMcpServers().map((s) => s.name),
154
+ }, rows - 10);
124
155
  useEffect(() => {
125
156
  const id = setInterval(() => setTick((t) => t + 1), 1000);
126
157
  return () => clearInterval(id);
127
158
  }, []);
159
+ // Name the terminal tab from the first frame: the resumed session's title,
160
+ // or just "mini-agent" until the first message earns a real one.
161
+ useEffect(() => {
162
+ setTerminalTitle(session.initialTitle);
163
+ }, [session.initialTitle]);
128
164
  // Open a one-of-N menu and run `onChoose` with the picked index (-1 = cancel).
129
165
  const openSelect = (header, options, onChoose) => {
166
+ setDetails(null);
130
167
  setMenuSel(0);
131
168
  setPending({ kind: "select", header, options, onChoose });
132
169
  };
@@ -134,8 +171,11 @@ export function App({ session, runTurn }) {
134
171
  // as the prompt (a user highlight bar, or a note for /skill); the loop appends
135
172
  // `content` to `messages` itself. The permission menu + ask_user form raise a
136
173
  // `pending` prompt that returns a promise the loop awaits.
137
- const runConversationTurn = (content, display) => {
138
- pushItem(display);
174
+ const runConversationTurn = (content, display, attachments = []) => {
175
+ setDetails(null);
176
+ setDetailOffset(0);
177
+ if (display)
178
+ pushItem(display);
139
179
  clearReasoning(); // Ctrl+R should reveal THIS turn's thinking
140
180
  clearToolCalls(); // ...and Ctrl+T THIS turn's tool calls
141
181
  setBusy(true);
@@ -143,12 +183,14 @@ export function App({ session, runTurn }) {
143
183
  const tstate = { controller, interrupted: false };
144
184
  turn.current = tstate; // expose it so Esc can interrupt this turn
145
185
  const hooks = {
186
+ images: attachments,
146
187
  output: sink,
147
188
  confirm: (question, toolName) => new Promise((resolve) => {
148
189
  const allowLabel = toolName ? `Yes, and don't ask again for ${toolName} this session` : "Yes, and don't ask again this session";
149
- openSelect(`⚠ approval needed — ${question}`, ["Yes", allowLabel, "No — let me tell the agent what to do instead"], (i) => {
150
- const approved = i === 0 || i === 1;
151
- 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)
152
194
  CONFIG.permissions.allow.push(`tool:${toolName}`); // "don't ask again" → session allowlist
153
195
  note(`${chalk.yellow("⚠ approval")} — ${question.split("\n")[0]} → ${approved ? chalk.green("✓ approved") : chalk.yellow("✗ declined")}`);
154
196
  resolve(approved);
@@ -157,16 +199,20 @@ export function App({ session, runTurn }) {
157
199
  askUser: (questions) => new Promise((resolve) => {
158
200
  if (!questions.length)
159
201
  return resolve(null);
202
+ setDetails(null);
160
203
  setFormState(initFormState(questions));
161
204
  setPending({ kind: "form", questions, resolve });
162
205
  }),
163
206
  signal: controller.signal,
164
207
  isInterrupted: () => tstate.interrupted, // polled between steps for a clean stop
165
208
  judge,
209
+ autoMode,
210
+ followUps,
211
+ onFollowUp: (text) => pushItem({ kind: "user", text }),
166
212
  };
167
213
  runTurn(content, hooks)
168
214
  .then(async (result) => {
169
- if (result.reason !== TerminateReason.Done)
215
+ if (result.reason !== TerminateReason.Done && !(result.reason === TerminateReason.UserInterrupt && followUps.size))
170
216
  note(chalk.yellow(`⚠️ ${EXIT_NOTES[result.reason] ?? result.reason}`));
171
217
  saveSession(sessionId, model, messages, pendingTitle.current); // snapshot after every turn — crash-safe by construction
172
218
  if (CONFIG.memory.autoExtract && result.reason === TerminateReason.Done) {
@@ -188,6 +234,12 @@ export function App({ session, runTurn }) {
188
234
  setLive(null);
189
235
  });
190
236
  };
237
+ // Covers immediate interruption and a message arriving as the turn finishes.
238
+ // The queue survives the old turn; the new controller has a fresh abort signal.
239
+ useEffect(() => {
240
+ if (!busy && !pending && !turn.current && followUps.size)
241
+ runConversationTurn(null);
242
+ });
191
243
  // The cron IDLE PROCESSOR (Day s14): the scheduler fires jobs into a queue, but
192
244
  // injectCronMessages only drains it mid-turn — so a job that fires while you're
193
245
  // at the prompt would never run on its own. Here, while idle (no turn, no menu),
@@ -196,8 +248,8 @@ export function App({ session, runTurn }) {
196
248
  // Effect re-subscribes on busy/pending changes so the closure always sees the
197
249
  // current `runConversationTurn`; it only installs the poll when truly idle.
198
250
  useEffect(() => {
199
- if (busy || pending)
200
- return; // only fire scheduled work when the agent is idle
251
+ if (busy || pending || followUps.size)
252
+ return; // human follow-ups take priority
201
253
  const id = setInterval(() => {
202
254
  if (!cronItemsPending())
203
255
  return;
@@ -213,11 +265,16 @@ export function App({ session, runTurn }) {
213
265
  }, [busy, pending]);
214
266
  // A normal (non-command) prompt: run UserPromptSubmit hooks, expand @file
215
267
  // mentions, then start the turn. Mirrors agent.ts's submit path.
216
- const submitLine = async (text) => {
217
- 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"))
218
274
  return doExit();
219
- if (await handleCommand(text))
220
- return; // slash commands never reach the model
275
+ if (!pending && await handleCommand(draft))
276
+ return;
277
+ const text = pastedTexts.expand(draft);
221
278
  const hook = await runHooks("UserPromptSubmit", { prompt: text });
222
279
  if (hook.block)
223
280
  return note(chalk.yellow(`(prompt blocked by a UserPromptSubmit hook: ${hook.feedback.slice(0, 150)})`));
@@ -242,12 +299,38 @@ export function App({ session, runTurn }) {
242
299
  if (title) {
243
300
  pendingTitle.current = title;
244
301
  setSessionTitle(sessionId, title);
302
+ setTerminalTitle(title); // and rename the terminal tab, like Claude Code does
245
303
  }
246
304
  })
247
305
  .catch(() => { });
248
306
  }
249
307
  }
250
- 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)"));
251
334
  };
252
335
  // Handle a /slash command. Returns true if the line was a command (handled).
253
336
  // /mcp — list configured servers + status, then pick one to manage it.
@@ -293,6 +376,48 @@ export function App({ session, runTurn }) {
293
376
  });
294
377
  };
295
378
  const handleCommand = async (line) => {
379
+ if (line === "/status" || line.startsWith("/status ")) {
380
+ const controller = new AbortController();
381
+ statusRequest.current = controller;
382
+ setBusy(true);
383
+ setStatus("Checking account usage… (Esc to cancel)");
384
+ try {
385
+ note(await statusCommand(line, costMeter, { signal: controller.signal, columns }));
386
+ }
387
+ finally {
388
+ statusRequest.current = null;
389
+ setBusy(false);
390
+ setStatus(null);
391
+ }
392
+ return true;
393
+ }
394
+ if (line.startsWith("/skills "))
395
+ line = `/skill ${line.slice(8).trim()}`;
396
+ if (line === "/effort" || line.startsWith("/effort ")) {
397
+ const target = CONFIG.model;
398
+ const value = line.slice("/effort".length).trim();
399
+ if (value)
400
+ note(setEffort(target, value));
401
+ else {
402
+ const menu = effortMenu(target);
403
+ if (!menu.values.length)
404
+ note(menu.header);
405
+ else
406
+ openSelect(menu.header, menu.labels, (i) => { if (i >= 0)
407
+ note(setEffort(target, menu.values[i])); });
408
+ }
409
+ return true;
410
+ }
411
+ const debug = reviewDebugCommand(line);
412
+ if (debug !== null) {
413
+ note(chalk.dim(debug));
414
+ return true;
415
+ }
416
+ if (line === "/auto") {
417
+ note(chalk.dim(autoMode.toggle()));
418
+ setAutoEnabled(autoMode.enabled);
419
+ return true;
420
+ }
296
421
  const info = runInfoCommand(line, { skills, costMeter }); // /help /cost /memory /stats /todos /bg /team /tasks /skills /undo /diff
297
422
  if (info !== null) {
298
423
  note(info);
@@ -355,11 +480,13 @@ export function App({ session, runTurn }) {
355
480
  note(chalk.yellow(`(no skill named "${name}")`));
356
481
  return true;
357
482
  }
483
+ autoMode.recordRequest(`Run the "${s.name}" skill.`);
358
484
  runConversationTurn(`Run the "${s.name}" skill.\n\n${skillInstructions(s)}`, { kind: "note", text: chalk.dim(`(running skill: ${s.name})`) });
359
485
  return true;
360
486
  }
361
487
  switch (line) {
362
488
  case "/clear":
489
+ autoMode.clearRequests();
363
490
  messages.length = 0; // mutate in place — same array ref the loop holds
364
491
  messages.push({ role: "system", content: systemMessage });
365
492
  forgetFilesExcept([]);
@@ -372,6 +499,7 @@ export function App({ session, runTurn }) {
372
499
  setSessionId(newSessionId());
373
500
  titleAttempted.current = false; // a fresh session gets its own title on its first message
374
501
  pendingTitle.current = undefined;
502
+ setTerminalTitle(undefined); // the tab no longer describes the old conversation
375
503
  note(chalk.dim("(history cleared)"));
376
504
  return true;
377
505
  case "/plan":
@@ -418,10 +546,12 @@ export function App({ session, runTurn }) {
418
546
  if (!chosen)
419
547
  return note(chalk.yellow("(could not load that session — it may be corrupt)"));
420
548
  messages.length = 0;
549
+ autoMode.clearRequests();
421
550
  messages.push({ role: "system", content: systemMessage }, ...chosen.messages);
422
551
  setSessionId(chosen.id);
423
552
  titleAttempted.current = false; // a resumed session without a title gets one on its next message
424
553
  pendingTitle.current = chosen.title;
554
+ setTerminalTitle(chosen.title ?? sessions[i].title.slice(0, 40)); // the tab follows the resumed session (raw prompt if never titled)
425
555
  forgetFilesExcept([]);
426
556
  clearUndo();
427
557
  clearTodos();
@@ -456,20 +586,80 @@ export function App({ session, runTurn }) {
456
586
  tuiCleanup();
457
587
  return exit(); // Ctrl+C quits immediately (process.on exit cleans up MCP/background)
458
588
  }
459
- // Ctrl+R / Ctrl+T: reveal the LAST answer's collapsed thinking / tool calls.
460
- if (key.ctrl && char === "r")
461
- return note(getReasoning() ?? chalk.dim("(no thinking recorded for the last answer)"));
462
- if (key.ctrl && char === "t")
463
- 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
+ }
464
646
  // Esc while a sub-agent detail is open: close it, don't interrupt.
465
- if (key.escape && subAgentDetail && !pending) {
647
+ if (key.escape && (subAgentDetail || subAgentFocus !== null) && !pending) {
466
648
  setSubAgentDetail(null);
467
649
  setSubAgentFocus(null);
468
650
  return;
469
651
  }
470
652
  // Esc while a turn runs (and no menu is up): interrupt it — like the first
471
653
  // Ctrl+C of the readline REPL, without the force-quit escalation.
654
+ if (key.escape && statusRequest.current) {
655
+ statusRequest.current.abort();
656
+ return;
657
+ }
472
658
  if (key.escape && busy && !pending) {
659
+ if (followUps.size) {
660
+ interruptForFollowUp();
661
+ return;
662
+ }
473
663
  const t = turn.current;
474
664
  if (t && !t.interrupted) {
475
665
  t.interrupted = true; // the loop polls this between steps
@@ -481,25 +671,33 @@ export function App({ session, runTurn }) {
481
671
  // A select menu is up (approval / model / resume): ↑↓ move, Enter choose, Esc cancel.
482
672
  if (pending?.kind === "select") {
483
673
  const n = pending.options.length;
484
- if (key.upArrow)
674
+ if (key.upArrow) {
485
675
  setMenuSel((i) => (i - 1 + n) % n);
486
- else if (key.downArrow)
676
+ return;
677
+ }
678
+ else if (key.downArrow) {
487
679
  setMenuSel((i) => (i + 1) % n);
488
- else if (key.return || key.escape) {
680
+ return;
681
+ }
682
+ else if (((key.return || modifiedReturn) && !input.trim()) || key.escape) {
489
683
  const choice = key.escape ? -1 : menuSel;
490
684
  const onChoose = pending.onChoose;
491
685
  setPending(null);
492
686
  onChoose(choice);
687
+ return;
493
688
  }
494
- return;
495
689
  }
496
690
  // The ask_user form is up: reuse form.ts's pure state machine verbatim.
497
691
  if (pending?.kind === "form" && formState) {
498
- if (key.upArrow)
692
+ if (key.upArrow) {
499
693
  setFormState(reduceForm(pending.questions, formState, "up").state);
500
- else if (key.downArrow || key.tab)
694
+ return;
695
+ }
696
+ else if (key.downArrow || key.tab) {
501
697
  setFormState(reduceForm(pending.questions, formState, "down").state);
502
- else if (key.return || char === " ") {
698
+ return;
699
+ }
700
+ else if ((key.return || modifiedReturn) && !input.trim()) {
503
701
  const next = reduceForm(pending.questions, formState, "select");
504
702
  setFormState(next.state);
505
703
  if (next.done) {
@@ -507,76 +705,89 @@ export function App({ session, runTurn }) {
507
705
  setPending(null);
508
706
  setFormState(null);
509
707
  }
708
+ return;
510
709
  }
511
710
  else if (key.escape) {
512
711
  pending.resolve(null);
513
712
  setPending(null);
514
713
  setFormState(null);
714
+ return;
515
715
  }
516
- return;
517
716
  }
518
- // Tab / Shift+Tab: cycle through the sub-agent list below the input box.
519
- // Enter on a highlighted sub-agent toggles its detail view.
520
- const agents = listSubAgents();
521
- if (agents.length > 0 && !key.return && key.tab) {
522
- const back = key.shift; // Shift+Tab = go backwards
523
- if (subAgentDetail) {
524
- // Detail open: Tab moves to next + closes detail.
525
- setSubAgentDetail(null);
526
- setSubAgentFocus((prev) => {
527
- const n = agents.length;
528
- if (back) {
529
- const next = prev === null ? n - 1 : (prev - 1 + n) % n;
530
- return next;
531
- }
532
- const next = prev === null ? 0 : (prev + 1) % n;
533
- return next;
534
- });
717
+ const completionAction = completion.handleKey(key, (value) => { setInput(value); setCursor(value.length); setHistIdx(null); });
718
+ if (completionAction.handled && !completionAction.submit)
719
+ return;
720
+ // Agent navigation has its own focus. Typing/history keeps arrow keys
721
+ // until Tab, left at the input boundary, or down from an empty input.
722
+ const ids = ["main", ...listAgentViews().map((a) => a.id)];
723
+ if (!pending && ids.length > 1) {
724
+ if (subAgentFocus !== null) {
725
+ if (key.escape || key.rightArrow) {
726
+ setSubAgentFocus(null);
727
+ setSubAgentDetail(null);
728
+ return;
729
+ }
730
+ if (key.upArrow || key.downArrow || key.tab) {
731
+ const next = moveAgentFocus(ids, subAgentFocus, key.upArrow || (key.tab && key.shift) ? -1 : 1);
732
+ setSubAgentFocus(next);
733
+ if (subAgentDetail)
734
+ setSubAgentDetail(next === "main" ? null : next);
735
+ setAgentPage(0);
736
+ return;
737
+ }
738
+ if (key.pageUp || key.pageDown) {
739
+ setAgentPage((n) => Math.max(0, Math.min(agentDetailView.pages - 1, n + (key.pageUp ? 1 : -1))));
740
+ return;
741
+ }
742
+ if (key.return) {
743
+ setSubAgentDetail(subAgentFocus === "main" ? null : subAgentFocus);
744
+ if (subAgentFocus === "main")
745
+ setSubAgentFocus(null);
746
+ setDetails(null);
747
+ setAgentPage(0);
748
+ return;
749
+ }
750
+ return; // the input buffer stays untouched while selecting an agent
535
751
  }
536
- else {
537
- setSubAgentDetail(null);
538
- setSubAgentFocus((prev) => {
539
- const n = agents.length;
540
- if (back) {
541
- if (prev === null)
542
- return n - 1;
543
- const next = (prev - 1 + n) % n;
544
- return next === n - 1 ? null : next;
545
- }
546
- if (prev === null)
547
- return 0;
548
- const next = (prev + 1) % n;
549
- return next === 0 ? null : next;
550
- });
752
+ if (key.tab || (key.leftArrow && cursor === 0) || (key.downArrow && !input && histIdx === null)) {
753
+ setSubAgentFocus("main");
754
+ setDetails(null);
755
+ return;
551
756
  }
552
- return;
553
- }
554
- // Enter with a sub-agent highlighted: show its detail.
555
- if (agents.length > 0 && subAgentFocus !== null && key.return) {
556
- setSubAgentDetail(agents[subAgentFocus].id === subAgentDetail ? null : agents[subAgentFocus].id);
557
- return;
558
757
  }
559
- if (busy)
560
- return; // a turn in flight with no prompt — ignore typing for now (type-ahead is later)
561
- 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
+ }
562
767
  // Dismiss the sub-agent detail/focus on a normal submit.
768
+ setDetails(null);
563
769
  setSubAgentDetail(null);
564
770
  setSubAgentFocus(null);
565
- const text = input.trim();
771
+ const text = completionAction.submit ?? input.trim();
566
772
  setInput("");
567
773
  setCursor(0);
568
774
  setHistIdx(null);
775
+ const attachments = imagesInInput(text, images);
776
+ setImages([]);
569
777
  if (!text)
570
778
  return;
571
779
  if (text.trim())
572
780
  history.push(text); // remember non-empty entries for ↑/↓
573
- 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; });
574
785
  }
575
786
  else if (key.leftArrow) {
576
- 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));
577
788
  }
578
789
  else if (key.rightArrow) {
579
- 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));
580
791
  }
581
792
  else if (key.ctrl && char === "a") {
582
793
  setCursor(0); // Home — jump to the start of the line
@@ -612,7 +823,19 @@ export function App({ session, runTurn }) {
612
823
  else if (key.backspace || key.delete) {
613
824
  // Delete the char BEFORE the caret. Both Backspace and macOS DEL land here,
614
825
  // so this is always a backward delete (forward-delete is rare; we skip it).
615
- 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) {
616
839
  setInput(input.slice(0, cursor - 1) + input.slice(cursor));
617
840
  setCursor(cursor - 1);
618
841
  }
@@ -620,87 +843,85 @@ export function App({ session, runTurn }) {
620
843
  else if (char && !key.ctrl && !key.meta) {
621
844
  // Insert at the caret (a bulk chunk — paste / dropped file path — is
622
845
  // normalized: a dropped file becomes a clean absolute path; prose unchanged).
623
- const add = char.length > 1 ? normalizeDroppedPaths(char) : char;
846
+ const add = char.length > 1 ? pastedTexts.fold(normalizeDroppedPaths(char)) : char;
624
847
  setHistIdx(null);
625
848
  setInput(input.slice(0, cursor) + add + input.slice(cursor));
626
849
  setCursor(cursor + add.length);
627
850
  }
628
851
  });
629
- 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: (() => {
630
- const prompt = planMode ? "plan ❯ " : "❯ ";
631
- const promptColor = planMode ? "magenta" : "cyan";
632
- const cols = process.stdout.columns || 80;
633
- const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
634
- const promptW = displayWidth(prompt);
635
- const firstW = Math.max(1, innerWidth - promptW);
636
- // Build visual lines display-width-aware (CJK, emoji = 2 cols).
637
- const lines = [""];
638
- let row = 0;
639
- let col = 0;
640
- // Cursor tracking: map code-unit offset → (line, display-column).
641
- let cursorLine = 0;
642
- let cursorCol = 0;
643
- let set = false;
644
- let cu = 0; // code-unit offset, for matching `cursor`
645
- for (const ch of input) {
646
- if (!set && cu === cursor) {
647
- cursorLine = row;
648
- cursorCol = col;
649
- set = true;
650
- }
651
- const cw = displayWidth(ch);
652
- const limit = row === 0 ? firstW : innerWidth;
653
- if (col + cw > limit) {
654
- row++;
655
- col = 0;
656
- lines[row] = "";
657
- }
658
- lines[row] += ch;
659
- col += cw;
660
- cu += ch.length;
661
- }
662
- if (!set) {
663
- cursorLine = row;
664
- cursorCol = col;
665
- } // cursor at end
666
- // Cursor exactly at edge → wrap to start of next line for visibility
667
- const edge = cursorLine === 0 ? firstW : innerWidth;
668
- if (cursorCol >= edge) {
669
- cursorLine++;
670
- cursorCol = 0;
671
- if (lines.length <= cursorLine)
672
- lines.push("");
673
- }
674
- // Map display-column back to code-unit offset within the target line.
675
- const cursorLineText = lines[cursorLine] ?? "";
676
- let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
677
- let dcol = 0;
678
- for (const ch of cursorLineText) {
679
- if (dcol >= cursorCol)
680
- break;
681
- dcol += displayWidth(ch);
682
- cuOff += ch.length;
683
- }
684
- return lines.map((line, i) => {
685
- if (i === cursorLine) {
686
- const before = line.slice(0, cuOff);
687
- const at = line.slice(cuOff, cuOff + 1) || " ";
688
- const after = line.slice(cuOff + 1);
689
- 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));
690
- }
691
- return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
692
- });
693
- })() }), (() => {
694
- const agents = listSubAgents();
695
- if (!agents.length)
696
- return null;
697
- 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 && (() => {
698
- const detail = agents.find((sa) => sa.id === subAgentDetail);
699
- if (!detail)
700
- return null;
701
- const head = `${detail.id} · ${detail.status === "running" ? "running" : detail.status === "done" ? "done" : "failed"}`;
702
- const body = detail.result ?? "(still working…)";
703
- 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 }) })] }));
704
- })()] }));
705
- })(), _jsx(StatusBar, { model: model, dir: dir, branch: branch, status: getStatus() })] }));
852
+ const agents = listAgentViews();
853
+ const selectedAgent = agents.find((a) => a.id === subAgentDetail);
854
+ const agentDetailView = detailPage(selectedAgent?.transcript || "(Waiting for activity…)", agentPage, Math.max(1, Math.min(10, rows - 10)), Math.max(1, columns - 4));
855
+ const detailView = detailPage(details === "tools" ? getToolCalls() ?? "No tool calls this turn." : details === "reasoning" ? getReasoning() ?? "No thinking recorded this turn." : "", detailOffset, Math.max(1, Math.min(12, rows - 9)), Math.max(1, columns - 4));
856
+ const activity = rows >= 20 && busy && !details && !pending && !subAgentDetail && !followUps.size ? summarizeActivity(getToolActivity(), Date.now(), rows >= 30 && !agents.length ? 2 : 1) : [];
857
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: items, children: (item, i) => _jsx(ItemView, { item: item }, i) }), _jsxs(Box, { flexDirection: "column", height: !pending && (details || selectedAgent) ? Math.max(1, rows - 1) : undefined, overflow: "hidden", children: [live !== null && !details && !pending && !selectedAgent && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, flexDirection: "row", children: [_jsx(Text, { color: "green", children: "\u23FA " }), _jsx(Box, { flexGrow: 1, children: _jsx(Text, { children: live === "" ? chalk.dim("…") : liveTail(live, rows, columns, followUps.size > 0) }) })] })), activity.map((group, i) => (_jsxs(Box, { flexDirection: "column", marginTop: i === 0 ? 1 : 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: group.failed ? "yellow" : group.running ? "cyan" : "green", children: "\u25CF " }), _jsx(Text, { bold: true, children: group.title })] }), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u23BF ", group.detail] })] }, i))), !details && !pending && !selectedAgent && (status !== null || busy) && (_jsxs(Box, { marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Spinner, {}), _jsxs(Text, { color: "#D77757", wrap: "truncate-end", children: [" ", status?.replace("Ctrl+C to interrupt", "Esc to interrupt") ?? (live !== null ? "Writing…" : "Working…")] })] })), rows >= 20 && !details && !selectedAgent && !pending && (busy || getToolCallCount() > 0) && (_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", getToolCallCount(), " tool calls \u00B7 Ctrl+T details \u00B7 Ctrl+R thinking", busy ? " · Esc interrupt" : ""] })), details && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [details === "tools" ? "Tool details" : "Thinking", " \u00B7 ", detailView.pages - detailView.page, "/", detailView.pages, " \u00B7 \u2191\u2193 page \u00B7 Esc close", followUps.size ? ` · ${followUps.size} queued` : ""] }), _jsx(Text, { children: detailView.text })] })), selectedAgent && !pending && (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "blue", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "cyan", wrap: "truncate-end", children: [selectedAgent.name, " \u00B7 ", selectedAgent.model, " \u00B7 ", selectedAgent.status, " \u00B7 Esc main"] }), _jsxs(Box, { children: [selectedAgent.status === "running" && _jsx(Spinner, {}), _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" ", selectedAgent.activity, " \u00B7 ", selectedAgent.toolCalls, " tools \u00B7 PgUp/PgDn details"] })] }), _jsx(Text, { children: agentDetailView.text })] })), pending?.kind === "select" && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: pending.header }), _jsx(Text, { children: renderMenu(pending.options, menuSel) }), _jsx(Text, { children: MENU_HINT })] })), pending?.kind === "form" && formState && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { children: renderForm(pending.questions, formState) }) })), pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Type a follow-up \u00B7 Enter sends text; empty input selects \u00B7 Esc cancels" }), !details && !selectedAgent && !pending && followUps.size > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: rows >= 20 ? 1 : 0, children: [_jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Messages queued for the next tool boundary \u00B7 Esc / Ctrl+Enter interrupt and send" }), followUps.pending.slice(rows >= 20 ? -2 : -1).map((message, i) => _jsxs(Text, { dimColor: true, wrap: "truncate-end", children: [" \u21B3 ", message.displayText ?? message.text] }, i)), followUps.size > 2 && _jsxs(Text, { dimColor: true, children: [" \u2026 ", followUps.size - 2, " earlier messages queued"] })] })), _jsx(Box, { borderStyle: "round", borderColor: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan", paddingX: 1, marginTop: rows >= 20 ? 1 : 0, flexDirection: "column", flexShrink: 0, children: (() => {
858
+ const prompt = "❯ ";
859
+ const promptColor = planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : autoEnabled ? "yellow" : "cyan";
860
+ const cols = columns;
861
+ const innerWidth = Math.max(10, cols - 4); // border(2) + paddingX(2)
862
+ const promptW = displayWidth(prompt);
863
+ const firstW = Math.max(1, innerWidth - promptW);
864
+ // Build visual lines display-width-aware (CJK, emoji = 2 cols).
865
+ const lines = [""];
866
+ let row = 0;
867
+ let col = 0;
868
+ // Cursor tracking: map code-unit offset → (line, display-column).
869
+ let cursorLine = 0;
870
+ let cursorCol = 0;
871
+ let set = false;
872
+ let cu = 0; // code-unit offset, for matching `cursor`
873
+ for (const ch of input) {
874
+ if (!set && cu === cursor) {
875
+ cursorLine = row;
876
+ cursorCol = col;
877
+ set = true;
878
+ }
879
+ const cw = displayWidth(ch);
880
+ const limit = row === 0 ? firstW : innerWidth;
881
+ if (col + cw > limit) {
882
+ row++;
883
+ col = 0;
884
+ lines[row] = "";
885
+ }
886
+ lines[row] += ch;
887
+ col += cw;
888
+ cu += ch.length;
889
+ }
890
+ if (!set) {
891
+ cursorLine = row;
892
+ cursorCol = col;
893
+ } // cursor at end
894
+ // Cursor exactly at edge → wrap to start of next line for visibility
895
+ const edge = cursorLine === 0 ? firstW : innerWidth;
896
+ if (cursorCol >= edge) {
897
+ cursorLine++;
898
+ cursorCol = 0;
899
+ if (lines.length <= cursorLine)
900
+ lines.push("");
901
+ }
902
+ // Map display-column back to code-unit offset within the target line.
903
+ const cursorLineText = lines[cursorLine] ?? "";
904
+ let cuOff = 0; // code-unit offset into cursorLineText that matches cursorCol
905
+ let dcol = 0;
906
+ for (const ch of cursorLineText) {
907
+ if (dcol >= cursorCol)
908
+ break;
909
+ dcol += displayWidth(ch);
910
+ cuOff += ch.length;
911
+ }
912
+ // Long drafts must not grow the redraw region. Keep the cursor's
913
+ // neighborhood visible; the complete draft remains in input state.
914
+ const visibleRows = details || selectedAgent || followUps.size || completion.visible || rows < 20 ? 1 : 3;
915
+ const startLine = Math.max(0, cursorLine - visibleRows + 1);
916
+ return lines.slice(startLine, startLine + visibleRows).map((line, offset) => {
917
+ const i = startLine + offset;
918
+ if (i === cursorLine) {
919
+ const before = line.slice(0, cuOff);
920
+ const at = line.slice(cuOff, cuOff + 1) || " ";
921
+ const after = line.slice(cuOff + 1);
922
+ return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: before }), _jsx(Text, { inverse: true, children: at }), _jsx(Text, { children: after })] }, i));
923
+ }
924
+ return (_jsxs(Box, { children: [i === 0 && _jsx(Text, { color: promptColor, children: prompt }), _jsx(Text, { children: line })] }, i));
925
+ });
926
+ })() }), completion.view, rows >= 20 && busy && !details && !selectedAgent && !pending && _jsx(Text, { dimColor: true, wrap: "truncate-end", children: "Enter queue follow-up \u00B7 Ctrl+Enter interrupt and send now" }), rows >= 20 && !details && !selectedAgent && !pending && !completion.visible && _jsx(AgentList, { agents: agents, focus: subAgentFocus, viewing: subAgentDetail, mainBusy: busy, maxRows: Math.max(2, Math.min(3, rows - 24)) }), _jsx(StatusBar, { model: CONFIG.model, dir: dir, branch: branch, status: getStatus() }), (planMode || autoEnabled || CONFIG.bypassPermissions) && (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: planMode ? "magenta" : CONFIG.bypassPermissions ? "red" : "yellow", children: planMode ? "⏸ plan mode on" : CONFIG.bypassPermissions ? "▶▶ bypass permissions on" : "▶▶ auto mode on" }), _jsx(Text, { dimColor: true, children: planMode ? " (/plan to toggle)" : CONFIG.bypassPermissions ? " (this run)" : " (/auto to toggle)" })] }))] })] }));
706
927
  }