@bubblebrain-ai/bubble 0.0.30 → 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.
@@ -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);
@@ -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) {
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.31",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {