@bubblebrain-ai/bubble 0.0.29 → 0.0.31

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,7 +66,6 @@ Bubble ships with a catalog of built-in providers. Configure them inside the app
66
66
  | --- | --- |
67
67
  | `/login` | OAuth sign-in for ChatGPT; unlocks the OpenAI Codex models without an API key. |
68
68
  | `/provider` | Open a picker to connect, switch, add, or remove a provider. |
69
- | `/key <provider> <key>` | Set the API key for a provider. |
70
69
  | `/model` | Pick the active model and reasoning effort. |
71
70
 
72
71
  Built-in providers include OpenAI, Anthropic, Google, DeepSeek, Moonshot (CN and international), Kimi for Coding, Zhipu AI, Z.AI, Alibaba DashScope, Doubao (Volcengine Ark), MiniMax, StepFun, Groq, Together AI, Fireworks, and a `local` profile for any OpenAI-compatible endpoint (Ollama, vLLM, LM Studio, etc.).
@@ -155,7 +154,7 @@ Rules use a simple pattern syntax, for example:
155
154
  | --- | --- |
156
155
  | `/help` | List available commands. |
157
156
  | `/model` | Switch model and reasoning effort. |
158
- | `/provider`, `/login`, `/logout`, `/key` | Connect and manage providers. |
157
+ | `/provider`, `/login`, `/logout` | Connect and manage providers. |
159
158
  | `/session`, `/rewind`, `/clear` | Manage conversation history. |
160
159
  | `/skills` | Open the searchable skills picker. |
161
160
  | `/mcp` | List or reconnect MCP servers. |
@@ -163,7 +162,7 @@ Rules use a simple pattern syntax, for example:
163
162
  | `/permissions` | View or edit allow/deny rules. |
164
163
  | `/context`, `/stats`, `/compact` | Inspect context usage, model stats, and compact the session. |
165
164
  | `/lsp`, `/hooks` | Manage language servers and lifecycle hooks. |
166
- | `/theme`, `/sidebar` | Adjust the interface. |
165
+ | `/theme` | Adjust the interface theme. |
167
166
  | `/feedback` | Send feedback or report a bug. |
168
167
 
169
168
  ## Non-interactive mode
@@ -88,6 +88,13 @@ export class ChildRunner {
88
88
  record.abortController.signal,
89
89
  ]);
90
90
  for await (const event of subAgent.run(input, runCwd, { abortSignal: childAbortSignal, resumeWithoutInput })) {
91
+ if (event.type === "turn_start") {
92
+ // Leftovers here belong to a half-built attempt the agent discarded
93
+ // (stream-interruption retry re-issues the whole request); keeping
94
+ // them would duplicate the retried text in the turn summary.
95
+ turnSummaryBuffer = "";
96
+ turnHadToolCall = false;
97
+ }
91
98
  if (event.type === "text_delta") {
92
99
  turnSummaryBuffer += event.content;
93
100
  }
@@ -213,6 +220,11 @@ export class ChildRunner {
213
220
  let finalHadToolCall = false;
214
221
  const finalAbortSignal = composeAbortSignals([abortSignal, record.abortController.signal]);
215
222
  for await (const event of subAgent.run(prompt, cwd, { abortSignal: finalAbortSignal })) {
223
+ if (event.type === "turn_start") {
224
+ // Discarded stream-interruption attempt — drop its partial text so the
225
+ // retried response doesn't carry a duplicated prefix.
226
+ finalBuffer = "";
227
+ }
216
228
  if (event.type === "text_delta") {
217
229
  finalBuffer += event.content;
218
230
  }
@@ -13,7 +13,12 @@ export function reduceRunState(state, event) {
13
13
  state.updatedAt = Date.now();
14
14
  switch (event.type) {
15
15
  case "turn_start":
16
- // No state change just signals a new LLM round trip.
16
+ // A new LLM round trip. turn_end settles (closes) the blocks of every
17
+ // finished call, so anything still marked streaming here belongs to a
18
+ // half-built attempt the agent discarded (its stream-interruption retry
19
+ // re-issues the whole request). Drop it, or the retry re-streams the
20
+ // same opening text into the block and the card shows it twice.
21
+ state.blocks = state.blocks.filter((block) => !((block.kind === "text" || block.kind === "thinking") && block.streaming));
17
22
  return state;
18
23
  case "text_delta": {
19
24
  const last = state.blocks[state.blocks.length - 1];
@@ -81,6 +86,9 @@ export function reduceRunState(state, event) {
81
86
  return state;
82
87
  }
83
88
  case "turn_end": {
89
+ // Settle this call's output so the turn_start cleanup above can tell
90
+ // kept content (closed here) apart from a discarded retry attempt.
91
+ closeStreamingBlocks(state);
84
92
  if (event.usage) {
85
93
  state.usage = mergeUsage(state.usage, event.usage);
86
94
  }
@@ -14,5 +14,5 @@ export declare function goalSummaryText(goal: GoalState): string;
14
14
  * update_goal tool can't report this — see goal/tools.ts).
15
15
  */
16
16
  export declare function goalCompleteNotice(goal: GoalState): string;
17
- /** Compact single-line indicator for the status line / sidebar. */
17
+ /** Compact single-line indicator for status surfaces. */
18
18
  export declare function goalIndicatorLine(goal: GoalState, maxObjective?: number): string;
@@ -95,7 +95,7 @@ function completionTokenUsagePhrase(goal) {
95
95
  ? `${formatTokensCompact(goal.tokensUsed)}/${formatTokensCompact(goal.tokenBudget)} tok used`
96
96
  : `${formatTokensCompact(goal.tokensUsed)} tok used`;
97
97
  }
98
- /** Compact single-line indicator for the status line / sidebar. */
98
+ /** Compact single-line indicator for status surfaces. */
99
99
  export function goalIndicatorLine(goal, maxObjective = 48) {
100
100
  const segments = [`goal: ${goalStatusLabel(goal.status)}`, `${goal.turnsSpent} turns`];
101
101
  const tokens = tokensPart(goal);
package/dist/main.js CHANGED
@@ -483,9 +483,22 @@ async function main() {
483
483
  console.error(chalk.red("Error: No prompt provided."));
484
484
  process.exit(1);
485
485
  }
486
+ let printedTurnText = false;
486
487
  for await (const event of agent.run(prompt, args.cwd)) {
487
488
  traceEvent("print_agent_event", summarizeAgentEventForTrace(event));
488
- if (event.type === "text_delta") {
489
+ if (event.type === "turn_start") {
490
+ printedTurnText = false;
491
+ }
492
+ else if (event.type === "provider_retry") {
493
+ // The stream died mid-response and the agent re-issues the whole
494
+ // request. Text already on stdout cannot be un-printed, so at least
495
+ // separate the retried response and say what happened.
496
+ if (printedTurnText)
497
+ process.stdout.write("\n");
498
+ console.error(chalk.yellow(`[Stream interrupted; retrying (${event.attempt}/${event.maxAttempts}) — the partial text above is superseded by the retried response]`));
499
+ }
500
+ else if (event.type === "text_delta") {
501
+ printedTurnText = true;
489
502
  process.stdout.write(event.content);
490
503
  }
491
504
  else if (event.type === "tool_start") {
@@ -1,4 +1,4 @@
1
- import { UserConfig, maskKey } from "../config.js";
1
+ import { UserConfig } from "../config.js";
2
2
  import { formatContextUsage } from "../context/usage.js";
3
3
  import { formatDiagnostics } from "../lsp/index.js";
4
4
  import { normalizeNameForMCP } from "../mcp/name.js";
@@ -8,7 +8,6 @@ import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingL
8
8
  import { SessionManager } from "../session.js";
9
9
  import { buildSystemPrompt } from "../system-prompt.js";
10
10
  import { normalizeSingleLine } from "../text-display.js";
11
- import { copyToClipboard } from "../clipboard.js";
12
11
  import { formatRelativeTime } from "../tui/recent-activity.js";
13
12
  import { HOOK_EVENT_NAMES, isHookEventName } from "../hooks/index.js";
14
13
  import { isThinkingLevel } from "../variant/thinking-level.js";
@@ -287,17 +286,6 @@ async function handleMemoryCommand(args, ctx) {
287
286
  }
288
287
  return "Usage: /memory [status|search|compact|summarize|refresh|reset]";
289
288
  }
290
- function parseKeyArgs(args, ctx) {
291
- const trimmed = args.trim();
292
- const [first, ...rest] = trimmed.split(/\s+/);
293
- const explicitProvider = first
294
- ? ctx.registry.getConfigured().find((provider) => provider.id === first)
295
- : undefined;
296
- if (explicitProvider) {
297
- return { provider: explicitProvider, apiKey: rest.join(" ") };
298
- }
299
- return { provider: ctx.registry.getDefault(), apiKey: trimmed };
300
- }
301
289
  const builtinSlashCommandEntries = [
302
290
  {
303
291
  name: "skills",
@@ -306,13 +294,6 @@ const builtinSlashCommandEntries = [
306
294
  ctx.openPicker("skill");
307
295
  },
308
296
  },
309
- {
310
- name: "agents",
311
- description: "Inspect spawned subagents and their working traces (also Ctrl+G)",
312
- async handler(_args, ctx) {
313
- ctx.openPicker("agents");
314
- },
315
- },
316
297
  {
317
298
  name: "help",
318
299
  description: "Show available slash commands",
@@ -370,33 +351,6 @@ const builtinSlashCommandEntries = [
370
351
  return `Theme set to ${arg}${arg === "auto" ? ` (resolved to ${resolved})` : ""}.`;
371
352
  },
372
353
  },
373
- {
374
- name: "sidebar",
375
- description: "Toggle the right sidebar. Usage: /sidebar [open|close|auto]",
376
- async handler(args, ctx) {
377
- if (!ctx.toggleSidebar || !ctx.setSidebarMode) {
378
- return "Sidebar control is only available inside the TUI.";
379
- }
380
- const arg = args.trim().toLowerCase();
381
- if (!arg) {
382
- ctx.toggleSidebar();
383
- return;
384
- }
385
- if (["open", "show", "expand", "expanded", "on"].includes(arg)) {
386
- ctx.setSidebarMode("expanded");
387
- return;
388
- }
389
- if (["close", "hide", "collapse", "collapsed", "off"].includes(arg)) {
390
- ctx.setSidebarMode("collapsed");
391
- return;
392
- }
393
- if (arg === "auto") {
394
- ctx.setSidebarMode("auto");
395
- return;
396
- }
397
- return "Usage: /sidebar [open|close|auto]";
398
- },
399
- },
400
354
  {
401
355
  name: "clear",
402
356
  description: "Clear the current conversation history",
@@ -410,27 +364,6 @@ const builtinSlashCommandEntries = [
410
364
  ctx.clearMessages();
411
365
  },
412
366
  },
413
- {
414
- name: "copy",
415
- description: "Copy the last assistant message to the system clipboard",
416
- async handler(args, ctx) {
417
- const lastAssistant = [...ctx.agent.messages]
418
- .reverse()
419
- .find((m) => m.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0);
420
- if (!lastAssistant || typeof lastAssistant.content !== "string") {
421
- return "No assistant message to copy yet.";
422
- }
423
- const text = lastAssistant.content;
424
- try {
425
- await copyToClipboard(text);
426
- }
427
- catch (err) {
428
- return `Failed to copy to clipboard: ${err?.message || String(err)}`;
429
- }
430
- const chars = text.length;
431
- return `Copied last assistant message to clipboard (${chars} character${chars === 1 ? "" : "s"}).`;
432
- },
433
- },
434
367
  {
435
368
  name: "rewind",
436
369
  description: "Rewind conversation and/or file edits to before an earlier message. Usage: /rewind [n] [--code|--chat]",
@@ -674,31 +607,6 @@ const builtinSlashCommandEntries = [
674
607
  return `Model switched to ${displaySelectedModel(next, ctx.agent.thinking)}.`;
675
608
  },
676
609
  },
677
- {
678
- name: "key",
679
- description: "Set API key for the current or a specific provider. Usage: /key [provider-id] <key>",
680
- async handler(args, ctx) {
681
- if (!args) {
682
- ctx.openPicker("key");
683
- return;
684
- }
685
- const { provider, apiKey } = parseKeyArgs(args, ctx);
686
- if (!provider) {
687
- return "No provider configured. Use /provider --add <id> first.";
688
- }
689
- if (!apiKey) {
690
- return `Usage: /key ${provider.id} <key>`;
691
- }
692
- if (ctx.registry.getModelConfig().hasProvider(provider.id)) {
693
- return `API key for ${provider.name} is managed in ~/.bubble/models.json. Please edit that file directly.`;
694
- }
695
- ctx.registry.updateProviderKey(provider.id, apiKey);
696
- ctx.registry.setDefault(provider.id);
697
- ctx.agent.setProvider(ctx.createProvider(provider.id, apiKey, provider.baseURL));
698
- ctx.agent.providerId = provider.id;
699
- return `API key updated for ${provider.name} to ${maskKey(apiKey)}.`;
700
- },
701
- },
702
610
  {
703
611
  name: "logout",
704
612
  description: "Remove OAuth credentials for a provider. Usage: /logout [openai]",
@@ -739,32 +647,6 @@ const builtinSlashCommandEntries = [
739
647
  : "Exited plan mode.";
740
648
  },
741
649
  },
742
- {
743
- name: "todos",
744
- description: "Show the current todo list. Use /todos clear to reset it.",
745
- async handler(args, ctx) {
746
- const sub = args.trim();
747
- if (sub === "clear") {
748
- const previous = ctx.agent.getTodos().length;
749
- if (previous === 0) {
750
- return "Todo list is already empty.";
751
- }
752
- ctx.agent.setTodos([]);
753
- return `Cleared ${previous} todo item${previous === 1 ? "" : "s"}.`;
754
- }
755
- const todos = ctx.agent.getTodos();
756
- if (todos.length === 0) {
757
- return "No todos yet. The assistant will create some when working on multi-step tasks.";
758
- }
759
- const glyph = (status) => status === "completed" ? "✔" : status === "in_progress" ? "▶" : "○";
760
- const lines = ["Todos:"];
761
- for (const todo of todos) {
762
- const label = todo.status === "in_progress" ? (todo.activeForm || todo.content) : todo.content;
763
- lines.push(` ${glyph(todo.status)} ${label}`);
764
- }
765
- return lines.join("\n");
766
- },
767
- },
768
650
  {
769
651
  name: "permissions",
770
652
  description: "Inspect or edit allow/deny rules. Subcommands: add <scope> <list> <rule>, remove <scope> <list> <rule>, clear (session allowlist), reload.",
@@ -10,7 +10,6 @@ import type { LspService } from "../lsp/index.js";
10
10
  import type { MemoryScope } from "../memory/index.js";
11
11
  import type { ThemeMode } from "../config.js";
12
12
  import type { ExternalHookController } from "../hooks/controller.js";
13
- export type SidebarMode = "auto" | "expanded" | "collapsed";
14
13
  /**
15
14
  * Live progress for a manual `/compact` run, pushed to the TUI so it can render
16
15
  * a progress bar. `phase` advances collecting → summarizing → applying;
@@ -21,11 +20,6 @@ export interface CompactionProgress {
21
20
  phase: "collecting" | "summarizing" | "applying";
22
21
  streamedChars: number;
23
22
  }
24
- export interface SidebarCommandState {
25
- mode: SidebarMode;
26
- visible: boolean;
27
- active: boolean;
28
- }
29
23
  export interface SlashCommandContext {
30
24
  agent: Agent;
31
25
  addMessage: (role: "user" | "assistant" | "error", content: string) => void;
@@ -34,7 +28,7 @@ export interface SlashCommandContext {
34
28
  exit: () => void;
35
29
  sessionManager?: SessionManager;
36
30
  createProvider: (providerId: string, apiKey: string, baseURL: string) => Provider;
37
- openPicker: (mode: "model" | "key" | "provider" | "provider-add" | "login" | "logout" | "skill" | "feishu-setup" | "agents", providerId?: string) => void;
31
+ openPicker: (mode: "model" | "key" | "provider" | "provider-add" | "login" | "logout" | "skill" | "feishu-setup", providerId?: string) => void;
38
32
  registry: ProviderRegistry;
39
33
  skillRegistry: SkillRegistry;
40
34
  bashAllowlist?: BashAllowlist;
@@ -52,10 +46,6 @@ export interface SlashCommandContext {
52
46
  getResolvedTheme?: () => "light" | "dark";
53
47
  /** Persist a new theme mode AND apply it to the running TUI. */
54
48
  setThemeMode?: (mode: ThemeMode) => void;
55
- /** Toggle the right session sidebar in the running TUI. */
56
- toggleSidebar?: () => SidebarCommandState;
57
- /** Set the right session sidebar mode in the running TUI. */
58
- setSidebarMode?: (mode: SidebarMode) => SidebarCommandState;
59
49
  /** Open the feedback dialog. `initialDescription` prefills the description field. */
60
50
  openFeedback?: (initialDescription: string) => void;
61
51
  /** Open the interactive rewind picker. When absent, /rewind falls back to a text listing. */
@@ -60,26 +60,8 @@ export interface ExitSummary {
60
60
  wallMs: number;
61
61
  }
62
62
  export declare const INK_LOCAL_SLASH_COMMANDS: readonly [{
63
- readonly name: "thinking";
64
- readonly description: "Toggle thinking block visibility";
65
- }, {
66
- readonly name: "toggle-thinking";
67
- readonly description: "Toggle thinking block visibility";
68
- }, {
69
63
  readonly name: "goal";
70
64
  readonly description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)";
71
- }, {
72
- readonly name: "trace";
73
- readonly description: "Toggle verbose trace output";
74
- }, {
75
- readonly name: "verbose";
76
- readonly description: "Toggle verbose trace output";
77
- }, {
78
- readonly name: "debug";
79
- readonly description: "Toggle verbose trace output";
80
- }, {
81
- readonly name: "write-previews";
82
- readonly description: "Toggle write preview expansion";
83
65
  }];
84
66
  export declare function App({ agent, args, sessionManager: initialSessionManager, switchSession, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, goalStore, bypassEnabled, updateNotice, updateNoticeRefresh, hookController, onExit }: AppProps): import("react/jsx-runtime").JSX.Element;
85
67
  export {};
@@ -6,7 +6,7 @@ import { isHiddenToolMetadata } from "../agent/discovery-barrier.js";
6
6
  import { SessionManager } from "../session.js";
7
7
  import { registry as slashRegistry } from "../slash-commands/index.js";
8
8
  import { UserConfig, maskKey } from "../config.js";
9
- import { InputBox, isCtrlCInput, } from "./input-box.js";
9
+ import { InputBox, isCtrlCInput, isCtrlLetterInput, } from "./input-box.js";
10
10
  import { MessageList } from "./message-list.js";
11
11
  import { isMultiplexedTerminal } from "./terminal-env.js";
12
12
  import { appendTextPart, appendToolPart, compactDisplayMessages, contentFromParts, latestCompactionSummary, moveStatusMessageToEnd, nextDisplayMessageKey, setUserInputStatus, snapshotDisplayParts, stripInterruptedAssistantMarker, toolCallsFromParts, } from "./display-history.js";
@@ -227,34 +227,10 @@ function withMessageKey(message) {
227
227
  // 40ms keeps perceived latency invisible while capping layout work at 25fps.
228
228
  const STREAMING_FLUSH_INTERVAL_MS = 40;
229
229
  export const INK_LOCAL_SLASH_COMMANDS = [
230
- {
231
- name: "thinking",
232
- description: "Toggle thinking block visibility",
233
- },
234
- {
235
- name: "toggle-thinking",
236
- description: "Toggle thinking block visibility",
237
- },
238
230
  {
239
231
  name: "goal",
240
232
  description: "Set/manage an autonomous goal (/goal <objective>|clear|pause|resume|edit)",
241
233
  },
242
- {
243
- name: "trace",
244
- description: "Toggle verbose trace output",
245
- },
246
- {
247
- name: "verbose",
248
- description: "Toggle verbose trace output",
249
- },
250
- {
251
- name: "debug",
252
- description: "Toggle verbose trace output",
253
- },
254
- {
255
- name: "write-previews",
256
- description: "Toggle write preview expansion",
257
- },
258
234
  ];
259
235
  export function App({ agent, args, sessionManager: initialSessionManager, switchSession, createProvider, registry, skillRegistry, planHandlerRef, approvalHandlerRef, questionController, bashAllowlist, settingsManager, lspService, mcpManager, themeMode: initialThemeMode, themeOverrides, detectedTheme, onThemeModeChange, flushMemory, runMemoryCompaction, runMemorySummary, runMemoryRefresh, goalStore, bypassEnabled, updateNotice, updateNoticeRefresh, hookController, onExit }) {
260
236
  const [sessionManager, setSessionManager] = useState(initialSessionManager);
@@ -281,8 +257,8 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
281
257
  const [streamingReasoning, setStreamingReasoning] = useState("");
282
258
  const [streamingTools, setStreamingTools] = useState([]);
283
259
  const [streamingParts, setStreamingParts] = useState([]);
284
- // Live subagent groups for the Ctrl+G inspector; recomputed each render so it
285
- // reflects members as their events stream into the transcript.
260
+ // Live subagent groups for the inspector opened from the subagent entry line;
261
+ // recomputed each render so it reflects members as their events stream into the transcript.
286
262
  const subagentGroups = useMemo(() => collectSubagentGroups(messages, streamingTools), [messages, streamingTools]);
287
263
  const subagentMembers = useMemo(() => subagentGroups.flatMap((g) => g.members), [subagentGroups]);
288
264
  // Down-arrow from the composer focuses the subagent entry line; Enter then
@@ -320,9 +296,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
320
296
  const [composerDraft, setComposerDraft] = useState(null);
321
297
  const [keyProviderId, setKeyProviderId] = useState(null);
322
298
  const [showThinking, setShowThinking] = useState(false);
323
- const [expandedToolOutput, setExpandedToolOutput] = useState(false);
324
299
  const [verboseTrace, setVerboseTrace] = useState(false);
325
- const [sidebarMode, setSidebarMode] = useState("collapsed");
326
300
  const startedWithVisibleHistoryRef = useRef(messages.some((message) => message.syntheticKind !== "ui_summary"));
327
301
  const { columns: terminalColumns, rows: terminalRows } = useTerminalSize();
328
302
  const showWelcome = shouldShowWelcomeBanner({
@@ -338,6 +312,12 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
338
312
  // MessageList so Ink discards its already-printed rows and re-prints the
339
313
  // rebuilt list onto a freshly-cleared screen instead of appending duplicates.
340
314
  const [staticGeneration, setStaticGeneration] = useState(0);
315
+ const reprintTranscript = useCallback(() => {
316
+ if (process.stdout.isTTY) {
317
+ process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
318
+ }
319
+ setStaticGeneration((generation) => generation + 1);
320
+ }, []);
341
321
  // Steer/queue while the agent runs:
342
322
  // Enter steers the current run via the agent's input controller; Tab (or an
343
323
  // ineligible input) queues for the next turn. Both render placeholder user
@@ -567,7 +547,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
567
547
  setSubagentEntryFocused(false);
568
548
  return;
569
549
  }
570
- if (key.ctrl && input.toLowerCase() === "p" && !pickerMode && !activeAbortRef.current) {
550
+ if (isCtrlLetterInput(input, key, "p") && !pickerMode && !activeAbortRef.current) {
571
551
  setStatsPanel(null);
572
552
  setPickerMode("slash");
573
553
  return;
@@ -582,7 +562,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
582
562
  }
583
563
  return;
584
564
  }
585
- if (key.ctrl && input.toLowerCase() === "t" && !pickerMode) {
565
+ if (isCtrlLetterInput(input, key, "t") && !pickerMode) {
586
566
  setShowThinking((current) => {
587
567
  const next = !current;
588
568
  addMessage("assistant", next ? "Thinking blocks visible" : "Thinking blocks hidden");
@@ -590,12 +570,13 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
590
570
  });
591
571
  return;
592
572
  }
593
- if (key.ctrl && input === "o" && !pickerMode) {
573
+ if (isCtrlLetterInput(input, key, "o") && !pickerMode) {
594
574
  setVerboseTrace((v) => !v);
575
+ reprintTranscript();
595
576
  return;
596
577
  }
597
578
  // Ctrl+R: cycle thinking level (formerly Shift+Tab)
598
- if (key.ctrl && input === "r" && !pickerMode) {
579
+ if (isCtrlLetterInput(input, key, "r") && !pickerMode) {
599
580
  const modelParts = agent.model.includes(":")
600
581
  ? agent.model.split(":")
601
582
  : [agent.providerId || safeRegistry.getDefault()?.id || "openai", agent.model];
@@ -639,12 +620,9 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
639
620
  // un-printed, so we wipe the screen + scrollback and bump the Static key:
640
621
  // Ink then re-prints the rebuilt list fresh instead of appending duplicates.
641
622
  const resetTranscript = useCallback((updater) => {
642
- if (process.stdout.isTTY) {
643
- process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
644
- }
645
- setStaticGeneration((generation) => generation + 1);
623
+ reprintTranscript();
646
624
  updateDisplayMessages(updater);
647
- }, [updateDisplayMessages]);
625
+ }, [reprintTranscript, updateDisplayMessages]);
648
626
  const addMessage = useCallback((role, content) => {
649
627
  updateDisplayMessages((prev) => [...prev, withMessageKey({ role, content })]);
650
628
  }, [updateDisplayMessages]);
@@ -759,21 +737,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
759
737
  const { description: _drop, ...rest } = base;
760
738
  setPendingFeedback({ base: rest, initialDescription });
761
739
  }, [agent]);
762
- const sidebarFits = terminalColumns > 120;
763
- const sidebarVisible = sidebarMode === "expanded" ? sidebarFits : sidebarMode === "auto" && sidebarFits;
764
- const currentSidebarCommandState = useCallback((mode = sidebarMode) => {
765
- const visible = mode === "expanded" ? sidebarFits : mode === "auto" && sidebarFits;
766
- return { mode, visible, active: visible };
767
- }, [sidebarFits, sidebarMode]);
768
- const toggleSidebar = useCallback(() => {
769
- const next = sidebarVisible ? "collapsed" : "expanded";
770
- setSidebarMode(next);
771
- return currentSidebarCommandState(next);
772
- }, [currentSidebarCommandState, sidebarVisible]);
773
- const applySidebarMode = useCallback((mode) => {
774
- setSidebarMode(mode);
775
- return currentSidebarCommandState(mode);
776
- }, [currentSidebarCommandState]);
777
740
  const openSessionPicker = useCallback(() => {
778
741
  if (activeAbortRef.current) {
779
742
  addMessage("error", "Stop the current run before switching sessions.");
@@ -1162,6 +1125,16 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1162
1125
  inputController,
1163
1126
  })) {
1164
1127
  switch (event.type) {
1128
+ case "turn_start":
1129
+ // A fresh provider call is starting. Everything worth keeping
1130
+ // was committed at the preceding turn_end, so leftovers here
1131
+ // can only be a half-built attempt the agent discarded (its
1132
+ // stream-interruption retry re-issues the whole request and
1133
+ // never appends the partial message — see agent.ts). Drop the
1134
+ // stale buffer, or the retry re-streams the same opening text
1135
+ // on top of it and the answer duplicates on screen.
1136
+ clearAssistantStream();
1137
+ break;
1165
1138
  case "text_delta":
1166
1139
  assistantContent += event.content;
1167
1140
  appendTextPart(assistantParts, event.content);
@@ -1553,30 +1526,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1553
1526
  requestExit();
1554
1527
  return;
1555
1528
  }
1556
- if (/^\/(?:thinking|toggle-thinking)(?:\s|$)/.test(input.trim())) {
1557
- setShowThinking((current) => {
1558
- const next = !current;
1559
- addMessage("assistant", next ? "Thinking blocks visible" : "Thinking blocks hidden");
1560
- return next;
1561
- });
1562
- return;
1563
- }
1564
- if (/^\/(?:trace|verbose|debug)(?:\s|$)/.test(input.trim())) {
1565
- setVerboseTrace((current) => {
1566
- const next = !current;
1567
- addMessage("assistant", next ? "Verbose trace visible" : "Compact trace visible");
1568
- return next;
1569
- });
1570
- return;
1571
- }
1572
- if (/^\/write-previews(?:\s|$)/.test(input.trim())) {
1573
- setExpandedToolOutput((current) => {
1574
- const next = !current;
1575
- addMessage("assistant", next ? "Write previews expanded" : "Write previews collapsed");
1576
- return next;
1577
- });
1578
- return;
1579
- }
1580
1529
  if (/^\/goal(?:\s|$)/.test(input.trim())) {
1581
1530
  await handleGoalCommand(input);
1582
1531
  return;
@@ -1615,8 +1564,6 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1615
1564
  getThemeMode: () => themeMode,
1616
1565
  getResolvedTheme: () => themeResolved,
1617
1566
  setThemeMode: applyThemeMode,
1618
- toggleSidebar,
1619
- setSidebarMode: applySidebarMode,
1620
1567
  openStats: openStatsPanel,
1621
1568
  compactionProgress: setCompaction,
1622
1569
  });
@@ -1682,7 +1629,7 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1682
1629
  setStartingSubmit(null);
1683
1630
  }
1684
1631
  }
1685
- }, [addMessage, agent, args.cwd, openPicker, openSessionPicker, openRewindPicker, openStatsPanel, createProvider, currentSessionFile, fillComposer, prepareSubmitDisplay, safeRegistry, safeSkillRegistry, updateDisplayMessages, queueInput, submitSteer, requestExit, toggleSidebar, applySidebarMode, setStartingSubmit]);
1632
+ }, [addMessage, agent, args.cwd, openPicker, openSessionPicker, openRewindPicker, openStatsPanel, createProvider, currentSessionFile, fillComposer, prepareSubmitDisplay, safeRegistry, safeSkillRegistry, updateDisplayMessages, queueInput, submitSteer, requestExit, setStartingSubmit]);
1686
1633
  // Drain the queue once the run ends and no modal needs the user first.
1687
1634
  // The placeholder row is removed right before resubmitting — handleSubmit
1688
1635
  // renders the message again as a regular user row.
@@ -1747,81 +1694,80 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1747
1694
  // tail, pickers, composer, footer) occupies the live region. Letting it size
1748
1695
  // to its content keeps the composer pinned just below the latest output the
1749
1696
  // way ordinary shell programs do.
1750
- const sidebarWidth = sidebarVisible ? Math.min(42, Math.max(28, Math.floor(terminalColumns * 0.34))) : 0;
1751
- const mainWidth = Math.max(40, terminalColumns - sidebarWidth);
1752
- return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "row", width: terminalColumns, backgroundColor: palette.background, children: [_jsxs(Box, { flexDirection: "column", width: mainWidth, backgroundColor: palette.background, children: [_jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: mainWidth, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode, staticGeneration: staticGeneration, paddingX: 1, maxStreamRows: Math.max(6, terminalRows - 10) }), pickerMode === "model" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModelPicker, { registry: safeRegistry, current: agent.model, currentThinkingLevel: thinkingLevel, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker }) })), pickerMode === "provider" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1753
- .filter((p) => isUserVisibleProvider(p.id))
1754
- .map((p) => {
1755
- const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
1756
- const configuredLabel = configured?.apiKey ? "configured" : "needs key";
1757
- return {
1758
- id: p.id,
1759
- name: `${p.name} [${configuredLabel}]`,
1760
- enabled: true,
1761
- };
1762
- }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker }) })), pickerMode === "provider-add" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1763
- .filter((p) => isUserVisibleProvider(p.id))
1764
- .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
1765
- .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
1766
- .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()
1767
- .filter((p) => safeRegistry.getAuthStorage().has(p.id))
1768
- .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: () => {
1769
- closePicker();
1770
- setKeyProviderId(null);
1771
- } }) })), pickerMode === "skill" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1772
- fillComposer(`/${name} `);
1773
- closePicker();
1774
- }, onCancel: closePicker }) })), pickerMode === "slash" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(CommandPalette, { items: commandPaletteItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
1775
- closePicker();
1776
- if (item.action === "insert-skill") {
1777
- fillComposer(`/${item.value} `);
1778
- }
1779
- else {
1780
- void handleSubmit(item.command);
1781
- }
1782
- }, onCancel: closePicker }) })), pickerMode === "mcp-reconnect" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(McpReconnectPicker, { items: mcpReconnectItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
1783
- closePicker();
1784
- void handleSubmit(item.command);
1785
- }, onCancel: closePicker }) })), pickerMode === "session" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SessionPicker, { currentCwd: args.cwd, currentSessions: SessionManager.summarizeSessionsForCwd(args.cwd), allSessions: SessionManager.listAllSessions(), onSelect: handleSessionSelect, onCancel: closePicker }) })), pickerMode === "agents" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SubagentInspector, { groups: subagentGroups, onCancel: closePicker }) })), pickerMode === "rewind" && sessionManager && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(RewindPicker, { sessionManager: sessionManager, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (command) => {
1786
- closePicker();
1787
- void handleSubmit(command);
1788
- }, onCancel: closePicker }) })), pickerMode === "feishu-setup" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeishuSetupPicker, { onComplete: (summary) => {
1789
- closePicker();
1790
- addMessage("assistant", summary);
1791
- }, onCancel: () => {
1792
- closePicker();
1793
- addMessage("assistant", "已取消 Feishu setup。");
1794
- } }) })), statsPanel && !pickerMode && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(StatsPanel, { panel: statsPanel, terminalColumns: mainWidth, terminalRows: terminalRows, onRangeChange: (range) => setStatsPanel((current) => current ? { ...current, range } : current), onCancel: closeStatsPanel }) })), todos.length > 0 && !pickerMode && !statsPanel && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !statsPanel && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
1795
- const resolve = pendingPlan.resolve;
1796
- setPendingPlan(null);
1797
- resolve({ action: "approve", plan: finalPlan });
1798
- }, onReject: (reason) => {
1799
- const resolve = pendingPlan.resolve;
1800
- setPendingPlan(null);
1801
- resolve({ action: "reject", reason });
1802
- } }) })), pendingApproval && !pickerMode && !statsPanel && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ApprovalDialog, { request: pendingApproval.request, onDecision: (decision) => {
1803
- const resolve = pendingApproval.resolve;
1804
- setPendingApproval(null);
1805
- resolve(decision);
1806
- }, onAllowBashPrefix: (prefix) => {
1807
- bashAllowlist?.add(prefix);
1808
- } }) })), pendingQuestion && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingFeedback && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(QuestionDialog, { request: pendingQuestion, onSubmit: (answers) => {
1809
- questionController?.reply(pendingQuestion.id, answers);
1810
- setPendingQuestion(null);
1811
- }, onCancel: () => {
1812
- questionController?.reject(pendingQuestion.id);
1813
- setPendingQuestion(null);
1814
- } }) })), pendingFeedback && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeedbackDialog, { base: pendingFeedback.base, initialDescription: pendingFeedback.initialDescription, onDismiss: () => setPendingFeedback(null), onResult: (result) => {
1815
- if (result.kind === "success") {
1816
- addMessage("assistant", `Feedback submitted: ${result.url}`);
1817
- }
1818
- else if (result.kind === "error") {
1819
- addMessage("error", `Feedback failed: ${result.message}`);
1820
- }
1821
- } }) })), !isExiting && compaction && (_jsx(Box, { flexShrink: 0, backgroundColor: palette.background, children: _jsx(CompactionProgressCard, { progress: compaction }) })), !isExiting && isRunning && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, backgroundColor: palette.background, 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 && !statsPanel && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, backgroundColor: palette.background, children: _jsx(InputBox, { onSubmit: handleSubmit, onQueue: isRunning ? queueInput : undefined, onArrowDownAtBottom: () => {
1822
- if (subagentMembers.length > 0 && !pickerMode)
1823
- setSubagentEntryFocused(true);
1824
- }, disabled: !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback || !!statsPanel || subagentEntryFocused, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, localSlashCommands: [...INK_LOCAL_SLASH_COMMANDS], terminalColumns: mainWidth, cwd: args.cwd, sessionFile: currentSessionFile(), nextImageLabelStart: nextImageDisplayLabelStartRef.current }) })), !isExiting && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && subagentMembers.length > 0 && (_jsxs(Box, { paddingX: 1, flexShrink: 0, backgroundColor: palette.background, children: [_jsx(Text, { bold: subagentEntryFocused, color: subagentEntryFocused ? palette.accent : palette.toolName, children: subagentEntryFocused ? "> ↳ " : " ↳ " }), _jsxs(Text, { color: subagentEntryFocused ? palette.accent : palette.muted, children: [subagentMembers.length, " subagent", subagentMembers.length === 1 ? "" : "s", " \u00B7 ", subagentSummary(subagentMembers), " \u00B7 "] }), _jsx(Text, { color: palette.accent, children: subagentEntryFocused ? "Enter open · Esc back" : "↓ to inspect traces" })] })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({ mode: permissionMode, goalLine }) }) }))] }), sidebarVisible && (_jsx(InkSidebar, { width: sidebarWidth, agent: agent, sessionManager: sessionManager, cwd: args.cwd, mode: permissionMode, goalLine: goalLine, todos: todos, mcpManager: mcpManager, lspService: lspService }))] }) }));
1697
+ const mainWidth = Math.max(40, terminalColumns);
1698
+ return (_jsx(ThemeProvider, { value: palette, children: _jsxs(Box, { flexDirection: "column", width: mainWidth, backgroundColor: palette.background, children: [_jsx(MessageList, { messages: messages, streamingContent: streamingContent, streamingReasoning: streamingReasoning, streamingTools: streamingTools, streamingParts: streamingParts, terminalColumns: mainWidth, showThinking: showThinking, verboseTrace: verboseTrace, pendingApproval: approvalHint, nowTick: nowTick, welcomeBanner: welcomeBannerNode, staticGeneration: staticGeneration, paddingX: 1, maxStreamRows: Math.max(6, terminalRows - 10) }), pickerMode === "model" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ModelPicker, { registry: safeRegistry, current: agent.model, currentThinkingLevel: thinkingLevel, recent: userConfig.getRecentModels(), onSelect: handleModelSelect, onCancel: closePicker }) })), pickerMode === "provider" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1699
+ .filter((p) => isUserVisibleProvider(p.id))
1700
+ .map((p) => {
1701
+ const configured = safeRegistry.getConfigured().find((item) => item.id === p.id);
1702
+ const configuredLabel = configured?.apiKey ? "configured" : "needs key";
1703
+ return {
1704
+ id: p.id,
1705
+ name: `${p.name} [${configuredLabel}]`,
1706
+ enabled: true,
1707
+ };
1708
+ }), current: currentProviderId, onSelect: handleProviderSelect, onCancel: closePicker }) })), pickerMode === "provider-add" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ProviderPicker, { providers: BUILTIN_PROVIDERS
1709
+ .filter((p) => isUserVisibleProvider(p.id))
1710
+ .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
1711
+ .filter((p) => isUserVisibleProvider(p.id) && safeRegistry.supportsOAuth(p.id))
1712
+ .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()
1713
+ .filter((p) => safeRegistry.getAuthStorage().has(p.id))
1714
+ .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: () => {
1715
+ closePicker();
1716
+ setKeyProviderId(null);
1717
+ } }) })), pickerMode === "skill" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SkillPicker, { skills: safeSkillRegistry.summaries(), onSelect: (name) => {
1718
+ fillComposer(`/${name} `);
1719
+ closePicker();
1720
+ }, onCancel: closePicker }) })), pickerMode === "slash" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(CommandPalette, { items: commandPaletteItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
1721
+ closePicker();
1722
+ if (item.action === "insert-skill") {
1723
+ fillComposer(`/${item.value} `);
1724
+ }
1725
+ else {
1726
+ void handleSubmit(item.command);
1727
+ }
1728
+ }, onCancel: closePicker }) })), pickerMode === "mcp-reconnect" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(McpReconnectPicker, { items: mcpReconnectItems, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (item) => {
1729
+ closePicker();
1730
+ void handleSubmit(item.command);
1731
+ }, onCancel: closePicker }) })), pickerMode === "session" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SessionPicker, { currentCwd: args.cwd, currentSessions: SessionManager.summarizeSessionsForCwd(args.cwd), allSessions: SessionManager.listAllSessions(), onSelect: handleSessionSelect, onCancel: closePicker }) })), pickerMode === "agents" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(SubagentInspector, { groups: subagentGroups, onCancel: closePicker }) })), pickerMode === "rewind" && sessionManager && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(RewindPicker, { sessionManager: sessionManager, terminalColumns: mainWidth, terminalRows: terminalRows, onSelect: (command) => {
1732
+ closePicker();
1733
+ void handleSubmit(command);
1734
+ }, onCancel: closePicker }) })), pickerMode === "feishu-setup" && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeishuSetupPicker, { onComplete: (summary) => {
1735
+ closePicker();
1736
+ addMessage("assistant", summary);
1737
+ }, onCancel: () => {
1738
+ closePicker();
1739
+ addMessage("assistant", "已取消 Feishu setup。");
1740
+ } }) })), statsPanel && !pickerMode && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(StatsPanel, { panel: statsPanel, terminalColumns: mainWidth, terminalRows: terminalRows, onRangeChange: (range) => setStatsPanel((current) => current ? { ...current, range } : current), onCancel: closeStatsPanel }) })), todos.length > 0 && !pickerMode && !statsPanel && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(TodosPanel, { todos: todos, terminalColumns: terminalColumns }) })), pendingPlan && !pickerMode && !statsPanel && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(PlanConfirm, { initialPlan: pendingPlan.plan, onApprove: (finalPlan) => {
1741
+ const resolve = pendingPlan.resolve;
1742
+ setPendingPlan(null);
1743
+ resolve({ action: "approve", plan: finalPlan });
1744
+ }, onReject: (reason) => {
1745
+ const resolve = pendingPlan.resolve;
1746
+ setPendingPlan(null);
1747
+ resolve({ action: "reject", reason });
1748
+ } }) })), pendingApproval && !pickerMode && !statsPanel && !pendingPlan && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(ApprovalDialog, { request: pendingApproval.request, onDecision: (decision) => {
1749
+ const resolve = pendingApproval.resolve;
1750
+ setPendingApproval(null);
1751
+ resolve(decision);
1752
+ }, onAllowBashPrefix: (prefix) => {
1753
+ bashAllowlist?.add(prefix);
1754
+ } }) })), pendingQuestion && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingFeedback && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(QuestionDialog, { request: pendingQuestion, onSubmit: (answers) => {
1755
+ questionController?.reply(pendingQuestion.id, answers);
1756
+ setPendingQuestion(null);
1757
+ }, onCancel: () => {
1758
+ questionController?.reject(pendingQuestion.id);
1759
+ setPendingQuestion(null);
1760
+ } }) })), pendingFeedback && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && (_jsx(Box, { paddingX: 1, flexShrink: 0, children: _jsx(FeedbackDialog, { base: pendingFeedback.base, initialDescription: pendingFeedback.initialDescription, onDismiss: () => setPendingFeedback(null), onResult: (result) => {
1761
+ if (result.kind === "success") {
1762
+ addMessage("assistant", `Feedback submitted: ${result.url}`);
1763
+ }
1764
+ else if (result.kind === "error") {
1765
+ addMessage("error", `Feedback failed: ${result.message}`);
1766
+ }
1767
+ } }) })), !isExiting && compaction && (_jsx(Box, { flexShrink: 0, backgroundColor: palette.background, children: _jsx(CompactionProgressCard, { progress: compaction }) })), !isExiting && isRunning && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && (_jsx(Box, { paddingX: 1, paddingBottom: 1, flexShrink: 0, backgroundColor: palette.background, 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 && !statsPanel && (_jsx(Box, { paddingBottom: 1, flexShrink: 0, backgroundColor: palette.background, children: _jsx(InputBox, { onSubmit: handleSubmit, onQueue: isRunning ? queueInput : undefined, onArrowDownAtBottom: () => {
1768
+ if (subagentMembers.length > 0 && !pickerMode)
1769
+ setSubagentEntryFocused(true);
1770
+ }, disabled: !!pendingPlan || !!pendingApproval || !!pendingQuestion || !!pendingFeedback || !!statsPanel || subagentEntryFocused, cursorResetEpoch: cursorResetEpoch, draftText: composerDraft?.text, draftEpoch: composerDraft?.epoch, onDraftApplied: clearComposerDraft, skillRegistry: safeSkillRegistry, localSlashCommands: [...INK_LOCAL_SLASH_COMMANDS], terminalColumns: mainWidth, cwd: args.cwd, sessionFile: currentSessionFile(), nextImageLabelStart: nextImageDisplayLabelStartRef.current }) })), !isExiting && !pickerMode && !statsPanel && !pendingPlan && !pendingApproval && !pendingQuestion && !pendingFeedback && subagentMembers.length > 0 && (_jsxs(Box, { paddingX: 1, flexShrink: 0, backgroundColor: palette.background, children: [_jsx(Text, { bold: subagentEntryFocused, color: subagentEntryFocused ? palette.accent : palette.toolName, children: subagentEntryFocused ? "> ↳ " : " ↳ " }), _jsxs(Text, { color: subagentEntryFocused ? palette.accent : palette.muted, children: [subagentMembers.length, " subagent", subagentMembers.length === 1 ? "" : "s", " \u00B7 ", subagentSummary(subagentMembers), " \u00B7 "] }), _jsx(Text, { color: palette.accent, children: subagentEntryFocused ? "Enter open · Esc back" : "↓ to inspect traces" })] })), !isExiting && (_jsx(Box, { flexShrink: 0, children: _jsx(FooterBar, { data: buildFooterData({ mode: permissionMode, goalLine }) }) }))] }) }));
1825
1771
  }
1826
1772
  function buildCommandPaletteItems(skillRegistry) {
1827
1773
  const items = new Map();
@@ -2109,82 +2055,6 @@ function StatsPanel({ panel, terminalColumns, terminalRows, onRangeChange, onCan
2109
2055
  return (_jsx(Text, { color: heading ? theme.accent : undefined, bold: heading, children: line || " " }, key));
2110
2056
  }) }), maxScroll > 0 && (_jsxs(Text, { color: theme.muted, children: [scroll + 1, "-", Math.min(lines.length, scroll + maxVisible), " of ", lines.length] }))] }));
2111
2057
  }
2112
- function summarizeMcpStates(states) {
2113
- const summary = { connected: 0, starting: 0, failed: 0, disabled: 0, tools: 0 };
2114
- for (const state of states) {
2115
- if (state.status.kind === "connected") {
2116
- summary.connected += 1;
2117
- summary.tools += state.status.tools.length;
2118
- }
2119
- else if (state.status.kind === "failed") {
2120
- summary.failed += 1;
2121
- }
2122
- else {
2123
- summary.disabled += 1;
2124
- }
2125
- }
2126
- return summary;
2127
- }
2128
- function summarizeLspStatuses(statuses) {
2129
- const summary = { connected: 0, starting: 0, failed: 0, disabled: 0 };
2130
- for (const status of statuses) {
2131
- if (status.status === "connected")
2132
- summary.connected += 1;
2133
- else if (status.status === "starting")
2134
- summary.starting += 1;
2135
- else
2136
- summary.failed += 1;
2137
- }
2138
- return summary;
2139
- }
2140
- function formatStatusCount(summary) {
2141
- const parts = [];
2142
- if (summary.connected > 0)
2143
- parts.push(`${summary.connected} up`);
2144
- if (summary.starting > 0)
2145
- parts.push(`${summary.starting} starting`);
2146
- if (summary.failed > 0)
2147
- parts.push(`${summary.failed} failed`);
2148
- if (summary.disabled > 0)
2149
- parts.push(`${summary.disabled} disabled`);
2150
- return parts.join(" · ") || "none";
2151
- }
2152
- function SidebarSection({ title, children }) {
2153
- const theme = useTheme();
2154
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { color: theme.accent, bold: true, children: title }), children] }));
2155
- }
2156
- function SidebarRow({ label, value, color, }) {
2157
- const theme = useTheme();
2158
- return (_jsxs(Box, { children: [_jsxs(Text, { color: theme.muted, children: [label, ": "] }), _jsx(Text, { color: color ?? theme.userMessageText, children: value })] }));
2159
- }
2160
- function InkSidebar({ width, agent, sessionManager, cwd, mode, goalLine, todos, mcpManager, lspService, }) {
2161
- const theme = useTheme();
2162
- const innerWidth = Math.max(12, width - 4);
2163
- const todoCounts = todos.reduce((acc, todo) => {
2164
- acc[todo.status] = (acc[todo.status] ?? 0) + 1;
2165
- return acc;
2166
- }, {});
2167
- const todoSummary = todos.length === 0
2168
- ? "none"
2169
- : [
2170
- todoCounts.in_progress ? `${todoCounts.in_progress} active` : "",
2171
- todoCounts.pending ? `${todoCounts.pending} pending` : "",
2172
- todoCounts.completed ? `${todoCounts.completed} done` : "",
2173
- ].filter(Boolean).join(" · ");
2174
- const mcpStates = mcpManager?.getStates() ?? [];
2175
- const mcpSummary = summarizeMcpStates(mcpStates);
2176
- const lspSummary = lspService?.isDisabled()
2177
- ? { connected: 0, starting: 0, failed: 0, disabled: 1 }
2178
- : summarizeLspStatuses(lspService?.status() ?? []);
2179
- const latestMcpFailure = mcpStates.find((state) => state.status.kind === "failed");
2180
- const latestLspFailure = lspService?.status().find((status) => status.status === "error");
2181
- const sessionTitle = truncate(sessionDisplayName(sessionManager), innerWidth);
2182
- const modelLabel = agent.model ? displayModel(agent.model) : "not selected";
2183
- const route = agent.providerId
2184
- ? `${agent.providerId}/${modelLabel}`
2185
- : modelLabel;
2186
- return (_jsxs(Box, { flexDirection: "column", width: width, height: "100%", borderStyle: "single", borderColor: theme.border, paddingX: 1, paddingY: 1, flexShrink: 0, children: [_jsx(Text, { color: theme.borderActive, bold: true, children: "Session" }), _jsx(Text, { color: theme.userMessageText, children: sessionTitle }), _jsx(Text, { color: theme.muted, children: truncate(friendlyCwd(cwd), innerWidth) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(SidebarSection, { title: "Runtime", children: [_jsx(SidebarRow, { label: "model", value: truncate(route, innerWidth - 7) }), _jsx(SidebarRow, { label: "mode", value: mode, color: mode === "bypassPermissions" ? theme.warning : theme.userMessageText }), _jsx(SidebarRow, { label: "thinking", value: agent.thinking || "off" })] }), goalLine && (_jsx(SidebarSection, { title: "Goal", children: _jsx(Text, { color: theme.userMessageText, children: truncate(goalLine, innerWidth) }) })), _jsx(SidebarSection, { title: "Todos", children: _jsx(Text, { color: todos.length > 0 ? theme.userMessageText : theme.muted, children: truncate(todoSummary, innerWidth) }) }), _jsxs(SidebarSection, { title: "MCP", children: [_jsx(Text, { color: mcpSummary.failed > 0 ? theme.warning : theme.userMessageText, children: truncate(`${formatStatusCount(mcpSummary)}${mcpSummary.tools > 0 ? ` · ${mcpSummary.tools} tools` : ""}`, innerWidth) }), latestMcpFailure?.status.kind === "failed" && (_jsx(Text, { color: theme.muted, children: truncate(latestMcpFailure.status.error, innerWidth) }))] }), _jsxs(SidebarSection, { title: "LSP", children: [_jsx(Text, { color: lspSummary.failed > 0 ? theme.warning : theme.userMessageText, children: truncate(formatStatusCount(lspSummary), innerWidth) }), latestLspFailure?.message && (_jsx(Text, { color: theme.muted, children: truncate(latestLspFailure.message, innerWidth) }))] })] })] }));
2187
- }
2188
2058
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
2189
2059
  const GENERIC_PHRASES = [
2190
2060
  "mapping the workspace",
@@ -47,6 +47,9 @@ export declare function resolveCursorRowCompensation(input: {
47
47
  viewportRows: number;
48
48
  previousOutputHeight: number | null;
49
49
  }): number;
50
+ export declare function isCtrlLetterInput(input: string, key: {
51
+ ctrl?: boolean;
52
+ }, letter: string): boolean;
50
53
  export declare function isCtrlCInput(input: string, key: {
51
54
  ctrl?: boolean;
52
55
  }): boolean;
@@ -37,8 +37,15 @@ export function resolveCursorRowCompensation(input) {
37
37
  return input.previousRowCompensation;
38
38
  return needsCursorRowCompensation(input.nextOutputHeight, input.viewportRows, input.previousOutputHeight) ? 1 : 0;
39
39
  }
40
+ export function isCtrlLetterInput(input, key, letter) {
41
+ const normalized = letter.toLowerCase();
42
+ if (!/^[a-z]$/.test(normalized))
43
+ return false;
44
+ const rawControlInput = String.fromCharCode(normalized.charCodeAt(0) - 96);
45
+ return input === rawControlInput || (key.ctrl === true && input.toLowerCase() === normalized);
46
+ }
40
47
  export function isCtrlCInput(input, key) {
41
- return input === "\x03" || (key.ctrl === true && input.toLowerCase() === "c");
48
+ return isCtrlLetterInput(input, key, "c");
42
49
  }
43
50
  export function shouldUseLineComposerFrame(_background) {
44
51
  return true;
@@ -5,7 +5,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
5
5
  */
6
6
  import React from "react";
7
7
  import { Box, Text } from "ink";
8
- import { visualWidth, graphemeWidth } from "./width.js";
8
+ import { ambiguousIsWide, visualWidth, graphemeWidth } from "./width.js";
9
9
  import { useTerminalSize } from "./use-terminal-size.js";
10
10
  import { useTheme } from "./theme.js";
11
11
  import { highlightCode, highlightCodeSync } from "./code-highlight.js";
@@ -383,6 +383,15 @@ function TableBlock({ headers, rows, maxWidth, }) {
383
383
  // Reserve a buffer so the table fits even when wrapped inside an indented
384
384
  // box (e.g. the timeline gutter contributes marginLeft + "● " = 5 cells).
385
385
  const budget = Math.max(20, (maxWidth ?? termWidth) - 8);
386
+ // Box-drawing ─│┌┬┼… are East Asian *Ambiguous*-width: on a terminal that
387
+ // renders them 2 cells wide, border rows would paint at twice the width the
388
+ // cell rows were budgeted for (and twice what Ink itself measures), so the
389
+ // terminal hard-wraps them into scattered fragments. There is no way to hit
390
+ // odd widths with 2-cell dashes, so on such terminals draw ASCII borders —
391
+ // the only glyphs whose width every layer agrees on.
392
+ const g = ambiguousIsWide()
393
+ ? { h: "-", v: "|", tl: "+", tm: "+", tr: "+", ml: "+", mm: "+", mr: "+", bl: "+", bm: "+", br: "+" }
394
+ : { h: "─", v: "│", tl: "┌", tm: "┬", tr: "┐", ml: "├", mm: "┼", mr: "┤", bl: "└", bm: "┴", br: "┘" };
386
395
  const maxWidths = headers.map((h, i) => {
387
396
  let max = visualWidth(inlinePlainText(h));
388
397
  for (const row of rows) {
@@ -399,11 +408,26 @@ function TableBlock({ headers, rows, maxWidth, }) {
399
408
  const available = Math.max(budget - separatorsWidth, colCount * 4);
400
409
  const ratio = totalInnerWidth > 0 ? available / totalInnerWidth : 1;
401
410
  widths = maxWidths.map((w) => Math.max(4, Math.floor(w * ratio)));
411
+ // The 4-cell floor can push the sum back above `available`; shave the
412
+ // overshoot off the widest columns so the row never exceeds the budget
413
+ // and gets hard-wrapped by the terminal.
414
+ let excess = widths.reduce((a, b) => a + b, 0) - available;
415
+ while (excess > 0) {
416
+ let widest = -1;
417
+ for (let i = 0; i < widths.length; i++) {
418
+ if (widths[i] > 4 && (widest === -1 || widths[i] > widths[widest]))
419
+ widest = i;
420
+ }
421
+ if (widest === -1)
422
+ break;
423
+ widths[widest] -= 1;
424
+ excess -= 1;
425
+ }
402
426
  }
403
- const top = "┌" + widths.map((w) => "─".repeat(w + 2)).join("┬") + "┐";
404
- const mid = "├" + widths.map((w) => "─".repeat(w + 2)).join("┼") + "┤";
405
- const bot = "└" + widths.map((w) => "─".repeat(w + 2)).join("┴") + "┘";
406
- const renderRow = (cells, keyPrefix, isHeader = false) => (_jsxs(Text, { children: ["│ ", cells.map((c, i) => (_jsxs(React.Fragment, { children: [renderTableCell(c, widths[i] ?? 4, isHeader, `${keyPrefix}-cell-${i}`), i < colCount - 1 ? " " : " │"] }, i)))] }, keyPrefix));
427
+ const top = g.tl + widths.map((w) => g.h.repeat(w + 2)).join(g.tm) + g.tr;
428
+ const mid = g.ml + widths.map((w) => g.h.repeat(w + 2)).join(g.mm) + g.mr;
429
+ const bot = g.bl + widths.map((w) => g.h.repeat(w + 2)).join(g.bm) + g.br;
430
+ const renderRow = (cells, keyPrefix, isHeader = false) => (_jsxs(Text, { children: [`${g.v} `, cells.map((c, i) => (_jsxs(React.Fragment, { children: [renderTableCell(c, widths[i] ?? 4, isHeader, `${keyPrefix}-cell-${i}`), i < colCount - 1 ? ` ${g.v} ` : ` ${g.v}`] }, i)))] }, keyPrefix));
407
431
  return (_jsxs(Box, { flexDirection: "column", marginY: 1, children: [_jsx(Text, { children: top }), renderRow(headers, "header", true), _jsx(Text, { children: mid }), rows.map((row, ri) => renderRow(row, `row-${ri}`)), _jsx(Text, { children: bot })] }));
408
432
  }
409
433
  function renderTableCell(cell, width, isHeader, keyPrefix) {
@@ -417,9 +441,12 @@ function renderTableCell(cell, width, isHeader, keyPrefix) {
417
441
  function truncateInlineSegments(segments, width) {
418
442
  if (inlineSegmentsWidth(segments) <= width)
419
443
  return segments;
420
- if (width <= 1)
444
+ // The ellipsis is itself ambiguous-width (2 cells on an ambiguous-wide
445
+ // terminal) — reserve its real width or every truncated cell overflows.
446
+ const ellipsisWidth = graphemeWidth("…");
447
+ if (width <= ellipsisWidth)
421
448
  return [{ text: "…" }];
422
- const target = width - 1;
449
+ const target = width - ellipsisWidth;
423
450
  const output = [];
424
451
  let used = 0;
425
452
  for (const segment of segments) {
@@ -18,7 +18,6 @@ interface MessageListProps {
18
18
  streamingParts: DisplayMessagePart[];
19
19
  terminalColumns: number;
20
20
  showThinking?: boolean;
21
- expandedToolOutput?: boolean;
22
21
  verboseTrace: boolean;
23
22
  pendingApproval?: PendingApprovalHint | null;
24
23
  /** Animation tick used to refresh in-progress elapsed counters. */
@@ -42,5 +41,5 @@ interface MessageListProps {
42
41
  */
43
42
  maxStreamRows?: number;
44
43
  }
45
- export declare function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking, expandedToolOutput, verboseTrace, pendingApproval, nowTick, welcomeBanner, staticGeneration, paddingX, maxStreamRows, }: MessageListProps): import("react/jsx-runtime").JSX.Element;
44
+ export declare function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking, verboseTrace, pendingApproval, nowTick, welcomeBanner, staticGeneration, paddingX, maxStreamRows, }: MessageListProps): import("react/jsx-runtime").JSX.Element;
46
45
  export {};
@@ -12,7 +12,7 @@ import { latestSubagentNote, sortSubagents, subagentDescriptor, subagentLabel, s
12
12
  import { sanitizeInternalReminderBlocks } from "../agent/internal-reminder-sanitizer.js";
13
13
  import { splitImageDisplayContent } from "../tui/image-display.js";
14
14
  const EXECUTE_COMMAND_BLOCK_MAX_LINES = 4;
15
- export function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking = false, expandedToolOutput = false, verboseTrace, pendingApproval, nowTick, welcomeBanner, staticGeneration = 0, paddingX = 1, maxStreamRows, }) {
15
+ export function MessageList({ messages, streamingContent, streamingReasoning, streamingTools, streamingParts, terminalColumns, showThinking = false, verboseTrace, pendingApproval, nowTick, welcomeBanner, staticGeneration = 0, paddingX = 1, maxStreamRows, }) {
16
16
  const theme = useTheme();
17
17
  const hasStreaming = !!(streamingContent ||
18
18
  streamingReasoning ||
@@ -47,8 +47,8 @@ export function MessageList({ messages, streamingContent, streamingReasoning, st
47
47
  if (item.kind === "welcome") {
48
48
  return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, children: welcomeBanner }, item.key));
49
49
  }
50
- return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, children: _jsx(MessageItem, { message: item.message, terminalColumns: terminalColumns, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, showExpandHint: item.showExpandHint, separateFromPrevious: item.separateFromPrevious }) }, item.key));
51
- } }, `transcript-${staticGeneration}`), hasDynamic && (_jsxs(DynamicClamp, { maxRows: clampDynamic ? maxStreamRows : undefined, paddingX: paddingX, children: [hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, showThinking: showThinking, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick })), pendingSteerMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: pendingSteerMessages, terminalColumns: terminalColumns, title: "Messages to steer at next model call", hint: "applies before the next provider request", bulletColor: theme.warning })), queuedInputMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: queuedInputMessages, terminalColumns: terminalColumns, title: "Messages queued for next turn", hint: "runs after the current answer", bulletColor: theme.muted }))] }))] }));
50
+ return (_jsx(Box, { flexDirection: "column", paddingX: paddingX, children: _jsx(MessageItem, { message: item.message, terminalColumns: terminalColumns, showThinking: showThinking, verboseTrace: verboseTrace, showExpandHint: item.showExpandHint, separateFromPrevious: item.separateFromPrevious }) }, item.key));
51
+ } }, `transcript-${staticGeneration}`), hasDynamic && (_jsxs(DynamicClamp, { maxRows: clampDynamic ? maxStreamRows : undefined, paddingX: paddingX, children: [hasStreaming && (_jsx(StreamingMessage, { content: streamingContent, reasoning: streamingReasoning, tools: streamingTools, parts: streamingParts, terminalColumns: terminalColumns, showThinking: showThinking, verboseTrace: verboseTrace, pendingApproval: pendingApproval, nowTick: nowTick })), pendingSteerMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: pendingSteerMessages, terminalColumns: terminalColumns, title: "Messages to steer at next model call", hint: "applies before the next provider request", bulletColor: theme.warning })), queuedInputMessages.length > 0 && (_jsx(PendingInputMessagesBlock, { messages: queuedInputMessages, terminalColumns: terminalColumns, title: "Messages queued for next turn", hint: "runs after the current answer", bulletColor: theme.muted }))] }))] }));
52
52
  }
53
53
  /**
54
54
  * Bounds the live (in-progress turn) region to at most `maxRows` rows, pinned
@@ -99,7 +99,7 @@ function DynamicClamp({ maxRows, paddingX, children, }) {
99
99
  // append-only (compaction reuses already-compacted instances), keys are
100
100
  // stable, and nowTick is only threaded to the last row, so memo hits for all
101
101
  // settled history rows.
102
- const MessageItem = React.memo(function MessageItem({ message, terminalColumns, showThinking, expandedToolOutput, verboseTrace, showExpandHint, separateFromPrevious, nowTick, }) {
102
+ const MessageItem = React.memo(function MessageItem({ message, terminalColumns, showThinking, verboseTrace, showExpandHint, separateFromPrevious, nowTick, }) {
103
103
  const theme = useTheme();
104
104
  if (message.role === "user") {
105
105
  return (_jsx(UserMessageBlock, { content: message.content, terminalColumns: terminalColumns, inputStatus: message.inputStatus, separateFromPrevious: separateFromPrevious }));
@@ -139,9 +139,9 @@ const MessageItem = React.memo(function MessageItem({ message, terminalColumns,
139
139
  message.taskElapsedMs !== undefined;
140
140
  if (!hasVisibleAssistantContent)
141
141
  return null;
142
- return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && (showThinking || verboseTrace) && _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), visibleContent.trim() && _jsx(MarkdownContent, { content: visibleContent })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
142
+ return (_jsxs(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: [visibleReasoning && (showThinking || verboseTrace) && _jsx(ReasoningTraceBlock, { reasoning: visibleReasoning }), message.parts && message.parts.length > 0 ? (_jsx(MessageParts, { parts: message.parts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })) : (_jsxs(_Fragment, { children: [message.toolCalls && (_jsx(ToolsPart, { toolCalls: message.toolCalls, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: undefined, showExpandHint: showExpandHint, nowTick: nowTick })), visibleContent.trim() && _jsx(MarkdownContent, { content: visibleContent })] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
143
143
  });
144
- function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, showThinking, expandedToolOutput, verboseTrace, pendingApproval, nowTick, }) {
144
+ function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, showThinking, verboseTrace, pendingApproval, nowTick, }) {
145
145
  const deferredContent = React.useDeferredValue(content);
146
146
  const deferredReasoning = React.useDeferredValue(reasoning);
147
147
  const deferredParts = React.useDeferredValue(parts);
@@ -155,16 +155,16 @@ function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, s
155
155
  // turn commits — no spacing jump at finalize time. (The old marginTop=0
156
156
  // was a flicker mitigation for the main-screen <Static> renderer; the
157
157
  // alt-screen viewport repaints frames atomically, so it's obsolete.)
158
- _jsx(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: _jsx(MessageParts, { parts: visibleParts, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: true, nowTick: nowTick, showActivity: true, streaming: true }) }))] }));
158
+ _jsx(Box, { marginTop: 1, marginBottom: 1, flexDirection: "column", children: _jsx(MessageParts, { parts: visibleParts, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: true, nowTick: nowTick, showActivity: true, streaming: true }) }))] }));
159
159
  }
160
- function MessageParts({ parts, terminalColumns, expandedToolOutput, verboseTrace, pendingApproval, showExpandHint, nowTick, showActivity = false, streaming = false, }) {
160
+ function MessageParts({ parts, terminalColumns, verboseTrace, pendingApproval, showExpandHint, nowTick, showActivity = false, streaming = false, }) {
161
161
  const lastToolsPartIndex = findLastToolsPartIndex(parts);
162
162
  const lastTextPartIndex = findLastTextPartIndex(parts);
163
163
  return (_jsx(Box, { flexDirection: "column", children: parts.map((part, idx) => {
164
164
  if (part.type === "text") {
165
165
  return (_jsx(TimelineText, { content: part.content, compactTop: idx === 0, terminalColumns: terminalColumns, streaming: streaming && idx === lastTextPartIndex }, `text-${idx}`));
166
166
  }
167
- return (_jsx(ToolsPart, { toolCalls: part.toolCalls, terminalColumns: terminalColumns, expandedToolOutput: expandedToolOutput, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: showExpandHint && idx === lastToolsPartIndex, compactTop: idx === 0, nowTick: nowTick, showActivity: showActivity && idx === lastToolsPartIndex }, `tools-${idx}`));
167
+ return (_jsx(ToolsPart, { toolCalls: part.toolCalls, terminalColumns: terminalColumns, verboseTrace: verboseTrace, pendingApproval: pendingApproval, showExpandHint: showExpandHint && idx === lastToolsPartIndex, compactTop: idx === 0, nowTick: nowTick, showActivity: showActivity && idx === lastToolsPartIndex }, `tools-${idx}`));
168
168
  }) }));
169
169
  }
170
170
  function findLastTextPartIndex(parts) {
@@ -195,17 +195,16 @@ function TimelineText({ content, compactTop, terminalColumns, streaming = false,
195
195
  const trimmed = visible.trim();
196
196
  return (_jsxs(Box, { marginLeft: 2, marginTop: compactTop ? 0 : 1, children: [_jsx(Text, { color: theme.agent, children: "\u25CF " }), _jsx(Box, { flexDirection: "column", flexGrow: 1, children: streaming ? (_jsx(StreamingMarkdown, { content: trimmed, maxWidth: available })) : (_jsx(MarkdownContent, { content: trimmed, maxWidth: available })) })] }));
197
197
  }
198
- function ToolsPart({ toolCalls, terminalColumns, expandedToolOutput, verboseTrace, pendingApproval, showExpandHint, compactTop = false, nowTick, showActivity = false, }) {
198
+ function ToolsPart({ toolCalls, terminalColumns, verboseTrace, pendingApproval, showExpandHint, compactTop = false, nowTick, showActivity = false, }) {
199
199
  if (toolCalls.length === 0)
200
200
  return null;
201
- const expandTools = verboseTrace || expandedToolOutput;
202
- if (!expandTools) {
201
+ if (!verboseTrace) {
203
202
  return (_jsx(TraceGroupList, { toolCalls: toolCalls, terminalColumns: terminalColumns, pendingApproval: pendingApproval, nowTick: nowTick, compactTop: compactTop, showActivity: showActivity }));
204
203
  }
205
204
  const lastIdx = toolCalls.length - 1;
206
205
  return (_jsx(Box, { flexDirection: "column", children: toolCalls.map((tc, idx) => {
207
206
  const isWaitingApproval = isToolPending(tc) && !!pendingApproval && approvalMatchesTool(pendingApproval, tc);
208
- return (_jsx(ToolCallDisplay, { toolCall: tc, isStreaming: isToolPending(tc), verbose: expandTools, terminalColumns: terminalColumns, showExpandHint: showExpandHint && idx === lastIdx, waitingApproval: isWaitingApproval, compactTop: idx === 0 && compactTop, nowTick: nowTick }, tc.id));
207
+ return (_jsx(ToolCallDisplay, { toolCall: tc, isStreaming: isToolPending(tc), verbose: verboseTrace, terminalColumns: terminalColumns, showExpandHint: showExpandHint && idx === lastIdx, waitingApproval: isWaitingApproval, compactTop: idx === 0 && compactTop, nowTick: nowTick }, tc.id));
209
208
  }) }));
210
209
  }
211
210
  function fallbackStreamingParts(content, tools) {
@@ -553,7 +552,7 @@ function SubagentToolDisplay({ toolCall, verbose, terminalColumns, compactTop, }
553
552
  const descriptor = padVisual(truncateVisual(subagentDescriptor(subagent), descriptorWidth), descriptorWidth);
554
553
  const note = truncateVisual(latestSubagentNote(subagent), Math.max(12, detailWidth - 16 - descriptorWidth - 10));
555
554
  return (_jsxs(Box, { children: [_jsx(Text, { color: subagentStatusColor(status, theme), children: label }), _jsxs(Text, { color: theme.traceAction, children: [" ", descriptor] }), _jsxs(Text, { color: subagentStatusColor(status, theme), children: [" ", padVisual(status, 9)] }), note && _jsxs(Text, { color: subagent.error ? theme.error : theme.traceDetail, children: [" ", note] })] }, subagent.subAgentId ?? `${subagentLabel(subagent)}-${index}`));
556
- }), omitted > 0 && (_jsxs(Text, { color: theme.muted, children: ["... ", omitted, " more \u00B7 Ctrl+O to expand \u00B7 Ctrl+G to inspect traces"] }))] })), subagents.length === 0 && toolCall.result && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: hasError ? theme.error : theme.muted, children: summarizeToolResult(toolCall) }) }))] }));
555
+ }), omitted > 0 && (_jsxs(Text, { color: theme.muted, children: ["... ", omitted, " more \u00B7 Ctrl+O to expand \u00B7 \u2193 then Enter to inspect traces"] }))] })), subagents.length === 0 && toolCall.result && (_jsx(Box, { marginLeft: 2, children: _jsx(Text, { color: hasError ? theme.error : theme.muted, children: summarizeToolResult(toolCall) }) }))] }));
557
556
  }
558
557
  function TruncationHint({ remaining, verbose, showExpandHint, }) {
559
558
  const theme = useTheme();
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Full-screen subagent inspector (Ctrl+G / /agents).
2
+ * Full-screen subagent inspector opened from the subagent entry line.
3
3
  *
4
4
  * Two-level drill-in modeled on Claude Code's workflow view: a grouped list of
5
5
  * subagents (each spawn_agent is one member; each agent_team/agent_batch is a
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
- * Full-screen subagent inspector (Ctrl+G / /agents).
3
+ * Full-screen subagent inspector opened from the subagent entry line.
4
4
  *
5
5
  * Two-level drill-in modeled on Claude Code's workflow view: a grouped list of
6
6
  * subagents (each spawn_agent is one member; each agent_team/agent_batch is a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {