tinker-agent 1.5.0 → 1.6.0

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 (48) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +13 -5
  3. package/package.json +7 -5
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/tui-runner.tsx +21 -2
  14. package/src/events/observation-text-log.ts +21 -0
  15. package/src/events/stdout-event-printer.ts +11 -0
  16. package/src/events/types.ts +14 -2
  17. package/src/model/fake-model-client.ts +190 -0
  18. package/src/model/model-client.ts +3 -0
  19. package/src/model/openai-chat-model-client.ts +54 -15
  20. package/src/model/openai-chat-stream.ts +95 -72
  21. package/src/observation/observation-builder.ts +11 -0
  22. package/src/session/session-store.ts +1 -0
  23. package/src/tools/bash-guard.ts +131 -0
  24. package/src/tools/bash.ts +31 -0
  25. package/src/tools/delete.ts +182 -0
  26. package/src/tools/edit.ts +68 -9
  27. package/src/tools/registry.ts +47 -3
  28. package/src/tools/turn-undo-manager.ts +794 -0
  29. package/src/tools/types.ts +13 -0
  30. package/src/tools/write.ts +65 -14
  31. package/src/tui/app.tsx +301 -134
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/assistant-markdown.tsx +27 -26
  34. package/src/tui/components/background-tasks.tsx +7 -2
  35. package/src/tui/components/bash-confirmation.tsx +27 -0
  36. package/src/tui/components/context-status.tsx +11 -1
  37. package/src/tui/components/file-viewer.tsx +2 -2
  38. package/src/tui/components/footer.tsx +9 -12
  39. package/src/tui/components/memory-browser.tsx +1 -1
  40. package/src/tui/components/prompt-input.tsx +13 -1
  41. package/src/tui/components/resume-session-picker.tsx +3 -1
  42. package/src/tui/components/timeline.tsx +19 -11
  43. package/src/tui/context-format.ts +17 -0
  44. package/src/tui/event-store.ts +75 -3
  45. package/src/tui/shiki-highlighter.ts +104 -0
  46. package/src/tui/slash-commands.ts +28 -0
  47. package/src/tui/tui-projection-store.ts +277 -5
  48. package/src/tui/tui-session-controller.ts +32 -8
@@ -0,0 +1,104 @@
1
+ import type { BundledLanguage, BundledTheme } from "shiki";
2
+
3
+ export type TuiShikiHighlighter = (code: string, language?: string) => string;
4
+
5
+ type ShikiToken = {
6
+ readonly content: string;
7
+ readonly color?: string;
8
+ };
9
+
10
+ export type TuiShikiTokenizer = {
11
+ codeToTokensBase(
12
+ code: string,
13
+ options: { readonly lang: string; readonly theme: string },
14
+ ): readonly (readonly ShikiToken[])[];
15
+ };
16
+
17
+ const THEME = "github-dark";
18
+ const HIGHLIGHTED_LANGUAGES = [
19
+ "typescript",
20
+ "javascript",
21
+ "tsx",
22
+ "jsx",
23
+ "json",
24
+ "bash",
25
+ "shellscript",
26
+ "python",
27
+ "markdown",
28
+ "html",
29
+ "css",
30
+ "yaml",
31
+ "diff",
32
+ ] as const;
33
+
34
+ let preparation: Promise<void> | undefined;
35
+ let highlighter: TuiShikiHighlighter | undefined;
36
+
37
+ export function prepareShikiHighlighter(): Promise<void> {
38
+ preparation ??= createTuiShikiHighlighter(async () => {
39
+ const { createHighlighter } = await import("shiki");
40
+ const tokenizer = await createHighlighter({
41
+ themes: [THEME],
42
+ langs: [...HIGHLIGHTED_LANGUAGES],
43
+ });
44
+ return {
45
+ codeToTokensBase: (code, options) =>
46
+ tokenizer.codeToTokensBase(code, {
47
+ lang: options.lang as BundledLanguage,
48
+ theme: options.theme as BundledTheme,
49
+ }),
50
+ };
51
+ }).then((prepared) => {
52
+ highlighter = prepared;
53
+ });
54
+ return preparation;
55
+ }
56
+
57
+ export function getPreparedShikiHighlighter(): TuiShikiHighlighter | undefined {
58
+ return highlighter;
59
+ }
60
+
61
+ export async function createTuiShikiHighlighter(
62
+ createTokenizer: () => Promise<TuiShikiTokenizer>,
63
+ ): Promise<TuiShikiHighlighter | undefined> {
64
+ try {
65
+ const tokenizer = await createTokenizer();
66
+ return (code, language) => {
67
+ if (language === undefined || language === "") {
68
+ return code;
69
+ }
70
+ try {
71
+ return tokenizer
72
+ .codeToTokensBase(code, { lang: language, theme: THEME })
73
+ .map((line) =>
74
+ line
75
+ .map((token) => {
76
+ const ansi = tokenColorToAnsi(token.color);
77
+ return ansi === undefined
78
+ ? token.content
79
+ : `${ansi}${token.content}\u001b[39m`;
80
+ })
81
+ .join(""),
82
+ )
83
+ .join("\n");
84
+ } catch {
85
+ return code;
86
+ }
87
+ };
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function tokenColorToAnsi(color: string | undefined): string | undefined {
94
+ if (color === undefined || !color.startsWith("#") || color.length < 7) {
95
+ return undefined;
96
+ }
97
+ const red = Number.parseInt(color.slice(1, 3), 16);
98
+ const green = Number.parseInt(color.slice(3, 5), 16);
99
+ const blue = Number.parseInt(color.slice(5, 7), 16);
100
+ if ([red, green, blue].some((component) => Number.isNaN(component))) {
101
+ return undefined;
102
+ }
103
+ return `\u001b[38;2;${red};${green};${blue}m`;
104
+ }
@@ -24,6 +24,11 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
24
24
  usage: "/mcp",
25
25
  description: "Show MCP servers and runtime tools",
26
26
  },
27
+ {
28
+ name: "yolo",
29
+ usage: "/yolo [on|off]",
30
+ description: "Show or change destructive Bash confirmation",
31
+ },
27
32
  {
28
33
  name: "memory",
29
34
  usage: "/memory",
@@ -34,6 +39,11 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
34
39
  usage: "/compact [retire]",
35
40
  description: "Swap tool output or retire a cold history prefix",
36
41
  },
42
+ {
43
+ name: "undo",
44
+ usage: "/undo",
45
+ description: "Undo the latest Write/Edit/Delete turn",
46
+ },
37
47
  {
38
48
  name: "clear",
39
49
  usage: "/clear",
@@ -70,11 +80,14 @@ export const SLASH_COMMANDS: readonly BuiltInSlashCommand[] = [
70
80
 
71
81
  export type ParsedSlashCommand =
72
82
  | { type: "status" }
83
+ | { type: "yolo_status" }
84
+ | { type: "yolo"; enabled: boolean }
73
85
  | { type: "skills" }
74
86
  | { type: "mcp" }
75
87
  | { type: "memory" }
76
88
  | { type: "compact" }
77
89
  | { type: "compact_retire" }
90
+ | { type: "undo" }
78
91
  | { type: "clear" }
79
92
  | { type: "fork" }
80
93
  | { type: "view"; filePath: string }
@@ -111,6 +124,15 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
111
124
  if (command === "/status" && tokens.length === 1) {
112
125
  return { type: "status" };
113
126
  }
127
+ if (command === "/yolo") {
128
+ if (tokens.length === 1) {
129
+ return { type: "yolo_status" };
130
+ }
131
+ if (tokens.length === 2 && (tokens[1] === "on" || tokens[1] === "off")) {
132
+ return { type: "yolo", enabled: tokens[1] === "on" };
133
+ }
134
+ throw slashCommandUsageError("yolo");
135
+ }
114
136
  if (command === "/skills" && tokens.length === 1) {
115
137
  return { type: "skills" };
116
138
  }
@@ -135,6 +157,12 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
135
157
  }
136
158
  throw slashCommandUsageError("compact");
137
159
  }
160
+ if (command === "/undo") {
161
+ if (tokens.length === 1) {
162
+ return { type: "undo" };
163
+ }
164
+ throw slashCommandUsageError("undo");
165
+ }
138
166
  if (command === "/clear") {
139
167
  if (tokens.length === 1) {
140
168
  return { type: "clear" };
@@ -1,8 +1,16 @@
1
1
  import type { EventSink } from "../events/event-sink";
2
2
  import type { AgentEvent } from "../events/types";
3
+ import type {
4
+ AssistantTextDeltaSink,
5
+ AssistantTextDeltaUpdate,
6
+ } from "../agent/assistant-text-delta";
3
7
  import {
4
8
  createInitialTuiProjectionState,
9
+ firstRunningIndex,
5
10
  reduceTuiProjection,
11
+ timelineStreamItems,
12
+ visibleTimelineItems,
13
+ type TimelineItem,
6
14
  type TuiProjectionState,
7
15
  } from "./event-store";
8
16
  import {
@@ -10,6 +18,7 @@ import {
10
18
  type TuiProjectionPolicy,
11
19
  validateTuiProjectionPolicy,
12
20
  } from "./tui-projection-policy";
21
+ import { MarkdownSectionFramer } from "./assistant-markdown-section-framer";
13
22
 
14
23
  export type TuiProjectionStoreInput = {
15
24
  sessionId: string;
@@ -19,11 +28,49 @@ export type TuiProjectionStoreInput = {
19
28
  initialSnapshot?: TuiProjectionState;
20
29
  };
21
30
 
22
- export class TuiProjectionStore implements EventSink {
31
+ export type AssistantStreamSectionItem = Readonly<{
32
+ kind: "assistant-stream-section";
33
+ id: string;
34
+ iterationId: string;
35
+ attemptNumber: number;
36
+ sectionNumber: number;
37
+ markdown: string;
38
+ showAssistantLabel: boolean;
39
+ }>;
40
+
41
+ export type TuiCommittedItem = TimelineItem | AssistantStreamSectionItem;
42
+
43
+ export function isAssistantStreamSectionItem(
44
+ item: TuiCommittedItem,
45
+ ): item is AssistantStreamSectionItem {
46
+ return "kind" in item && item.kind === "assistant-stream-section";
47
+ }
48
+
49
+ export type TuiTimelineLog = Readonly<{
50
+ committed: readonly TuiCommittedItem[];
51
+ live: readonly TimelineItem[];
52
+ }>;
53
+
54
+ type AssistantStreamAttempt = {
55
+ readonly sessionId: string;
56
+ readonly turnId: string;
57
+ readonly turnNumber: number;
58
+ readonly iterationId: string;
59
+ readonly iterationNumber: number;
60
+ readonly attemptNumber: number;
61
+ readonly framer: MarkdownSectionFramer;
62
+ sectionCount: number;
63
+ };
64
+
65
+ export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
23
66
  readonly name = "tui-projection-store";
24
67
  private readonly listeners = new Set<() => void>();
25
68
  private readonly policy: TuiProjectionPolicy;
69
+ private readonly printed = new Set<string>();
70
+ private readonly physicallyAdoptedIterations = new Map<string, string>();
26
71
  private snapshot: TuiProjectionState;
72
+ private log: TuiTimelineLog = { committed: [], live: [] };
73
+ private assistantStreamAttempt?: AssistantStreamAttempt;
27
74
 
28
75
  constructor(input: TuiProjectionStoreInput) {
29
76
  this.policy = validateTuiProjectionPolicy(
@@ -33,10 +80,15 @@ export class TuiProjectionStore implements EventSink {
33
80
  input.initialSnapshot === undefined
34
81
  ? createInitialTuiProjectionState(input)
35
82
  : validateInitialSnapshot(input, input.initialSnapshot, this.policy);
83
+ if (input.initialSnapshot !== undefined) {
84
+ this.refreshLog(visibleTimelineItems(this.snapshot));
85
+ }
36
86
  }
37
87
 
38
88
  readonly getSnapshot = (): TuiProjectionState => this.snapshot;
39
89
 
90
+ readonly getLogSnapshot = (): TuiTimelineLog => this.log;
91
+
40
92
  readonly subscribe = (listener: () => void): (() => void) => {
41
93
  this.listeners.add(listener);
42
94
  return () => {
@@ -46,14 +98,40 @@ export class TuiProjectionStore implements EventSink {
46
98
 
47
99
  async append(event: AgentEvent): Promise<void> {
48
100
  const next = reduceTuiProjection(this.snapshot, event, this.policy);
49
- if (next === this.snapshot) {
101
+ const snapshotChanged = next !== this.snapshot;
102
+ this.snapshot = next;
103
+ const presentationChanged = this.applyAssistantStreamEvent(event);
104
+ if (!snapshotChanged && !presentationChanged) {
50
105
  return;
51
106
  }
52
107
 
53
- this.snapshot = next;
54
- for (const listener of this.listeners) {
55
- listener();
108
+ if (snapshotChanged) {
109
+ this.refreshLog();
110
+ }
111
+ this.notifyListeners();
112
+ }
113
+
114
+ updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
115
+ const attempt = this.assistantStreamAttempt;
116
+ if (
117
+ attempt === undefined ||
118
+ update.content === "" ||
119
+ !sameAssistantStreamAttempt(attempt, update)
120
+ ) {
121
+ return;
122
+ }
123
+
124
+ const frames = attempt.framer.push(update.content);
125
+ if (frames.length === 0) {
126
+ return;
56
127
  }
128
+ const committed = [...this.log.committed];
129
+ for (const frame of frames) {
130
+ attempt.sectionCount += 1;
131
+ committed.push(this.sectionItem(attempt, frame.markdown));
132
+ }
133
+ this.log = { committed, live: this.log.live };
134
+ this.notifyListeners();
57
135
  }
58
136
 
59
137
  hydrate(snapshot: TuiProjectionState): void {
@@ -73,10 +151,204 @@ export class TuiProjectionStore implements EventSink {
73
151
  snapshot,
74
152
  this.policy,
75
153
  );
154
+ this.refreshLog(visibleTimelineItems(this.snapshot));
155
+ this.notifyListeners();
156
+ }
157
+
158
+ private applyAssistantStreamEvent(event: AgentEvent): boolean {
159
+ switch (event.type) {
160
+ case "model.request.started":
161
+ this.assistantStreamAttempt = assistantStreamAttempt(event);
162
+ return false;
163
+ case "model.request.failed":
164
+ return this.failAssistantStreamAttempt(event);
165
+ case "model.request.finished":
166
+ return this.finishAssistantStreamAttempt(event);
167
+ case "assistant.progress":
168
+ if (
169
+ event.iterationId !== undefined &&
170
+ this.physicallyAdoptedIterations.has(event.iterationId)
171
+ ) {
172
+ this.printed.add(`assistant-${event.iterationId}-${event.eventSequence}`);
173
+ }
174
+ return false;
175
+ case "turn.finished": {
176
+ const adopted = this.physicallyAdoptedIterations.has(
177
+ event.data.lastIteration.iterationId,
178
+ );
179
+ if (adopted) {
180
+ this.printed.add(`turn-${event.turnId}-final-${event.eventSequence}`);
181
+ }
182
+ this.clearAdoptedTurn(event.turnId);
183
+ this.assistantStreamAttempt = undefined;
184
+ return false;
185
+ }
186
+ case "turn.failed":
187
+ case "turn.cancelled":
188
+ this.clearAdoptedTurn(event.turnId);
189
+ this.assistantStreamAttempt = undefined;
190
+ return false;
191
+ case "session.finished":
192
+ this.physicallyAdoptedIterations.clear();
193
+ this.assistantStreamAttempt = undefined;
194
+ return false;
195
+ default:
196
+ return false;
197
+ }
198
+ }
199
+
200
+ private failAssistantStreamAttempt(
201
+ event: Extract<AgentEvent, { type: "model.request.failed" }>,
202
+ ): boolean {
203
+ const attempt = this.assistantStreamAttempt;
204
+ if (attempt === undefined || !sameAssistantStreamEvent(attempt, event)) {
205
+ return false;
206
+ }
207
+ this.assistantStreamAttempt = undefined;
208
+ if (event.data.retryDisposition !== "scheduled" || attempt.sectionCount === 0) {
209
+ return false;
210
+ }
211
+ this.appendCommitted({
212
+ id: `assistant-stream-retry-${attempt.iterationId}-${attempt.attemptNumber}`,
213
+ text: "assistant response interrupted · retrying",
214
+ status: "info",
215
+ });
216
+ return true;
217
+ }
218
+
219
+ private finishAssistantStreamAttempt(
220
+ event: Extract<AgentEvent, { type: "model.request.finished" }>,
221
+ ): boolean {
222
+ const attempt = this.assistantStreamAttempt;
223
+ if (attempt === undefined || !sameAssistantStreamEvent(attempt, event)) {
224
+ return false;
225
+ }
226
+ this.assistantStreamAttempt = undefined;
227
+ const result = attempt.framer.finish();
228
+ if (
229
+ attempt.sectionCount === 0 ||
230
+ result.content !== (event.data.output.message.content ?? "")
231
+ ) {
232
+ return false;
233
+ }
234
+
235
+ this.printed.add(`model-${attempt.iterationId}`);
236
+ this.physicallyAdoptedIterations.set(attempt.iterationId, attempt.turnId);
237
+ if (result.tail === "") {
238
+ return false;
239
+ }
240
+ attempt.sectionCount += 1;
241
+ this.appendCommitted(this.sectionItem(attempt, result.tail));
242
+ return true;
243
+ }
244
+
245
+ private sectionItem(
246
+ attempt: AssistantStreamAttempt,
247
+ markdown: string,
248
+ ): AssistantStreamSectionItem {
249
+ return Object.freeze({
250
+ kind: "assistant-stream-section",
251
+ id: `assistant-stream-${attempt.iterationId}-${attempt.attemptNumber}-${attempt.sectionCount}`,
252
+ iterationId: attempt.iterationId,
253
+ attemptNumber: attempt.attemptNumber,
254
+ sectionNumber: attempt.sectionCount,
255
+ markdown,
256
+ showAssistantLabel: attempt.sectionCount === 1,
257
+ });
258
+ }
259
+
260
+ private appendCommitted(item: TuiCommittedItem): void {
261
+ this.log = {
262
+ committed: [...this.log.committed, item],
263
+ live: this.log.live,
264
+ };
265
+ }
266
+
267
+ private clearAdoptedTurn(turnId: string | undefined): void {
268
+ if (turnId === undefined) {
269
+ return;
270
+ }
271
+ for (const [iterationId, adoptedTurnId] of this.physicallyAdoptedIterations) {
272
+ if (adoptedTurnId === turnId) {
273
+ this.physicallyAdoptedIterations.delete(iterationId);
274
+ }
275
+ }
276
+ }
277
+
278
+ private notifyListeners(): void {
76
279
  for (const listener of this.listeners) {
77
280
  listener();
78
281
  }
79
282
  }
283
+
284
+ private refreshLog(stream = timelineStreamItems(this.snapshot)): void {
285
+ const settledEnd = firstRunningIndex(stream);
286
+ const pending = stream
287
+ .slice(0, settledEnd)
288
+ .filter((item) => !this.printed.has(item.id));
289
+ for (const item of pending) {
290
+ this.printed.add(item.id);
291
+ }
292
+ this.log = {
293
+ committed:
294
+ pending.length === 0 ? this.log.committed : [...this.log.committed, ...pending],
295
+ live: stream.slice(settledEnd),
296
+ };
297
+ }
298
+ }
299
+
300
+ function assistantStreamAttempt(
301
+ event: Extract<AgentEvent, { type: "model.request.started" }>,
302
+ ): AssistantStreamAttempt | undefined {
303
+ if (
304
+ event.turnId === undefined ||
305
+ event.turnNumber === undefined ||
306
+ event.iterationId === undefined ||
307
+ event.iterationNumber === undefined
308
+ ) {
309
+ return undefined;
310
+ }
311
+ return {
312
+ sessionId: event.sessionId,
313
+ turnId: event.turnId,
314
+ turnNumber: event.turnNumber,
315
+ iterationId: event.iterationId,
316
+ iterationNumber: event.iterationNumber,
317
+ attemptNumber: event.data.attemptNumber,
318
+ framer: new MarkdownSectionFramer(),
319
+ sectionCount: 0,
320
+ };
321
+ }
322
+
323
+ function sameAssistantStreamAttempt(
324
+ attempt: AssistantStreamAttempt,
325
+ update: AssistantTextDeltaUpdate,
326
+ ): boolean {
327
+ return (
328
+ attempt.sessionId === update.sessionId &&
329
+ attempt.turnId === update.turnId &&
330
+ attempt.turnNumber === update.turnNumber &&
331
+ attempt.iterationId === update.iterationId &&
332
+ attempt.iterationNumber === update.iterationNumber &&
333
+ attempt.attemptNumber === update.attemptNumber
334
+ );
335
+ }
336
+
337
+ function sameAssistantStreamEvent(
338
+ attempt: AssistantStreamAttempt,
339
+ event: Extract<
340
+ AgentEvent,
341
+ { type: "model.request.failed" | "model.request.finished" }
342
+ >,
343
+ ): boolean {
344
+ return (
345
+ attempt.sessionId === event.sessionId &&
346
+ attempt.turnId === event.turnId &&
347
+ attempt.turnNumber === event.turnNumber &&
348
+ attempt.iterationId === event.iterationId &&
349
+ attempt.iterationNumber === event.iterationNumber &&
350
+ attempt.attemptNumber === event.data.attemptNumber
351
+ );
80
352
  }
81
353
 
82
354
  function validateInitialSnapshot(
@@ -5,11 +5,13 @@ import type {
5
5
  import type {
6
6
  ExecuteTurnInput,
7
7
  AcceptedTurn,
8
+ BashGuardSnapshot,
8
9
  RuntimeSession,
9
10
  RuntimeSkillsSnapshot,
10
11
  SessionDisposeReason,
11
12
  } from "../agent/runtime-session";
12
13
  import type { RunAgentResult, UserMessage } from "../agent/types";
14
+ import type { TurnUndoResult } from "../tools/turn-undo-manager";
13
15
  import type { ImageAssetRef } from "../image/image-types";
14
16
  import type { ImportedImageAsset } from "../image/image-asset-store";
15
17
  import type { SessionId } from "../ids/runtime-id";
@@ -39,6 +41,10 @@ export type TuiSessionBinding = {
39
41
  ) => Promise<void>;
40
42
  admitTurn?: (userMessage: UserMessage, signal: AbortSignal) => Promise<AcceptedTurn>;
41
43
  executeTurn(userMessage: UserMessage, signal: AbortSignal): Promise<RunAgentResult>;
44
+ bashGuard(): BashGuardSnapshot;
45
+ subscribeBashGuard(listener: () => void): () => void;
46
+ setYoloMode(enabled: boolean): void;
47
+ resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
42
48
  };
43
49
 
44
50
  export type TuiSessionController = {
@@ -47,11 +53,12 @@ export type TuiSessionController = {
47
53
  listSessions: () => Promise<readonly SessionSummary[]>;
48
54
  compact: () => Promise<ContextCompactionResult>;
49
55
  retire: () => Promise<ContextRetirementResult>;
50
- fork: () => Promise<SessionId>;
51
- clear: () => Promise<void>;
52
- resume: (sessionId: SessionId) => Promise<void>;
56
+ undo: () => Promise<TurnUndoResult>;
57
+ fork: (beforeCommit?: () => void) => Promise<SessionId>;
58
+ clear: (beforeCommit?: () => void) => Promise<void>;
59
+ resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
53
60
  delete: (sessionId: SessionId) => Promise<void>;
54
- switchModel: (profile: ModelProfile) => Promise<void>;
61
+ switchModel: (profile: ModelProfile, beforeCommit?: () => void) => Promise<void>;
55
62
  };
56
63
 
57
64
  export type ManagedTuiSessionBinding = TuiSessionBinding & {
@@ -100,7 +107,13 @@ export class DefaultTuiSessionController implements TuiSessionController {
100
107
  return this.serialize(() => this.binding.runtimeSession.retireContext());
101
108
  }
102
109
 
103
- fork(): Promise<SessionId> {
110
+ undo(): Promise<TurnUndoResult> {
111
+ return this.serialize(() =>
112
+ this.binding.runtimeSession.undoLatestFileMutationTurn(),
113
+ );
114
+ }
115
+
116
+ fork(beforeCommit?: () => void): Promise<SessionId> {
104
117
  return this.serialize(async () => {
105
118
  const targetSessionId = createUuidV7() as SessionId;
106
119
  await this.replaceSession(
@@ -109,21 +122,23 @@ export class DefaultTuiSessionController implements TuiSessionController {
109
122
  await current.runtimeSession.cloneSession(targetSessionId);
110
123
  return this.openSession(targetSessionId);
111
124
  },
125
+ beforeCommit,
112
126
  );
113
127
  return targetSessionId;
114
128
  });
115
129
  }
116
130
 
117
- clear(): Promise<void> {
131
+ clear(beforeCommit?: () => void): Promise<void> {
118
132
  return this.serialize(() =>
119
133
  this.replaceSession(
120
134
  "Cannot clear the session while a turn, context operation, or background task is active.",
121
135
  (current) => this.createFreshSession(current),
136
+ beforeCommit,
122
137
  ),
123
138
  );
124
139
  }
125
140
 
126
- resume(sessionId: SessionId): Promise<void> {
141
+ resume(sessionId: SessionId, beforeCommit?: () => void): Promise<void> {
127
142
  return this.serialize(async () => {
128
143
  if (sessionId === this.binding.sessionId) {
129
144
  throw new Error(`Session ${sessionId} is already current.`);
@@ -131,6 +146,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
131
146
  await this.replaceSession(
132
147
  "Cannot switch sessions while a turn or background task is active.",
133
148
  () => this.openSession(sessionId),
149
+ beforeCommit,
134
150
  );
135
151
  });
136
152
  }
@@ -139,11 +155,12 @@ export class DefaultTuiSessionController implements TuiSessionController {
139
155
  return this.serialize(() => this.catalog.delete(sessionId, this.binding.sessionId));
140
156
  }
141
157
 
142
- switchModel(profile: ModelProfile): Promise<void> {
158
+ switchModel(profile: ModelProfile, beforeCommit?: () => void): Promise<void> {
143
159
  return this.serialize(() =>
144
160
  this.replaceSession(
145
161
  "Cannot switch models while a turn or background task is active.",
146
162
  () => this.createSessionWithProfile(profile),
163
+ beforeCommit,
147
164
  ),
148
165
  );
149
166
  }
@@ -157,6 +174,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
157
174
  createTarget: (
158
175
  current: ManagedTuiSessionBinding,
159
176
  ) => Promise<ManagedTuiSessionBinding>,
177
+ beforeCommit?: () => void,
160
178
  ): Promise<void> {
161
179
  const current = this.binding;
162
180
  if (!current.runtimeSession.canSwitchSession()) {
@@ -172,6 +190,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
172
190
  .catch(() => undefined);
173
191
  throw error;
174
192
  }
193
+ beforeCommit?.();
175
194
  this.binding = target;
176
195
  for (const listener of this.listeners) {
177
196
  listener();
@@ -224,6 +243,11 @@ export function managedTuiBinding(input: {
224
243
  userMessage,
225
244
  signal,
226
245
  } satisfies ExecuteTurnInput),
246
+ bashGuard: () => input.runtimeSession.bashGuard(),
247
+ subscribeBashGuard: (listener) => input.runtimeSession.subscribeBashGuard(listener),
248
+ setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
249
+ resolveBashConfirmation: (decision) =>
250
+ input.runtimeSession.resolveBashConfirmation(decision),
227
251
  };
228
252
  }
229
253