@bubblebrain-ai/bubble 0.0.30 → 0.0.32

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.
@@ -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
  }
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") {
@@ -1125,6 +1125,16 @@ export function App({ agent, args, sessionManager: initialSessionManager, switch
1125
1125
  inputController,
1126
1126
  })) {
1127
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;
1128
1138
  case "text_delta":
1129
1139
  assistantContent += event.content;
1130
1140
  appendTextPart(assistantParts, event.content);
@@ -44,6 +44,13 @@ export declare function parseMarkdownBlocks(text: string): MarkdownBlock[];
44
44
  */
45
45
  export declare function findLastBlockStart(text: string): number;
46
46
  export declare function parseMarkdownInlineSegments(text: string, style?: InlineStyle): MarkdownInlineSegment[];
47
+ /**
48
+ * Distribute `available` inner cells across columns. Columns whose natural
49
+ * width already fits their fair share keep it untouched (numbers and dates
50
+ * never wrap); only the wider columns split the remainder proportionally and
51
+ * wrap their content.
52
+ */
53
+ export declare function allocateColumnWidths(natural: number[], available: number): number[];
47
54
  /**
48
55
  * CJK-aware line wrap over styled inline segments.
49
56
  *
@@ -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) {
@@ -397,17 +406,92 @@ function TableBlock({ headers, rows, maxWidth, }) {
397
406
  let widths = [...maxWidths];
398
407
  if (totalWidth > budget) {
399
408
  const available = Math.max(budget - separatorsWidth, colCount * 4);
400
- const ratio = totalInnerWidth > 0 ? available / totalInnerWidth : 1;
401
- widths = maxWidths.map((w) => Math.max(4, Math.floor(w * ratio)));
409
+ widths = allocateColumnWidths(maxWidths, available);
402
410
  }
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));
411
+ const top = g.tl + widths.map((w) => g.h.repeat(w + 2)).join(g.tm) + g.tr;
412
+ const mid = g.ml + widths.map((w) => g.h.repeat(w + 2)).join(g.mm) + g.mr;
413
+ const bot = g.bl + widths.map((w) => g.h.repeat(w + 2)).join(g.bm) + g.br;
414
+ // A cell wider than its column wraps onto continuation lines (CJK-aware, so
415
+ // 、-joined user lists break cleanly); the row grows to its tallest cell.
416
+ const renderRow = (cells, keyPrefix, isHeader = false) => {
417
+ const wrapped = cells.map((c, i) => {
418
+ const width = widths[i] ?? 4;
419
+ let lines = wrapInlineSegments(parseMarkdownInlineSegments(c, { bold: isHeader }), width);
420
+ if (lines.length > MAX_TABLE_CELL_LINES) {
421
+ lines = lines.slice(0, MAX_TABLE_CELL_LINES);
422
+ const last = lines[MAX_TABLE_CELL_LINES - 1];
423
+ lines[MAX_TABLE_CELL_LINES - 1] = truncateInlineSegments([...last, { text: " …" }], width);
424
+ }
425
+ return lines;
426
+ });
427
+ const height = Math.max(1, ...wrapped.map((lines) => lines.length));
428
+ return (_jsx(Box, { flexDirection: "column", children: Array.from({ length: height }, (_, line) => (_jsxs(Text, { children: [`${g.v} `, wrapped.map((cellLines, i) => (_jsxs(React.Fragment, { children: [renderCellLine(cellLines[line] ?? [], widths[i] ?? 4, `${keyPrefix}-cell-${i}-${line}`), i < colCount - 1 ? ` ${g.v} ` : ` ${g.v}`] }, i)))] }, `${keyPrefix}-line-${line}`))) }, keyPrefix));
429
+ };
407
430
  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
431
  }
409
- function renderTableCell(cell, width, isHeader, keyPrefix) {
410
- const segments = truncateInlineSegments(parseMarkdownInlineSegments(cell, { bold: isHeader }), width);
432
+ /** Cap a pathological cell (huge blob of text) at this many wrapped lines. */
433
+ const MAX_TABLE_CELL_LINES = 8;
434
+ /**
435
+ * Distribute `available` inner cells across columns. Columns whose natural
436
+ * width already fits their fair share keep it untouched (numbers and dates
437
+ * never wrap); only the wider columns split the remainder proportionally and
438
+ * wrap their content.
439
+ */
440
+ export function allocateColumnWidths(natural, available) {
441
+ const count = natural.length;
442
+ const widths = new Array(count).fill(0);
443
+ const fixed = new Array(count).fill(false);
444
+ let remaining = available;
445
+ let unfixed = count;
446
+ // Each fixed column frees slack that can fit further columns — iterate to a
447
+ // fixpoint. Terminates: natural sum exceeds `available` (only caller path),
448
+ // so at least one column always stays unfixed.
449
+ let changed = true;
450
+ while (changed && unfixed > 0) {
451
+ changed = false;
452
+ const fair = remaining / unfixed;
453
+ for (let i = 0; i < count; i++) {
454
+ if (fixed[i] || natural[i] > fair)
455
+ continue;
456
+ fixed[i] = true;
457
+ widths[i] = natural[i];
458
+ remaining -= natural[i];
459
+ unfixed -= 1;
460
+ changed = true;
461
+ }
462
+ }
463
+ if (unfixed > 0) {
464
+ const wideTotal = natural.reduce((sum, w, i) => sum + (fixed[i] ? 0 : w), 0);
465
+ let assigned = 0;
466
+ let lastWide = -1;
467
+ for (let i = 0; i < count; i++) {
468
+ if (fixed[i])
469
+ continue;
470
+ widths[i] = Math.max(4, Math.floor((remaining * natural[i]) / wideTotal));
471
+ assigned += widths[i];
472
+ lastWide = i;
473
+ }
474
+ // Flooring leftovers go to the last wide column instead of being wasted.
475
+ if (lastWide >= 0 && assigned < remaining)
476
+ widths[lastWide] += remaining - assigned;
477
+ }
478
+ // On a very narrow budget the 4-cell floor can overshoot; shave the widest
479
+ // columns so the row never exceeds `available` and hard-wraps.
480
+ let excess = widths.reduce((a, b) => a + b, 0) - available;
481
+ while (excess > 0) {
482
+ let widest = -1;
483
+ for (let i = 0; i < count; i++) {
484
+ if (widths[i] > 4 && (widest === -1 || widths[i] > widths[widest]))
485
+ widest = i;
486
+ }
487
+ if (widest === -1)
488
+ break;
489
+ widths[widest] -= 1;
490
+ excess -= 1;
491
+ }
492
+ return widths;
493
+ }
494
+ function renderCellLine(segments, width, keyPrefix) {
411
495
  const padding = " ".repeat(Math.max(0, width - inlineSegmentsWidth(segments)));
412
496
  return [
413
497
  ...segments.map((segment, index) => (_jsx(Text, { bold: segment.bold, italic: segment.italic, color: segment.code ? "#a78bfa" : undefined, children: segment.text }, `${keyPrefix}-${index}`))),
@@ -417,9 +501,12 @@ function renderTableCell(cell, width, isHeader, keyPrefix) {
417
501
  function truncateInlineSegments(segments, width) {
418
502
  if (inlineSegmentsWidth(segments) <= width)
419
503
  return segments;
420
- if (width <= 1)
504
+ // The ellipsis is itself ambiguous-width (2 cells on an ambiguous-wide
505
+ // terminal) — reserve its real width or every truncated cell overflows.
506
+ const ellipsisWidth = graphemeWidth("…");
507
+ if (width <= ellipsisWidth)
421
508
  return [{ text: "…" }];
422
- const target = width - 1;
509
+ const target = width - ellipsisWidth;
423
510
  const output = [];
424
511
  let used = 0;
425
512
  for (const segment of segments) {
@@ -139,7 +139,11 @@ 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, 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 }))] }));
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() && (
143
+ // Thread the real terminal width down (minus the row's paddingX
144
+ // inset); without it, width-aware blocks like tables fall back to
145
+ // a stale/default useTerminalSize and mis-budget their columns.
146
+ _jsx(MarkdownContent, { content: visibleContent, maxWidth: Math.max(20, terminalColumns - 4) }))] })), verboseTrace && message.toolCalls && message.toolCalls.length > 0 && (_jsx(TurnDigest, { toolCalls: message.toolCalls })), message.taskElapsedMs !== undefined && (_jsx(TaskDurationLine, { elapsedMs: message.taskElapsedMs }))] }));
143
147
  });
144
148
  function StreamingMessage({ content, reasoning, tools, parts, terminalColumns, showThinking, verboseTrace, pendingApproval, nowTick, }) {
145
149
  const deferredContent = React.useDeferredValue(content);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {