@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.
- package/dist/agent/task-size.d.ts +9 -0
- package/dist/agent/task-size.js +33 -0
- package/dist/agent/tool-intent.d.ts +1 -0
- package/dist/agent/tool-intent.js +1 -1
- package/dist/agent.js +46 -2
- package/dist/orchestrator/default-hooks.js +80 -69
- package/dist/orchestrator/hooks.d.ts +5 -8
- package/dist/prompt/compose.js +3 -0
- package/dist/prompt/environment.js +2 -0
- package/dist/prompt/provider-prompts/deepseek.js +1 -2
- package/dist/prompt/provider-prompts/kimi.js +1 -2
- package/dist/prompt/reminders.d.ts +20 -3
- package/dist/prompt/reminders.js +43 -17
- package/dist/prompt/runtime.js +17 -23
- package/dist/provider.d.ts +10 -1
- package/dist/provider.js +87 -34
- package/dist/tools/bash.d.ts +2 -1
- package/dist/tools/bash.js +1 -1
- package/dist/tools/edit-apply.js +37 -6
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +18 -6
- package/dist/tools/file-state.d.ts +25 -0
- package/dist/tools/file-state.js +52 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +6 -4
- package/dist/tools/read.d.ts +2 -1
- package/dist/tools/read.js +5 -1
- package/dist/tools/write.d.ts +4 -3
- package/dist/tools/write.js +133 -54
- package/dist/tui/display-history.d.ts +2 -0
- package/dist/tui/run.js +115 -23
- package/dist/tui/streaming-tool-args.d.ts +15 -0
- package/dist/tui/streaming-tool-args.js +30 -0
- package/dist/tui/tool-renderers/write-preview.d.ts +1 -1
- package/dist/tui/tool-renderers/write-preview.js +9 -1
- package/dist/tui/tool-renderers/write.js +13 -7
- package/dist/types.d.ts +15 -0
- package/package.json +1 -1
package/dist/tools/write.js
CHANGED
|
@@ -1,86 +1,165 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Write tool - create or
|
|
2
|
+
* Write tool - create files or safely replace full file contents.
|
|
3
3
|
*/
|
|
4
|
-
import {
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
content
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
5199
|
+
entry.refs.reasoningStreaming = streamingReasoning;
|
|
5144
5200
|
entry.refs.reasoningToggleText.content = visibleReasoning
|
|
5145
|
-
? thinkingLabelContent(
|
|
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
|
-
|
|
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
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
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
|
|
5890
|
-
const
|
|
5891
|
-
|
|
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
|
-
|
|
6754
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
38
|
-
const
|
|
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
|
-
|
|
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;
|