@bubblebrain-ai/bubble 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/agent/task-size.d.ts +9 -0
  2. package/dist/agent/task-size.js +33 -0
  3. package/dist/agent/tool-intent.d.ts +1 -0
  4. package/dist/agent/tool-intent.js +1 -1
  5. package/dist/agent.js +46 -2
  6. package/dist/orchestrator/default-hooks.js +80 -69
  7. package/dist/orchestrator/hooks.d.ts +5 -8
  8. package/dist/prompt/compose.js +3 -0
  9. package/dist/prompt/environment.js +2 -0
  10. package/dist/prompt/provider-prompts/deepseek.js +1 -2
  11. package/dist/prompt/provider-prompts/kimi.js +1 -2
  12. package/dist/prompt/reminders.d.ts +20 -3
  13. package/dist/prompt/reminders.js +43 -17
  14. package/dist/prompt/runtime.js +17 -23
  15. package/dist/provider.d.ts +10 -1
  16. package/dist/provider.js +87 -34
  17. package/dist/tools/bash.d.ts +2 -1
  18. package/dist/tools/bash.js +1 -1
  19. package/dist/tools/edit-apply.js +37 -6
  20. package/dist/tools/edit.d.ts +2 -1
  21. package/dist/tools/edit.js +18 -6
  22. package/dist/tools/file-state.d.ts +25 -0
  23. package/dist/tools/file-state.js +52 -0
  24. package/dist/tools/index.d.ts +2 -0
  25. package/dist/tools/index.js +6 -4
  26. package/dist/tools/read.d.ts +2 -1
  27. package/dist/tools/read.js +5 -1
  28. package/dist/tools/write.d.ts +4 -3
  29. package/dist/tools/write.js +133 -54
  30. package/dist/tui/display-history.d.ts +2 -0
  31. package/dist/tui/run.js +115 -23
  32. package/dist/tui/streaming-tool-args.d.ts +15 -0
  33. package/dist/tui/streaming-tool-args.js +30 -0
  34. package/dist/tui/tool-renderers/write-preview.d.ts +1 -1
  35. package/dist/tui/tool-renderers/write-preview.js +9 -1
  36. package/dist/tui/tool-renderers/write.js +13 -7
  37. package/dist/types.d.ts +15 -0
  38. package/package.json +1 -1
@@ -1,86 +1,165 @@
1
1
  /**
2
- * Write tool - create or overwrite files.
2
+ * Write tool - create files or safely replace full file contents.
3
3
  */
4
- import { constants } from "node:fs";
5
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
5
  import { dirname, resolve } from "node:path";
7
6
  import { createTwoFilesPatch } from "diff";
8
7
  import { gateToolAction } from "../approval/tool-helper.js";
9
8
  import { formatDiagnosticBlocks } from "../lsp/index.js";
10
- export function createWriteTool(cwd, options = {}, approval, lsp) {
9
+ import { isWithinWorkspace } from "./file-state.js";
10
+ import { withFileMutationQueue } from "./file-mutation-queue.js";
11
+ export function createWriteTool(cwd, options = {}, approval, lsp, fileState) {
11
12
  return {
12
13
  name: "write",
13
14
  effect: "write_direct",
14
15
  requiresApproval: true,
15
- description: `Write a file to disk. Creates parent directories if needed.${options.refuseOverwrite ? " Will not overwrite existing files." : ""}`,
16
+ description: "Write a file to disk. Creates parent directories if needed. For an existing file, use overwrite=true only for full-file replacement after the file has been read or modified in this session; use edit for small targeted changes.",
16
17
  parameters: {
17
18
  type: "object",
18
19
  properties: {
19
20
  path: { type: "string", description: "Path to the file (relative or absolute)" },
20
21
  content: { type: "string", description: "File contents" },
22
+ overwrite: {
23
+ type: "boolean",
24
+ description: "Set true only for full-file replacement of an existing file. Existing files must have been read or modified in this session.",
25
+ },
21
26
  },
22
27
  required: ["path", "content"],
23
28
  },
24
29
  async execute(args) {
25
30
  const filePath = resolve(cwd, args.path);
26
- if (options.refuseOverwrite) {
31
+ const overwrite = args.overwrite === true;
32
+ if (!isWithinWorkspace(cwd, filePath)) {
33
+ return {
34
+ content: `Error: Write path is outside the workspace: ${filePath}`,
35
+ isError: true,
36
+ status: "blocked",
37
+ metadata: {
38
+ kind: "security",
39
+ path: filePath,
40
+ reason: "Write path is outside the workspace.",
41
+ },
42
+ };
43
+ }
44
+ return withFileMutationQueue(filePath, async () => {
45
+ let existed = false;
46
+ let oldContent = "";
27
47
  try {
28
- await access(filePath, constants.F_OK);
48
+ oldContent = await readFile(filePath, "utf-8");
49
+ existed = true;
50
+ }
51
+ catch {
52
+ // New file.
53
+ }
54
+ if (existed && options.refuseOverwrite && !overwrite) {
29
55
  return {
30
- content: `Error: File already exists: ${filePath}. Use edit tool to modify existing files.`,
56
+ content: `Error: File already exists: ${filePath}.\n\n`
57
+ + "For small targeted changes, use edit.\n"
58
+ + "For a full-file replacement, call write again with overwrite=true. Existing files must be read or modified in this session before they can be safely overwritten.\n"
59
+ + "Do not delete and recreate the file just to overwrite it.",
31
60
  isError: true,
32
61
  };
33
62
  }
34
- catch {
35
- // file doesn't exist, proceed
63
+ if (existed && overwrite && options.refuseOverwrite) {
64
+ if (!fileState) {
65
+ return {
66
+ content: `Error: Cannot safely overwrite ${filePath} because file-state tracking is unavailable. `
67
+ + "Read the file first in this agent session, then retry the full-file replacement.",
68
+ isError: true,
69
+ status: "blocked",
70
+ };
71
+ }
72
+ const freshness = await fileState.checkFresh(filePath);
73
+ if (!freshness.ok) {
74
+ return staleOverwriteResult(filePath, freshness.reason);
75
+ }
36
76
  }
37
- }
38
- let existed = false;
39
- let oldContent = "";
40
- try {
41
- oldContent = await readFile(filePath, "utf-8");
42
- existed = true;
43
- }
44
- catch {
45
- // new file
46
- }
47
- const diff = createTwoFilesPatch(filePath, filePath, oldContent, args.content, "original", "modified", { context: 3 });
48
- const gate = await gateToolAction(approval, {
49
- type: "write",
50
- path: filePath,
51
- content: args.content,
52
- diff,
53
- fileExists: existed,
54
- });
55
- if (!gate.approved)
56
- return gate.result;
57
- try {
58
- await mkdir(dirname(filePath), { recursive: true });
59
- await writeFile(filePath, args.content, "utf-8");
60
- const lineCount = args.content.split("\n").length;
61
- const verb = existed ? "Updated" : "Wrote";
62
- let content = `${verb} ${lineCount} lines to ${filePath}`;
63
- if (lsp) {
64
- try {
65
- await lsp.touchFile(filePath, "document");
66
- content += formatDiagnosticBlocks(cwd, filePath, lsp.diagnostics());
77
+ const diff = createTwoFilesPatch(filePath, filePath, oldContent, args.content, "original", "modified", { context: 3 });
78
+ const gate = await gateToolAction(approval, {
79
+ type: "write",
80
+ path: filePath,
81
+ content: args.content,
82
+ diff,
83
+ fileExists: existed,
84
+ });
85
+ if (!gate.approved)
86
+ return gate.result;
87
+ if (existed && overwrite && options.refuseOverwrite && fileState) {
88
+ const freshness = await fileState.checkFresh(filePath);
89
+ if (!freshness.ok) {
90
+ return staleOverwriteResult(filePath, freshness.reason);
67
91
  }
68
- catch {
69
- // LSP diagnostics should not turn a successful write into a failed tool call.
92
+ }
93
+ try {
94
+ await mkdir(dirname(filePath), { recursive: true });
95
+ await writeFile(filePath, args.content, "utf-8");
96
+ await fileState?.observe(filePath, "write", args.content).catch(() => undefined);
97
+ const lineCount = args.content.split("\n").length;
98
+ const verb = existed ? "Updated" : "Wrote";
99
+ let content = `${verb} ${lineCount} lines to ${filePath}`;
100
+ if (lsp) {
101
+ try {
102
+ await lsp.touchFile(filePath, "document");
103
+ content += formatDiagnosticBlocks(cwd, filePath, lsp.diagnostics());
104
+ }
105
+ catch {
106
+ // LSP diagnostics should not turn a successful write into a failed tool call.
107
+ }
70
108
  }
109
+ return {
110
+ content,
111
+ status: "success",
112
+ metadata: {
113
+ kind: "write",
114
+ path: filePath,
115
+ overwrite,
116
+ },
117
+ };
71
118
  }
72
- return {
73
- content,
74
- status: "success",
75
- metadata: {
76
- kind: "write",
77
- path: filePath,
78
- },
79
- };
80
- }
81
- catch (err) {
82
- return { content: `Error: ${err.message}`, isError: true };
83
- }
119
+ catch (err) {
120
+ return { content: `Error: ${err.message}`, isError: true };
121
+ }
122
+ });
123
+ },
124
+ };
125
+ }
126
+ function staleOverwriteResult(filePath, reason) {
127
+ if (reason === "unobserved") {
128
+ return {
129
+ content: `Error: Cannot safely overwrite existing file: ${filePath}.\n\n`
130
+ + "This file has not been read or modified in this agent session. Read it first, then retry write with overwrite=true.\n"
131
+ + "For small targeted changes, use edit. Do not delete and recreate the file just to overwrite it.",
132
+ isError: true,
133
+ status: "blocked",
134
+ metadata: {
135
+ kind: "security",
136
+ path: filePath,
137
+ reason,
138
+ },
139
+ };
140
+ }
141
+ if (reason === "changed") {
142
+ return {
143
+ content: `Error: Cannot safely overwrite ${filePath} because it changed since the last read/write/edit in this agent session.\n\n`
144
+ + "Re-read the file to pick up the latest content, then retry write with overwrite=true if a full-file replacement is still intended.",
145
+ isError: true,
146
+ status: "blocked",
147
+ metadata: {
148
+ kind: "security",
149
+ path: filePath,
150
+ reason,
151
+ },
152
+ };
153
+ }
154
+ return {
155
+ content: `Error: Cannot safely overwrite ${filePath} because it is missing now.\n\n`
156
+ + "Check the path before retrying.",
157
+ isError: true,
158
+ status: "blocked",
159
+ metadata: {
160
+ kind: "security",
161
+ path: filePath,
162
+ reason,
84
163
  },
85
164
  };
86
165
  }
@@ -30,6 +30,8 @@ export interface DisplayToolCall {
30
30
  args: Record<string, any>;
31
31
  rawArguments?: string;
32
32
  streamingArgs?: boolean;
33
+ /** During streaming, an approximate line count derived from `\n` escapes in rawArguments. */
34
+ streamingNewlineCount?: number;
33
35
  status?: "pending" | "running" | "completed" | "error";
34
36
  result?: string;
35
37
  isError?: boolean;
package/dist/tui/run.js CHANGED
@@ -18,6 +18,7 @@ import { hashString } from "./render-signature.js";
18
18
  import { findToolRenderer } from "./tool-renderers/registry.js";
19
19
  import { writeToolKey } from "./tool-renderers/write.js";
20
20
  import { formatWritePreview, isWritePreviewTool } from "./tool-renderers/write-preview.js";
21
+ import { extractStreamingArgsHint } from "./streaming-tool-args.js";
21
22
  import { getNextPermissionMode, PERMISSION_MODE_INFO } from "../permission/mode.js";
22
23
  import { getContextBudget } from "../context/budget.js";
23
24
  import { getLspService } from "../lsp/index.js";
@@ -3565,6 +3566,33 @@ function OpenTuiApp(props) {
3565
3566
  let turnStartedAt;
3566
3567
  let runError;
3567
3568
  let runCancelled = false;
3569
+ // Throttle redraws driven by per-token streaming events (reasoning_delta
3570
+ // and tool_call_delta). Both can fire hundreds of times per second on a
3571
+ // long reply; coalescing into ~16fps keeps the transcript alive without
3572
+ // thrashing OpenTUI's layout or re-parsing markdown per token.
3573
+ let pendingStreamingRedrawTimer;
3574
+ const STREAMING_REDRAW_INTERVAL_MS = 60;
3575
+ const buildStreamingDisplay = (status) => ({
3576
+ role: "assistant",
3577
+ content: "",
3578
+ reasoning: assistantReasoning || undefined,
3579
+ toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3580
+ status,
3581
+ streaming: true,
3582
+ turnStartedAt,
3583
+ });
3584
+ const flushStreamingRedraw = () => {
3585
+ if (pendingStreamingRedrawTimer === undefined)
3586
+ return;
3587
+ clearTimeout(pendingStreamingRedrawTimer);
3588
+ pendingStreamingRedrawTimer = undefined;
3589
+ redrawTranscript(buildStreamingDisplay(toolCalls.length ? undefined : "thinking"));
3590
+ };
3591
+ const scheduleStreamingRedraw = () => {
3592
+ if (pendingStreamingRedrawTimer !== undefined)
3593
+ return;
3594
+ pendingStreamingRedrawTimer = setTimeout(flushStreamingRedraw, STREAMING_REDRAW_INTERVAL_MS);
3595
+ };
3568
3596
  try {
3569
3597
  for await (const event of props.agent.run(actualInput, props.args.cwd, { abortSignal: run.abortController.signal })) {
3570
3598
  if (event.type === "turn_start") {
@@ -3586,41 +3614,60 @@ function OpenTuiApp(props) {
3586
3614
  }
3587
3615
  else if (event.type === "reasoning_delta") {
3588
3616
  assistantReasoning += event.content;
3589
- redrawTranscript({
3590
- role: "assistant",
3591
- content: "",
3592
- reasoning: assistantReasoning || undefined,
3593
- toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3594
- status: "thinking",
3595
- streaming: true,
3596
- turnStartedAt,
3597
- });
3617
+ scheduleStreamingRedraw();
3598
3618
  }
3599
3619
  else if (event.type === "tool_call_start") {
3600
3620
  currentTurnHasToolCall = true;
3621
+ // Insert a streaming placeholder so the user sees feedback the moment
3622
+ // the model commits to a tool call, instead of waiting for the args
3623
+ // JSON to fully stream + parse.
3624
+ if (!toolCalls.find((item) => item.id === event.id)) {
3625
+ toolCalls.push({
3626
+ id: event.id,
3627
+ name: event.name,
3628
+ args: {},
3629
+ rawArguments: "",
3630
+ streamingArgs: true,
3631
+ status: "pending",
3632
+ });
3633
+ }
3601
3634
  redrawTranscript({
3602
3635
  role: "assistant",
3603
3636
  content: "",
3604
3637
  reasoning: assistantReasoning || undefined,
3605
- toolCalls: toolCalls.length ? [...toolCalls] : undefined,
3606
- status: toolCalls.length ? undefined : "thinking",
3638
+ toolCalls: [...toolCalls],
3607
3639
  streaming: true,
3608
3640
  turnStartedAt,
3609
3641
  });
3610
3642
  }
3611
3643
  else if (event.type === "tool_call_delta") {
3612
3644
  currentTurnHasToolCall = true;
3645
+ const existing = toolCalls.find((item) => item.id === event.id);
3646
+ if (existing) {
3647
+ existing.name = event.name || existing.name;
3648
+ existing.rawArguments = event.arguments;
3649
+ existing.streamingArgs = true;
3650
+ const hint = extractStreamingArgsHint(event.arguments);
3651
+ if (hint.path && existing.args.path !== hint.path) {
3652
+ existing.args = { ...existing.args, path: hint.path };
3653
+ }
3654
+ existing.streamingNewlineCount = hint.newlineCount;
3655
+ scheduleStreamingRedraw();
3656
+ }
3613
3657
  }
3614
3658
  else if (event.type === "tool_call_end") {
3615
3659
  currentTurnHasToolCall = true;
3616
3660
  }
3617
3661
  else if (event.type === "tool_start") {
3618
3662
  currentTurnHasToolCall = true;
3663
+ flushStreamingRedraw();
3619
3664
  const now = Date.now();
3620
3665
  const existing = toolCalls.find((item) => item.id === event.id);
3621
3666
  if (existing) {
3622
3667
  existing.args = event.args;
3623
3668
  existing.streamingArgs = false;
3669
+ existing.streamingNewlineCount = undefined;
3670
+ existing.rawArguments = undefined;
3624
3671
  existing.status = "running";
3625
3672
  existing.startedAt = existing.startedAt ?? now;
3626
3673
  }
@@ -3697,6 +3744,10 @@ function OpenTuiApp(props) {
3697
3744
  bumpSidebar();
3698
3745
  }
3699
3746
  else if (event.type === "turn_end") {
3747
+ if (pendingStreamingRedrawTimer !== undefined) {
3748
+ clearTimeout(pendingStreamingRedrawTimer);
3749
+ pendingStreamingRedrawTimer = undefined;
3750
+ }
3700
3751
  if (event.usage) {
3701
3752
  setSidebarUsage((current) => ({
3702
3753
  contextTokens: event.usage.promptTokens || current.contextTokens,
@@ -3739,6 +3790,10 @@ function OpenTuiApp(props) {
3739
3790
  }
3740
3791
  }
3741
3792
  finally {
3793
+ if (pendingStreamingRedrawTimer !== undefined) {
3794
+ clearTimeout(pendingStreamingRedrawTimer);
3795
+ pendingStreamingRedrawTimer = undefined;
3796
+ }
3742
3797
  pendingApprovalRef = undefined;
3743
3798
  setPendingApproval(undefined);
3744
3799
  setApprovalOptionIdx(0);
@@ -5139,14 +5194,27 @@ function updateAssistantEntry(entry, message, showThinking, options) {
5139
5194
  if (entry.refs.statusBox) {
5140
5195
  entry.refs.statusBox.visible = showStatus;
5141
5196
  }
5197
+ const streamingReasoning = message.streaming === true;
5142
5198
  if (entry.refs.reasoningToggleText) {
5143
- entry.refs.reasoningStreaming = message.streaming === true;
5199
+ entry.refs.reasoningStreaming = streamingReasoning;
5144
5200
  entry.refs.reasoningToggleText.content = visibleReasoning
5145
- ? thinkingLabelContent(message.streaming === true, reasoningElapsedMs(message))
5201
+ ? thinkingLabelContent(streamingReasoning, reasoningElapsedMs(message))
5146
5202
  : new StyledText([fg(theme.messageThinkingText)("")]);
5147
5203
  }
5204
+ // During streaming we update only the plain text node — cheap per-delta. The
5205
+ // markdown node stays hidden + stale. Once streaming ends (turn_end), we
5206
+ // pay the parse cost exactly once and swap visibility.
5207
+ if (entry.refs.reasoningPlainText) {
5208
+ if (streamingReasoning) {
5209
+ entry.refs.reasoningPlainText.content = formatThinkingMarkdown(visibleReasoning);
5210
+ }
5211
+ entry.refs.reasoningPlainText.visible = streamingReasoning && !!visibleReasoning;
5212
+ }
5148
5213
  if (entry.refs.reasoningMarkdown) {
5149
- syncMarkdownRenderable(entry.refs.reasoningMarkdown, formatThinkingMarkdown(visibleReasoning), message.streaming === true);
5214
+ if (!streamingReasoning) {
5215
+ syncMarkdownRenderable(entry.refs.reasoningMarkdown, formatThinkingMarkdown(visibleReasoning), false);
5216
+ }
5217
+ entry.refs.reasoningMarkdown.visible = !streamingReasoning && !!visibleReasoning;
5150
5218
  }
5151
5219
  if (entry.refs.reasoningBox) {
5152
5220
  entry.refs.reasoningBox.visible = !!visibleReasoning;
@@ -5562,11 +5630,24 @@ function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key,
5562
5630
  wrapMode: "none",
5563
5631
  });
5564
5632
  refs.reasoningToggleText = labelText;
5565
- refs.reasoningStreaming = message.streaming === true;
5566
- const markdown = createMarkdown(ctx, formatThinkingMarkdown(visibleReasoning ?? ""), subtleSyntaxStyle, {
5567
- streaming: message.streaming === true,
5633
+ const streamingReasoning = message.streaming === true;
5634
+ refs.reasoningStreaming = streamingReasoning;
5635
+ // While the model is still streaming we render reasoning as plain text — a
5636
+ // single TextRenderable.content update is cheap, whereas re-parsing markdown
5637
+ // (treesitter + cache clear) per token grows to O(N²) and freezes the TUI.
5638
+ // The markdown variant is parsed once at turn_end and only then becomes
5639
+ // visible.
5640
+ const plainText = createText(ctx, formatThinkingMarkdown(visibleReasoning ?? ""), {
5641
+ fg: theme.messageThinkingContentText,
5642
+ wrapMode: "word",
5643
+ visible: streamingReasoning && !!visibleReasoning,
5644
+ });
5645
+ refs.reasoningPlainText = plainText;
5646
+ const markdown = createMarkdown(ctx, streamingReasoning ? "" : formatThinkingMarkdown(visibleReasoning ?? ""), subtleSyntaxStyle, {
5647
+ streaming: false,
5568
5648
  fg: theme.messageThinkingContentText,
5569
5649
  });
5650
+ markdown.visible = !streamingReasoning && !!visibleReasoning;
5570
5651
  refs.reasoningMarkdown = markdown;
5571
5652
  const reasoningBox = createBox(ctx, {
5572
5653
  paddingLeft: 2,
@@ -5580,6 +5661,7 @@ function createAssistantEntry(ctx, message, syntaxStyle, subtleSyntaxStyle, key,
5580
5661
  createBox(ctx, {
5581
5662
  flexShrink: 0,
5582
5663
  }, [labelText]),
5664
+ plainText,
5583
5665
  markdown,
5584
5666
  ]);
5585
5667
  refs.reasoningBox = reasoningBox;
@@ -5886,11 +5968,16 @@ function renderTool(tool, syntaxStyle, width = 80) {
5886
5968
  return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 1, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, renderDiffContent(diff, toolPath(tool), syntaxStyle, width)));
5887
5969
  }
5888
5970
  if (isWritePreviewTool(tool)) {
5889
- const preview = formatWritePreview(tool.args.content, false);
5890
- const summary = tool.result ?? `${isToolFinished(tool) ? "Prepared" : "Writing"} ${tool.args.content.split(/\r?\n/).length} lines to ${toolPath(tool) ?? "file"}`;
5891
- return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 0, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, h("text", { fg: theme.textMuted }, `└ ${summary}`), renderCodeBlockContent(preview.content, toolPath(tool), syntaxStyle), preview.omittedLines > 0
5971
+ const hasContent = typeof tool.args.content === "string";
5972
+ const contentStr = hasContent ? String(tool.args.content) : "";
5973
+ const preview = hasContent ? formatWritePreview(contentStr, false) : null;
5974
+ const lineCount = hasContent
5975
+ ? contentStr.split(/\r?\n/).length
5976
+ : (tool.streamingNewlineCount ?? 0) + 1;
5977
+ const summary = tool.result ?? `${isToolFinished(tool) ? "Prepared" : "Writing"} ${lineCount} lines to ${toolPath(tool) ?? "file"}`;
5978
+ return h("box", { paddingLeft: 3, marginTop: 1, flexDirection: "column", flexShrink: 0 }, h("text", { fg: color }, `${icon} ${displayToolName(tool.name)}${toolHeader(tool) ? ` ${toolHeader(tool)}` : ""}`), h("box", { paddingLeft: 1, marginTop: 0, border: ["left"], borderColor: theme.borderSubtle, flexDirection: "column", flexShrink: 0 }, h("text", { fg: theme.textMuted }, `└ ${summary}`), preview ? renderCodeBlockContent(preview.content, toolPath(tool), syntaxStyle) : null, preview && preview.omittedLines > 0
5892
5979
  ? h("text", { fg: theme.textMuted }, `... +${preview.omittedLines} lines (ctrl+o to expand)`)
5893
- : preview.omittedChars > 0
5980
+ : preview && preview.omittedChars > 0
5894
5981
  ? h("text", { fg: theme.textMuted }, `... +${preview.omittedChars} chars (ctrl+o to expand)`)
5895
5982
  : null));
5896
5983
  }
@@ -6750,8 +6837,13 @@ function formatDuration(ms) {
6750
6837
  return `${seconds.toFixed(1)}s`;
6751
6838
  if (seconds < 60)
6752
6839
  return `${Math.round(seconds)}s`;
6753
- const minutes = Math.floor(seconds / 60);
6754
- const remSec = Math.round(seconds - minutes * 60);
6840
+ let minutes = Math.floor(seconds / 60);
6841
+ let remSec = Math.round(seconds - minutes * 60);
6842
+ // Math.round can lift remSec to exactly 60 (e.g. 239.6s → 3m60s). Carry into minutes.
6843
+ if (remSec >= 60) {
6844
+ minutes += Math.floor(remSec / 60);
6845
+ remSec = remSec % 60;
6846
+ }
6755
6847
  return remSec === 0 ? `${minutes}m` : `${minutes}m${remSec}s`;
6756
6848
  }
6757
6849
  function reasoningElapsedMs(message) {
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Extract user-visible signals from a partial tool-call JSON buffer.
3
+ *
4
+ * We deliberately do NOT attempt a full partial-JSON parse. The goal is just
5
+ * to surface the file path (so the tool header can render) and a coarse
6
+ * "how much has been streamed" hint, both available the moment the model has
7
+ * emitted enough text for them to be unambiguous.
8
+ */
9
+ export interface StreamingArgsHint {
10
+ /** First fully-closed string value found for a known path field. */
11
+ path?: string;
12
+ /** Count of escaped newline sequences (`\n`) seen so far — proxy for written line count. */
13
+ newlineCount: number;
14
+ }
15
+ export declare function extractStreamingArgsHint(raw: string): StreamingArgsHint;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Extract user-visible signals from a partial tool-call JSON buffer.
3
+ *
4
+ * We deliberately do NOT attempt a full partial-JSON parse. The goal is just
5
+ * to surface the file path (so the tool header can render) and a coarse
6
+ * "how much has been streamed" hint, both available the moment the model has
7
+ * emitted enough text for them to be unambiguous.
8
+ */
9
+ const PATH_FIELDS = ["path", "file_path", "filePath"];
10
+ export function extractStreamingArgsHint(raw) {
11
+ let path;
12
+ for (const field of PATH_FIELDS) {
13
+ const re = new RegExp(`"${field}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`);
14
+ const m = raw.match(re);
15
+ if (m) {
16
+ try {
17
+ path = JSON.parse(`"${m[1]}"`);
18
+ break;
19
+ }
20
+ catch {
21
+ // The matched substring ended mid-escape; ignore and wait for more.
22
+ }
23
+ }
24
+ }
25
+ const newlines = raw.match(/\\n/g);
26
+ return {
27
+ path,
28
+ newlineCount: newlines ? newlines.length : 0,
29
+ };
30
+ }
@@ -2,7 +2,7 @@ import type { DisplayToolCall } from "../display-history.js";
2
2
  export declare const WRITE_PREVIEW_CHAR_LIMIT = 5000;
3
3
  export declare function isWritePreviewTool(tool: DisplayToolCall): tool is DisplayToolCall & {
4
4
  args: {
5
- content: string;
5
+ content?: string;
6
6
  };
7
7
  };
8
8
  export declare function formatWritePreview(content: string, expanded: boolean): {
@@ -1,7 +1,15 @@
1
1
  const WRITE_PREVIEW_LINE_LIMIT = 10;
2
2
  export const WRITE_PREVIEW_CHAR_LIMIT = 5000;
3
3
  export function isWritePreviewTool(tool) {
4
- return !tool.isError && tool.name === "write" && typeof tool.args?.content === "string";
4
+ if (tool.isError)
5
+ return false;
6
+ if (tool.name !== "write")
7
+ return false;
8
+ if (typeof tool.args?.content === "string")
9
+ return true;
10
+ // While the model is still streaming the JSON args, content may not be
11
+ // populated yet — keep ownership so the header renders progressively.
12
+ return tool.streamingArgs === true;
5
13
  }
6
14
  export function formatWritePreview(content, expanded) {
7
15
  const lines = content.split(/\r?\n/);
@@ -19,7 +19,7 @@ export function writeToolExpansionDigest(message, messageKey, expandedWrites) {
19
19
  export function writeToolExpansionSignature(messageKey, tool, expandedWrites) {
20
20
  if (!isWritePreviewTool(tool))
21
21
  return "";
22
- const content = tool.args.content;
22
+ const content = typeof tool.args.content === "string" ? tool.args.content : "";
23
23
  return [
24
24
  tool.id,
25
25
  expandedWrites.has(writeToolKey(messageKey, tool)) ? "expanded" : "collapsed",
@@ -34,16 +34,20 @@ function renderWriteTool({ ctx, tool, syntaxStyle, writeExpanded, onToggleWrite,
34
34
  const color = helpers.toolColor(tool);
35
35
  const icon = "●";
36
36
  const header = helpers.toolHeader(tool);
37
- const preview = formatWritePreview(String(tool.args.content), writeExpanded);
38
- const writeLineCount = String(tool.args.content).split(/\r?\n/).length;
37
+ const hasContent = typeof tool.args.content === "string";
38
+ const contentStr = hasContent ? String(tool.args.content) : "";
39
+ const preview = hasContent ? formatWritePreview(contentStr, writeExpanded) : null;
40
+ const writeLineCount = hasContent
41
+ ? contentStr.split(/\r?\n/).length
42
+ : (tool.streamingNewlineCount ?? 0) + 1;
39
43
  const summary = tool.result
40
44
  ? helpers.summarizeToolResult(tool)
41
45
  : `${helpers.isToolFinished(tool) ? "Prepared" : "Writing"} ${writeLineCount} line${writeLineCount === 1 ? "" : "s"} to ${helpers.toolPath(tool) ?? "file"}`;
42
- const hint = preview.omittedLines > 0
46
+ const hint = preview && preview.omittedLines > 0
43
47
  ? `... +${preview.omittedLines} lines (${writeExpanded ? "ctrl+o to collapse" : "ctrl+o to expand"})`
44
- : preview.omittedChars > 0
48
+ : preview && preview.omittedChars > 0
45
49
  ? `... +${preview.omittedChars} chars (${writeExpanded ? "ctrl+o to collapse" : "ctrl+o to expand"})`
46
- : writeExpanded
50
+ : preview && writeExpanded
47
51
  ? "(ctrl+o to collapse)"
48
52
  : "";
49
53
  return helpers.createBox(ctx, {
@@ -70,7 +74,9 @@ function renderWriteTool({ ctx, tool, syntaxStyle, writeExpanded, onToggleWrite,
70
74
  fg: tool.isError ? theme.toolError : theme.textMuted,
71
75
  onMouseUp: onToggleWrite,
72
76
  }),
73
- helpers.createCodeBlockRenderable(ctx, preview.content, helpers.toolPath(tool), syntaxStyle),
77
+ preview
78
+ ? helpers.createCodeBlockRenderable(ctx, preview.content, helpers.toolPath(tool), syntaxStyle)
79
+ : null,
74
80
  hint
75
81
  ? helpers.createText(ctx, hint, {
76
82
  fg: theme.textMuted,
package/dist/types.d.ts CHANGED
@@ -72,9 +72,23 @@ export interface ToolCall {
72
72
  id: string;
73
73
  name: string;
74
74
  arguments: string;
75
+ /**
76
+ * Provider-side flag set when the streamed arguments were unsalvageable
77
+ * (truncated mid-JSON, malformed snapshot). Persists into history so the
78
+ * model and the orchestrator can both see the call was rejected upstream
79
+ * rather than executed silently with empty args.
80
+ */
81
+ argsCorrupt?: boolean;
75
82
  }
76
83
  export interface ParsedToolCall extends ToolCall {
77
84
  parsedArgs: Record<string, any>;
85
+ /**
86
+ * Set when the raw `arguments` string failed to JSON.parse, indicating
87
+ * upstream streaming corruption (truncated chunks, malformed deltas, etc.).
88
+ * Consumers should refuse to execute the tool and surface a tool_use_error
89
+ * so the model can re-issue the call.
90
+ */
91
+ argsCorrupt?: boolean;
78
92
  }
79
93
  export type ToolResultStatus = "success" | "no_match" | "partial" | "timeout" | "blocked" | "command_error";
80
94
  export interface ToolResultMetadata {
@@ -214,6 +228,7 @@ export type StreamChunk = {
214
228
  isStart: boolean;
215
229
  isEnd: boolean;
216
230
  argumentsFull?: string;
231
+ argumentsCorrupt?: boolean;
217
232
  } | {
218
233
  type: "usage";
219
234
  usage: TokenUsage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {