@bubblebrain-ai/bubble 0.0.59 → 0.0.61

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 (51) hide show
  1. package/dist/agent/subagent/runtime.d.ts +28 -0
  2. package/dist/agent/subagent/runtime.js +4 -24
  3. package/dist/agent.d.ts +7 -6
  4. package/dist/agent.js +18 -1
  5. package/dist/cli.d.ts +5 -0
  6. package/dist/cli.js +25 -2
  7. package/dist/config.d.ts +3 -0
  8. package/dist/config.js +7 -0
  9. package/dist/context/compact.js +6 -2
  10. package/dist/context/tool-images.d.ts +3 -0
  11. package/dist/context/tool-images.js +35 -0
  12. package/dist/context/usage.js +22 -5
  13. package/dist/feishu/agent-host/run-driver.js +3 -0
  14. package/dist/lsp/service.js +3 -2
  15. package/dist/main.js +11 -1
  16. package/dist/model-catalog.js +6 -0
  17. package/dist/model-pricing.js +17 -0
  18. package/dist/permissions/shell-command.js +35 -3
  19. package/dist/prompt/compose.d.ts +3 -0
  20. package/dist/prompt/compose.js +17 -3
  21. package/dist/provider-ai-sdk.d.ts +2 -1
  22. package/dist/provider-ai-sdk.js +26 -9
  23. package/dist/provider-openai-codex.d.ts +4 -0
  24. package/dist/provider-openai-codex.js +28 -9
  25. package/dist/provider-registry.js +4 -2
  26. package/dist/sdk/index.d.ts +21 -2
  27. package/dist/sdk/index.js +92 -11
  28. package/dist/session.d.ts +5 -1
  29. package/dist/session.js +13 -5
  30. package/dist/skills/format.d.ts +31 -1
  31. package/dist/skills/format.js +45 -24
  32. package/dist/slash-commands/commands.d.ts +3 -0
  33. package/dist/slash-commands/commands.js +6 -4
  34. package/dist/system-prompt.d.ts +3 -1
  35. package/dist/tools/edit-apply.d.ts +0 -3
  36. package/dist/tools/edit-apply.js +45 -249
  37. package/dist/tools/edit.js +3 -3
  38. package/dist/tools/grep.js +20 -1
  39. package/dist/tools/skill.d.ts +2 -0
  40. package/dist/tools/skill.js +34 -2
  41. package/dist/tui/app.js +6 -0
  42. package/dist/tui/components/skills-panel.d.ts +4 -0
  43. package/dist/tui/components/skills-panel.js +15 -1
  44. package/dist/tui/components/tasks-pane.d.ts +38 -0
  45. package/dist/tui/components/tasks-pane.js +182 -32
  46. package/dist/tui/controller/controller.d.ts +7 -0
  47. package/dist/tui/controller/controller.js +13 -0
  48. package/dist/types.d.ts +7 -0
  49. package/package.json +1 -1
  50. package/dist/prompt/skills.d.ts +0 -2
  51. package/dist/prompt/skills.js +0 -4
@@ -7,6 +7,8 @@ export interface TasksPaneSnapshot {
7
7
  groups: SubagentGroup[];
8
8
  workflows: WorkflowRunSnapshot[];
9
9
  tasks: BackgroundTaskInfo[];
10
+ /** Start of the current agent run; undefined keeps finished work hidden. */
11
+ turnStartedAt?: number;
10
12
  }
11
13
  export interface WorkflowPaneItem {
12
14
  kind: "workflow";
@@ -51,10 +53,25 @@ export declare class TasksPaneComponent implements Component {
51
53
  focused: boolean;
52
54
  private open;
53
55
  private manuallyClosed;
56
+ /**
57
+ * History is a view the user opts into for one look, never a mode: it is
58
+ * dropped whenever the pane closes and when a new round of activity starts.
59
+ * Otherwise one Ctrl+G after a finished turn left every earlier subagent
60
+ * listed above the composer for the rest of the session, next to a header
61
+ * that counts only the running ones.
62
+ */
54
63
  private showHistory;
55
64
  private selectedId?;
65
+ /**
66
+ * Whether the user picked the selection (keys or click). The pane always
67
+ * needs one, so it defaults to the first row — but a default nobody chose
68
+ * must not steer the window: a task that was once the only row stayed
69
+ * "selected" and scrolled later subagents out of an untouched pane.
70
+ */
71
+ private selectionIsUsers;
56
72
  private hoveredId?;
57
73
  private lastActiveCount;
74
+ private lastTurnStartedAt;
58
75
  private lastRows;
59
76
  private allRows;
60
77
  private frame;
@@ -67,11 +84,32 @@ export declare class TasksPaneComponent implements Component {
67
84
  isOpen(): boolean;
68
85
  isAvailable(): boolean;
69
86
  activeCount(): number;
87
+ /** Rows the default view lists: running work plus what this turn launched. */
88
+ defaultViewCount(): number;
89
+ visibleCount(): number;
90
+ /**
91
+ * Finished rows the pane lists next to the running ones, by outcome. Counted
92
+ * in the pane's current view, history included when it is showing, so the
93
+ * header never reports fewer rows than the list holds.
94
+ */
95
+ settledCounts(): {
96
+ done: number;
97
+ failed: number;
98
+ stopped: number;
99
+ };
70
100
  totalCount(): number;
71
101
  activityGlyph(): string;
72
102
  theme(): Theme;
73
103
  toggle(forceFocus?: boolean): void;
74
104
  close(): void;
105
+ /**
106
+ * Advance open/history state from the latest snapshot. Both the status bar
107
+ * and the pane call this before rendering: the bar is laid out first, so a
108
+ * transition made only inside the pane's render would leave the bar one
109
+ * frame behind — forever, when nothing is animating to trigger a redraw.
110
+ * Idempotent per snapshot (it compares against the last values it saw).
111
+ */
112
+ syncState(): void;
75
113
  render(width: number): string[];
76
114
  private renderItem;
77
115
  handleInput(data: string): void;
@@ -116,7 +116,18 @@ function normalize(snapshot, showHistory) {
116
116
  status: task.status,
117
117
  task,
118
118
  }));
119
- const filter = (items) => (showHistory ? items : items.filter((item) => isActive(item.status)))
119
+ // Which rows belong above the composer is derived, not remembered: whatever
120
+ // is still running, plus whatever this turn launched — so a sibling that
121
+ // finishes (or fails) first stays in view with its outcome until the next
122
+ // turn, whether its siblings ran in parallel or one after another. Work from
123
+ // earlier turns is history and shows only on request.
124
+ const launchedThisTurn = (item) => {
125
+ const launchedAt = itemLaunchedAt(item);
126
+ return snapshot.turnStartedAt !== undefined && launchedAt !== undefined && launchedAt >= snapshot.turnStartedAt;
127
+ };
128
+ const filter = (items) => (showHistory
129
+ ? items
130
+ : items.filter((item) => isActive(item.status) || launchedThisTurn(item)))
120
131
  .sort((left, right) => {
121
132
  const activityOrder = Number(isActive(right.status)) - Number(isActive(left.status));
122
133
  if (activityOrder !== 0)
@@ -129,6 +140,13 @@ function normalize(snapshot, showHistory) {
129
140
  tasks: filter(taskItems),
130
141
  };
131
142
  }
143
+ function itemLaunchedAt(item) {
144
+ if (item.kind === "workflow")
145
+ return item.createdAt;
146
+ if (item.kind === "subagent")
147
+ return item.member.createdAt;
148
+ return item.task.startedAt;
149
+ }
132
150
  function itemUpdatedAt(item) {
133
151
  if (item.kind === "workflow")
134
152
  return item.updatedAt ?? item.createdAt ?? 0;
@@ -144,10 +162,25 @@ export class TasksPaneComponent {
144
162
  focused = false;
145
163
  open = false;
146
164
  manuallyClosed = false;
165
+ /**
166
+ * History is a view the user opts into for one look, never a mode: it is
167
+ * dropped whenever the pane closes and when a new round of activity starts.
168
+ * Otherwise one Ctrl+G after a finished turn left every earlier subagent
169
+ * listed above the composer for the rest of the session, next to a header
170
+ * that counts only the running ones.
171
+ */
147
172
  showHistory = false;
148
173
  selectedId;
174
+ /**
175
+ * Whether the user picked the selection (keys or click). The pane always
176
+ * needs one, so it defaults to the first row — but a default nobody chose
177
+ * must not steer the window: a task that was once the only row stayed
178
+ * "selected" and scrolled later subagents out of an untouched pane.
179
+ */
180
+ selectionIsUsers = false;
149
181
  hoveredId;
150
182
  lastActiveCount = 0;
183
+ lastTurnStartedAt;
151
184
  lastRows = [];
152
185
  allRows = [];
153
186
  frame = 0;
@@ -179,6 +212,32 @@ export class TasksPaneComponent {
179
212
  const items = normalize(this.getSnapshot(), true);
180
213
  return Object.values(items).flat().filter((item) => isActive(item.status)).length;
181
214
  }
215
+ /** Rows the default view lists: running work plus what this turn launched. */
216
+ defaultViewCount() {
217
+ return Object.values(normalize(this.getSnapshot(), false)).flat().length;
218
+ }
219
+ visibleCount() {
220
+ return Object.values(normalize(this.getSnapshot(), this.showHistory)).flat().length;
221
+ }
222
+ /**
223
+ * Finished rows the pane lists next to the running ones, by outcome. Counted
224
+ * in the pane's current view, history included when it is showing, so the
225
+ * header never reports fewer rows than the list holds.
226
+ */
227
+ settledCounts() {
228
+ const counts = { done: 0, failed: 0, stopped: 0 };
229
+ for (const item of Object.values(normalize(this.getSnapshot(), this.showHistory)).flat()) {
230
+ if (isActive(item.status))
231
+ continue;
232
+ if (item.status === "failed" || item.status === "blocked")
233
+ counts.failed += 1;
234
+ else if (item.status === "cancelled" || item.status === "killed")
235
+ counts.stopped += 1;
236
+ else
237
+ counts.done += 1;
238
+ }
239
+ return counts;
240
+ }
182
241
  totalCount() {
183
242
  const items = normalize(this.getSnapshot(), true);
184
243
  return Object.values(items).flat().length;
@@ -193,6 +252,14 @@ export class TasksPaneComponent {
193
252
  if (this.open && !forceFocus) {
194
253
  this.open = false;
195
254
  this.manuallyClosed = true;
255
+ this.showHistory = false;
256
+ // Closing through the status bar (a click) bypasses the app's Ctrl+G
257
+ // handler. A pane that still owned the keyboard would swallow input while
258
+ // invisible — and the bar itself can now be hidden — so hand focus back.
259
+ if (this.focused) {
260
+ this.focused = false;
261
+ this.callbacks.onEscape();
262
+ }
196
263
  }
197
264
  else {
198
265
  this.open = true;
@@ -200,7 +267,9 @@ export class TasksPaneComponent {
200
267
  // Once all activity has settled, Ctrl+G must remain a usable route back
201
268
  // to the completed child transcript. Opening an empty active-only pane
202
269
  // makes the work look lost even though the child session still exists.
203
- if (this.activeCount() === 0 && this.totalCount() > 0)
270
+ // This turn's rows (what the bar counts) open first; older history only
271
+ // when there is nothing else to show, and behind `h` otherwise.
272
+ if (this.activeCount() === 0 && this.defaultViewCount() === 0 && this.totalCount() > 0)
204
273
  this.showHistory = true;
205
274
  }
206
275
  this.callbacks.onRender();
@@ -208,30 +277,66 @@ export class TasksPaneComponent {
208
277
  close() {
209
278
  this.open = false;
210
279
  this.manuallyClosed = true;
280
+ this.showHistory = false;
211
281
  this.callbacks.onRender();
212
282
  }
213
- render(width) {
214
- const terminalRows = this.getTerminalRows();
215
- if (terminalRows < 12) {
216
- this.lastRows = [];
217
- this.allRows = [];
218
- return [];
219
- }
283
+ /**
284
+ * Advance open/history state from the latest snapshot. Both the status bar
285
+ * and the pane call this before rendering: the bar is laid out first, so a
286
+ * transition made only inside the pane's render would leave the bar one
287
+ * frame behind — forever, when nothing is animating to trigger a redraw.
288
+ * Idempotent per snapshot (it compares against the last values it saw).
289
+ */
290
+ syncState() {
291
+ if (this.getTerminalRows() < 12)
292
+ return;
220
293
  const activeCount = this.activeCount();
221
- if (activeCount > 0 && this.lastActiveCount === 0 && !this.manuallyClosed)
294
+ // A new run, or activity starting from idle, ends the history view. The
295
+ // run boundary matters on its own: with a task still running the count
296
+ // never crosses zero, and new work would otherwise keep listing history.
297
+ // A user who is browsing history inside the pane keeps their rows; the
298
+ // view resets when they close it.
299
+ const turnStartedAt = this.getSnapshot().turnStartedAt;
300
+ const newTurn = turnStartedAt !== this.lastTurnStartedAt;
301
+ this.lastTurnStartedAt = turnStartedAt;
302
+ const activityFromIdle = activeCount > 0 && this.lastActiveCount === 0;
303
+ if ((newTurn || activityFromIdle) && !this.focused) {
304
+ this.showHistory = false;
305
+ // If that leaves nothing to list (the pane was opened while idle, then a
306
+ // turn launches nothing), close it rather than keep an empty pane under
307
+ // a "0 completed" header. Not a manual close: the next activity reopens it.
308
+ if (this.open && this.visibleCount() === 0) {
309
+ this.open = false;
310
+ this.manuallyClosed = false;
311
+ }
312
+ }
313
+ if (activityFromIdle && !this.manuallyClosed)
222
314
  this.open = true;
223
315
  if (activeCount === 0 && this.lastActiveCount > 0) {
224
316
  if (this.focused) {
225
317
  // A user who is already inspecting the pane should see the final
226
318
  // status land in place instead of watching the selected row vanish.
227
- this.showHistory = true;
319
+ // This turn's rows stay on their own; only work launched in an earlier
320
+ // turn needs the history view to remain visible.
321
+ if (this.visibleCount() === 0)
322
+ this.showHistory = true;
228
323
  }
229
324
  else {
230
325
  this.open = false;
231
326
  this.manuallyClosed = false;
327
+ this.showHistory = false;
232
328
  }
233
329
  }
234
330
  this.lastActiveCount = activeCount;
331
+ }
332
+ render(width) {
333
+ const terminalRows = this.getTerminalRows();
334
+ if (terminalRows < 12) {
335
+ this.lastRows = [];
336
+ this.allRows = [];
337
+ return [];
338
+ }
339
+ this.syncState();
235
340
  if (!this.open) {
236
341
  this.lastRows = [];
237
342
  this.allRows = [];
@@ -253,28 +358,55 @@ export class TasksPaneComponent {
253
358
  }
254
359
  const itemRows = rows.filter((row) => row.kind === "item");
255
360
  this.allRows = rows;
256
- if (!this.selectedId || !itemRows.some((row) => `${row.item.kind}:${row.item.id}` === this.selectedId)) {
361
+ const selectionGone = !this.selectedId
362
+ || !itemRows.some((row) => `${row.item.kind}:${row.item.id}` === this.selectedId);
363
+ if (selectionGone)
364
+ this.selectionIsUsers = false;
365
+ if (!this.selectionIsUsers) {
257
366
  this.selectedId = itemRows[0] ? `${itemRows[0].item.kind}:${itemRows[0].item.id}` : undefined;
258
367
  }
368
+ // When the rows do not fit, the last line says how many are out of view
369
+ // rather than clipping them silently. Only a focused pane scrolls to its
370
+ // selection; untouched, it shows the top, where running work sorts first.
259
371
  const maxRows = Math.max(3, Math.min(8, Math.floor(terminalRows * 0.15)));
260
- let start = 0;
261
- const selectedIndex = rows.findIndex((row) => row.kind === "item" && `${row.item.kind}:${row.item.id}` === this.selectedId);
262
- if (selectedIndex >= maxRows)
263
- start = selectedIndex - maxRows + 1;
264
- const visibleRows = rows.slice(start, start + maxRows);
372
+ const selectedIndex = this.focused
373
+ ? rows.findIndex((row) => row.kind === "item" && `${row.item.kind}:${row.item.id}` === this.selectedId)
374
+ : -1;
375
+ const layout = (windowRows) => {
376
+ const start = selectedIndex >= windowRows ? selectedIndex - windowRows + 1 : 0;
377
+ const items = (slice) => slice.filter((row) => row.kind === "item").length;
378
+ return { start, windowRows, above: items(rows.slice(0, start)), below: items(rows.slice(start + windowRows)) };
379
+ };
380
+ // Reserve the last line only when it has something to say: hidden rows can
381
+ // be nothing but collapsed section headers, and a blank reserved slot would
382
+ // cost one of as few as three lines.
383
+ let view = layout(maxRows);
384
+ if (rows.length > maxRows) {
385
+ const reserved = layout(maxRows - 1);
386
+ if (reserved.above + reserved.below > 0)
387
+ view = reserved;
388
+ }
389
+ const visibleRows = rows.slice(view.start, view.start + view.windowRows);
265
390
  this.lastRows = visibleRows;
266
391
  const theme = this.getTheme();
267
- return visibleRows.map((row) => {
268
- if (row.kind === "header") {
269
- const count = items[row.section].length;
270
- const marker = this.collapsed.has(row.section) ? "▸" : "▾";
271
- const label = sections.find(([section]) => section === row.section)?.[1] ?? row.section;
272
- return themeDim(theme.dim, pad(` ${marker} ${label} ${count}`, width));
273
- }
274
- const key = `${row.item.kind}:${row.item.id}`;
275
- const state = key === this.selectedId && this.focused ? "selected" : key === this.hoveredId ? "hover" : "idle";
276
- return paint(this.renderItem(row.item, width), width, state, theme);
277
- });
392
+ const more = [
393
+ view.above > 0 ? `${view.above} more above` : "",
394
+ view.below > 0 ? `${view.below} more below` : "",
395
+ ].filter(Boolean).join(" · ");
396
+ const moreLine = view.windowRows < maxRows && more
397
+ ? [themeDim(theme.dim, pad(` … ${more}${this.focused ? "" : " · Ctrl+G"}`, width))]
398
+ : [];
399
+ return [...visibleRows.map((row) => {
400
+ if (row.kind === "header") {
401
+ const count = items[row.section].length;
402
+ const marker = this.collapsed.has(row.section) ? "▸" : "▾";
403
+ const label = sections.find(([section]) => section === row.section)?.[1] ?? row.section;
404
+ return themeDim(theme.dim, pad(` ${marker} ${label} ${count}`, width));
405
+ }
406
+ const key = `${row.item.kind}:${row.item.id}`;
407
+ const state = key === this.selectedId && this.focused ? "selected" : key === this.hoveredId ? "hover" : "idle";
408
+ return paint(this.renderItem(row.item, width), width, state, theme);
409
+ }), ...moreLine];
278
410
  }
279
411
  renderItem(item, width) {
280
412
  const theme = this.getTheme();
@@ -314,15 +446,19 @@ export class TasksPaneComponent {
314
446
  const index = Math.max(0, itemRows.findIndex((row) => `${row.item.kind}:${row.item.id}` === this.selectedId));
315
447
  if (matchesKey(data, "up") || data === "k") {
316
448
  const row = itemRows[Math.max(0, index - 1)];
317
- if (row)
449
+ if (row) {
318
450
  this.selectedId = `${row.item.kind}:${row.item.id}`;
451
+ this.selectionIsUsers = true;
452
+ }
319
453
  this.callbacks.onRender();
320
454
  return;
321
455
  }
322
456
  if (matchesKey(data, "down") || data === "j") {
323
457
  const row = itemRows[Math.min(itemRows.length - 1, index + 1)];
324
- if (row)
458
+ if (row) {
325
459
  this.selectedId = `${row.item.kind}:${row.item.id}`;
460
+ this.selectionIsUsers = true;
461
+ }
326
462
  this.callbacks.onRender();
327
463
  return;
328
464
  }
@@ -366,6 +502,7 @@ export class TasksPaneComponent {
366
502
  return true;
367
503
  }
368
504
  this.selectedId = `${row.item.kind}:${row.item.id}`;
505
+ this.selectionIsUsers = true;
369
506
  if (event.clickCount >= 2)
370
507
  this.openItem(row.item);
371
508
  return true;
@@ -395,14 +532,27 @@ export class TaskStatusBarComponent {
395
532
  render(width) {
396
533
  if (!this.pane.isAvailable())
397
534
  return [];
535
+ this.pane.syncState();
398
536
  const count = this.pane.activeCount();
399
- const total = this.pane.totalCount();
537
+ // The bar describes what the pane lists: open, the rows on screen; closed,
538
+ // the default view Ctrl+G opens (running work plus this turn's). It used to
539
+ // count the whole session, a number that only ever grew. With nothing from
540
+ // this turn the bar is hidden; Ctrl+G still reaches the history.
541
+ const total = this.pane.isOpen() ? this.pane.visibleCount() : this.pane.defaultViewCount();
400
542
  if (total === 0 && !this.pane.isOpen())
401
543
  return [];
402
544
  const marker = this.pane.isOpen() ? "▾" : "▸";
403
545
  const theme = this.pane.theme();
546
+ // The list below also holds this turn's finished rows; say so, or the
547
+ // header counts fewer activities than the pane shows.
548
+ const settled = this.pane.settledCounts();
549
+ const outcome = [
550
+ settled.failed > 0 ? themeForeground(theme.error, `${settled.failed} failed`) : "",
551
+ settled.done > 0 ? `${settled.done} done` : "",
552
+ settled.stopped > 0 ? `${settled.stopped} stopped` : "",
553
+ ].filter(Boolean).map((part) => ` · ${part}`).join("");
404
554
  const text = count > 0
405
- ? `${marker} ${themeForeground(theme.accent, this.pane.activityGlyph())} ${count} background activit${count === 1 ? "y" : "ies"} · Ctrl+G`
555
+ ? `${marker} ${themeForeground(theme.accent, this.pane.activityGlyph())} ${count} background activit${count === 1 ? "y" : "ies"}${outcome} · Ctrl+G`
406
556
  : `${marker} ${themeForeground(theme.success, "✓")} ${total} completed activit${total === 1 ? "y" : "ies"} · Ctrl+G`;
407
557
  return [themeDim(theme.dim, truncateToWidth(` ${text}`, Math.max(1, width), ""))];
408
558
  }
@@ -60,6 +60,7 @@ export declare class BubbleTuiController {
60
60
  private readonly queue;
61
61
  private readonly sessionTransition;
62
62
  private readonly startedAtMs;
63
+ private turnStartedAt;
63
64
  private readonly goalRuntime?;
64
65
  private readonly taskRuntime?;
65
66
  private readonly pendingTaskAnnouncements;
@@ -85,6 +86,12 @@ export declare class BubbleTuiController {
85
86
  getGoalIndicator(): string | undefined;
86
87
  handleGoalCommand(input: string, cwd: string): void;
87
88
  getSubagentGroups(): SubagentGroup[];
89
+ /**
90
+ * Start of the current (or, when idle, the latest) agent run. The tasks pane
91
+ * keeps finished work that was launched since then; wall clock on purpose,
92
+ * it is compared with subagent/task/workflow creation stamps.
93
+ */
94
+ getTurnStartedAt(): number | undefined;
88
95
  getWorkflows(): WorkflowRunSnapshot[];
89
96
  getBackgroundTasks(): BackgroundTaskInfo[];
90
97
  getChildTranscript(agentId: string): DisplayMessage[];
@@ -30,6 +30,7 @@ export class BubbleTuiController {
30
30
  queue = createInputQueueState();
31
31
  sessionTransition;
32
32
  startedAtMs;
33
+ turnStartedAt;
33
34
  goalRuntime;
34
35
  taskRuntime;
35
36
  pendingTaskAnnouncements = new Map();
@@ -74,6 +75,9 @@ export class BubbleTuiController {
74
75
  this.queue.queued.length = 0;
75
76
  this.liveStreamVisible = false;
76
77
  this.runState = null;
78
+ // Session-scoped: the restored session has not started a run, so none
79
+ // of its subagents or tasks belong to a "current turn".
80
+ this.turnStartedAt = undefined;
77
81
  this.activeInputController = null;
78
82
  this.activeAbortController = null;
79
83
  this.commandActivity = null;
@@ -197,6 +201,14 @@ export class BubbleTuiController {
197
201
  }));
198
202
  return [...fromTrace, ...direct];
199
203
  }
204
+ /**
205
+ * Start of the current (or, when idle, the latest) agent run. The tasks pane
206
+ * keeps finished work that was launched since then; wall clock on purpose,
207
+ * it is compared with subagent/task/workflow creation stamps.
208
+ */
209
+ getTurnStartedAt() {
210
+ return this.turnStartedAt;
211
+ }
200
212
  getWorkflows() {
201
213
  return this.deps.agent.listWorkflows();
202
214
  }
@@ -562,6 +574,7 @@ export class BubbleTuiController {
562
574
  const agentInput = isSubmitPayload(input) ? this.submitAgentInput(input) : input;
563
575
  this.runActive = true;
564
576
  this.liveStreamVisible = true;
577
+ this.turnStartedAt = Date.now();
565
578
  this.runState = createRunState(Date.now());
566
579
  const inputController = new AgentRunInputQueue(`run-${this.runState.accumulator.runId}`);
567
580
  const abortController = new AbortController();
package/dist/types.d.ts CHANGED
@@ -36,6 +36,8 @@ export interface AssistantProviderMetadata {
36
36
  }
37
37
  export interface UserMessage {
38
38
  role: "user";
39
+ /** Internal image observation; not an authored user prompt. */
40
+ toolObservation?: true;
39
41
  content: string | ContentPart[];
40
42
  /** Local presentation metadata; provider adapters intentionally ignore it. */
41
43
  ui?: {
@@ -147,6 +149,11 @@ export interface ToolResultMetadata {
147
149
  [key: string]: unknown;
148
150
  }
149
151
  export interface ToolResult {
152
+ /** Bounded screenshots, delivered as a model-visible image observation after the tool batch. */
153
+ images?: {
154
+ mimeType: "image/png" | "image/jpeg";
155
+ data: string;
156
+ }[];
150
157
  content: string;
151
158
  isError?: boolean;
152
159
  status?: ToolResultStatus;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bubblebrain-ai/bubble",
3
- "version": "0.0.59",
3
+ "version": "0.0.61",
4
4
  "description": "A terminal coding agent",
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,2 +0,0 @@
1
- import type { SkillSummary } from "../skills/types.js";
2
- export declare function buildSkillsPrompt(skills?: SkillSummary[]): string;
@@ -1,4 +0,0 @@
1
- import { formatSkillsPrompt } from "../skills/format.js";
2
- export function buildSkillsPrompt(skills = []) {
3
- return formatSkillsPrompt(skills);
4
- }