tinker-agent 1.4.0 → 1.5.1

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 (39) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +54 -0
  3. package/package.json +6 -1
  4. package/src/agent/runtime-session.ts +79 -0
  5. package/src/cli/config.ts +29 -0
  6. package/src/cli/model-profiles.ts +84 -1
  7. package/src/cli/public-config-contract.ts +82 -0
  8. package/src/cli/runner-dependencies.ts +8 -0
  9. package/src/cli/tui-memory.ts +67 -0
  10. package/src/cli/tui-runner.tsx +31 -0
  11. package/src/context/context-policy.ts +2 -2
  12. package/src/events/stdout-event-printer.ts +1 -0
  13. package/src/memory/contracts.ts +148 -0
  14. package/src/memory/embedding-client.ts +105 -0
  15. package/src/memory/memory-coordinator.ts +556 -0
  16. package/src/memory/memory-extractor.ts +231 -0
  17. package/src/memory/memory-log.ts +88 -0
  18. package/src/memory/memory-search-tool.ts +100 -0
  19. package/src/memory/memory-store.ts +687 -0
  20. package/src/memory/vector.ts +153 -0
  21. package/src/model/fake-model-client.ts +1068 -3
  22. package/src/observation/observation-builder.ts +20 -0
  23. package/src/session/session-store.ts +123 -0
  24. package/src/tools/registry.ts +4 -0
  25. package/src/tools/types.ts +16 -0
  26. package/src/tui/app.tsx +211 -93
  27. package/src/tui/clipboard.ts +22 -0
  28. package/src/tui/components/assistant-markdown.tsx +27 -26
  29. package/src/tui/components/background-tasks.tsx +7 -2
  30. package/src/tui/components/file-viewer.tsx +2 -2
  31. package/src/tui/components/footer.tsx +5 -6
  32. package/src/tui/components/memory-browser.tsx +151 -0
  33. package/src/tui/components/resume-session-picker.tsx +3 -1
  34. package/src/tui/components/timeline.tsx +9 -11
  35. package/src/tui/event-store.ts +22 -2
  36. package/src/tui/shiki-highlighter.ts +104 -0
  37. package/src/tui/slash-commands.ts +12 -0
  38. package/src/tui/tui-projection-store.ts +33 -0
  39. package/src/tui/tui-session-controller.ts +14 -8
@@ -1,5 +1,4 @@
1
- import { StatusMessage } from "@inkjs/ui";
2
- import { Text } from "ink";
1
+ import { Spinner, StatusMessage } from "@inkjs/ui";
3
2
 
4
3
  export type FooterProps = {
5
4
  status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
@@ -14,7 +13,7 @@ export function Footer(props: FooterProps) {
14
13
 
15
14
  return (
16
15
  <StatusMessage variant="success">
17
- Worked for {formatWorkedDuration(props.workedForMs)}
16
+ Worked for {formatDuration(props.workedForMs)}
18
17
  </StatusMessage>
19
18
  );
20
19
  }
@@ -24,7 +23,7 @@ export function Footer(props: FooterProps) {
24
23
  }
25
24
 
26
25
  if (props.status === "running") {
27
- return <Text color="yellow">• Running</Text>;
26
+ return <Spinner label="Running" />;
28
27
  }
29
28
 
30
29
  if (props.status === "cancelling") {
@@ -38,9 +37,9 @@ export function Footer(props: FooterProps) {
38
37
  return <StatusMessage variant="info">idle</StatusMessage>;
39
38
  }
40
39
 
41
- function formatWorkedDuration(durationMs: number): string {
40
+ function formatDuration(durationMs: number): string {
42
41
  if (!Number.isFinite(durationMs) || durationMs < 0) {
43
- throw new Error(`Invalid worked duration: ${durationMs}`);
42
+ throw new Error(`Invalid duration: ${durationMs}`);
44
43
  }
45
44
 
46
45
  const totalSeconds = Math.floor(durationMs / 1000);
@@ -0,0 +1,151 @@
1
+ import {
2
+ Box,
3
+ Text,
4
+ useBoxMetrics,
5
+ useInput,
6
+ useWindowSize,
7
+ type DOMElement,
8
+ } from "ink";
9
+ import { useRef, useState } from "react";
10
+ import type { StoredMemorySummary } from "../../memory/contracts";
11
+
12
+ const BROWSER_CHROME_ROWS = 3;
13
+
14
+ export type MemoryBrowserProps = {
15
+ readonly memories: readonly StoredMemorySummary[];
16
+ readonly onClose: () => void;
17
+ readonly viewportRows?: number;
18
+ readonly viewportColumns?: number;
19
+ };
20
+
21
+ export function MemoryBrowser(props: MemoryBrowserProps) {
22
+ const windowSize = useWindowSize();
23
+ const rows = Math.max(4, props.viewportRows ?? windowSize.rows - 1);
24
+ const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
25
+ const bodyRows = Math.max(1, rows - BROWSER_CHROME_ROWS);
26
+ const contentRef = useRef<DOMElement>(null);
27
+ const { height: measuredContentRows, hasMeasured } = useBoxMetrics(contentRef);
28
+ const totalLines = props.memories.length === 0 ? 0 : measuredContentRows;
29
+ const maxTopLine = Math.max(0, totalLines - bodyRows);
30
+ const [topLine, setTopLine] = useState(0);
31
+ const visibleTopLine = clamp(topLine, 0, maxTopLine);
32
+ const visibleEnd = Math.min(totalLines, visibleTopLine + bodyRows);
33
+
34
+ useInput((input, key) => {
35
+ if (key.escape) {
36
+ props.onClose();
37
+ return;
38
+ }
39
+ if (key.home) {
40
+ setTopLine(0);
41
+ return;
42
+ }
43
+ if (key.end) {
44
+ setTopLine(maxTopLine);
45
+ return;
46
+ }
47
+ if (key.pageUp || key.pageDown) {
48
+ const direction = key.pageUp ? -1 : 1;
49
+ setTopLine((current) => clamp(current + direction * bodyRows, 0, maxTopLine));
50
+ return;
51
+ }
52
+ const direction =
53
+ key.upArrow || (input === "k" && !key.ctrl && !key.meta)
54
+ ? -1
55
+ : key.downArrow || (input === "j" && !key.ctrl && !key.meta)
56
+ ? 1
57
+ : 0;
58
+ if (direction !== 0) {
59
+ setTopLine((current) => clamp(current + direction, 0, maxTopLine));
60
+ }
61
+ });
62
+
63
+ return (
64
+ <Box width={columns} height={rows} flexDirection="column" overflow="hidden">
65
+ <Text bold>Global memory</Text>
66
+ <Text dimColor wrap="truncate-end">
67
+ ↑/↓ or j/k · PgUp/PgDn · Home/End · Esc close
68
+ </Text>
69
+ <Box
70
+ height={bodyRows}
71
+ width={columns}
72
+ position="relative"
73
+ overflow="hidden"
74
+ flexDirection="column"
75
+ >
76
+ {props.memories.length === 0 ? (
77
+ <Text dimColor>No stored memories.</Text>
78
+ ) : (
79
+ <Box
80
+ ref={contentRef}
81
+ position="absolute"
82
+ top={-visibleTopLine}
83
+ width={columns}
84
+ flexDirection="column"
85
+ >
86
+ {props.memories.map((memory, index) => (
87
+ <Box
88
+ key={memory.memoryId}
89
+ flexDirection="column"
90
+ marginBottom={index === props.memories.length - 1 ? 0 : 1}
91
+ >
92
+ <Text dimColor wrap="truncate-middle">
93
+ {formatMemoryCreatedAt(memory.createdAt)} · {memory.sourceWorkspace}
94
+ </Text>
95
+ <Text>{normalizeMemoryDisplayText(memory.text)}</Text>
96
+ </Box>
97
+ ))}
98
+ </Box>
99
+ )}
100
+ </Box>
101
+ <Text dimColor wrap="truncate-end">
102
+ {props.memories.length === 0
103
+ ? "0 memories"
104
+ : memoryBrowserStatus(
105
+ visibleTopLine,
106
+ visibleEnd,
107
+ hasMeasured ? totalLines : 0,
108
+ props.memories.length,
109
+ )}
110
+ </Text>
111
+ </Box>
112
+ );
113
+ }
114
+
115
+ export function normalizeMemoryDisplayText(text: string): string {
116
+ return (
117
+ text
118
+ .replaceAll(/\r\n?/g, "\n")
119
+ .replaceAll("\t", " ")
120
+ // eslint-disable-next-line no-control-regex
121
+ .replaceAll(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "\uFFFD")
122
+ );
123
+ }
124
+
125
+ export function formatMemoryCreatedAt(createdAt: string): string {
126
+ const date = new Date(createdAt);
127
+ const year = String(date.getFullYear()).padStart(4, "0");
128
+ const month = String(date.getMonth() + 1).padStart(2, "0");
129
+ const day = String(date.getDate()).padStart(2, "0");
130
+ const hours = String(date.getHours()).padStart(2, "0");
131
+ const minutes = String(date.getMinutes()).padStart(2, "0");
132
+ return `${year}-${month}-${day} ${hours}:${minutes}`;
133
+ }
134
+
135
+ function memoryBrowserStatus(
136
+ topLine: number,
137
+ endLine: number,
138
+ totalLines: number,
139
+ memoryCount: number,
140
+ ): string {
141
+ if (totalLines === 0) {
142
+ return `0 lines · ${memoryCount} ${memoryCount === 1 ? "memory" : "memories"}`;
143
+ }
144
+ return `${topLine + 1}–${endLine} / ${totalLines} lines · ${memoryCount} ${
145
+ memoryCount === 1 ? "memory" : "memories"
146
+ }`;
147
+ }
148
+
149
+ function clamp(value: number, minimum: number, maximum: number): number {
150
+ return Math.min(Math.max(value, minimum), maximum);
151
+ }
@@ -10,6 +10,7 @@ export type ResumeSessionPickerProps = {
10
10
  isResuming?: boolean;
11
11
  error?: string;
12
12
  now?: Date;
13
+ viewportRows?: number;
13
14
  visibleItemCount?: number;
14
15
  onCancel: () => void;
15
16
  onSelect: (session: SessionSummary) => void;
@@ -29,7 +30,8 @@ export function ResumeSessionPicker(props: ResumeSessionPickerProps) {
29
30
  }
30
31
 
31
32
  function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
32
- const { rows } = useWindowSize();
33
+ const windowSize = useWindowSize();
34
+ const rows = props.viewportRows ?? windowSize.rows - 1;
33
35
  const visibleItemCount = Math.min(
34
36
  props.sessions.length,
35
37
  Math.max(
@@ -6,27 +6,25 @@ import { BashResultView } from "./bash-result-view";
6
6
  import { DiffView } from "./diff-view";
7
7
 
8
8
  export type TimelineProps = {
9
- items: TimelineItem[];
9
+ items: readonly TimelineItem[];
10
10
  };
11
11
 
12
12
  export function Timeline(props: TimelineProps) {
13
13
  return (
14
14
  <Box flexDirection="column">
15
- <Text bold>Timeline</Text>
16
- {props.items.length === 0 ? (
17
- <Text color="gray">idle</Text>
18
- ) : (
19
- props.items.map((item) => renderTimelineItem(item))
20
- )}
15
+ {props.items.map((item) => (
16
+ <TimelineRow key={item.id} item={item} />
17
+ ))}
21
18
  </Box>
22
19
  );
23
20
  }
24
21
 
25
- function renderTimelineItem(item: TimelineItem) {
22
+ export function TimelineRow(props: { item: TimelineItem }) {
23
+ const { item } = props;
26
24
  if (item.label !== undefined) {
27
25
  if (item.label === "assistant") {
28
26
  return (
29
- <Fragment key={item.id}>
27
+ <Fragment>
30
28
  <Text color="gray">- {item.label}</Text>
31
29
  <AssistantMarkdown text={item.text} />
32
30
  </Fragment>
@@ -34,7 +32,7 @@ function renderTimelineItem(item: TimelineItem) {
34
32
  }
35
33
 
36
34
  return (
37
- <Fragment key={item.id}>
35
+ <Fragment>
38
36
  <Text color="gray">- {item.label}</Text>
39
37
  {item.userPrompt === undefined ? (
40
38
  <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
@@ -48,7 +46,7 @@ function renderTimelineItem(item: TimelineItem) {
48
46
  }
49
47
 
50
48
  return (
51
- <Fragment key={item.id}>
49
+ <Fragment>
52
50
  <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
53
51
  {renderItemBash(item)}
54
52
  {renderItemDiff(item)}
@@ -393,6 +393,19 @@ function formatSurfaceComponent(component: string): string {
393
393
 
394
394
  export const applyAgentEvent = reduceTuiProjection;
395
395
 
396
+ export function timelineStreamItems(state: TuiProjectionState): TimelineItem[] {
397
+ return [
398
+ ...state.notices,
399
+ ...state.recentTurns.flatMap((turn) => turn.items),
400
+ ...(state.activeTurn?.items ?? []),
401
+ ];
402
+ }
403
+
404
+ export function firstRunningIndex(items: readonly TimelineItem[]): number {
405
+ const index = items.findIndex((item) => item.status === "running");
406
+ return index === -1 ? items.length : index;
407
+ }
408
+
396
409
  export function visibleTimelineItems(state: TuiProjectionState): TimelineItem[] {
397
410
  const items = [...state.notices];
398
411
 
@@ -697,8 +710,8 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
697
710
  if (input.name === "Grep") {
698
711
  return `Grep ${toolPattern(input.args) ?? ""}`.trim();
699
712
  }
700
- if (input.name === "WebSearch") {
701
- return `WebSearch ${toolQuery(input.args) ?? ""}`.trim();
713
+ if (input.name === "WebSearch" || input.name === "MemorySearch") {
714
+ return `${input.name} ${toolQuery(input.args) ?? ""}`.trim();
702
715
  }
703
716
  if (input.name === "WebFetch") {
704
717
  return `WebFetch ${toolUrl(input.args) ?? ""}`.trim();
@@ -779,6 +792,11 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
779
792
  return raw.mode === "search"
780
793
  ? `${base} -> ${raw.page.hits.length} historical match${raw.page.hits.length === 1 ? "" : "es"}`
781
794
  : `${base} -> ${raw.page.returnedBytes} historical bytes`;
795
+ case "memory_search":
796
+ if (!raw.ok) {
797
+ return base;
798
+ }
799
+ return `${base} -> ${raw.matches.length} derived memor${raw.matches.length === 1 ? "y" : "ies"}`;
782
800
  case "skill":
783
801
  if (!raw.ok) {
784
802
  return `${base} failed -> ${boundedToolError(raw.error)}`;
@@ -846,6 +864,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
846
864
  case "web_search":
847
865
  case "web_fetch":
848
866
  case "recall":
867
+ case "memory_search":
849
868
  case "skill":
850
869
  case "mcp":
851
870
  case "generic":
@@ -880,6 +899,7 @@ function toolRawResultDiff(
880
899
  case "web_search":
881
900
  case "web_fetch":
882
901
  case "recall":
902
+ case "memory_search":
883
903
  case "skill":
884
904
  case "mcp":
885
905
  case "generic":
@@ -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: "memory",
29
+ usage: "/memory",
30
+ description: "Browse stored global memories",
31
+ },
27
32
  {
28
33
  name: "compact",
29
34
  usage: "/compact [retire]",
@@ -67,6 +72,7 @@ export type ParsedSlashCommand =
67
72
  | { type: "status" }
68
73
  | { type: "skills" }
69
74
  | { type: "mcp" }
75
+ | { type: "memory" }
70
76
  | { type: "compact" }
71
77
  | { type: "compact_retire" }
72
78
  | { type: "clear" }
@@ -114,6 +120,12 @@ export function parseSlashCommand(input: string): ParsedSlashCommand {
114
120
  }
115
121
  throw slashCommandUsageError("mcp");
116
122
  }
123
+ if (command === "/memory") {
124
+ if (tokens.length === 1) {
125
+ return { type: "memory" };
126
+ }
127
+ throw slashCommandUsageError("memory");
128
+ }
117
129
  if (command === "/compact") {
118
130
  if (tokens.length === 1) {
119
131
  return { type: "compact" };
@@ -2,7 +2,11 @@ import type { EventSink } from "../events/event-sink";
2
2
  import type { AgentEvent } from "../events/types";
3
3
  import {
4
4
  createInitialTuiProjectionState,
5
+ firstRunningIndex,
5
6
  reduceTuiProjection,
7
+ timelineStreamItems,
8
+ visibleTimelineItems,
9
+ type TimelineItem,
6
10
  type TuiProjectionState,
7
11
  } from "./event-store";
8
12
  import {
@@ -19,11 +23,18 @@ export type TuiProjectionStoreInput = {
19
23
  initialSnapshot?: TuiProjectionState;
20
24
  };
21
25
 
26
+ export type TuiTimelineLog = Readonly<{
27
+ committed: readonly TimelineItem[];
28
+ live: readonly TimelineItem[];
29
+ }>;
30
+
22
31
  export class TuiProjectionStore implements EventSink {
23
32
  readonly name = "tui-projection-store";
24
33
  private readonly listeners = new Set<() => void>();
25
34
  private readonly policy: TuiProjectionPolicy;
35
+ private readonly printed = new Set<string>();
26
36
  private snapshot: TuiProjectionState;
37
+ private log: TuiTimelineLog = { committed: [], live: [] };
27
38
 
28
39
  constructor(input: TuiProjectionStoreInput) {
29
40
  this.policy = validateTuiProjectionPolicy(
@@ -33,10 +44,15 @@ export class TuiProjectionStore implements EventSink {
33
44
  input.initialSnapshot === undefined
34
45
  ? createInitialTuiProjectionState(input)
35
46
  : validateInitialSnapshot(input, input.initialSnapshot, this.policy);
47
+ if (input.initialSnapshot !== undefined) {
48
+ this.refreshLog(visibleTimelineItems(this.snapshot));
49
+ }
36
50
  }
37
51
 
38
52
  readonly getSnapshot = (): TuiProjectionState => this.snapshot;
39
53
 
54
+ readonly getLogSnapshot = (): TuiTimelineLog => this.log;
55
+
40
56
  readonly subscribe = (listener: () => void): (() => void) => {
41
57
  this.listeners.add(listener);
42
58
  return () => {
@@ -51,6 +67,7 @@ export class TuiProjectionStore implements EventSink {
51
67
  }
52
68
 
53
69
  this.snapshot = next;
70
+ this.refreshLog();
54
71
  for (const listener of this.listeners) {
55
72
  listener();
56
73
  }
@@ -73,10 +90,26 @@ export class TuiProjectionStore implements EventSink {
73
90
  snapshot,
74
91
  this.policy,
75
92
  );
93
+ this.refreshLog(visibleTimelineItems(this.snapshot));
76
94
  for (const listener of this.listeners) {
77
95
  listener();
78
96
  }
79
97
  }
98
+
99
+ private refreshLog(stream = timelineStreamItems(this.snapshot)): void {
100
+ const settledEnd = firstRunningIndex(stream);
101
+ const pending = stream
102
+ .slice(0, settledEnd)
103
+ .filter((item) => !this.printed.has(item.id));
104
+ for (const item of pending) {
105
+ this.printed.add(item.id);
106
+ }
107
+ this.log = {
108
+ committed:
109
+ pending.length === 0 ? this.log.committed : [...this.log.committed, ...pending],
110
+ live: stream.slice(settledEnd),
111
+ };
112
+ }
80
113
  }
81
114
 
82
115
  function validateInitialSnapshot(
@@ -47,11 +47,11 @@ export type TuiSessionController = {
47
47
  listSessions: () => Promise<readonly SessionSummary[]>;
48
48
  compact: () => Promise<ContextCompactionResult>;
49
49
  retire: () => Promise<ContextRetirementResult>;
50
- fork: () => Promise<SessionId>;
51
- clear: () => Promise<void>;
52
- resume: (sessionId: SessionId) => Promise<void>;
50
+ fork: (beforeCommit?: () => void) => Promise<SessionId>;
51
+ clear: (beforeCommit?: () => void) => Promise<void>;
52
+ resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
53
53
  delete: (sessionId: SessionId) => Promise<void>;
54
- switchModel: (profile: ModelProfile) => Promise<void>;
54
+ switchModel: (profile: ModelProfile, beforeCommit?: () => void) => Promise<void>;
55
55
  };
56
56
 
57
57
  export type ManagedTuiSessionBinding = TuiSessionBinding & {
@@ -100,7 +100,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
100
100
  return this.serialize(() => this.binding.runtimeSession.retireContext());
101
101
  }
102
102
 
103
- fork(): Promise<SessionId> {
103
+ fork(beforeCommit?: () => void): Promise<SessionId> {
104
104
  return this.serialize(async () => {
105
105
  const targetSessionId = createUuidV7() as SessionId;
106
106
  await this.replaceSession(
@@ -109,21 +109,23 @@ export class DefaultTuiSessionController implements TuiSessionController {
109
109
  await current.runtimeSession.cloneSession(targetSessionId);
110
110
  return this.openSession(targetSessionId);
111
111
  },
112
+ beforeCommit,
112
113
  );
113
114
  return targetSessionId;
114
115
  });
115
116
  }
116
117
 
117
- clear(): Promise<void> {
118
+ clear(beforeCommit?: () => void): Promise<void> {
118
119
  return this.serialize(() =>
119
120
  this.replaceSession(
120
121
  "Cannot clear the session while a turn, context operation, or background task is active.",
121
122
  (current) => this.createFreshSession(current),
123
+ beforeCommit,
122
124
  ),
123
125
  );
124
126
  }
125
127
 
126
- resume(sessionId: SessionId): Promise<void> {
128
+ resume(sessionId: SessionId, beforeCommit?: () => void): Promise<void> {
127
129
  return this.serialize(async () => {
128
130
  if (sessionId === this.binding.sessionId) {
129
131
  throw new Error(`Session ${sessionId} is already current.`);
@@ -131,6 +133,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
131
133
  await this.replaceSession(
132
134
  "Cannot switch sessions while a turn or background task is active.",
133
135
  () => this.openSession(sessionId),
136
+ beforeCommit,
134
137
  );
135
138
  });
136
139
  }
@@ -139,11 +142,12 @@ export class DefaultTuiSessionController implements TuiSessionController {
139
142
  return this.serialize(() => this.catalog.delete(sessionId, this.binding.sessionId));
140
143
  }
141
144
 
142
- switchModel(profile: ModelProfile): Promise<void> {
145
+ switchModel(profile: ModelProfile, beforeCommit?: () => void): Promise<void> {
143
146
  return this.serialize(() =>
144
147
  this.replaceSession(
145
148
  "Cannot switch models while a turn or background task is active.",
146
149
  () => this.createSessionWithProfile(profile),
150
+ beforeCommit,
147
151
  ),
148
152
  );
149
153
  }
@@ -157,6 +161,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
157
161
  createTarget: (
158
162
  current: ManagedTuiSessionBinding,
159
163
  ) => Promise<ManagedTuiSessionBinding>,
164
+ beforeCommit?: () => void,
160
165
  ): Promise<void> {
161
166
  const current = this.binding;
162
167
  if (!current.runtimeSession.canSwitchSession()) {
@@ -172,6 +177,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
172
177
  .catch(() => undefined);
173
178
  throw error;
174
179
  }
180
+ beforeCommit?.();
175
181
  this.binding = target;
176
182
  for (const listener of this.listeners) {
177
183
  listener();