@bubblebrain-ai/bubble 0.0.24 → 0.0.26

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 (171) hide show
  1. package/README.md +5 -3
  2. package/dist/agent.js +1 -1
  3. package/dist/clipboard.d.ts +14 -0
  4. package/dist/clipboard.js +132 -0
  5. package/dist/config.d.ts +3 -0
  6. package/dist/config.js +22 -6
  7. package/dist/goal/format.js +34 -4
  8. package/dist/goal/store.d.ts +3 -0
  9. package/dist/goal/store.js +14 -1
  10. package/dist/goal/usage.d.ts +2 -0
  11. package/dist/goal/usage.js +3 -0
  12. package/dist/main.js +23 -42
  13. package/dist/model-catalog.d.ts +3 -1
  14. package/dist/model-catalog.js +17 -28
  15. package/dist/prompt/compose.js +1 -1
  16. package/dist/provider-anthropic.d.ts +4 -0
  17. package/dist/provider-anthropic.js +31 -0
  18. package/dist/provider-ark-responses.d.ts +17 -0
  19. package/dist/provider-ark-responses.js +462 -0
  20. package/dist/provider-transform.js +7 -0
  21. package/dist/provider.d.ts +7 -0
  22. package/dist/provider.js +170 -27
  23. package/dist/slash-commands/commands.js +22 -0
  24. package/dist/tools/todo.js +22 -38
  25. package/dist/tui/detect-theme.d.ts +1 -0
  26. package/dist/tui/detect-theme.js +23 -0
  27. package/dist/tui/image-display.d.ts +13 -0
  28. package/dist/tui/image-display.js +49 -0
  29. package/dist/tui/input-history.d.ts +37 -6
  30. package/dist/tui/input-history.js +194 -23
  31. package/dist/tui/model-switch.d.ts +42 -0
  32. package/dist/tui/model-switch.js +55 -0
  33. package/dist/tui-ink/app.d.ts +32 -2
  34. package/dist/tui-ink/app.js +1409 -549
  35. package/dist/tui-ink/approval/select.js +10 -0
  36. package/dist/tui-ink/detect-theme.d.ts +1 -2
  37. package/dist/tui-ink/detect-theme.js +1 -87
  38. package/dist/tui-ink/display-history.d.ts +1 -0
  39. package/dist/tui-ink/display-history.js +11 -0
  40. package/dist/tui-ink/feedback-dialog.js +10 -0
  41. package/dist/tui-ink/feishu-setup-picker.js +10 -0
  42. package/dist/tui-ink/footer.d.ts +1 -0
  43. package/dist/tui-ink/footer.js +8 -2
  44. package/dist/tui-ink/input-box.d.ts +71 -9
  45. package/dist/tui-ink/input-box.js +359 -121
  46. package/dist/tui-ink/input-history.d.ts +1 -16
  47. package/dist/tui-ink/input-history.js +1 -79
  48. package/dist/tui-ink/input-queue.d.ts +12 -0
  49. package/dist/tui-ink/input-queue.js +17 -0
  50. package/dist/tui-ink/key-events.d.ts +9 -0
  51. package/dist/tui-ink/key-events.js +8 -0
  52. package/dist/tui-ink/markdown.js +1 -1
  53. package/dist/tui-ink/message-list.d.ts +19 -1
  54. package/dist/tui-ink/message-list.js +111 -32
  55. package/dist/tui-ink/model-picker.d.ts +25 -2
  56. package/dist/tui-ink/model-picker.js +237 -20
  57. package/dist/tui-ink/plan-confirm.js +10 -0
  58. package/dist/tui-ink/question-dialog.js +46 -10
  59. package/dist/tui-ink/run.d.ts +10 -1
  60. package/dist/tui-ink/run.js +27 -42
  61. package/dist/tui-ink/session-picker.js +3 -0
  62. package/dist/tui-ink/submit-dedupe.d.ts +5 -0
  63. package/dist/tui-ink/submit-dedupe.js +25 -0
  64. package/dist/tui-ink/terminal-mouse.d.ts +24 -1
  65. package/dist/tui-ink/terminal-mouse.js +76 -21
  66. package/dist/tui-ink/theme.d.ts +6 -3
  67. package/dist/tui-ink/theme.js +10 -4
  68. package/dist/tui-ink/welcome.d.ts +1 -0
  69. package/dist/tui-ink/welcome.js +34 -27
  70. package/dist/variant/variant-resolver.js +4 -1
  71. package/package.json +1 -5
  72. package/dist/tui/clipboard.d.ts +0 -1
  73. package/dist/tui/clipboard.js +0 -53
  74. package/dist/tui/escape-confirmation.d.ts +0 -15
  75. package/dist/tui/escape-confirmation.js +0 -30
  76. package/dist/tui/global-key-router.d.ts +0 -3
  77. package/dist/tui/global-key-router.js +0 -87
  78. package/dist/tui/markdown-inline.d.ts +0 -22
  79. package/dist/tui/markdown-inline.js +0 -68
  80. package/dist/tui/markdown-theme-rules.d.ts +0 -23
  81. package/dist/tui/markdown-theme-rules.js +0 -164
  82. package/dist/tui/markdown-theme.d.ts +0 -5
  83. package/dist/tui/markdown-theme.js +0 -27
  84. package/dist/tui/opencode-spinner.d.ts +0 -22
  85. package/dist/tui/opencode-spinner.js +0 -216
  86. package/dist/tui/prompt-keybindings.d.ts +0 -42
  87. package/dist/tui/prompt-keybindings.js +0 -35
  88. package/dist/tui/render-signature.d.ts +0 -1
  89. package/dist/tui/render-signature.js +0 -7
  90. package/dist/tui/run.d.ts +0 -67
  91. package/dist/tui/run.js +0 -10166
  92. package/dist/tui/sidebar-mcp.d.ts +0 -31
  93. package/dist/tui/sidebar-mcp.js +0 -62
  94. package/dist/tui/sidebar-state.d.ts +0 -12
  95. package/dist/tui/sidebar-state.js +0 -69
  96. package/dist/tui/streaming-tool-args.d.ts +0 -15
  97. package/dist/tui/streaming-tool-args.js +0 -30
  98. package/dist/tui/tool-renderers/fallback.d.ts +0 -2
  99. package/dist/tui/tool-renderers/fallback.js +0 -75
  100. package/dist/tui/tool-renderers/registry.d.ts +0 -3
  101. package/dist/tui/tool-renderers/registry.js +0 -11
  102. package/dist/tui/tool-renderers/subagent.d.ts +0 -2
  103. package/dist/tui/tool-renderers/subagent.js +0 -135
  104. package/dist/tui/tool-renderers/types.d.ts +0 -36
  105. package/dist/tui/tool-renderers/types.js +0 -1
  106. package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
  107. package/dist/tui/tool-renderers/write-preview.js +0 -32
  108. package/dist/tui/tool-renderers/write.d.ts +0 -6
  109. package/dist/tui/tool-renderers/write.js +0 -88
  110. package/dist/tui/transcript-scroll.d.ts +0 -25
  111. package/dist/tui/transcript-scroll.js +0 -20
  112. package/dist/tui-ink/transcript-viewport-math.d.ts +0 -11
  113. package/dist/tui-ink/transcript-viewport-math.js +0 -17
  114. package/dist/tui-ink/transcript-viewport.d.ts +0 -24
  115. package/dist/tui-ink/transcript-viewport.js +0 -83
  116. package/dist/tui-opentui/app.d.ts +0 -54
  117. package/dist/tui-opentui/app.js +0 -1371
  118. package/dist/tui-opentui/approval/approval-dialog.d.ts +0 -15
  119. package/dist/tui-opentui/approval/approval-dialog.js +0 -155
  120. package/dist/tui-opentui/approval/diff-view.d.ts +0 -9
  121. package/dist/tui-opentui/approval/diff-view.js +0 -43
  122. package/dist/tui-opentui/approval/select.d.ts +0 -37
  123. package/dist/tui-opentui/approval/select.js +0 -91
  124. package/dist/tui-opentui/detect-theme.d.ts +0 -2
  125. package/dist/tui-opentui/detect-theme.js +0 -87
  126. package/dist/tui-opentui/display-history.d.ts +0 -56
  127. package/dist/tui-opentui/display-history.js +0 -130
  128. package/dist/tui-opentui/edit-diff.d.ts +0 -11
  129. package/dist/tui-opentui/edit-diff.js +0 -57
  130. package/dist/tui-opentui/feedback-dialog.d.ts +0 -21
  131. package/dist/tui-opentui/feedback-dialog.js +0 -164
  132. package/dist/tui-opentui/feishu-setup-picker.d.ts +0 -7
  133. package/dist/tui-opentui/feishu-setup-picker.js +0 -272
  134. package/dist/tui-opentui/file-mentions.d.ts +0 -29
  135. package/dist/tui-opentui/file-mentions.js +0 -174
  136. package/dist/tui-opentui/footer.d.ts +0 -26
  137. package/dist/tui-opentui/footer.js +0 -40
  138. package/dist/tui-opentui/image-paste.d.ts +0 -54
  139. package/dist/tui-opentui/image-paste.js +0 -288
  140. package/dist/tui-opentui/input-box.d.ts +0 -32
  141. package/dist/tui-opentui/input-box.js +0 -462
  142. package/dist/tui-opentui/input-history.d.ts +0 -16
  143. package/dist/tui-opentui/input-history.js +0 -79
  144. package/dist/tui-opentui/markdown.d.ts +0 -66
  145. package/dist/tui-opentui/markdown.js +0 -127
  146. package/dist/tui-opentui/message-list.d.ts +0 -31
  147. package/dist/tui-opentui/message-list.js +0 -131
  148. package/dist/tui-opentui/model-picker.d.ts +0 -63
  149. package/dist/tui-opentui/model-picker.js +0 -450
  150. package/dist/tui-opentui/plan-confirm.d.ts +0 -9
  151. package/dist/tui-opentui/plan-confirm.js +0 -124
  152. package/dist/tui-opentui/question-dialog.d.ts +0 -10
  153. package/dist/tui-opentui/question-dialog.js +0 -110
  154. package/dist/tui-opentui/recent-activity.d.ts +0 -8
  155. package/dist/tui-opentui/recent-activity.js +0 -71
  156. package/dist/tui-opentui/run-session-picker.d.ts +0 -10
  157. package/dist/tui-opentui/run-session-picker.js +0 -28
  158. package/dist/tui-opentui/run.d.ts +0 -38
  159. package/dist/tui-opentui/run.js +0 -48
  160. package/dist/tui-opentui/session-picker.d.ts +0 -12
  161. package/dist/tui-opentui/session-picker.js +0 -120
  162. package/dist/tui-opentui/theme.d.ts +0 -89
  163. package/dist/tui-opentui/theme.js +0 -157
  164. package/dist/tui-opentui/todos.d.ts +0 -9
  165. package/dist/tui-opentui/todos.js +0 -45
  166. package/dist/tui-opentui/trace-groups.d.ts +0 -27
  167. package/dist/tui-opentui/trace-groups.js +0 -455
  168. package/dist/tui-opentui/use-terminal-size.d.ts +0 -4
  169. package/dist/tui-opentui/use-terminal-size.js +0 -5
  170. package/dist/tui-opentui/welcome.d.ts +0 -25
  171. package/dist/tui-opentui/welcome.js +0 -77
package/dist/provider.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import OpenAI from "openai";
7
7
  import { appendFileSync } from "node:fs";
8
8
  import { createAnthropicMessagesProvider } from "./provider-anthropic.js";
9
+ import { createArkResponsesProvider } from "./provider-ark-responses.js";
9
10
  import { createOpenAICodexProvider, isOpenAICodexBaseUrl } from "./provider-openai-codex.js";
10
11
  import { createProviderProtocolArtifactFilter } from "./provider-artifacts.js";
11
12
  import { resolveProviderRequestConfig } from "./provider-transform.js";
@@ -76,9 +77,13 @@ export function createUnavailableProvider(message) {
76
77
  return { streamChat, complete };
77
78
  }
78
79
  export function createProviderInstance(options) {
79
- if (resolveProviderProtocol(options) === "anthropic-messages") {
80
+ const protocol = resolveProviderProtocol(options);
81
+ if (protocol === "anthropic-messages") {
80
82
  return createAnthropicMessagesProvider(options);
81
83
  }
84
+ if (protocol === "ark-responses") {
85
+ return createArkResponsesProvider(options);
86
+ }
82
87
  if (isOpenAICodexBaseUrl(options.baseURL)) {
83
88
  return createOpenAICodexProvider({
84
89
  ...options,
@@ -110,8 +115,10 @@ export function createProviderInstance(options) {
110
115
  tool_choice: tools && tools.length > 0 ? chatOptions.toolChoice ?? "auto" : undefined,
111
116
  stream: true,
112
117
  };
113
- // DeepSeek and MiniMax only emit final usage in streaming mode when this flag is set.
114
- if (options.providerId === "deepseek" || isMiniMaxOpenAICompatible(options)) {
118
+ // Several OpenAI-compatible streaming APIs only emit final usage when this
119
+ // flag is set. Without it, downstream goal/stat accounting can only report
120
+ // "usage unavailable".
121
+ if (shouldRequestStreamUsage(options)) {
115
122
  body.stream_options = { include_usage: true };
116
123
  }
117
124
  if (!requestConfig.omitTemperature) {
@@ -129,28 +136,39 @@ export function createProviderInstance(options) {
129
136
  if (requestConfig.reasoningEffort && requestConfig.reasoningEffort !== "off") {
130
137
  body.reasoning = { enabled: true };
131
138
  }
139
+ const createCompletion = async (requestBody) => {
140
+ try {
141
+ return await client.chat.completions.create(requestBody, {
142
+ signal: chatOptions.abortSignal,
143
+ ...(chatOptions.rateLimitPolicy === "defer" ? { maxRetries: 0 } : {}),
144
+ });
145
+ }
146
+ catch (error) {
147
+ if (error?.status === 429) {
148
+ const retryAfterHeader = error?.headers?.["retry-after"];
149
+ const retryAfterSeconds = Number(retryAfterHeader);
150
+ throw new RateLimitError(error?.message || "Rate limited (429)", {
151
+ status: 429,
152
+ retryAfterMs: Number.isFinite(retryAfterSeconds) ? Math.round(retryAfterSeconds * 1000) : undefined,
153
+ cause: error,
154
+ });
155
+ }
156
+ throw error;
157
+ }
158
+ };
159
+ if (shouldUseNonStreamingToolCalls(options, tools, chatOptions.toolChoice)) {
160
+ body.stream = false;
161
+ delete body.stream_options;
162
+ const response = await createCompletion(body);
163
+ yield* translateOpenAIFullResponse(response);
164
+ yield { type: "done" };
165
+ return;
166
+ }
132
167
  // Rate-limit contract (design §4.5): "defer" disables the SDK's own
133
168
  // retries so the caller is the single 429 backoff layer; either policy
134
169
  // surfaces a final 429 as a typed RateLimitError instead of a string.
135
170
  let stream;
136
- try {
137
- stream = (await client.chat.completions.create(body, {
138
- signal: chatOptions.abortSignal,
139
- ...(chatOptions.rateLimitPolicy === "defer" ? { maxRetries: 0 } : {}),
140
- }));
141
- }
142
- catch (error) {
143
- if (error?.status === 429) {
144
- const retryAfterHeader = error?.headers?.["retry-after"];
145
- const retryAfterSeconds = Number(retryAfterHeader);
146
- throw new RateLimitError(error?.message || "Rate limited (429)", {
147
- status: 429,
148
- retryAfterMs: Number.isFinite(retryAfterSeconds) ? Math.round(retryAfterSeconds * 1000) : undefined,
149
- cause: error,
150
- });
151
- }
152
- throw error;
153
- }
171
+ stream = (await createCompletion(body));
154
172
  yield* translateOpenAIStream(stream, {
155
173
  toolArgsMergeMode: resolveToolArgsMergeMode(options.providerId || "", options.baseURL),
156
174
  reasoningMergeMode: resolveReasoningMergeMode(options.providerId || "", options.baseURL),
@@ -192,6 +210,10 @@ function resolveProviderProtocol(options) {
192
210
  return options.protocol;
193
211
  const providerId = (options.providerId || "").toLowerCase();
194
212
  const baseURL = options.baseURL.toLowerCase();
213
+ if (providerId === "doubao"
214
+ && baseURL.replace(/\/+$/, "") === "https://ark.cn-beijing.volces.com/api/v3") {
215
+ return "ark-responses";
216
+ }
195
217
  if (providerId === "anthropic"
196
218
  || providerId.endsWith("-anthropic")
197
219
  || baseURL.includes("/anthropic")) {
@@ -207,6 +229,24 @@ function isMiniMaxOpenAICompatible(options) {
207
229
  || baseURL.includes("api.minimaxi.com/v1")
208
230
  || baseURL.includes("api.minimax.io/v1");
209
231
  }
232
+ function shouldRequestStreamUsage(options) {
233
+ const providerId = (options.providerId || "").toLowerCase();
234
+ return providerId === "openai"
235
+ || providerId === "deepseek"
236
+ || providerId === "moonshot-cn"
237
+ || providerId === "moonshot-intl"
238
+ || providerId === "zhipuai"
239
+ || providerId === "zhipuai-coding-plan"
240
+ || providerId === "zai"
241
+ || providerId === "zai-coding-plan"
242
+ || isMiniMaxOpenAICompatible(options);
243
+ }
244
+ function shouldUseNonStreamingToolCalls(options, tools, toolChoice) {
245
+ return (options.providerId || "").toLowerCase() === "doubao"
246
+ && !!tools
247
+ && tools.length > 0
248
+ && toolChoice !== "none";
249
+ }
210
250
  export function normalizeToolArgsDetailed(raw) {
211
251
  const s = (raw ?? "").trim();
212
252
  if (!s) {
@@ -307,6 +347,101 @@ function extractBalancedJson(s, start) {
307
347
  }
308
348
  return null;
309
349
  }
350
+ /**
351
+ * Convert a non-streaming OpenAI-compatible chat-completions response into the
352
+ * same chunk protocol used by the streaming adapter. This is used for provider
353
+ * tool-call paths where streamed function arguments are not reliable enough to
354
+ * execute safely.
355
+ */
356
+ export async function* translateOpenAIFullResponse(response) {
357
+ const usageChunk = usageToStreamChunk(response?.usage);
358
+ if (usageChunk)
359
+ yield usageChunk;
360
+ const choice = response?.choices?.[0];
361
+ const finishReason = choice?.finish_reason;
362
+ const truncatedByLength = finishReason === "length";
363
+ const message = choice?.message;
364
+ if (!message)
365
+ return;
366
+ const reasoningDetails = extractReasoningDetailsText(message.reasoning_details);
367
+ const reasoning = reasoningDetails
368
+ ?? (typeof message.reasoning === "string" ? message.reasoning : undefined)
369
+ ?? (typeof message.thinking === "string" ? message.thinking : undefined)
370
+ ?? (typeof message.reasoning_content === "string" ? message.reasoning_content : undefined);
371
+ if (reasoning) {
372
+ yield { type: "reasoning_delta", content: reasoning };
373
+ }
374
+ if (typeof message.content === "string" && message.content) {
375
+ const textFilter = createProviderProtocolArtifactFilter();
376
+ const cleaned = textFilter.push(message.content) + textFilter.flush();
377
+ if (cleaned) {
378
+ yield { type: "text", content: cleaned };
379
+ }
380
+ }
381
+ const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
382
+ for (let index = 0; index < toolCalls.length; index += 1) {
383
+ const toolCall = toolCalls[index];
384
+ const name = typeof toolCall?.function?.name === "string" ? toolCall.function.name : "";
385
+ if (!name)
386
+ continue;
387
+ const id = typeof toolCall?.id === "string" && toolCall.id
388
+ ? toolCall.id
389
+ : `call_${index}`;
390
+ const rawArgs = typeof toolCall?.function?.arguments === "string"
391
+ ? toolCall.function.arguments
392
+ : JSON.stringify(toolCall?.function?.arguments ?? {});
393
+ const normalized = normalizeToolArgsDetailed(rawArgs);
394
+ const corrupt = normalized.corrupt || truncatedByLength;
395
+ debugToolArgs({
396
+ stage: "full-response-tool-call",
397
+ id,
398
+ name,
399
+ entryArgs: rawArgs,
400
+ finalArgs: normalized.args,
401
+ finishReason,
402
+ corrupt,
403
+ });
404
+ yield { type: "tool_call", id, name, arguments: "", isStart: true, isEnd: false };
405
+ if (rawArgs) {
406
+ yield { type: "tool_call", id, name, arguments: rawArgs, isStart: false, isEnd: false };
407
+ }
408
+ yield {
409
+ type: "tool_call",
410
+ id,
411
+ name,
412
+ arguments: "",
413
+ argumentsFull: normalized.args,
414
+ argumentsCorrupt: corrupt || undefined,
415
+ isStart: false,
416
+ isEnd: true,
417
+ };
418
+ }
419
+ }
420
+ function usageToStreamChunk(usage) {
421
+ if (!usage)
422
+ return undefined;
423
+ return {
424
+ type: "usage",
425
+ usage: {
426
+ promptTokens: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : 0,
427
+ completionTokens: typeof usage.completion_tokens === "number" ? usage.completion_tokens : 0,
428
+ promptCacheHitTokens: typeof usage.prompt_cache_hit_tokens === "number"
429
+ ? usage.prompt_cache_hit_tokens
430
+ : typeof usage.prompt_tokens_details?.cached_tokens === "number"
431
+ ? usage.prompt_tokens_details.cached_tokens
432
+ : undefined,
433
+ promptCacheMissTokens: typeof usage.prompt_cache_miss_tokens === "number"
434
+ ? usage.prompt_cache_miss_tokens
435
+ : typeof usage.prompt_tokens_details?.cached_tokens === "number" && typeof usage.prompt_tokens === "number"
436
+ ? Math.max(0, usage.prompt_tokens - usage.prompt_tokens_details.cached_tokens)
437
+ : undefined,
438
+ reasoningTokens: typeof usage.completion_tokens_details?.reasoning_tokens === "number"
439
+ ? usage.completion_tokens_details.reasoning_tokens
440
+ : undefined,
441
+ totalTokens: typeof usage.total_tokens === "number" ? usage.total_tokens : undefined,
442
+ },
443
+ };
444
+ }
310
445
  /**
311
446
  * Convert an OpenAI-compatible chat-completions stream into our internal StreamChunk events.
312
447
  *
@@ -345,14 +480,15 @@ export async function* translateOpenAIStream(stream, options = {}) {
345
480
  }
346
481
  }
347
482
  const normalized = normalizeToolArgsDetailed(entry.args);
348
- debugToolArgs({ stage: "flush-end", id: entry.id, name: entry.name, entryArgs: entry.args, finalArgs: normalized.args, corrupt: normalized.corrupt });
483
+ const corrupt = normalized.corrupt || !!entry.corrupt;
484
+ debugToolArgs({ stage: "flush-end", id: entry.id, name: entry.name, entryArgs: entry.args, finalArgs: normalized.args, corrupt });
349
485
  yield {
350
486
  type: "tool_call",
351
487
  id: entry.id,
352
488
  name: entry.name,
353
489
  arguments: "",
354
490
  argumentsFull: normalized.args,
355
- argumentsCorrupt: normalized.corrupt || undefined,
491
+ argumentsCorrupt: corrupt || undefined,
356
492
  isStart: false,
357
493
  isEnd: true,
358
494
  };
@@ -370,9 +506,10 @@ export async function* translateOpenAIStream(stream, options = {}) {
370
506
  }
371
507
  for await (const chunk of stream) {
372
508
  rawChunkSeq += 1;
373
- const delta = chunk.choices?.[0]?.delta;
374
- const usage = chunk.usage;
375
- const finishReason = chunk.choices?.[0]?.finish_reason;
509
+ const choice = chunk.choices?.[0];
510
+ const delta = choice?.delta;
511
+ const usage = chunk.usage ?? choice?.usage;
512
+ const finishReason = choice?.finish_reason;
376
513
  debugReasoningStream({
377
514
  stage: "provider_raw",
378
515
  providerId: options.debugProviderId,
@@ -511,7 +648,13 @@ export async function* translateOpenAIStream(stream, options = {}) {
511
648
  }
512
649
  }
513
650
  }
514
- if (finishReason === "tool_calls") {
651
+ if (finishReason === "length") {
652
+ for (const entry of toolCalls.values()) {
653
+ entry.corrupt = true;
654
+ }
655
+ yield* flushToolCalls();
656
+ }
657
+ else if (finishReason === "tool_calls") {
515
658
  yield* flushToolCalls();
516
659
  }
517
660
  }
@@ -8,6 +8,7 @@ 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";
11
12
  import { formatRelativeTime } from "../tui/recent-activity.js";
12
13
  import { HOOK_EVENT_NAMES, isHookEventName } from "../hooks/index.js";
13
14
  import { isThinkingLevel } from "../variant/thinking-level.js";
@@ -402,6 +403,27 @@ const builtinSlashCommandEntries = [
402
403
  ctx.clearMessages();
403
404
  },
404
405
  },
406
+ {
407
+ name: "copy",
408
+ description: "Copy the last assistant message to the system clipboard",
409
+ async handler(args, ctx) {
410
+ const lastAssistant = [...ctx.agent.messages]
411
+ .reverse()
412
+ .find((m) => m.role === "assistant" && typeof m.content === "string" && m.content.trim().length > 0);
413
+ if (!lastAssistant || typeof lastAssistant.content !== "string") {
414
+ return "No assistant message to copy yet.";
415
+ }
416
+ const text = lastAssistant.content;
417
+ try {
418
+ await copyToClipboard(text);
419
+ }
420
+ catch (err) {
421
+ return `Failed to copy to clipboard: ${err?.message || String(err)}`;
422
+ }
423
+ const chars = text.length;
424
+ return `Copied last assistant message to clipboard (${chars} character${chars === 1 ? "" : "s"}).`;
425
+ },
426
+ },
405
427
  {
406
428
  name: "rewind",
407
429
  description: "Rewind conversation and/or file edits to before an earlier message. Usage: /rewind [n] [--code|--chat]",
@@ -13,50 +13,33 @@ export function createTodoTool(store) {
13
13
 
14
14
  ## When to use
15
15
 
16
- Use this tool proactively when any of these apply:
17
- 1. Complex multi-step work — 3 or more distinct steps or file locations
18
- 2. Non-trivial tasks requiring planning or coordination across multiple operations
19
- 3. The user explicitly asks for a todo list
20
- 4. The user provides a list of things to do (numbered, comma-separated, bulleted)
21
- 5. New instructions arrive mid-session — capture them as todos before starting
22
- 6. Starting work on a task — mark it in_progress BEFORE beginning. Only one item may be in_progress at a time
23
- 7. Finishing a task — mark it completed immediately, don't batch completions
16
+ Default to just doing the work. Reach for a list only when actively tracking progress would genuinely help you or the user follow it — never to pad simple work with filler steps or to state the obvious. When in doubt, skip the list and do the task; a list you never meaningfully update is just noise.
24
17
 
25
- ## When NOT to use
18
+ A list earns its place when:
19
+ - The task is non-trivial and spans many actions across several areas of the codebase
20
+ - There are non-obvious phases or dependencies you must hold in mind to avoid losing track (a plain read → edit → test sequence does not count)
21
+ - The work is ambiguous and benefits from outlining the goals up front
22
+ - The user asked for several distinct things in one prompt, or gave a numbered/bulleted list
23
+ - The user explicitly asked for a todo list (aka TODOs)
24
+ - You discover extra steps mid-task and intend to finish them before yielding
26
25
 
27
- Skip this tool when:
28
- 1. There is a single, straightforward task
29
- 2. The task is trivial and tracking provides no organizational benefit
30
- 3. The work can be completed in fewer than 3 trivial steps
31
- 4. The request is purely conversational or informational
26
+ ## Quality bar
32
27
 
33
- If there is only one trivial task, just do it don't create a todo first.
28
+ If you do make a list, make a good one: meaningful, logically ordered steps that are easy to verify as you go.
34
29
 
35
- ## Examples
30
+ Good — distinct, verifiable steps for genuinely multi-part work:
31
+ 1. Add CSS variables for the color palette
32
+ 2. Add the toggle with localStorage state
33
+ 3. Refactor components to use the variables
34
+ 4. Verify every view for readability
36
35
 
37
- <example>
38
- User: Add a dark mode toggle to the settings page, then run tests and build.
39
- Assistant: *creates a 5-item todo: toggle UI, theme state, CSS tokens, update components, run tests + build*
40
- <reasoning>Multiple distinct steps across UI, state, styles, and verification. User explicitly asked for tests + build.</reasoning>
41
- </example>
36
+ Good — scope a search uncovers makes the list worth it:
37
+ "Rename getCwd across the project" grep finds 15 call sites in 8 files → one item per file so none are missed.
42
38
 
43
- <example>
44
- User: Rename getCwd to getCurrentWorkingDirectory across the project.
45
- Assistant: *greps, finds 15 call sites across 8 files, creates a per-file todo list*
46
- <reasoning>Scope discovered via grep shows many locations; a todo ensures each file is tracked and none are missed.</reasoning>
47
- </example>
39
+ Bad — padding a task you could just do; do NOT create a list for this:
40
+ "Fix the typo in the README title" → 1. Find typo 2. Open file 3. Fix it 4. Save. That is one edit — just make it.
48
41
 
49
- <example>
50
- User: How do I print "Hello World" in Python?
51
- Assistant: *answers in one sentence with a snippet — no todo*
52
- <reasoning>Informational, one-step, no tracking benefit.</reasoning>
53
- </example>
54
-
55
- <example>
56
- User: Add a comment to calculateTotal explaining what it does.
57
- Assistant: *calls edit directly — no todo*
58
- <reasoning>Single, localized change in one file.</reasoning>
59
- </example>
42
+ Bad — vague, unverifiable filler: "Make it work", "Improve the styling", "Clean things up".
60
43
 
61
44
  ## Task states
62
45
 
@@ -73,7 +56,8 @@ Each item needs:
73
56
  - Update status in real time; mark completed IMMEDIATELY on finishing.
74
57
  - Never mark completed if tests are failing, implementation is partial, errors are unresolved, or needed files are missing — keep as in_progress.
75
58
  - When blocked, add a new task describing what must be resolved.
76
- - Remove items that are no longer relevant; don't leave stale entries.`,
59
+ - Remove items that are no longer relevant; don't leave stale entries.
60
+ - Do not re-send the list when nothing meaningful has changed since the last call; update only after real progress.`,
77
61
  parameters: {
78
62
  type: "object",
79
63
  properties: {
@@ -1,2 +1,3 @@
1
1
  export type ResolvedTheme = "light" | "dark";
2
2
  export declare function detectTerminalTheme(timeoutMs?: number): Promise<ResolvedTheme>;
3
+ export declare function themeFromMacOsAppearance(output: string | null | undefined): ResolvedTheme;
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  export async function detectTerminalTheme(timeoutMs = 150) {
2
3
  const fromEnv = parseColorFgBg(process.env.COLORFGBG);
3
4
  if (fromEnv)
@@ -7,6 +8,9 @@ export async function detectTerminalTheme(timeoutMs = 150) {
7
8
  if (fromOsc)
8
9
  return fromOsc;
9
10
  }
11
+ const fromOs = detectOsAppearanceTheme();
12
+ if (fromOs)
13
+ return fromOs;
10
14
  return "dark";
11
15
  }
12
16
  function parseColorFgBg(value) {
@@ -85,3 +89,22 @@ function relativeLuminance(r, g, b) {
85
89
  const channel = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
86
90
  return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
87
91
  }
92
+ function detectOsAppearanceTheme() {
93
+ if (process.platform !== "darwin")
94
+ return null;
95
+ try {
96
+ const output = execFileSync("/usr/bin/defaults", ["read", "-g", "AppleInterfaceStyle"], {
97
+ encoding: "utf8",
98
+ stdio: ["ignore", "pipe", "ignore"],
99
+ timeout: 100,
100
+ });
101
+ return themeFromMacOsAppearance(output);
102
+ }
103
+ catch {
104
+ // On macOS the key is absent in Light mode, and `defaults read` exits 1.
105
+ return "light";
106
+ }
107
+ }
108
+ export function themeFromMacOsAppearance(output) {
109
+ return output?.trim().toLowerCase() === "dark" ? "dark" : "light";
110
+ }
@@ -0,0 +1,13 @@
1
+ export interface ImageDisplayMessage {
2
+ content?: string | null;
3
+ }
4
+ export declare function imageDisplayLabel(index: number): string;
5
+ export declare function imageDisplayLabels(count: number, labelStart?: number): string[];
6
+ export declare function imageDisplayReferenceLine(label: string): string;
7
+ export declare function isImageDisplayReferenceLine(line: string): boolean;
8
+ export declare function splitImageDisplayContent(content: string): {
9
+ bodyLines: string[];
10
+ referenceLines: string[];
11
+ };
12
+ export declare function formatImageUserDisplayText(input: string, imageCount: number, labelStart?: number): string;
13
+ export declare function nextImageDisplayLabelStart(messages: Iterable<ImageDisplayMessage>): number;
@@ -0,0 +1,49 @@
1
+ export function imageDisplayLabel(index) {
2
+ return `[Image #${index}]`;
3
+ }
4
+ export function imageDisplayLabels(count, labelStart = 1) {
5
+ return Array.from({ length: Math.max(0, count) }, (_, index) => imageDisplayLabel(labelStart + index));
6
+ }
7
+ export function imageDisplayReferenceLine(label) {
8
+ return `└ ${label}`;
9
+ }
10
+ export function isImageDisplayReferenceLine(line) {
11
+ return /^└ \[Image #\d+\]$/.test(line.trimEnd());
12
+ }
13
+ export function splitImageDisplayContent(content) {
14
+ const bodyLines = [];
15
+ const referenceLines = [];
16
+ for (const line of content.split("\n")) {
17
+ if (isImageDisplayReferenceLine(line)) {
18
+ referenceLines.push(line);
19
+ }
20
+ else {
21
+ bodyLines.push(line);
22
+ }
23
+ }
24
+ return { bodyLines, referenceLines };
25
+ }
26
+ export function formatImageUserDisplayText(input, imageCount, labelStart = 1) {
27
+ if (imageCount <= 0)
28
+ return input;
29
+ const labels = imageDisplayLabels(imageCount, labelStart);
30
+ const base = input.trim();
31
+ const headline = base ? `${labels.join(" ")} ${base}` : labels.join(" ");
32
+ return [
33
+ headline,
34
+ ...labels.map(imageDisplayReferenceLine),
35
+ ].join("\n");
36
+ }
37
+ export function nextImageDisplayLabelStart(messages) {
38
+ let max = 0;
39
+ const pattern = /\[Image #(\d+)\]/g;
40
+ for (const message of messages) {
41
+ const content = message.content ?? "";
42
+ for (const match of content.matchAll(pattern)) {
43
+ const value = Number(match[1]);
44
+ if (Number.isFinite(value))
45
+ max = Math.max(max, value);
46
+ }
47
+ }
48
+ return max + 1;
49
+ }
@@ -1,16 +1,47 @@
1
+ export interface HistoryScope {
2
+ sessionFile?: string | null;
3
+ cwd?: string | null;
4
+ }
5
+ export interface HistoryLoadOptions {
6
+ filePath?: string;
7
+ scope?: HistoryScope;
8
+ includeLegacy?: boolean;
9
+ }
10
+ export interface HistoryAppendOptions {
11
+ filePath?: string;
12
+ scope?: HistoryScope;
13
+ createdAt?: Date | string;
14
+ }
15
+ export interface HistoryImageAttachment {
16
+ mediaType: string;
17
+ bytes: number;
18
+ dataUrl: string;
19
+ base64: string;
20
+ filename?: string;
21
+ sourcePath?: string;
22
+ }
23
+ export interface HistoryEntry {
24
+ text: string;
25
+ images: HistoryImageAttachment[];
26
+ imageDisplayStart?: number;
27
+ }
1
28
  export declare function defaultHistoryFilePath(): string;
2
- export declare function loadHistorySync(filePath?: string): string[];
3
- export declare function appendHistoryEntry(entry: string, filePath?: string): void;
29
+ export declare function loadHistoryEntriesSync(arg?: string | HistoryLoadOptions): HistoryEntry[];
30
+ export declare function loadHistorySync(arg?: string | HistoryLoadOptions): string[];
31
+ export declare function appendHistoryEntry(entry: string | HistoryEntry, arg?: string | HistoryAppendOptions): void;
4
32
  export interface HistoryNavState {
5
- history: string[];
33
+ history: Array<string | HistoryEntry>;
6
34
  index: number | null;
7
- draft: string;
35
+ draft: string | HistoryEntry;
8
36
  }
9
37
  export interface HistoryNavResult {
10
38
  text: string;
39
+ images?: HistoryImageAttachment[];
40
+ imageDisplayStart?: number;
11
41
  index: number | null;
12
- draft: string;
42
+ draft: string | HistoryEntry;
13
43
  changed: boolean;
14
44
  }
15
- export declare function stepHistory(state: HistoryNavState, direction: "up" | "down", currentText: string): HistoryNavResult;
45
+ export declare function stepHistory(state: HistoryNavState, direction: "up" | "down", currentEntry: string | HistoryEntry): HistoryNavResult;
16
46
  export declare function pushHistoryEntry(history: string[], entry: string): string[];
47
+ export declare function pushHistoryEntry(history: HistoryEntry[], entry: HistoryEntry): HistoryEntry[];