@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.
- package/dist/agent.d.ts +1 -0
- package/dist/agent.js +5 -1
- package/dist/checkpoints.d.ts +57 -0
- package/dist/checkpoints.js +0 -0
- package/dist/feishu/agent-host/run-driver.js +1 -0
- package/dist/main.js +54 -13
- package/dist/session.d.ts +31 -0
- package/dist/session.js +69 -0
- package/dist/slash-commands/commands.js +80 -0
- package/dist/slash-commands/types.d.ts +4 -0
- package/dist/tools/bash.js +4 -0
- package/dist/tools/edit.d.ts +2 -1
- package/dist/tools/edit.js +2 -1
- package/dist/tools/index.d.ts +7 -0
- package/dist/tools/index.js +2 -2
- package/dist/tools/write.d.ts +2 -1
- package/dist/tools/write.js +2 -1
- package/dist/tui/image-paste.d.ts +18 -0
- package/dist/tui/image-paste.js +60 -0
- package/dist/tui/run.js +309 -69
- package/dist/tui/trace-groups.d.ts +16 -0
- package/dist/tui/trace-groups.js +42 -1
- package/dist/tui/transcript-scroll.d.ts +25 -0
- package/dist/tui/transcript-scroll.js +20 -0
- package/dist/tui-ink/app.d.ts +4 -1
- package/dist/tui-ink/app.js +301 -247
- package/dist/tui-ink/display-history.d.ts +16 -1
- package/dist/tui-ink/display-history.js +50 -21
- package/dist/tui-ink/footer.d.ts +6 -12
- package/dist/tui-ink/footer.js +10 -29
- package/dist/tui-ink/image-paste.d.ts +59 -0
- package/dist/tui-ink/image-paste.js +277 -0
- package/dist/tui-ink/input-box.d.ts +26 -1
- package/dist/tui-ink/input-box.js +171 -41
- package/dist/tui-ink/message-list.d.ts +1 -1
- package/dist/tui-ink/message-list.js +46 -29
- package/dist/tui-ink/run.d.ts +7 -2
- package/dist/tui-ink/run.js +73 -23
- package/dist/tui-ink/terminal-mouse.d.ts +1 -0
- package/dist/tui-ink/terminal-mouse.js +4 -0
- package/dist/tui-ink/trace-groups.d.ts +16 -0
- package/dist/tui-ink/trace-groups.js +50 -2
- package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
- package/dist/tui-ink/transcript-viewport-math.js +17 -0
- package/dist/tui-ink/transcript-viewport.d.ts +24 -0
- package/dist/tui-ink/transcript-viewport.js +83 -0
- package/dist/tui-ink/welcome.d.ts +9 -7
- package/dist/tui-ink/welcome.js +7 -33
- 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
|
-
|
|
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
|
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 } =
|
|
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
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/tools/bash.js
CHANGED
|
@@ -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"],
|
package/dist/tools/edit.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/edit.js
CHANGED
|
@@ -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}`;
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -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[];
|
package/dist/tools/index.js
CHANGED
|
@@ -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),
|
package/dist/tools/write.d.ts
CHANGED
|
@@ -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;
|
package/dist/tools/write.js
CHANGED
|
@@ -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;
|