tinker-agent 1.5.1 → 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 (42) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/README.md +13 -5
  3. package/package.json +6 -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 +17 -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 +93 -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 +161 -45
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/bash-confirmation.tsx +27 -0
  34. package/src/tui/components/context-status.tsx +11 -1
  35. package/src/tui/components/footer.tsx +8 -5
  36. package/src/tui/components/prompt-input.tsx +13 -1
  37. package/src/tui/components/timeline.tsx +10 -0
  38. package/src/tui/context-format.ts +17 -0
  39. package/src/tui/event-store.ts +62 -3
  40. package/src/tui/slash-commands.ts +28 -0
  41. package/src/tui/tui-projection-store.ts +246 -7
  42. package/src/tui/tui-session-controller.ts +18 -0
@@ -3,9 +3,11 @@ import { Spinner, StatusMessage } from "@inkjs/ui";
3
3
  export type FooterProps = {
4
4
  status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
5
5
  workedForMs?: number;
6
+ yolo?: boolean;
6
7
  };
7
8
 
8
9
  export function Footer(props: FooterProps) {
10
+ const suffix = props.yolo ? " · yolo" : "";
9
11
  if (props.status === "done") {
10
12
  if (props.workedForMs === undefined) {
11
13
  throw new Error("Done footer requires workedForMs");
@@ -14,27 +16,28 @@ export function Footer(props: FooterProps) {
14
16
  return (
15
17
  <StatusMessage variant="success">
16
18
  Worked for {formatDuration(props.workedForMs)}
19
+ {suffix}
17
20
  </StatusMessage>
18
21
  );
19
22
  }
20
23
 
21
24
  if (props.status === "failed") {
22
- return <StatusMessage variant="error">failed</StatusMessage>;
25
+ return <StatusMessage variant="error">failed{suffix}</StatusMessage>;
23
26
  }
24
27
 
25
28
  if (props.status === "running") {
26
- return <Spinner label="Running" />;
29
+ return <Spinner label={`Running${suffix}`} />;
27
30
  }
28
31
 
29
32
  if (props.status === "cancelling") {
30
- return <StatusMessage variant="info">cancelling</StatusMessage>;
33
+ return <StatusMessage variant="info">cancelling{suffix}</StatusMessage>;
31
34
  }
32
35
 
33
36
  if (props.status === "cancelled") {
34
- return <StatusMessage variant="info">cancelled</StatusMessage>;
37
+ return <StatusMessage variant="info">cancelled{suffix}</StatusMessage>;
35
38
  }
36
39
 
37
- return <StatusMessage variant="info">idle</StatusMessage>;
40
+ return <StatusMessage variant="info">idle{suffix}</StatusMessage>;
38
41
  }
39
42
 
40
43
  function formatDuration(durationMs: number): string {
@@ -9,7 +9,10 @@ import { ImageNotRecognizedError } from "../../image/image-probe";
9
9
  import type { ImportedImageAsset } from "../../image/image-asset-store";
10
10
  import type { ImageAssetRef } from "../../image/image-types";
11
11
  import { runtimeIdFactory, type RuntimeIdFactory } from "../../ids/runtime-id";
12
- import { formatContextUsageLine } from "../context-format";
12
+ import {
13
+ formatContextUsageLine,
14
+ formatLatestProviderCacheRate,
15
+ } from "../context-format";
13
16
  import {
14
17
  type FileMentionMatch,
15
18
  findFileMention,
@@ -722,6 +725,9 @@ export function PromptInput(props: PromptInputProps) {
722
725
  const showFileSuggestions = filePopupActive;
723
726
  const showSlashSuggestions = suggestions.length > 0 && !locked;
724
727
  const showSuggestions = showFileSuggestions || showSlashSuggestions;
728
+ const cacheRate = formatLatestProviderCacheRate(
729
+ props.contextUsage?.lastProviderUsage,
730
+ );
725
731
  return (
726
732
  <Box flexDirection="column">
727
733
  <Box width="100%" borderStyle="single" borderLeft={false} borderRight={false}>
@@ -745,6 +751,12 @@ export function PromptInput(props: PromptInputProps) {
745
751
  </Text>
746
752
  </>
747
753
  )}
754
+ {cacheRate === undefined ? null : (
755
+ <>
756
+ <Text dimColor> · </Text>
757
+ <Text dimColor>{cacheRate}</Text>
758
+ </>
759
+ )}
748
760
  </Box>
749
761
  )}
750
762
  {showFileSuggestions ? (
@@ -1,6 +1,7 @@
1
1
  import { Box, Text } from "ink";
2
2
  import { Fragment } from "react";
3
3
  import type { TimelineItem } from "../event-store";
4
+ import type { AssistantStreamSectionItem } from "../tui-projection-store";
4
5
  import { AssistantMarkdown } from "./assistant-markdown";
5
6
  import { BashResultView } from "./bash-result-view";
6
7
  import { DiffView } from "./diff-view";
@@ -54,6 +55,15 @@ export function TimelineRow(props: { item: TimelineItem }) {
54
55
  );
55
56
  }
56
57
 
58
+ export function AssistantStreamSectionRow(props: { item: AssistantStreamSectionItem }) {
59
+ return (
60
+ <Fragment>
61
+ {props.item.showAssistantLabel ? <Text color="gray">- assistant</Text> : null}
62
+ <AssistantMarkdown text={props.item.markdown} />
63
+ </Fragment>
64
+ );
65
+ }
66
+
57
67
  function renderUserPrompt(prompt: NonNullable<TimelineItem["userPrompt"]>) {
58
68
  const chars = [...prompt.text];
59
69
  const fragments: React.ReactNode[] = [];
@@ -1,4 +1,21 @@
1
1
  import type { ContextUsageSnapshot } from "../agent/context-meter";
2
+ import type { ModelUsage } from "../model/model-client";
3
+
4
+ export function formatLatestProviderCacheRate(
5
+ usage: ModelUsage | undefined,
6
+ ): string | undefined {
7
+ const hit = usage?.promptCacheHitTokens;
8
+ const miss = usage?.promptCacheMissTokens;
9
+ if (hit === undefined || miss === undefined || hit + miss === 0) {
10
+ return undefined;
11
+ }
12
+ // Floor instead of round: an append turn is never a true 100% hit, and
13
+ // rounding would display 99.5%+ as a misleading "cache 100%". A genuine
14
+ // full hit (miss === 0, e.g. an identical resent request) still floors to
15
+ // exactly 100. The min() guards against float rounding when miss > 0.
16
+ const percent = Math.min(99, Math.floor((hit / (hit + miss)) * 100));
17
+ return `cache ${miss === 0 ? 100 : percent}%`;
18
+ }
2
19
 
3
20
  export function formatContextUsageLine(
4
21
  usage: Pick<
@@ -3,7 +3,7 @@ import {
3
3
  bashResultDetail,
4
4
  type BashDisplayDetail,
5
5
  } from "../events/bash-result-detail";
6
- import type { AgentEvent } from "../events/types";
6
+ import type { AgentEvent, ModelRequestFailedData } from "../events/types";
7
7
  import type { ContextUsageSnapshot } from "../agent/context-meter";
8
8
  import type {
9
9
  ModelContextBudget,
@@ -162,12 +162,21 @@ export function reduceTuiProjection(
162
162
  })
163
163
  : updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
164
164
  ...item,
165
- text: `model iteration ${event.iterationNumber} · retrying`,
165
+ text: `model iteration ${event.iterationNumber} · retrying (attempt ${event.data.attemptNumber}/${event.data.maxAttempts})`,
166
166
  status: "running",
167
167
  })),
168
168
  );
169
169
  case "model.request.failed":
170
- return state;
170
+ if (event.data.retryDisposition !== "scheduled") {
171
+ return state;
172
+ }
173
+ return updateActiveTurn(state, event, policy, (turn) =>
174
+ updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
175
+ ...item,
176
+ text: `model iteration ${requireEventNumber(event.iterationNumber, "model.request.failed iterationNumber")} · ${modelRequestRetryText(event.data)}`,
177
+ status: "running",
178
+ })),
179
+ );
171
180
  case "model.request.finished":
172
181
  return updateActiveTurn(state, event, policy, (turn) =>
173
182
  updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
@@ -214,6 +223,37 @@ export function reduceTuiProjection(
214
223
  status: event.data.ok ? "ok" : "failed",
215
224
  })),
216
225
  );
226
+ case "tool.confirmation.requested":
227
+ return updateActiveTurn(state, event, policy, (turn) =>
228
+ updateTurnItem(
229
+ turn,
230
+ toolCallRef(
231
+ requireEventString(
232
+ event.toolCallId,
233
+ "tool.confirmation.requested toolCallId",
234
+ ),
235
+ ),
236
+ (item) => ({
237
+ ...item,
238
+ text: `Bash confirmation requested · ${event.data.reason}`,
239
+ status: "running",
240
+ }),
241
+ ),
242
+ );
243
+ case "tool.confirmation.resolved":
244
+ return updateActiveTurn(state, event, policy, (turn) =>
245
+ appendTurnItem(turn, {
246
+ id: `confirmation-${event.toolCallId}-${event.eventSequence}`,
247
+ label: "bash guard",
248
+ text: `${event.data.decision} · ${event.data.reason}`,
249
+ status:
250
+ event.data.decision === "allow"
251
+ ? "ok"
252
+ : event.data.decision === "cancelled"
253
+ ? "cancelled"
254
+ : "failed",
255
+ }),
256
+ );
217
257
  case "bash.task.backgrounded":
218
258
  case "bash.task.stopping":
219
259
  case "bash.task.finished":
@@ -679,6 +719,21 @@ export function completedModelRequestText(
679
719
  return `model iteration ${iterationNumber} -> assistant response`;
680
720
  }
681
721
 
722
+ function modelRequestRetryText(data: ModelRequestFailedData): string {
723
+ const attempt = `attempt ${data.attemptNumber + 1}/${data.maxAttempts}`;
724
+ if (data.retryDelayMs !== undefined && data.retryDelayMs > 0) {
725
+ const seconds = Math.max(1, Math.round(data.retryDelayMs / 1000));
726
+ const reason =
727
+ data.code === "provider_rate_limited"
728
+ ? "rate limited"
729
+ : data.code === "provider_unavailable"
730
+ ? "provider unavailable"
731
+ : data.code;
732
+ return `${reason} · retrying in ${seconds}s (${attempt})`;
733
+ }
734
+ return `retrying (${attempt})`;
735
+ }
736
+
682
737
  export function toolCallStartedProjection(input: {
683
738
  name: string;
684
739
  args: unknown;
@@ -774,6 +829,8 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
774
829
  ? base
775
830
  : `${base} -> ${raw.bytesWritten} bytes`;
776
831
  }
832
+ case "delete":
833
+ return `${base} -> deleted`;
777
834
  case "web_search":
778
835
  return raw.resultCount === undefined
779
836
  ? base
@@ -857,6 +914,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
857
914
  case "read":
858
915
  case "write":
859
916
  case "edit":
917
+ case "delete":
860
918
  case "glob":
861
919
  case "grep":
862
920
  case "task_list":
@@ -890,6 +948,7 @@ function toolRawResultDiff(
890
948
  diffTruncated: raw.patchTruncated === true,
891
949
  };
892
950
  case "read":
951
+ case "delete":
893
952
  case "glob":
894
953
  case "grep":
895
954
  case "bash":
@@ -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,5 +1,9 @@
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,
5
9
  firstRunningIndex,
@@ -14,6 +18,7 @@ import {
14
18
  type TuiProjectionPolicy,
15
19
  validateTuiProjectionPolicy,
16
20
  } from "./tui-projection-policy";
21
+ import { MarkdownSectionFramer } from "./assistant-markdown-section-framer";
17
22
 
18
23
  export type TuiProjectionStoreInput = {
19
24
  sessionId: string;
@@ -23,18 +28,49 @@ export type TuiProjectionStoreInput = {
23
28
  initialSnapshot?: TuiProjectionState;
24
29
  };
25
30
 
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
+
26
49
  export type TuiTimelineLog = Readonly<{
27
- committed: readonly TimelineItem[];
50
+ committed: readonly TuiCommittedItem[];
28
51
  live: readonly TimelineItem[];
29
52
  }>;
30
53
 
31
- export class TuiProjectionStore implements EventSink {
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 {
32
66
  readonly name = "tui-projection-store";
33
67
  private readonly listeners = new Set<() => void>();
34
68
  private readonly policy: TuiProjectionPolicy;
35
69
  private readonly printed = new Set<string>();
70
+ private readonly physicallyAdoptedIterations = new Map<string, string>();
36
71
  private snapshot: TuiProjectionState;
37
72
  private log: TuiTimelineLog = { committed: [], live: [] };
73
+ private assistantStreamAttempt?: AssistantStreamAttempt;
38
74
 
39
75
  constructor(input: TuiProjectionStoreInput) {
40
76
  this.policy = validateTuiProjectionPolicy(
@@ -62,15 +98,40 @@ export class TuiProjectionStore implements EventSink {
62
98
 
63
99
  async append(event: AgentEvent): Promise<void> {
64
100
  const next = reduceTuiProjection(this.snapshot, event, this.policy);
65
- if (next === this.snapshot) {
101
+ const snapshotChanged = next !== this.snapshot;
102
+ this.snapshot = next;
103
+ const presentationChanged = this.applyAssistantStreamEvent(event);
104
+ if (!snapshotChanged && !presentationChanged) {
66
105
  return;
67
106
  }
68
107
 
69
- this.snapshot = next;
70
- this.refreshLog();
71
- for (const listener of this.listeners) {
72
- 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;
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));
73
132
  }
133
+ this.log = { committed, live: this.log.live };
134
+ this.notifyListeners();
74
135
  }
75
136
 
76
137
  hydrate(snapshot: TuiProjectionState): void {
@@ -91,6 +152,130 @@ export class TuiProjectionStore implements EventSink {
91
152
  this.policy,
92
153
  );
93
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 {
94
279
  for (const listener of this.listeners) {
95
280
  listener();
96
281
  }
@@ -112,6 +297,60 @@ export class TuiProjectionStore implements EventSink {
112
297
  }
113
298
  }
114
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
+ );
352
+ }
353
+
115
354
  function validateInitialSnapshot(
116
355
  input: Pick<TuiProjectionStoreInput, "sessionId" | "modelName" | "workspaceRoot">,
117
356
  snapshot: TuiProjectionState,
@@ -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,6 +53,7 @@ export type TuiSessionController = {
47
53
  listSessions: () => Promise<readonly SessionSummary[]>;
48
54
  compact: () => Promise<ContextCompactionResult>;
49
55
  retire: () => Promise<ContextRetirementResult>;
56
+ undo: () => Promise<TurnUndoResult>;
50
57
  fork: (beforeCommit?: () => void) => Promise<SessionId>;
51
58
  clear: (beforeCommit?: () => void) => Promise<void>;
52
59
  resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
@@ -100,6 +107,12 @@ export class DefaultTuiSessionController implements TuiSessionController {
100
107
  return this.serialize(() => this.binding.runtimeSession.retireContext());
101
108
  }
102
109
 
110
+ undo(): Promise<TurnUndoResult> {
111
+ return this.serialize(() =>
112
+ this.binding.runtimeSession.undoLatestFileMutationTurn(),
113
+ );
114
+ }
115
+
103
116
  fork(beforeCommit?: () => void): Promise<SessionId> {
104
117
  return this.serialize(async () => {
105
118
  const targetSessionId = createUuidV7() as SessionId;
@@ -230,6 +243,11 @@ export function managedTuiBinding(input: {
230
243
  userMessage,
231
244
  signal,
232
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),
233
251
  };
234
252
  }
235
253