@bubblebrain-ai/bubble 0.0.20 → 0.0.21

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.
Files changed (49) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +5 -1
  3. package/dist/checkpoints.d.ts +57 -0
  4. package/dist/checkpoints.js +0 -0
  5. package/dist/feishu/agent-host/run-driver.js +1 -0
  6. package/dist/main.js +54 -13
  7. package/dist/session.d.ts +31 -0
  8. package/dist/session.js +69 -0
  9. package/dist/slash-commands/commands.js +80 -0
  10. package/dist/slash-commands/types.d.ts +4 -0
  11. package/dist/tools/bash.js +4 -0
  12. package/dist/tools/edit.d.ts +2 -1
  13. package/dist/tools/edit.js +2 -1
  14. package/dist/tools/index.d.ts +7 -0
  15. package/dist/tools/index.js +2 -2
  16. package/dist/tools/write.d.ts +2 -1
  17. package/dist/tools/write.js +2 -1
  18. package/dist/tui/image-paste.d.ts +18 -0
  19. package/dist/tui/image-paste.js +60 -0
  20. package/dist/tui/run.js +309 -69
  21. package/dist/tui/trace-groups.d.ts +16 -0
  22. package/dist/tui/trace-groups.js +42 -1
  23. package/dist/tui/transcript-scroll.d.ts +25 -0
  24. package/dist/tui/transcript-scroll.js +20 -0
  25. package/dist/tui-ink/app.d.ts +4 -1
  26. package/dist/tui-ink/app.js +301 -247
  27. package/dist/tui-ink/display-history.d.ts +16 -1
  28. package/dist/tui-ink/display-history.js +50 -21
  29. package/dist/tui-ink/footer.d.ts +6 -12
  30. package/dist/tui-ink/footer.js +10 -29
  31. package/dist/tui-ink/image-paste.d.ts +59 -0
  32. package/dist/tui-ink/image-paste.js +277 -0
  33. package/dist/tui-ink/input-box.d.ts +26 -1
  34. package/dist/tui-ink/input-box.js +171 -41
  35. package/dist/tui-ink/message-list.d.ts +1 -1
  36. package/dist/tui-ink/message-list.js +46 -29
  37. package/dist/tui-ink/run.d.ts +7 -2
  38. package/dist/tui-ink/run.js +73 -23
  39. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  40. package/dist/tui-ink/terminal-mouse.js +4 -0
  41. package/dist/tui-ink/trace-groups.d.ts +16 -0
  42. package/dist/tui-ink/trace-groups.js +50 -2
  43. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  44. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  45. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  46. package/dist/tui-ink/transcript-viewport.js +83 -0
  47. package/dist/tui-ink/welcome.d.ts +9 -7
  48. package/dist/tui-ink/welcome.js +7 -33
  49. package/package.json +1 -1
@@ -1,13 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
3
  import { Box, Text, useApp, useInput } from "ink";
4
- import { AgentAbortError } from "../agent.js";
4
+ import { AgentAbortError, INTERRUPTED_ASSISTANT_CONTENT } from "../agent.js";
5
5
  import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
6
6
  import { registry as slashRegistry } from "../slash-commands/index.js";
7
7
  import { UserConfig, maskKey } from "../config.js";
8
8
  import { createPastedContentMarker, InputBox, isCtrlCInput, shouldCollapsePastedContent, } from "./input-box.js";
9
9
  import { MessageList } from "./message-list.js";
10
- import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, nextDisplayMessageKey, snapshotDisplayParts, toolCallsFromParts, } from "./display-history.js";
10
+ import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
11
+ import { AgentRunInputQueue } from "../agent/input-controller.js";
11
12
  import { paletteFor, ThemeProvider, useTheme } from "./theme.js";
12
13
  import { ModelPicker, ProviderPicker, KeyPicker, SkillPicker } from "./model-picker.js";
13
14
  import { FeishuSetupPicker } from "./feishu-setup-picker.js";
@@ -28,9 +29,8 @@ import { QuestionDialog } from "./question-dialog.js";
28
29
  import { FeedbackDialog } from "./feedback-dialog.js";
29
30
  import { collectFeedback } from "../feedback/collect.js";
30
31
  import { hasTerminalMouseSequence } from "./terminal-mouse.js";
32
+ import { TranscriptViewport } from "./transcript-viewport.js";
31
33
  import os from "node:os";
32
- import { existsSync } from "node:fs";
33
- import { join } from "node:path";
34
34
  function buildTips(agent, registry) {
35
35
  const tips = [];
36
36
  const hasProvider = registry.getEnabled().length > 0;
@@ -95,13 +95,31 @@ function reconstructDisplayMessages(agentMessages) {
95
95
  });
96
96
  }
97
97
  }
98
- result.push({
99
- key: nextDisplayMessageKey("asst"),
100
- role: "assistant",
101
- content: m.content,
102
- reasoning: m.reasoning || undefined,
103
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
104
- });
98
+ // An aborted assistant message carries the model-facing interruption
99
+ // note in its content. Render only what the assistant actually said
100
+ // (partial streamed text, if any) plus a dedicated interrupt row —
101
+ // never the note itself, which reads like a leaked system prompt.
102
+ const interrupted = m.error?.aborted === true;
103
+ const content = interrupted
104
+ ? stripInterruptedAssistantMarker(m.content, INTERRUPTED_ASSISTANT_CONTENT)
105
+ : m.content;
106
+ if (content || m.reasoning || toolCalls.length > 0) {
107
+ result.push({
108
+ key: nextDisplayMessageKey("asst"),
109
+ role: "assistant",
110
+ content,
111
+ reasoning: m.reasoning || undefined,
112
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
113
+ });
114
+ }
115
+ if (interrupted) {
116
+ result.push({
117
+ key: nextDisplayMessageKey("asst"),
118
+ role: "assistant",
119
+ content: "Interrupted by user",
120
+ syntheticKind: "ui_interrupt",
121
+ });
122
+ }
105
123
  }
106
124
  }
107
125
  return result;
@@ -183,116 +201,12 @@ function withMessageKey(message) {
183
201
  const prefix = message.role === "user" ? "user" : message.role === "error" ? "err" : "asst";
184
202
  return { ...message, key: nextDisplayMessageKey(prefix) };
185
203
  }
186
- // Keep the live (non-Static) region small so non-GPU terminals (xterm.js DOM
187
- // renderer, ssh into a basic terminal, tmux without GPU) don't flicker when
188
- // Ink re-reconciles the streaming block on every token. Flushing earlier and
189
- // in smaller chunks shifts most of the answer into terminal scrollback, where
190
- // it's a one-time write that doesn't get re-rendered.
191
- const STREAMING_STATIC_FLUSH_MIN_CHARS = 600;
192
- const STREAMING_STATIC_FLUSH_TARGET_CHARS = 400;
193
- const STREAMING_STATIC_FLUSH_MIN_TAIL = 120;
194
- /**
195
- * True iff `prefix` ends inside an open ```/~~~ fenced code block. Splitting
196
- * the streaming buffer at such a point would let the flushed half render
197
- * without its closing fence — `MarkdownContent` would then treat the body as
198
- * plain prose and the trailing half would render as an isolated code block
199
- * with no opener. Fence delimiters of different families don't close each
200
- * other (a `~~~` inside a ``` block is just text). We use a permissive
201
- * "line starts with three or more of the same char" rule, ignoring the info
202
- * string — that's enough to spot when we're mid-block.
203
- */
204
- function endsInsideUnclosedCodeFence(prefix) {
205
- let openMarker = null;
206
- for (const rawLine of prefix.split("\n")) {
207
- const line = rawLine.replace(/^ {0,3}/, "");
208
- if (openMarker === null) {
209
- if (line.startsWith("```"))
210
- openMarker = "`";
211
- else if (line.startsWith("~~~"))
212
- openMarker = "~";
213
- }
214
- else if (line.startsWith(openMarker.repeat(3))) {
215
- openMarker = null;
216
- }
217
- }
218
- return openMarker !== null;
219
- }
220
- function findStreamingStaticFlushIndex(content) {
221
- if (content.length < STREAMING_STATIC_FLUSH_MIN_CHARS)
222
- return -1;
223
- const upper = Math.min(STREAMING_STATIC_FLUSH_TARGET_CHARS, content.length - STREAMING_STATIC_FLUSH_MIN_TAIL);
224
- if (upper <= 0)
225
- return -1;
226
- const search = content.slice(0, upper);
227
- const paragraphBreak = search.lastIndexOf("\n\n");
228
- if (paragraphBreak >= STREAMING_STATIC_FLUSH_TARGET_CHARS / 2) {
229
- const splitIndex = paragraphBreak + 2;
230
- if (!endsInsideUnclosedCodeFence(content.slice(0, splitIndex))) {
231
- return splitIndex;
232
- }
233
- }
234
- const lineBreak = search.lastIndexOf("\n");
235
- if (lineBreak >= STREAMING_STATIC_FLUSH_TARGET_CHARS / 2) {
236
- const splitIndex = lineBreak + 1;
237
- if (!endsInsideUnclosedCodeFence(content.slice(0, splitIndex))) {
238
- return splitIndex;
239
- }
240
- }
241
- // Inside an open code fence: hold off flushing until the closing fence
242
- // arrives. The live region grows a bit, but Markdown rendering stays correct.
243
- return -1;
244
- }
245
- function cloneDisplayPart(part) {
246
- if (part.type === "text") {
247
- return { type: "text", content: part.content };
248
- }
249
- return {
250
- type: "tools",
251
- toolCalls: part.toolCalls.map((toolCall) => ({
252
- ...toolCall,
253
- args: { ...toolCall.args },
254
- })),
255
- };
256
- }
257
- function splitDisplayPartsAtTextOffset(parts, offset) {
258
- const flushedParts = [];
259
- const remainingParts = [];
260
- let remainingOffset = Math.max(0, offset);
261
- let reachedTail = false;
262
- for (const part of parts) {
263
- if (part.type === "text") {
264
- if (!reachedTail && remainingOffset >= part.content.length) {
265
- if (part.content)
266
- flushedParts.push(cloneDisplayPart(part));
267
- remainingOffset -= part.content.length;
268
- continue;
269
- }
270
- if (!reachedTail && remainingOffset > 0) {
271
- const head = part.content.slice(0, remainingOffset);
272
- const tail = part.content.slice(remainingOffset);
273
- if (head)
274
- flushedParts.push({ type: "text", content: head });
275
- if (tail)
276
- remainingParts.push({ type: "text", content: tail });
277
- remainingOffset = 0;
278
- reachedTail = true;
279
- continue;
280
- }
281
- remainingParts.push(cloneDisplayPart(part));
282
- reachedTail = true;
283
- continue;
284
- }
285
- if (!reachedTail && remainingOffset > 0) {
286
- flushedParts.push(cloneDisplayPart(part));
287
- }
288
- else {
289
- remainingParts.push(cloneDisplayPart(part));
290
- reachedTail = true;
291
- }
292
- }
293
- return { flushedParts, remainingParts };
294
- }
295
- export function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, onExit }) {
204
+ // Batch streaming text deltas before committing them to React state. Without
205
+ // <Static>, every commit re-renders the full-screen frame; per-token commits
206
+ // would make Yoga re-lay-out the transcript for every few bytes of output.
207
+ // 40ms keeps perceived latency invisible while capping layout work at 25fps.
208
+ const STREAMING_FLUSH_INTERVAL_MS = 40;
209
+ export function App({ agent, args, sessionManager, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, bypassEnabled, updateNotice, hookController, onExit }) {
296
210
  const [themeMode, setThemeMode] = useState(initialThemeMode ?? "auto");
297
211
  // `detectedTheme` is captured once at startup in main.ts. We keep it in state
298
212
  // so future re-detection (e.g. if a user runs `/theme auto` after switching
@@ -310,13 +224,11 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
310
224
  const themeResolved = themeMode === "auto" ? autoResolved : themeMode;
311
225
  const { exit } = useApp();
312
226
  const [messages, setMessages] = useState(() => compactDisplayMessages(reconstructDisplayMessages(agent.messages)));
313
- const [clearEpoch, setClearEpoch] = useState(0);
314
227
  const [isRunning, setIsRunning] = useState(false);
315
228
  const [streamingContent, setStreamingContent] = useState("");
316
229
  const [streamingReasoning, setStreamingReasoning] = useState("");
317
230
  const [streamingTools, setStreamingTools] = useState([]);
318
231
  const [streamingParts, setStreamingParts] = useState([]);
319
- const [usageTotals, setUsageTotals] = useState({ prompt: 0, completion: 0 });
320
232
  const [thinkingLevel, setThinkingLevel] = useState(agent.thinking);
321
233
  const [permissionMode, setPermissionMode] = useState(agent.mode);
322
234
  const [todos, setTodos] = useState(() => agent.getTodos());
@@ -330,7 +242,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
330
242
  const [keyProviderId, setKeyProviderId] = useState(null);
331
243
  const [verboseTrace, setVerboseTrace] = useState(false);
332
244
  const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
333
- const { columns: terminalColumns } = useTerminalSize();
245
+ const { columns: terminalColumns, rows: terminalRows } = useTerminalSize();
334
246
  const showWelcome = shouldShowWelcomeBanner({
335
247
  messages,
336
248
  startedWithVisibleHistory: startedWithVisibleHistoryRef.current,
@@ -338,27 +250,17 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
338
250
  const activeAbortRef = useRef(null);
339
251
  const exitRequestedRef = useRef(false);
340
252
  const sessionStartRef = useRef(Date.now());
341
- const previousTerminalColumnsRef = useRef(null);
342
- useEffect(() => {
343
- if (previousTerminalColumnsRef.current === null) {
344
- previousTerminalColumnsRef.current = terminalColumns;
345
- return;
346
- }
347
- if (previousTerminalColumnsRef.current === terminalColumns)
348
- return;
349
- previousTerminalColumnsRef.current = terminalColumns;
350
- // This follows Gemini CLI's normal terminal-buffer strategy: after a
351
- // resize, the previous live Ink frame may have wrapped at the old width,
352
- // so cursor-up based repaint can leave stale progress frames behind.
353
- // Debounce resize storms, then clear and replay Static at the settled width.
354
- const timer = setTimeout(() => {
355
- if (exitRequestedRef.current)
356
- return;
357
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
358
- setClearEpoch((epoch) => epoch + 1);
359
- }, 300);
360
- return () => clearTimeout(timer);
361
- }, [terminalColumns]);
253
+ const viewportRef = useRef(null);
254
+ // Steer/queue while the agent runs (parity with the OpenTUI composer):
255
+ // Enter steers the current run via the agent's input controller; Tab (or an
256
+ // ineligible input) queues for the next turn. Both render placeholder user
257
+ // rows whose badge tracks the input's lifecycle.
258
+ const inputControllerRef = useRef(null);
259
+ const pendingSteersRef = useRef(new Map());
260
+ const queuedInputsRef = useRef([]);
261
+ const [pendingSteerCount, setPendingSteerCount] = useState(0);
262
+ const [queuedCount, setQueuedCount] = useState(0);
263
+ const nextRunIdRef = useRef(0);
362
264
  // Set true the moment /quit is invoked so we can hide dynamic UI (composer,
363
265
  // waiting indicator, footer) before Ink snapshots its final frame into the
364
266
  // shell scrollback. Without this, the last visible "> " input row stays
@@ -488,6 +390,14 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
488
390
  syncFirstPending();
489
391
  return unsubscribe;
490
392
  }, [questionController]);
393
+ // An approval or question demands the user's attention: re-engage
394
+ // bottom-follow even if they had scrolled up (second force trigger
395
+ // documented in transcript-scroll.ts).
396
+ useEffect(() => {
397
+ if (pendingApproval || pendingQuestion) {
398
+ viewportRef.current?.forceScrollToBottom();
399
+ }
400
+ }, [pendingApproval, pendingQuestion]);
491
401
  const rebuildSystemPrompt = useCallback((overrides) => {
492
402
  const modelParts = agent.model.includes(":")
493
403
  ? agent.model.split(":")
@@ -509,10 +419,22 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
509
419
  requestExit();
510
420
  return;
511
421
  }
512
- if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback)
513
- return;
422
+ // Mouse reporting is off (native drag-select/copy works directly), so no
423
+ // SGR wheel events arrive — wheel scrolling reaches the app as Up/Down
424
+ // arrows via the terminal's alternate-scroll mode, classified in the
425
+ // composer. Defensively drop any stray mouse report bytes.
514
426
  if (hasTerminalMouseSequence(input))
515
427
  return;
428
+ if (!pickerMode && key.pageUp) {
429
+ viewportRef.current?.scrollPage("up");
430
+ return;
431
+ }
432
+ if (!pickerMode && key.pageDown) {
433
+ viewportRef.current?.scrollPage("down");
434
+ return;
435
+ }
436
+ if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback)
437
+ return;
516
438
  if (key.ctrl && input === "o" && !pickerMode) {
517
439
  setVerboseTrace((v) => !v);
518
440
  return;
@@ -560,12 +482,34 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
560
482
  updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
561
483
  }, [updateDisplayMessages]);
562
484
  const clearMessages = useCallback(() => {
563
- // Static history is already written to terminal scrollback, so clearing
564
- // React state alone would leave old rows visible.
565
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
485
+ // The transcript lives entirely in React state now (alt-screen viewport,
486
+ // no terminal scrollback) clearing state clears the screen. Writing
487
+ // \x1b[2J here would just flash a black frame before the next paint.
566
488
  setMessages([]);
567
- setClearEpoch((epoch) => epoch + 1);
568
489
  }, []);
490
+ // Render a placeholder user row for input waiting to enter the run.
491
+ const addStatusUserMessage = useCallback((content, status) => {
492
+ const key = nextDisplayMessageKey("user");
493
+ updateDisplayMessages((prev) => [...prev, { key, role: "user", content, inputStatus: status }]);
494
+ viewportRef.current?.forceScrollToBottom();
495
+ return key;
496
+ }, [updateDisplayMessages]);
497
+ const queueInput = useCallback((payload) => {
498
+ const displayKey = addStatusUserMessage(payload.displayText ?? payload.text, "queued");
499
+ queuedInputsRef.current.push({ payload, displayKey });
500
+ setQueuedCount(queuedInputsRef.current.length);
501
+ }, [addStatusUserMessage]);
502
+ const submitSteer = useCallback((payload) => {
503
+ const controller = inputControllerRef.current;
504
+ if (!controller) {
505
+ queueInput(payload);
506
+ return;
507
+ }
508
+ const displayKey = addStatusUserMessage(payload.displayText ?? payload.text, "pending_steer");
509
+ const pending = controller.enqueue(payload.text);
510
+ pendingSteersRef.current.set(pending.id, { displayKey });
511
+ setPendingSteerCount(pendingSteersRef.current.size);
512
+ }, [addStatusUserMessage, queueInput]);
569
513
  const openPicker = useCallback((mode, providerId) => {
570
514
  if (mode === "key") {
571
515
  setKeyProviderId(providerId ?? null);
@@ -683,6 +627,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
683
627
  settingsManager,
684
628
  lspService,
685
629
  mcpManager,
630
+ hookController,
686
631
  flushMemory,
687
632
  runMemoryCompaction,
688
633
  runMemorySummary,
@@ -716,6 +661,7 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
716
661
  settingsManager,
717
662
  lspService,
718
663
  mcpManager,
664
+ hookController,
719
665
  flushMemory,
720
666
  runMemoryCompaction,
721
667
  runMemorySummary,
@@ -753,6 +699,26 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
753
699
  const images = normalized.images;
754
700
  if (!input.trim() && images.length === 0)
755
701
  return;
702
+ // Agent already running: route the submit into the live run instead of
703
+ // starting a new one. Plain prose steers the current turn; slash
704
+ // commands, @-mentions and image payloads queue for the next turn
705
+ // (mirrors the OpenTUI boundary-steer eligibility rules).
706
+ if (activeAbortRef.current) {
707
+ if (/^\/(?:quit|exit)\s*$/.test(input.trim())) {
708
+ requestExit();
709
+ return;
710
+ }
711
+ const steerEligible = !displayInput.trim().startsWith("/") &&
712
+ !input.includes("@") &&
713
+ images.length === 0;
714
+ if (steerEligible) {
715
+ submitSteer(normalized);
716
+ }
717
+ else {
718
+ queueInput(normalized);
719
+ }
720
+ return;
721
+ }
756
722
  const runAgentInput = async (actualInput, displayInput, attachedImages = []) => {
757
723
  const activeProviderId = agent.providerId || safeRegistry.getDefault()?.id;
758
724
  const hasActiveProvider = !!activeProviderId && safeRegistry.getEnabled().some((provider) => provider.id === activeProviderId);
@@ -773,6 +739,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
773
739
  ...prev,
774
740
  withMessageKey({ role: "user", content: displayContent }),
775
741
  ]);
742
+ // Sending is an explicit "watch the newest turn" intent: snap the
743
+ // transcript back to the bottom even if the user had scrolled up.
744
+ viewportRef.current?.forceScrollToBottom();
776
745
  setIsRunning(true);
777
746
  runStartRef.current = Date.now();
778
747
  setStreamingContent("");
@@ -785,9 +754,31 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
785
754
  const assistantParts = [];
786
755
  const abortController = new AbortController();
787
756
  activeAbortRef.current = abortController;
757
+ const inputController = new AgentRunInputQueue(`run-${++nextRunIdRef.current}`);
758
+ inputControllerRef.current = inputController;
788
759
  const syncStreamingParts = () => {
789
760
  setStreamingParts(snapshotDisplayParts(assistantParts));
790
761
  };
762
+ // Text/reasoning deltas arrive far faster than the screen needs to
763
+ // update; batch them so the full-frame re-render runs at most every
764
+ // STREAMING_FLUSH_INTERVAL_MS. Tool events stay immediate.
765
+ let streamingFlushTimer = null;
766
+ const cancelStreamingFlush = () => {
767
+ if (streamingFlushTimer !== null) {
768
+ clearTimeout(streamingFlushTimer);
769
+ streamingFlushTimer = null;
770
+ }
771
+ };
772
+ const scheduleStreamingFlush = () => {
773
+ if (streamingFlushTimer !== null)
774
+ return;
775
+ streamingFlushTimer = setTimeout(() => {
776
+ streamingFlushTimer = null;
777
+ setStreamingContent(assistantContent);
778
+ setStreamingReasoning(assistantReasoning);
779
+ syncStreamingParts();
780
+ }, STREAMING_FLUSH_INTERVAL_MS);
781
+ };
791
782
  const hasAssistantOutput = () => (!!assistantContent ||
792
783
  !!assistantReasoning ||
793
784
  toolCalls.length > 0 ||
@@ -821,6 +812,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
821
812
  updateDisplayMessages((prev) => [...prev, msg]);
822
813
  };
823
814
  const clearAssistantStream = () => {
815
+ // A timer firing after this reset would resurrect the just-committed
816
+ // text as a phantom streaming block — cancel before clearing.
817
+ cancelStreamingFlush();
824
818
  setStreamingContent("");
825
819
  setStreamingReasoning("");
826
820
  setStreamingTools([]);
@@ -830,58 +824,20 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
830
824
  toolCalls.length = 0;
831
825
  assistantParts.length = 0;
832
826
  };
833
- const flushAssistantStaticChunk = () => {
834
- if (toolCalls.some((toolCall) => toolCall.result === undefined)) {
835
- return false;
836
- }
837
- const splitIndex = findStreamingStaticFlushIndex(assistantContent);
838
- if (splitIndex <= 0)
839
- return false;
840
- const { flushedParts, remainingParts } = splitDisplayPartsAtTextOffset(assistantParts, splitIndex);
841
- const flushedContent = contentFromParts(flushedParts);
842
- const flushedToolCalls = toolCallsFromParts(flushedParts);
843
- if (!flushedContent && flushedToolCalls.length === 0)
844
- return false;
845
- const msg = {
846
- key: nextDisplayMessageKey("asst"),
847
- role: "assistant",
848
- content: flushedContent,
849
- };
850
- if (assistantReasoning) {
851
- msg.reasoning = assistantReasoning;
852
- assistantReasoning = "";
853
- setStreamingReasoning("");
854
- }
855
- if (flushedToolCalls.length > 0) {
856
- msg.toolCalls = flushedToolCalls;
857
- }
858
- if (flushedParts.length > 0) {
859
- msg.parts = flushedParts;
860
- }
861
- updateDisplayMessages((prev) => [...prev, msg]);
862
- assistantParts.splice(0, assistantParts.length, ...remainingParts);
863
- assistantContent = contentFromParts(assistantParts);
864
- const remainingToolCalls = toolCallsFromParts(assistantParts);
865
- toolCalls.splice(0, toolCalls.length, ...remainingToolCalls);
866
- setStreamingContent(assistantContent);
867
- setStreamingTools([...toolCalls]);
868
- syncStreamingParts();
869
- return true;
870
- };
871
827
  try {
872
- for await (const event of agent.run(actualInput, args.cwd, { abortSignal: abortController.signal })) {
828
+ for await (const event of agent.run(actualInput, args.cwd, {
829
+ abortSignal: abortController.signal,
830
+ inputController,
831
+ })) {
873
832
  switch (event.type) {
874
833
  case "text_delta":
875
834
  assistantContent += event.content;
876
835
  appendTextPart(assistantParts, event.content);
877
- if (!flushAssistantStaticChunk()) {
878
- setStreamingContent(assistantContent);
879
- syncStreamingParts();
880
- }
836
+ scheduleStreamingFlush();
881
837
  break;
882
838
  case "reasoning_delta":
883
839
  assistantReasoning += event.content;
884
- setStreamingReasoning(assistantReasoning);
840
+ scheduleStreamingFlush();
885
841
  break;
886
842
  case "tool_call_start": {
887
843
  // The LLM has begun emitting this tool call. Args are still
@@ -980,13 +936,41 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
980
936
  sessionManager?.appendMarker("mode_switch", event.mode);
981
937
  break;
982
938
  }
983
- case "turn_end": {
984
- if (event.usage) {
985
- setUsageTotals((totals) => ({
986
- prompt: totals.prompt + event.usage.promptTokens,
987
- completion: totals.completion + event.usage.completionTokens,
988
- }));
939
+ case "input_applied": {
940
+ // The steer joined the current turn — its placeholder row
941
+ // becomes a regular user message (badge cleared).
942
+ const steer = pendingSteersRef.current.get(event.id);
943
+ if (steer) {
944
+ pendingSteersRef.current.delete(event.id);
945
+ setPendingSteerCount(pendingSteersRef.current.size);
946
+ updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message) : message));
947
+ }
948
+ break;
949
+ }
950
+ case "input_rejected": {
951
+ // No model continuation left in this run: the steer moves to
952
+ // the next turn's queue, badge flips to QUEUED.
953
+ const steer = pendingSteersRef.current.get(event.id);
954
+ if (steer) {
955
+ pendingSteersRef.current.delete(event.id);
956
+ setPendingSteerCount(pendingSteersRef.current.size);
957
+ updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message, "queued") : message));
958
+ queuedInputsRef.current.push({
959
+ payload: { text: event.content, images: [] },
960
+ displayKey: steer.displayKey,
961
+ });
962
+ setQueuedCount(queuedInputsRef.current.length);
963
+ }
964
+ break;
965
+ }
966
+ case "input_pending_changed": {
967
+ if (event.pending === 0 && pendingSteersRef.current.size > 0) {
968
+ pendingSteersRef.current.clear();
989
969
  }
970
+ setPendingSteerCount(event.pending === 0 ? 0 : event.pending);
971
+ break;
972
+ }
973
+ case "turn_end": {
990
974
  if (event.willContinue) {
991
975
  syncStreamingParts();
992
976
  break;
@@ -1011,6 +995,32 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1011
995
  }
1012
996
  }
1013
997
  finally {
998
+ cancelStreamingFlush();
999
+ // Leftover steers that never reached a model-call boundary: drop
1000
+ // them on cancel (the user asked the run to stop); requeue them for
1001
+ // the next turn on a normal end (mirrors the OpenTUI run teardown).
1002
+ const cancelled = abortController.signal.aborted;
1003
+ for (const leftover of inputController.clear()) {
1004
+ const steer = pendingSteersRef.current.get(leftover.id);
1005
+ pendingSteersRef.current.delete(leftover.id);
1006
+ if (cancelled) {
1007
+ if (steer) {
1008
+ updateDisplayMessages((prev) => prev.filter((message) => message.key !== steer.displayKey));
1009
+ }
1010
+ continue;
1011
+ }
1012
+ if (steer) {
1013
+ updateDisplayMessages((prev) => prev.map((message) => message.key === steer.displayKey ? setUserInputStatus(message, "queued") : message));
1014
+ }
1015
+ queuedInputsRef.current.push({
1016
+ payload: { text: leftover.content, images: [] },
1017
+ displayKey: steer?.displayKey,
1018
+ });
1019
+ }
1020
+ setPendingSteerCount(0);
1021
+ setQueuedCount(queuedInputsRef.current.length);
1022
+ if (inputControllerRef.current === inputController)
1023
+ inputControllerRef.current = null;
1014
1024
  if (activeAbortRef.current === abortController)
1015
1025
  activeAbortRef.current = null;
1016
1026
  setIsRunning(false);
@@ -1051,12 +1061,14 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1051
1061
  }),
1052
1062
  openPicker,
1053
1063
  openFeedback,
1064
+ fillComposer,
1054
1065
  registry: safeRegistry,
1055
1066
  skillRegistry: safeSkillRegistry,
1056
1067
  bashAllowlist,
1057
1068
  settingsManager,
1058
1069
  lspService,
1059
1070
  mcpManager,
1071
+ hookController,
1060
1072
  flushMemory,
1061
1073
  runMemoryCompaction,
1062
1074
  runMemorySummary,
@@ -1085,6 +1097,14 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1085
1097
  },
1086
1098
  ]);
1087
1099
  }
1100
+ else if (result.startsWith("⏪")) {
1101
+ // /rewind truncated agent.messages — rebuild the transcript from
1102
+ // the rewound state before appending the summary.
1103
+ updateDisplayMessages(() => [
1104
+ ...reconstructDisplayMessages(agent.messages),
1105
+ { role: "assistant", content: result },
1106
+ ]);
1107
+ }
1088
1108
  else {
1089
1109
  addMessage("assistant", result);
1090
1110
  }
@@ -1113,7 +1133,32 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1113
1133
  ]
1114
1134
  : expansion.text;
1115
1135
  await runAgentInput(agentInput, displayInput, images.map((img) => ({ filename: img.filename, bytes: img.bytes })));
1116
- }, [addMessage, agent, args.cwd, openPicker, createProvider, safeRegistry, safeSkillRegistry, updateDisplayMessages]);
1136
+ }, [addMessage, agent, args.cwd, openPicker, createProvider, fillComposer, safeRegistry, safeSkillRegistry, updateDisplayMessages, queueInput, submitSteer, requestExit]);
1137
+ // Drain the queue once the run ends and no modal needs the user first.
1138
+ // The placeholder row is removed right before resubmitting — handleSubmit
1139
+ // renders the message again as a regular user row.
1140
+ const drainQueuedInput = useCallback(() => {
1141
+ if (activeAbortRef.current)
1142
+ return;
1143
+ if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || pickerMode)
1144
+ return;
1145
+ const next = queuedInputsRef.current.shift();
1146
+ if (!next)
1147
+ return;
1148
+ setQueuedCount(queuedInputsRef.current.length);
1149
+ if (next.displayKey) {
1150
+ updateDisplayMessages((prev) => prev.filter((message) => message.key !== next.displayKey));
1151
+ }
1152
+ void handleSubmit(next.payload);
1153
+ }, [pendingPlan, pendingApproval, pendingQuestion, pendingFeedback, pickerMode, updateDisplayMessages, handleSubmit]);
1154
+ useEffect(() => {
1155
+ if (isRunning || queuedCount === 0)
1156
+ return;
1157
+ if (pendingPlan || pendingApproval || pendingQuestion || pendingFeedback || pickerMode)
1158
+ return;
1159
+ const timer = setTimeout(drainQueuedInput, 0);
1160
+ return () => clearTimeout(timer);
1161
+ }, [isRunning, queuedCount, pendingPlan, pendingApproval, pendingQuestion, pendingFeedback, pickerMode, drainQueuedInput]);
1117
1162
  const currentProviderId = agent.providerId || safeRegistry.getDefault()?.id;
1118
1163
  const keyTarget = keyProviderId
1119
1164
  ? safeRegistry.getConfigured().find((p) => p.id === keyProviderId)
@@ -1133,39 +1178,47 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1133
1178
  return null;
1134
1179
  })()
1135
1180
  : null;
1136
- const mcpStates = mcpManager?.getStates() ?? [];
1137
- const mcpConnectedCount = mcpStates.filter((state) => state.status.kind === "connected").length;
1138
- const hasAgentsFile = useMemo(() => existsSync(join(args.cwd, "AGENTS.md")) || existsSync(join(args.cwd, ".bubble", "AGENTS.md")), [args.cwd]);
1139
- const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, modelLabel: agent.model ? displayModel(agent.model) : undefined, cwd: friendlyCwd(args.cwd), tips: buildTips(agent, safeRegistry), skillsCount: safeSkillRegistry.summaries().length, mcpConnectedCount: mcpConnectedCount, mcpTotalCount: mcpStates.length, hasAgentsFile: hasAgentsFile })) : null;
1140
- return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, flexShrink: 0, children: [_jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }, clearEpoch), pickerMode === "model" && (_jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker })), pickerMode === "provider" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1141
- .filter((p) => isUserVisibleProvider(p.id))
1142
- .map((p) => {
1143
- const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
1144
- const configuredLabel = configured?.apiKey ? "configured" : "needs key";
1145
- return {
1146
- id: p.id,
1147
- name: `${p.name} [${configuredLabel}]`,
1148
- enabled: true,
1149
- };
1150
- }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker })), pickerMode === "provider-add" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1151
- .filter((p) => isUserVisibleProvider(p.id))
1152
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: closePicker, title: "Add Provider" })), pickerMode === "login" && (_jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1153
- .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
1154
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: closePicker, title: "Select Login Provider" })), pickerMode === "logout" && (_jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
1155
- .filter((p) => safeRegistry.getAuthStorage().has(p.id))
1156
- .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: closePicker, title: "Select Logout Provider" })), pickerMode === "key" && keyTarget && (_jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
1157
- closePicker();
1158
- setKeyProviderId(null);
1159
- } })), pickerMode === "skill" && (_jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1160
- fillComposer(`/${name} `);
1161
- closePicker();
1162
- }, onCancel: closePicker })), pickerMode === "feishu-setup" && (_jsx(FeishuSetupPicker, { onComplete: (summary) => {
1163
- closePicker();
1164
- addMessage("assistant", summary);
1165
- }, onCancel: () => {
1166
- closePicker();
1167
- addMessage("assistant", "已取消 Feishu setup。");
1168
- } }))] }), todos.length > 0 && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
1181
+ const showThinkingLabel = getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2
1182
+ && thinkingLevel
1183
+ && thinkingLevel !== "off";
1184
+ const welcomeBannerNode = showWelcome ? (_jsx(WelcomeBanner, { terminalColumns: terminalColumns, tips: buildTips(agent, safeRegistry), updateNotice: updateNotice, cwd: friendlyCwd(args.cwd), providerId: agent.providerId || safeRegistry.getDefault()?.id, modelLabel: agent.model ? displayModel(agent.model) : undefined, thinkingLabel: showThinkingLabel ? thinkingLevel : undefined })) : null;
1185
+ // One row shorter than the terminal on purpose. A frame that exactly fills
1186
+ // the screen makes Ink omit the trailing newline ("fullscreen" mode), and
1187
+ // Ink's cursor-only repositioning (buildReturnToBottom) miscalculates by one
1188
+ // row for such frames the composer cursor lands one row above the prompt
1189
+ // after any cursor-only update (e.g. restoreLastOutput following an external
1190
+ // stdout write). Keeping every frame below viewport height keeps all of
1191
+ // Ink's cursor paths on the consistent trailing-newline math.
1192
+ const frameRows = Math.max(4, terminalRows - 1);
1193
+ return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "column", width: terminalColumns, height: frameRows, children: [_jsx(TranscriptViewport, { ref: viewportRef, children: _jsx(Box, { flexDirection: "column", paddingX: 1, paddingTop: 1, flexShrink: 0, children: _jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode }) }) }), pickerMode === "model" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModelPicker, { registry: safeRegistry, current: agent.model, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker }) })), pickerMode === "provider" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1194
+ .filter((p) => isUserVisibleProvider(p.id))
1195
+ .map((p) => {
1196
+ const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
1197
+ const configuredLabel = configured?.apiKey ? "configured" : "needs key";
1198
+ return {
1199
+ id: p.id,
1200
+ name: `${p.name} [${configuredLabel}]`,
1201
+ enabled: true,
1202
+ };
1203
+ }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker }) })), pickerMode === "provider-add" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1204
+ .filter((p) => isUserVisibleProvider(p.id))
1205
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleProviderAddSelect, onCancel: closePicker, title: "Add Provider" }) })), pickerMode === "login" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1206
+ .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
1207
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLoginProviderSelect, onCancel: closePicker, title: "Select Login Provider" }) })), pickerMode === "logout" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: safeRegistry.getConfigured()
1208
+ .filter((p) => safeRegistry.getAuthStorage().has(p.id))
1209
+ .map((p) => ({ id: p.id, name: p.name, enabled: true })), current: currentProviderId, onSelect: handleLogoutProviderSelect, onCancel: closePicker, title: "Select Logout Provider" }) })), pickerMode === "key" && keyTarget && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(KeyPicker, { providerName: keyTarget.name, onSubmit: handleKeySubmit, onCancel: () => {
1210
+ closePicker();
1211
+ setKeyProviderId(null);
1212
+ } }) })), pickerMode === "skill" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1213
+ fillComposer(`/${name} `);
1214
+ closePicker();
1215
+ }, onCancel: closePicker }) })), pickerMode === "feishu-setup" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeishuSetupPicker, { onComplete: (summary) => {
1216
+ closePicker();
1217
+ addMessage("assistant", summary);
1218
+ }, onCancel: () => {
1219
+ closePicker();
1220
+ addMessage("assistant", "已取消 Feishu setup。");
1221
+ } }) })), todos.length > 0 && !pickerMode && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
1169
1222
  const resolve = pendingPlan.resolve;
1170
1223
  setPendingPlan(null);
1171
1224
  resolve({ action: "approve", plan: finalPlan });
@@ -1192,16 +1245,9 @@ export function App({ agent, args, sessionManager, createProvider, registry, ski
1192
1245
  else if (result.kind === "error") {
1193
1246
  addMessage("error", `Feedback failed: ${result.message}`);
1194
1247
  }
1195
- } }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick }) })), !isExiting && !pickerMode && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit, disabled: isRunning || !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }) })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({
1196
- cwd: args.cwd,
1197
- providerId: agent.providerId || safeRegistry.getDefault()?.id || "unknown",
1198
- model: displayModel(agent.model) || "no model",
1199
- thinkingLevel,
1200
- showThinking: getAvailableThinkingLevels(agent.providerId, agent.apiModel).length > 2,
1201
- mode: permissionMode,
1202
- usageTotals,
1203
- verboseTrace,
1204
- }) }) }))] }) }));
1248
+ } }) })), !isExiting && isRunning && !pickerMode && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, children: _jsx(WaitingIndicator, { tools: streamingTools, hasStreamingText: streamingContent.length > 0, hasStreamingReasoning: streamingReasoning.length > 0, streamedChars: streamingContent.length + streamingReasoning.length, nowTick: nowTick, pendingSteerCount: pendingSteerCount, queuedCount: queuedCount }) })), !isExiting && !pickerMode && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, children: _jsx(InputBox, { onSubmit: handleSubmit, onQueue: isRunning ? queueInput : undefined, onWheelScroll: (direction, lines) => {
1249
+ viewportRef.current?.scrollBy(direction === "up" ? -lines : lines);
1250
+ }, disabled: !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, terminalColumns: terminalColumns, cwd: args.cwd }) })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({ mode: permissionMode }) }) }))] }) }));
1205
1251
  }
1206
1252
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1207
1253
  const GENERIC_PHRASES = [
@@ -1266,7 +1312,7 @@ function formatTokensApprox(chars) {
1266
1312
  return `${(tokens / 1000).toFixed(1)}k`;
1267
1313
  return `${Math.round(tokens / 1000)}k`;
1268
1314
  }
1269
- function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, nowTick, }) {
1315
+ function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, streamedChars, nowTick, pendingSteerCount = 0, queuedCount = 0, }) {
1270
1316
  void nowTick;
1271
1317
  const theme = useTheme();
1272
1318
  const [frameIndex, setFrameIndex] = useState(0);
@@ -1314,5 +1360,13 @@ function WaitingIndicator({ tools, hasStreamingText, hasStreamingReasoning, stre
1314
1360
  phrase = idlePhrase;
1315
1361
  }
1316
1362
  const tokenText = streamedChars > 0 ? `↓${formatTokensApprox(streamedChars)} tok` : "";
1317
- return (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frameIndex] }), _jsxs(Text, { color: theme.muted, children: [" ", phrase, " "] }), _jsxs(Text, { color: theme.muted, dimColor: true, children: ["(", tokenText ? `${tokenText} · ` : "", "esc\u00B7esc to interrupt)"] })] }));
1363
+ const hintParts = [];
1364
+ if (tokenText)
1365
+ hintParts.push(tokenText);
1366
+ if (pendingSteerCount > 0)
1367
+ hintParts.push(`${pendingSteerCount} pending steer${pendingSteerCount === 1 ? "" : "s"}`);
1368
+ if (queuedCount > 0)
1369
+ hintParts.push(`${queuedCount} queued`);
1370
+ hintParts.push("enter steer", "tab queue", "esc stop");
1371
+ return (_jsxs(Box, { children: [_jsx(Text, { color: theme.accent, children: SPINNER_FRAMES[frameIndex] }), _jsxs(Text, { color: theme.muted, children: [" ", phrase, " "] }), _jsxs(Text, { color: theme.muted, dimColor: true, children: ["(", hintParts.join(" · "), ")"] })] }));
1318
1372
  }