@bubblebrain-ai/bubble 0.0.20 → 0.0.21

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 (49) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +5 -1
  3. package/dist/checkpoints.d.ts +57 -0
  4. package/dist/checkpoints.js +0 -0
  5. package/dist/feishu/agent-host/run-driver.js +1 -0
  6. package/dist/main.js +54 -13
  7. package/dist/session.d.ts +31 -0
  8. package/dist/session.js +69 -0
  9. package/dist/slash-commands/commands.js +80 -0
  10. package/dist/slash-commands/types.d.ts +4 -0
  11. package/dist/tools/bash.js +4 -0
  12. package/dist/tools/edit.d.ts +2 -1
  13. package/dist/tools/edit.js +2 -1
  14. package/dist/tools/index.d.ts +7 -0
  15. package/dist/tools/index.js +2 -2
  16. package/dist/tools/write.d.ts +2 -1
  17. package/dist/tools/write.js +2 -1
  18. package/dist/tui/image-paste.d.ts +18 -0
  19. package/dist/tui/image-paste.js +60 -0
  20. package/dist/tui/run.js +309 -69
  21. package/dist/tui/trace-groups.d.ts +16 -0
  22. package/dist/tui/trace-groups.js +42 -1
  23. package/dist/tui/transcript-scroll.d.ts +25 -0
  24. package/dist/tui/transcript-scroll.js +20 -0
  25. package/dist/tui-ink/app.d.ts +4 -1
  26. package/dist/tui-ink/app.js +301 -247
  27. package/dist/tui-ink/display-history.d.ts +16 -1
  28. package/dist/tui-ink/display-history.js +50 -21
  29. package/dist/tui-ink/footer.d.ts +6 -12
  30. package/dist/tui-ink/footer.js +10 -29
  31. package/dist/tui-ink/image-paste.d.ts +59 -0
  32. package/dist/tui-ink/image-paste.js +277 -0
  33. package/dist/tui-ink/input-box.d.ts +26 -1
  34. package/dist/tui-ink/input-box.js +171 -41
  35. package/dist/tui-ink/message-list.d.ts +1 -1
  36. package/dist/tui-ink/message-list.js +46 -29
  37. package/dist/tui-ink/run.d.ts +7 -2
  38. package/dist/tui-ink/run.js +73 -23
  39. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  40. package/dist/tui-ink/terminal-mouse.js +4 -0
  41. package/dist/tui-ink/trace-groups.d.ts +16 -0
  42. package/dist/tui-ink/trace-groups.js +50 -2
  43. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  44. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  45. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  46. package/dist/tui-ink/transcript-viewport.js +83 -0
  47. package/dist/tui-ink/welcome.d.ts +9 -7
  48. package/dist/tui-ink/welcome.js +7 -33
  49. package/package.json +1 -1
package/dist/agent.d.ts CHANGED
@@ -12,6 +12,7 @@ import { type AgentProfile, type SubagentRunResult } from "./agent/profiles.js";
12
12
  import { type SubagentThreadSnapshot } from "./agent/subagent-control.js";
13
13
  import type { SkillSummary } from "./skills/types.js";
14
14
  import type { FileStateTracker } from "./tools/file-state.js";
15
+ export declare const INTERRUPTED_ASSISTANT_CONTENT = "Interrupted by user. The prior request was stopped and should not be resumed unless the user asks.";
15
16
  export declare class AgentAbortError extends Error {
16
17
  constructor(message?: string);
17
18
  }
package/dist/agent.js CHANGED
@@ -40,7 +40,11 @@ const EMPTY_ASSISTANT_RECOVERY_REMINDER = "The previous model response contained
40
40
  "Respond now with a concise, user-visible answer, or call an available tool if more work is required. " +
41
41
  "Do not put the final answer only in hidden reasoning.";
42
42
  const EMPTY_ASSISTANT_FALLBACK = "The model returned no user-visible response. Please retry, or switch models if this keeps happening.";
43
- const INTERRUPTED_ASSISTANT_CONTENT = "Interrupted by user. The prior request was stopped and should not be resumed unless the user asks.";
43
+ // Model-facing interruption boundary. Persisted into the transcript so the
44
+ // next turn sees an explicit stop instead of a dangling request — but it must
45
+ // never render in the UI as if the assistant said it (the TUIs strip it and
46
+ // show their own interrupt indicator instead).
47
+ export const INTERRUPTED_ASSISTANT_CONTENT = "Interrupted by user. The prior request was stopped and should not be resumed unless the user asks.";
44
48
  function agentEventFromHookProgress(event) {
45
49
  const source = `${event.source.scope}:${event.source.index}`;
46
50
  if (event.type === "hook_start") {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Checkpoint store - pre-mutation file snapshots, keyed by conversation turn.
3
+ *
4
+ * Before the edit/write tools persist a change, they record the file's prior
5
+ * content here so /rewind can restore the workspace to the state it had just
6
+ * before a given user message. Changes made by bash commands are NOT tracked;
7
+ * checkpoints complement git, they do not replace it.
8
+ *
9
+ * On-disk layout (sibling of the session JSONL):
10
+ * <session>.checkpoints/
11
+ * blobs/<sha256> full file content, content-addressed (deduplicated)
12
+ * manifest.jsonl one {turn, path, blob, timestamp} line per first
13
+ * capture of a file within a turn
14
+ *
15
+ * blob === null means the file did not exist before that turn, so rewinding
16
+ * deletes it.
17
+ */
18
+ export interface CheckpointManifestEntry {
19
+ turn: string;
20
+ path: string;
21
+ blob: string | null;
22
+ timestamp: number;
23
+ }
24
+ export interface CheckpointRestoreResult {
25
+ /** Files whose pre-turn content was written back. */
26
+ restored: string[];
27
+ /** Files deleted because they were created during the rewound turns. */
28
+ deleted: string[];
29
+ /** Files that could not be restored (I/O errors). */
30
+ failed: string[];
31
+ }
32
+ export declare class CheckpointStore {
33
+ private readonly dir;
34
+ private readonly currentTurn;
35
+ private seen?;
36
+ constructor(dir: string, currentTurn: () => string);
37
+ /**
38
+ * Record a file's content before it is mutated. `priorContent` is the
39
+ * current on-disk content, or null when the file does not exist yet.
40
+ * Capturing must never break the mutation itself, so all errors are
41
+ * swallowed.
42
+ */
43
+ captureBefore(filePath: string, priorContent: string | null): Promise<void>;
44
+ listEntries(): CheckpointManifestEntry[];
45
+ /** Unique files first captured at or after the given turn. */
46
+ filesTouchedSince(turn: string): string[];
47
+ /** Unique files captured during exactly the given turn. */
48
+ filesTouchedAt(turn: string): string[];
49
+ /**
50
+ * Restore every tracked file to its content from just before `turn`.
51
+ * For each file the earliest capture at-or-after the cutoff wins (it holds
52
+ * the oldest pre-mutation content). Consumed manifest entries are pruned so
53
+ * a later rewind over reused turn ids cannot resurrect stale state.
54
+ */
55
+ restoreTo(turn: string): Promise<CheckpointRestoreResult>;
56
+ private loadSeen;
57
+ }
Binary file
@@ -78,6 +78,7 @@ export class RunDriver {
78
78
  approvalController,
79
79
  lspService,
80
80
  fileStateTracker,
81
+ checkpoints: () => session.manager.getCheckpoints(),
81
82
  // questionController intentionally omitted — Feishu v1 doesn't surface
82
83
  // the question tool to the agent.
83
84
  });
package/dist/main.js CHANGED
@@ -30,6 +30,10 @@ import { basename } from "node:path";
30
30
  import { normalizeSingleLine, truncateVisual } from "./text-display.js";
31
31
  import { BUBBLE_WORDMARK } from "./tui/wordmark.js";
32
32
  import { configureDebugTrace, summarizeAgentEventForTrace, summarizeTraceMessage, traceEvent, } from "./debug-trace.js";
33
+ // OpenTUI is the default renderer. The React Ink implementation (alt-screen
34
+ // viewport, src/tui-ink) is feature-complete but still maturing — opt in with
35
+ // BUBBLE_TUI=ink.
36
+ const USE_OPENTUI = process.env.BUBBLE_TUI !== "ink";
33
37
  async function main() {
34
38
  const args = parseArgs(process.argv.slice(2));
35
39
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
@@ -163,6 +167,8 @@ async function main() {
163
167
  toolSearchController,
164
168
  lspService,
165
169
  fileStateTracker,
170
+ // Lazy: sessionManager is resolved after tools are created.
171
+ checkpoints: () => sessionManager?.getCheckpoints(),
166
172
  });
167
173
  // Bring up MCP servers (if any). Failures are captured per-server and never
168
174
  // block the rest of startup; /mcp surfaces status at runtime.
@@ -225,7 +231,9 @@ async function main() {
225
231
  else {
226
232
  preResolvedTheme = themeConfig.mode;
227
233
  }
228
- const { runSessionPicker } = await import("./tui-opentui/run-session-picker.js");
234
+ const { runSessionPicker } = USE_OPENTUI
235
+ ? await import("./tui-opentui/run-session-picker.js")
236
+ : await import("./tui-ink/run-session-picker.js");
229
237
  const picked = await runSessionPicker({
230
238
  currentCwd: args.cwd,
231
239
  currentSessions,
@@ -304,7 +312,7 @@ async function main() {
304
312
  sessionFile: sessionManager?.getSessionFile(),
305
313
  provider: activeProviderId || "none",
306
314
  model: activeModel || "none",
307
- renderer: printMode ? "print" : "opentui-core",
315
+ renderer: printMode ? "print" : USE_OPENTUI ? "opentui-core" : "ink",
308
316
  });
309
317
  if (traceInfo.enabled) {
310
318
  traceEvent("run_start", {
@@ -523,19 +531,37 @@ async function main() {
523
531
  };
524
532
  const { getStartupUpdateNotice } = await import("./update/index.js");
525
533
  const updateNotice = await getStartupUpdateNotice();
526
- const { runTui } = await import("./tui/run.js");
527
- await runTui(agent, args, {
528
- ...commonOptions,
529
- themeMode: themeConfig.mode,
530
- themeOverrides: themeConfig.overrides,
531
- detectedTheme,
532
- onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
533
- updateNotice: updateNotice ?? undefined,
534
- });
534
+ // Two explicit branches (not a dynamic ternary import) so TypeScript
535
+ // checks each renderer's RunTuiOptions shape independently.
536
+ let exitWallMs;
537
+ if (USE_OPENTUI) {
538
+ const { runTui } = await import("./tui/run.js");
539
+ await runTui(agent, args, {
540
+ ...commonOptions,
541
+ themeMode: themeConfig.mode,
542
+ themeOverrides: themeConfig.overrides,
543
+ detectedTheme,
544
+ onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
545
+ updateNotice: updateNotice ?? undefined,
546
+ });
547
+ }
548
+ else {
549
+ const { runTui } = await import("./tui-ink/run.js");
550
+ const summary = await runTui(agent, args, {
551
+ ...commonOptions,
552
+ themeMode: themeConfig.mode,
553
+ themeOverrides: themeConfig.overrides,
554
+ detectedTheme,
555
+ onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
556
+ updateNotice: updateNotice ?? undefined,
557
+ });
558
+ exitWallMs = summary?.wallMs;
559
+ }
535
560
  if (sessionManager) {
536
- printOpenTuiExitSummary(sessionManager, {
561
+ printExitSummary(sessionManager, {
537
562
  resumed: resumedExistingSession,
538
563
  theme: detectedTheme,
564
+ wallMs: exitWallMs,
539
565
  });
540
566
  }
541
567
  }
@@ -545,7 +571,7 @@ async function main() {
545
571
  traceEvent("run_shutdown_end");
546
572
  }
547
573
  }
548
- function printOpenTuiExitSummary(sessionManager, options) {
574
+ function printExitSummary(sessionManager, options) {
549
575
  if (!process.stdout.isTTY)
550
576
  return;
551
577
  const sessionName = basename(sessionManager.getSessionFile());
@@ -589,6 +615,21 @@ function printOpenTuiExitSummary(sessionManager, options) {
589
615
  console.log();
590
616
  console.log(`${label("Session")}${colors.value(sessionLabel)}`);
591
617
  console.log(`${label("Continue")}${colors.value(continueCommand)}`);
618
+ if (options.wallMs !== undefined) {
619
+ console.log(`${label("Duration")}${colors.value(formatWallDuration(options.wallMs))}`);
620
+ }
621
+ }
622
+ function formatWallDuration(ms) {
623
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
624
+ if (totalSeconds < 60)
625
+ return `${totalSeconds}s`;
626
+ const minutes = Math.floor(totalSeconds / 60);
627
+ const seconds = totalSeconds % 60;
628
+ if (minutes < 60)
629
+ return `${minutes}m ${seconds}s`;
630
+ const hours = Math.floor(minutes / 60);
631
+ const minutesRest = minutes % 60;
632
+ return `${hours}h ${minutesRest}m ${seconds}s`;
592
633
  }
593
634
  async function readPipedStdin() {
594
635
  if (process.stdin.isTTY)
package/dist/session.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Session Manager - Append-only JSONL persistence over a structured session log.
3
3
  */
4
+ import { CheckpointStore } from "./checkpoints.js";
4
5
  import { type CompactOptions, type CompactResult } from "./context/compact.js";
5
6
  import type { Message, Todo } from "./types.js";
6
7
  import type { SessionLogEntry, SessionMarkerKind, SessionMetadata } from "./session-types.js";
@@ -16,9 +17,25 @@ export interface SessionSummary {
16
17
  mtime: number;
17
18
  }
18
19
  export type { SessionLogEntry, SessionMarkerKind, SessionMetadata } from "./session-types.js";
20
+ export interface UserTurn {
21
+ /** Session log entry id of the user message that starts the turn. */
22
+ id: string;
23
+ /** Single-line preview of the user message. */
24
+ preview: string;
25
+ /** Full text of the user message. */
26
+ text: string;
27
+ timestamp: number;
28
+ }
29
+ export interface RewindResult {
30
+ /** Number of log entries removed. */
31
+ removedEntries: number;
32
+ /** Full text of the user message the session was rewound to (for re-editing). */
33
+ targetText: string;
34
+ }
19
35
  export declare class SessionManager {
20
36
  private sessionFile;
21
37
  private log;
38
+ private checkpoints?;
22
39
  constructor(sessionFile: string);
23
40
  static create(cwd: string, sessionName?: string): SessionManager;
24
41
  static resume(cwd: string, sessionName?: string): SessionManager | undefined;
@@ -41,6 +58,20 @@ export declare class SessionManager {
41
58
  getTodos(): Todo[];
42
59
  compact(options?: CompactOptions): CompactResult;
43
60
  getMessages(): Message[];
61
+ /**
62
+ * Pre-edit file snapshot store for this session, used by /rewind.
63
+ * Lives next to the session JSONL as `<session>.checkpoints/`.
64
+ */
65
+ getCheckpoints(): CheckpointStore;
66
+ /** Entry id of the most recent user message, or "0" before the first one. */
67
+ lastUserEntryId(): string;
68
+ /** User messages after the latest /clear, oldest first — the valid rewind anchors. */
69
+ listUserTurns(): UserTurn[];
70
+ /**
71
+ * Truncate the session to just before the user message with the given
72
+ * entry id. Returns undefined when the id does not name a user message.
73
+ */
74
+ rewindToEntry(entryId: string): RewindResult | undefined;
44
75
  getEntries(): SessionLogEntry[];
45
76
  getSessionFile(): string;
46
77
  private maybeAutoCompact;
package/dist/session.js CHANGED
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
5
5
  import { mkdirSync, appendFileSync, existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { getBubbleHome } from "./bubble-home.js";
8
+ import { CheckpointStore } from "./checkpoints.js";
8
9
  import { compactSessionEntries } from "./context/compact.js";
9
10
  import { SessionLog } from "./session-log.js";
10
11
  import { normalizeSingleLine, truncateVisual } from "./text-display.js";
@@ -14,6 +15,7 @@ const AUTO_COMPACT_KEEP_RECENT_TURNS = 3;
14
15
  export class SessionManager {
15
16
  sessionFile;
16
17
  log = new SessionLog();
18
+ checkpoints;
17
19
  constructor(sessionFile) {
18
20
  this.sessionFile = sessionFile;
19
21
  if (existsSync(sessionFile)) {
@@ -163,6 +165,73 @@ export class SessionManager {
163
165
  getMessages() {
164
166
  return this.log.toMessages();
165
167
  }
168
+ /**
169
+ * Pre-edit file snapshot store for this session, used by /rewind.
170
+ * Lives next to the session JSONL as `<session>.checkpoints/`.
171
+ */
172
+ getCheckpoints() {
173
+ if (!this.checkpoints) {
174
+ this.checkpoints = new CheckpointStore(this.sessionFile.replace(/\.jsonl$/, "") + ".checkpoints", () => this.lastUserEntryId());
175
+ }
176
+ return this.checkpoints;
177
+ }
178
+ /** Entry id of the most recent user message, or "0" before the first one. */
179
+ lastUserEntryId() {
180
+ const entries = this.log.list();
181
+ for (let i = entries.length - 1; i >= 0; i--) {
182
+ const entry = entries[i];
183
+ if (entry.type === "user_message")
184
+ return entry.id;
185
+ }
186
+ return "0";
187
+ }
188
+ /** User messages after the latest /clear, oldest first — the valid rewind anchors. */
189
+ listUserTurns() {
190
+ const entries = this.log.list();
191
+ let start = 0;
192
+ for (let i = entries.length - 1; i >= 0; i--) {
193
+ const entry = entries[i];
194
+ if (entry.type === "marker" && entry.kind === "conversation_clear") {
195
+ start = i + 1;
196
+ break;
197
+ }
198
+ }
199
+ const turns = [];
200
+ for (let i = start; i < entries.length; i++) {
201
+ const entry = entries[i];
202
+ if (entry.type !== "user_message")
203
+ continue;
204
+ const text = messageText(entry.message);
205
+ turns.push({
206
+ id: entry.id,
207
+ text,
208
+ preview: truncateVisual(normalizeSingleLine(text), 80) || "(empty message)",
209
+ timestamp: entry.timestamp,
210
+ });
211
+ }
212
+ return turns;
213
+ }
214
+ /**
215
+ * Truncate the session to just before the user message with the given
216
+ * entry id. Returns undefined when the id does not name a user message.
217
+ */
218
+ rewindToEntry(entryId) {
219
+ const entries = this.log.list();
220
+ const index = entries.findIndex((entry) => entry.id === entryId && entry.type === "user_message");
221
+ if (index < 0)
222
+ return undefined;
223
+ const target = entries[index];
224
+ const removed = entries.slice(index);
225
+ this.rewrite(entries.slice(0, index));
226
+ const metadata = this.log.getMetadata();
227
+ if (metadata.titleUserMessageId && removed.some((entry) => entry.id === metadata.titleUserMessageId)) {
228
+ this.clearTitleMetadata();
229
+ }
230
+ return {
231
+ removedEntries: removed.length,
232
+ targetText: target.type === "user_message" ? messageText(target.message) : "",
233
+ };
234
+ }
166
235
  getEntries() {
167
236
  return this.log.list();
168
237
  }
@@ -399,6 +399,86 @@ const builtinSlashCommandEntries = [
399
399
  ctx.clearMessages();
400
400
  },
401
401
  },
402
+ {
403
+ name: "rewind",
404
+ description: "Rewind conversation and/or file edits to before an earlier message. Usage: /rewind [n] [--code|--chat]",
405
+ async handler(args, ctx) {
406
+ const session = ctx.sessionManager;
407
+ if (!session) {
408
+ return "Rewind requires an active session.";
409
+ }
410
+ const turns = session.listUserTurns();
411
+ if (turns.length === 0) {
412
+ return "Nothing to rewind: no user messages in this session.";
413
+ }
414
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
415
+ const flags = tokens.filter((token) => token.startsWith("--"));
416
+ const positional = tokens.filter((token) => !token.startsWith("--"));
417
+ const checkpoints = session.getCheckpoints();
418
+ if (positional.length === 0) {
419
+ if (ctx.openRewindPicker) {
420
+ ctx.openRewindPicker();
421
+ return;
422
+ }
423
+ const lines = ["Rewind points (oldest first):", ""];
424
+ turns.forEach((turn, index) => {
425
+ const time = new Date(turn.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
426
+ const files = checkpoints.filesTouchedAt(turn.id).length;
427
+ const fileNote = files > 0 ? ` [${files} file${files === 1 ? "" : "s"} changed]` : "";
428
+ lines.push(` ${index + 1}. ${time} ${turn.preview}${fileNote}`);
429
+ });
430
+ lines.push("", "Usage:", " /rewind <n> restore conversation AND files to just before message n", " /rewind <n> --chat conversation only", " /rewind <n> --code files only", "", "Note: only edits made by the edit/write tools are tracked; changes from", "bash commands are not. Checkpoints complement git, they don't replace it.");
431
+ return lines.join("\n");
432
+ }
433
+ const n = Number(positional[0]);
434
+ if (!Number.isInteger(n) || n < 1 || n > turns.length) {
435
+ return `Invalid rewind point "${positional[0]}". Run /rewind to list points (1-${turns.length}).`;
436
+ }
437
+ const target = turns[n - 1];
438
+ const codeOnly = flags.includes("--code");
439
+ const chatOnly = flags.includes("--chat") || flags.includes("--conversation");
440
+ if (codeOnly && chatOnly) {
441
+ return "Pick at most one of --code / --chat.";
442
+ }
443
+ // The "⏪" prefix is recognized by the TUIs: they rebuild the visible
444
+ // transcript from the rewound agent.messages before showing this text.
445
+ const lines = [
446
+ codeOnly
447
+ ? `Files restored to just before: ${target.preview}`
448
+ : `⏪ Rewound to before: ${target.preview}`,
449
+ ];
450
+ if (!chatOnly) {
451
+ const restore = await checkpoints.restoreTo(target.id);
452
+ const touched = restore.restored.length + restore.deleted.length;
453
+ if (touched === 0 && restore.failed.length === 0) {
454
+ lines.push("Files: no tracked edits to undo.");
455
+ }
456
+ else {
457
+ for (const file of restore.restored)
458
+ lines.push(`Restored ${file}`);
459
+ for (const file of restore.deleted)
460
+ lines.push(`Deleted ${file} (created after this point)`);
461
+ for (const file of restore.failed)
462
+ lines.push(`FAILED to restore ${file}`);
463
+ }
464
+ }
465
+ if (!codeOnly) {
466
+ session.rewindToEntry(target.id);
467
+ const head = ctx.agent.messages.filter((m) => m.role === "system" || m.role === "meta");
468
+ ctx.agent.messages = [...head, ...session.getMessages()];
469
+ ctx.agent.setTodos(session.getTodos());
470
+ ctx.agent.resetContextUsageAnchor();
471
+ if (ctx.fillComposer) {
472
+ // Put the rewound message back into the input box for re-editing.
473
+ ctx.fillComposer(target.text);
474
+ }
475
+ else {
476
+ lines.push("", "Rewound message (copy to re-edit):", target.text);
477
+ }
478
+ }
479
+ return lines.join("\n");
480
+ },
481
+ },
402
482
  {
403
483
  name: "session",
404
484
  description: "Show current session information",
@@ -48,6 +48,10 @@ export interface SlashCommandContext {
48
48
  setSidebarMode?: (mode: SidebarMode) => SidebarCommandState;
49
49
  /** Open the feedback dialog. `initialDescription` prefills the description field. */
50
50
  openFeedback?: (initialDescription: string) => void;
51
+ /** Open the interactive rewind picker. When absent, /rewind falls back to a text listing. */
52
+ openRewindPicker?: () => void;
53
+ /** Replace the composer/input box content (e.g. /rewind restores the rewound message for re-editing). */
54
+ fillComposer?: (text: string) => void;
51
55
  /** Open the interactive usage stats panel. */
52
56
  openStats?: () => void;
53
57
  }
@@ -21,6 +21,10 @@ export function createBashTool(cwd, approval, _fileState) {
21
21
  type: "object",
22
22
  properties: {
23
23
  command: { type: "string", description: "Bash command to execute" },
24
+ description: {
25
+ type: "string",
26
+ description: "One short sentence (5-10 words) describing what this command does, shown to the user in the UI. Write it in the same language the user is conversing in.",
27
+ },
24
28
  timeout: { type: "number", description: "Timeout in seconds (optional)" },
25
29
  },
26
30
  required: ["command"],
@@ -4,6 +4,7 @@
4
4
  * This is the safest way to edit files: old_string must exist exactly once.
5
5
  */
6
6
  import type { ApprovalController } from "../approval/types.js";
7
+ import type { CheckpointStore } from "../checkpoints.js";
7
8
  import type { ToolRegistryEntry } from "../types.js";
8
9
  import { type LspService } from "../lsp/index.js";
9
10
  import { type FileStateTracker } from "./file-state.js";
@@ -14,4 +15,4 @@ export interface EditArgs {
14
15
  newText: string;
15
16
  }>;
16
17
  }
17
- export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
18
+ export declare function createEditTool(cwd: string, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker, checkpoints?: () => CheckpointStore | undefined): ToolRegistryEntry;
@@ -60,7 +60,7 @@ function firstString(...values) {
60
60
  }
61
61
  return undefined;
62
62
  }
63
- export function createEditTool(cwd, approval, lsp, fileState) {
63
+ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
64
64
  return {
65
65
  name: "edit",
66
66
  effect: "write_direct",
@@ -178,6 +178,7 @@ export function createEditTool(cwd, approval, lsp, fileState) {
178
178
  },
179
179
  };
180
180
  }
181
+ await checkpoints?.()?.captureBefore(filePath, original);
181
182
  await writeFile(filePath, applied.content, "utf-8");
182
183
  await fileState?.observe(filePath, "edit", applied.content).catch(() => undefined);
183
184
  let output = `Edited ${filePath}${formatEditMatchNotes(applied.matches)}\n\nDiff:\n${diff}`;
@@ -28,6 +28,7 @@ import { type LspService } from "../lsp/index.js";
28
28
  import { type TodoStore } from "./todo.js";
29
29
  import { type ToolSearchController } from "./tool-search.js";
30
30
  import type { QuestionController } from "../question/index.js";
31
+ import type { CheckpointStore } from "../checkpoints.js";
31
32
  import { FileStateTracker } from "./file-state.js";
32
33
  export interface CreateAllToolsOptions {
33
34
  todoStore?: TodoStore;
@@ -37,5 +38,11 @@ export interface CreateAllToolsOptions {
37
38
  toolSearchController?: ToolSearchController;
38
39
  lspService?: LspService;
39
40
  fileStateTracker?: FileStateTracker;
41
+ /**
42
+ * Lazy accessor for the session's checkpoint store (the session manager may
43
+ * not exist yet when tools are created). Used by edit/write to snapshot
44
+ * files before mutating them so /rewind can restore.
45
+ */
46
+ checkpoints?: () => CheckpointStore | undefined;
40
47
  }
41
48
  export declare function createAllTools(cwd: string, skillRegistry?: SkillRegistry, options?: CreateAllToolsOptions): ToolRegistryEntry[];
@@ -48,8 +48,8 @@ export function createAllTools(cwd, skillRegistry, options = {}) {
48
48
  createReadTool(cwd, approval, lsp, fileState),
49
49
  createBashTool(cwd, approval, fileState),
50
50
  ...createManagedServerTools(cwd, approval),
51
- createWriteTool(cwd, {}, approval, lsp, fileState),
52
- createEditTool(cwd, approval, lsp, fileState),
51
+ createWriteTool(cwd, {}, approval, lsp, fileState, options.checkpoints),
52
+ createEditTool(cwd, approval, lsp, fileState, options.checkpoints),
53
53
  createGlobTool(cwd),
54
54
  createGrepTool(cwd),
55
55
  createLspTool(cwd, lsp, approval),
@@ -2,8 +2,9 @@
2
2
  * Write tool - create files or replace full file contents.
3
3
  */
4
4
  import type { ApprovalController } from "../approval/types.js";
5
+ import type { CheckpointStore } from "../checkpoints.js";
5
6
  import type { ToolRegistryEntry } from "../types.js";
6
7
  import { type LspService } from "../lsp/index.js";
7
8
  import { type FileStateTracker } from "./file-state.js";
8
9
  export type WriteToolOptions = Record<string, never>;
9
- export declare function createWriteTool(cwd: string, _options?: WriteToolOptions, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker): ToolRegistryEntry;
10
+ export declare function createWriteTool(cwd: string, _options?: WriteToolOptions, approval?: ApprovalController, lsp?: LspService, fileState?: FileStateTracker, checkpoints?: () => CheckpointStore | undefined): ToolRegistryEntry;
@@ -18,7 +18,7 @@ function prepareWriteArguments(input) {
18
18
  }
19
19
  return args;
20
20
  }
21
- export function createWriteTool(cwd, _options = {}, approval, lsp, fileState) {
21
+ export function createWriteTool(cwd, _options = {}, approval, lsp, fileState, checkpoints) {
22
22
  return {
23
23
  name: "write",
24
24
  effect: "write_direct",
@@ -76,6 +76,7 @@ export function createWriteTool(cwd, _options = {}, approval, lsp, fileState) {
76
76
  return changedDuringApprovalResult(filePath, changed);
77
77
  }
78
78
  try {
79
+ await checkpoints?.()?.captureBefore(filePath, existed ? oldContent : null);
79
80
  await mkdir(dirname(filePath), { recursive: true });
80
81
  await writeFile(filePath, args.content, "utf-8");
81
82
  await fileState?.observe(filePath, "write", args.content).catch(() => undefined);
@@ -44,6 +44,12 @@ export declare function isImageFilePath(raw: string): boolean;
44
44
  export declare function extractImagePathTokens(input: string): ImagePathToken[];
45
45
  export declare function removeImagePathTokens(input: string, tokens: ImagePathToken[]): string;
46
46
  export declare function imageAttachmentLabel(att: ImageAttachment, index: number): string;
47
+ /**
48
+ * Label for an image path before ingestion runs. Matches what
49
+ * imageAttachmentLabel produces for the same file, so a label inserted at
50
+ * paste time stays a valid key once the attachment is registered.
51
+ */
52
+ export declare function imageLabelForPath(rawPath: string, index: number): string;
47
53
  export declare function imageAttachmentReference(att: ImageAttachment, index: number): string;
48
54
  export declare function imageAttachmentLabelPattern(): RegExp;
49
55
  export declare function buildImageContentParts(promptText: string, attachments: ImageAttachment[]): ContentPart[];
@@ -61,6 +67,18 @@ export declare function buildImageContentPartsFromLabels(input: string, attachme
61
67
  * only on a space that is followed by the start of a new absolute path.
62
68
  */
63
69
  export declare function splitPastedPaths(pasted: string): string[];
70
+ /**
71
+ * True when a pasted blob consists solely of image file paths (drag from
72
+ * Finder, or a terminal that converts clipboard images to temp-file paths).
73
+ */
74
+ export declare function isImagePathPaste(pasted: string): boolean;
75
+ /**
76
+ * Bare image filename with no directory, e.g. "Screenshot ... AM.png".
77
+ * Copying an image file in Finder puts only the file's NAME in the
78
+ * clipboard's plain-text flavor — the actual bits arrive as a file-url or
79
+ * image flavor that must be read from the clipboard separately.
80
+ */
81
+ export declare function bareImageFilenameFromPaste(pasted: string): string | null;
64
82
  export declare function readImageFromPath(rawPath: string): Promise<ImageAttachment | null>;
65
83
  /** macOS screenshot shortcut writes to these paths and they may be auto-cleaned. */
66
84
  export declare function isScreenshotTempPath(s: string): boolean;