tinker-agent 1.5.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.5.1] - 2026-07-29
9
+
10
+ ### Changed
11
+
12
+ - Keep settled TUI history outside recurring live renders and reuse prepared
13
+ Markdown highlighting, reducing redraw work during long interactive sessions.
14
+ - Stop repainting the TUI once per second solely to update a running elapsed-time
15
+ counter; completed turns still show their final duration.
16
+
17
+ ### Fixed
18
+
19
+ - Restore the visible history tail after closing `/view`, `/memory`, `/resume`,
20
+ and other temporary TUI panels instead of leaving a mostly blank viewport.
21
+ - Keep the `/resume` picker bound to the session that opened it, preventing stale
22
+ picker state from affecting a newly activated session.
23
+
8
24
  ## [1.5.0] - 2026-07-27
9
25
 
10
26
  ### Added
@@ -84,7 +100,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
84
100
  - First formal npm release under the `tinker-agent` package name with the `tinker`
85
101
  executable.
86
102
 
87
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.5.0...HEAD
103
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.5.1...HEAD
104
+ [1.5.1]: https://github.com/ishowshao/tinker/releases/tag/v1.5.1
88
105
  [1.5.0]: https://github.com/ishowshao/tinker/releases/tag/v1.5.0
89
106
  [1.4.0]: https://github.com/ishowshao/tinker/releases/tag/v1.4.0
90
107
  [1.3.0]: https://github.com/ishowshao/tinker/releases/tag/v1.3.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -80,6 +80,7 @@
80
80
  "@modelcontextprotocol/sdk": "^1.29.0",
81
81
  "@mozilla/readability": "^0.6.0",
82
82
  "@vscode/ripgrep": "1.18.0",
83
+ "ansi-escapes": "^7.3.0",
83
84
  "bun": "1.3.14",
84
85
  "clipboardy": "^5.3.1",
85
86
  "commander": "^14.0.3",
@@ -43,6 +43,7 @@ import { loadProjectSlashCommands } from "../tui/project-slash-commands";
43
43
  import { createWorkspaceFileLister } from "../tui/workspace-file-search";
44
44
  import { clipboardWriterForEnvironment } from "../tui/clipboard";
45
45
  import { initializeTuiMemory } from "./tui-memory";
46
+ import { prepareShikiHighlighter } from "../tui/shiki-highlighter";
46
47
 
47
48
  export type RunTuiOptions = {
48
49
  readonly publicConfig: ResolvedPublicConfig;
@@ -51,6 +52,7 @@ export type RunTuiOptions = {
51
52
  };
52
53
 
53
54
  export async function runTui(options: RunTuiOptions): Promise<void> {
55
+ const shikiPreparation = prepareShikiHighlighter();
54
56
  const profiles =
55
57
  options.publicConfig.mode === "profile" ? options.publicConfig.profiles : undefined;
56
58
  const config = options.initialRunnerConfig;
@@ -256,6 +258,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
256
258
  createFreshSession,
257
259
  );
258
260
 
261
+ await shikiPreparation;
259
262
  instance = render(
260
263
  <App
261
264
  sessionController={controller}
@@ -285,6 +288,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
285
288
  : () => memoryCoordinator.listStoredMemories()
286
289
  }
287
290
  />,
291
+ { incrementalRendering: true },
288
292
  );
289
293
  await instance.waitUntilExit();
290
294
  } catch (error) {
@@ -226,6 +226,12 @@ export class FakeModelClient implements ModelClient {
226
226
  if (this.mode === "pty-echo-history") {
227
227
  return this.ptyEchoHistory(input, prepared);
228
228
  }
229
+ if (this.mode === "pty-static-history") {
230
+ return this.ptyStaticHistory(input, prepared, options);
231
+ }
232
+ if (this.mode === "pty-resume-layout") {
233
+ return this.ptyResumeLayout(input, prepared, options);
234
+ }
229
235
  if (this.mode === "pty-cancel-then-echo") {
230
236
  return this.ptyCancelThenEcho(input, prepared, options);
231
237
  }
@@ -307,6 +313,97 @@ export class FakeModelClient implements ModelClient {
307
313
  throw new Error(`Unexpected pty-echo-history prompt: ${JSON.stringify(prompt)}.`);
308
314
  }
309
315
 
316
+ private async ptyStaticHistory(
317
+ input: ModelRequestInput,
318
+ prepared: PreparedModelRequest,
319
+ options: ModelRequestOptions,
320
+ ): Promise<ModelRequestOutput> {
321
+ const prompt = lastUserMessage(input.messages);
322
+ const historyMatch = /^PTY_STATIC_HISTORY_([1-4])$/u.exec(prompt);
323
+ if (historyMatch !== null) {
324
+ const turn = historyMatch[1];
325
+ return textOutput(
326
+ prepared,
327
+ [
328
+ turn === "1"
329
+ ? "PTY_STATIC_HISTORY_EARLY_SENTINEL"
330
+ : `PTY_STATIC_HISTORY_${turn}`,
331
+ ...Array.from(
332
+ { length: 8 },
333
+ (_, index) => `- settled PTY history ${turn}.${index + 1}`,
334
+ ),
335
+ `PTY_STATIC_HISTORY_${turn}_DONE`,
336
+ ].join("\n"),
337
+ );
338
+ }
339
+ if (prompt !== "PTY_STATIC_LIVE") {
340
+ throw new Error(
341
+ `Unexpected pty-static-history prompt: ${JSON.stringify(prompt)}.`,
342
+ );
343
+ }
344
+
345
+ requireTools(input, ["Bash"]);
346
+ const bash = toolMessagesAfterLastUser(input.messages).find(
347
+ (message) => message.name === "Bash",
348
+ );
349
+ await Bun.sleep(200);
350
+ options.signal.throwIfAborted();
351
+ if (bash === undefined) {
352
+ return toolCallOutput(prepared, options, "Bash", {
353
+ command:
354
+ 'index=1; while [ "$index" -le 20 ]; do printf \'PTY_STATIC_LIVE_LINE_%s\\n\' "$index"; index=$((index + 1)); done; sleep 0.2',
355
+ description: "Exercise static history live tail",
356
+ });
357
+ }
358
+ if (!bash.content.includes("PTY_STATIC_LIVE_LINE_20")) {
359
+ throw new Error("PTY static-history Bash output was incomplete.");
360
+ }
361
+ return textOutput(prepared, "PTY_STATIC_LIVE_DONE");
362
+ }
363
+
364
+ private ptyResumeLayout(
365
+ input: ModelRequestInput,
366
+ prepared: PreparedModelRequest,
367
+ options: ModelRequestOptions,
368
+ ): ModelRequestOutput {
369
+ const prompt = lastUserMessage(input.messages);
370
+ if (/^PTY_RESUME_LAYOUT_PAD_\d+$/u.test(prompt)) {
371
+ return textOutput(prepared, `${prompt}_DONE`);
372
+ }
373
+ const match = /^PTY_RESUME_LAYOUT_([1-3])$/u.exec(prompt);
374
+ if (match === null) {
375
+ throw new Error(
376
+ `Unexpected pty-resume-layout prompt: ${JSON.stringify(prompt)}.`,
377
+ );
378
+ }
379
+
380
+ requireTools(input, ["Read"]);
381
+ const turn = Number(match[1]);
382
+ const targetToolCount = [8, 17, 4][turn - 1];
383
+ const finalLineCount = [31, 47, 8][turn - 1];
384
+ if (targetToolCount === undefined || finalLineCount === undefined) {
385
+ throw new Error(`Invalid pty-resume-layout turn: ${turn}.`);
386
+ }
387
+
388
+ const completedReads = toolMessagesAfterLastUser(input.messages).filter(
389
+ (message) => message.name === "Read",
390
+ ).length;
391
+ if (completedReads < targetToolCount) {
392
+ return toolCallOutput(prepared, options, "Read", {
393
+ file_path: "resume-layout.txt",
394
+ });
395
+ }
396
+
397
+ return textOutput(
398
+ prepared,
399
+ Array.from(
400
+ { length: finalLineCount },
401
+ (_, index) =>
402
+ `PTY_RESUME_LAYOUT_${turn}_FINAL_${String(index + 1).padStart(2, "0")}`,
403
+ ).join("\n"),
404
+ );
405
+ }
406
+
310
407
  private ptyCancelThenEcho(
311
408
  input: ModelRequestInput,
312
409
  prepared: PreparedModelRequest,
package/src/tui/app.tsx CHANGED
@@ -1,5 +1,13 @@
1
- import { Box, Text, useApp, useInput } from "ink";
2
- import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
1
+ import { Box, Static, Text, useApp, useInput, useStdout, useWindowSize } from "ink";
2
+ import {
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ useSyncExternalStore,
9
+ } from "react";
10
+ import { clearTerminal } from "ansi-escapes";
3
11
  import { TurnCancelledError } from "../agent/turn-cancellation";
4
12
  import { boundedMemoryError, type StoredMemorySummary } from "../memory/contracts";
5
13
  import {
@@ -11,9 +19,10 @@ import { ContextBudgetExceededError } from "../model/model-request-preflight";
11
19
  import { ModelRequestMediaAggregateError } from "../model/model-client";
12
20
  import type { SessionId } from "../ids/runtime-id";
13
21
  import { readLastAssistantResponse } from "../session/session-last-response-reader";
14
- import { visibleTimelineItems } from "./event-store";
22
+ import type { TimelineItem } from "./event-store";
15
23
  import type { PromptHistory } from "./prompt-history";
16
24
  import { Footer } from "./components/footer";
25
+ import { AssistantMarkdownProvider } from "./components/assistant-markdown";
17
26
  import { ContextStatus } from "./components/context-status";
18
27
  import { BackgroundTasks } from "./components/background-tasks";
19
28
  import { Header } from "./components/header";
@@ -33,7 +42,7 @@ import {
33
42
  ResumeSessionPicker,
34
43
  ResumeSessionPickerLoading,
35
44
  } from "./components/resume-session-picker";
36
- import { Timeline } from "./components/timeline";
45
+ import { Timeline, TimelineRow } from "./components/timeline";
37
46
  import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands";
38
47
  import {
39
48
  resolveProjectSlashCommand,
@@ -67,9 +76,10 @@ export type AppProps = {
67
76
  };
68
77
 
69
78
  type ResumePickerState =
70
- | { status: "loading" }
79
+ | { status: "loading"; ownerSessionId: SessionId }
71
80
  | {
72
81
  status: "ready";
82
+ ownerSessionId: SessionId;
73
83
  sessions: readonly SessionSummary[];
74
84
  isResuming: boolean;
75
85
  error?: string;
@@ -84,8 +94,15 @@ type FileViewState =
84
94
  | { status: "loading"; filePath: string }
85
95
  | { status: "ready"; file: ViewFile };
86
96
 
97
+ const STATIC_HEADER = Symbol("tui-static-header");
98
+ const LIVE_TIMELINE_MAX_ROWS = 8;
99
+ const LIVE_TIMELINE_WITH_TASKS_MAX_ROWS = 3;
100
+ const BACKGROUND_TASKS_MAX_ROWS = 12;
101
+
87
102
  export function App(props: AppProps) {
88
103
  const { exit } = useApp();
104
+ const { write } = useStdout();
105
+ const windowSize = useWindowSize();
89
106
  const binding = useSyncExternalStore(
90
107
  props.sessionController.subscribe,
91
108
  props.sessionController.getBinding,
@@ -100,6 +117,11 @@ export function App(props: AppProps) {
100
117
  binding.projectionStore.getSnapshot,
101
118
  binding.projectionStore.getSnapshot,
102
119
  );
120
+ const log = useSyncExternalStore(
121
+ binding.projectionStore.subscribe,
122
+ binding.projectionStore.getLogSnapshot,
123
+ binding.projectionStore.getLogSnapshot,
124
+ );
103
125
  const [isRunning, setIsRunning] = useState(false);
104
126
  const [isSessionOperation, setIsSessionOperation] = useState(false);
105
127
  const [isCopying, setIsCopying] = useState(false);
@@ -120,18 +142,32 @@ export function App(props: AppProps) {
120
142
  readonly StoredMemorySummary[] | undefined
121
143
  >(undefined);
122
144
  const [viewError, setViewError] = useState<string | undefined>(undefined);
145
+ const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
123
146
  const [gitBranch, setGitBranch] = useState<string | undefined>(undefined);
124
147
  const [gitBranchRefresh, setGitBranchRefresh] = useState(0);
125
148
  const gitBranchReadQueue = useRef<Promise<void>>(Promise.resolve());
126
149
  const activeController = useRef<AbortController | undefined>(undefined);
127
150
  const resumePickerRequest = useRef(0);
128
151
  const fileViewRequest = useRef(0);
152
+ const beforeSessionCommit = useCallback(() => {
153
+ write(clearTerminal);
154
+ }, [write]);
155
+ const restoreStaticViewport = useCallback(() => {
156
+ write(clearTerminal);
157
+ setStaticRenderEpoch((current) => current + 1);
158
+ }, [write]);
159
+ const staticItems = useMemo<Array<typeof STATIC_HEADER | TimelineItem>>(
160
+ () => [STATIC_HEADER, ...log.committed],
161
+ [log.committed],
162
+ );
129
163
 
130
164
  const canSwitchModel =
131
165
  state.recentTurns.length === 0 &&
132
166
  state.activeTurn === undefined &&
133
167
  props.profiles !== undefined &&
134
168
  props.profiles.profiles.size > 1;
169
+ const activeResumePicker =
170
+ resumePicker?.ownerSessionId === binding.sessionId ? resumePicker : undefined;
135
171
 
136
172
  const builtInCommands = canSwitchModel
137
173
  ? SLASH_COMMANDS
@@ -140,8 +176,6 @@ export function App(props: AppProps) {
140
176
 
141
177
  const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
142
178
 
143
- const runningElapsedMs = useElapsedMs(state.activeTurn?.startedAt);
144
-
145
179
  useEffect(() => {
146
180
  if (readGitBranch === undefined) {
147
181
  return;
@@ -186,17 +220,22 @@ export function App(props: AppProps) {
186
220
  );
187
221
 
188
222
  const closeResumePicker = () => {
223
+ const shouldRestoreViewport = activeResumePicker !== undefined;
189
224
  resumePickerRequest.current += 1;
190
225
  setResumePicker(undefined);
191
226
  setIsSessionOperation(false);
192
227
  setNotice(undefined);
228
+ if (shouldRestoreViewport) {
229
+ restoreStaticViewport();
230
+ }
193
231
  };
194
232
 
195
233
  const openResumePicker = () => {
196
234
  const requestId = resumePickerRequest.current + 1;
235
+ const ownerSessionId = binding.sessionId;
197
236
  resumePickerRequest.current = requestId;
198
237
  setNotice(undefined);
199
- setResumePicker({ status: "loading" });
238
+ setResumePicker({ status: "loading", ownerSessionId });
200
239
  setIsSessionOperation(true);
201
240
  void props.sessionController
202
241
  .listSessions()
@@ -207,14 +246,21 @@ export function App(props: AppProps) {
207
246
  if (sessions.length === 0) {
208
247
  setResumePicker(undefined);
209
248
  setNotice("No stored sessions found for this workspace.");
249
+ restoreStaticViewport();
210
250
  return;
211
251
  }
212
- setResumePicker({ status: "ready", sessions, isResuming: false });
252
+ setResumePicker({
253
+ status: "ready",
254
+ ownerSessionId,
255
+ sessions,
256
+ isResuming: false,
257
+ });
213
258
  })
214
259
  .catch((error: unknown) => {
215
260
  if (resumePickerRequest.current === requestId) {
216
261
  setResumePicker(undefined);
217
262
  setNotice(`Session operation failed: ${errorMessage(error)}`);
263
+ restoreStaticViewport();
218
264
  }
219
265
  })
220
266
  .finally(() => {
@@ -232,7 +278,7 @@ export function App(props: AppProps) {
232
278
  );
233
279
  setIsSessionOperation(true);
234
280
  void props.sessionController
235
- .resume(session.sessionId)
281
+ .resume(session.sessionId, beforeSessionCommit)
236
282
  .then(() => {
237
283
  setResumePicker(undefined);
238
284
  setNotice(`Resumed session ${session.sessionId}.`);
@@ -255,6 +301,7 @@ export function App(props: AppProps) {
255
301
  setShowModelPicker(false);
256
302
  setModelPickerState(undefined);
257
303
  setNotice(undefined);
304
+ restoreStaticViewport();
258
305
  };
259
306
 
260
307
  const doSwitchModel = (profile: ModelProfile) => {
@@ -263,7 +310,7 @@ export function App(props: AppProps) {
263
310
  setNotice(undefined);
264
311
  setIsSessionOperation(true);
265
312
  void props.sessionController
266
- .switchModel(profile)
313
+ .switchModel(profile, beforeSessionCommit)
267
314
  .then(async () => {
268
315
  setGitBranchRefresh((current) => current + 1);
269
316
  try {
@@ -276,6 +323,7 @@ export function App(props: AppProps) {
276
323
  }
277
324
  })
278
325
  .catch((error: unknown) => {
326
+ restoreStaticViewport();
279
327
  setNotice(`Model switch failed: ${errorMessage(error)}`);
280
328
  })
281
329
  .finally(() => setIsSessionOperation(false));
@@ -284,6 +332,12 @@ export function App(props: AppProps) {
284
332
  const closeFileView = () => {
285
333
  fileViewRequest.current += 1;
286
334
  setFileView(undefined);
335
+ restoreStaticViewport();
336
+ };
337
+
338
+ const closeMemoryView = () => {
339
+ setMemoryView(undefined);
340
+ restoreStaticViewport();
287
341
  };
288
342
 
289
343
  const openMemoryView = () => {
@@ -318,6 +372,7 @@ export function App(props: AppProps) {
318
372
  if (fileViewRequest.current === requestId) {
319
373
  setFileView(undefined);
320
374
  setViewError(`View failed: ${errorMessage(error)}`);
375
+ restoreStaticViewport();
321
376
  }
322
377
  });
323
378
  };
@@ -432,7 +487,7 @@ export function App(props: AppProps) {
432
487
  setNotice(formatContextRetirementNotice(result));
433
488
  return;
434
489
  }
435
- await props.sessionController.clear();
490
+ await props.sessionController.clear(beforeSessionCommit);
436
491
  signal.throwIfAborted();
437
492
  const sessionId = props.sessionController.getBinding().sessionId;
438
493
  setGitBranchRefresh((current) => current + 1);
@@ -450,10 +505,14 @@ export function App(props: AppProps) {
450
505
  ): PromptSubmissionOutcome | Promise<PromptSubmissionOutcome> => {
451
506
  const { userMessage } = submission;
452
507
  const trimmed = userMessage.content.trim();
508
+ const shouldRestoreViewport = showStatus || showSkills || showMcp;
453
509
  setShowStatus(false);
454
510
  setShowSkills(false);
455
511
  setShowMcp(false);
456
512
  setViewError(undefined);
513
+ if (shouldRestoreViewport) {
514
+ restoreStaticViewport();
515
+ }
457
516
 
458
517
  if (userMessage.attachments === undefined && trimmed.startsWith("/")) {
459
518
  try {
@@ -523,7 +582,7 @@ export function App(props: AppProps) {
523
582
  if (command.type === "clear") {
524
583
  setIsSessionOperation(true);
525
584
  void props.sessionController
526
- .clear()
585
+ .clear(beforeSessionCommit)
527
586
  .then(() => {
528
587
  const sessionId = props.sessionController.getBinding().sessionId;
529
588
  setGitBranchRefresh((current) => current + 1);
@@ -540,7 +599,7 @@ export function App(props: AppProps) {
540
599
  if (command.type === "fork") {
541
600
  setIsSessionOperation(true);
542
601
  void props.sessionController
543
- .fork()
602
+ .fork(beforeSessionCommit)
544
603
  .then((sessionId) => {
545
604
  setGitBranchRefresh((current) => current + 1);
546
605
  setNotice(
@@ -586,7 +645,7 @@ export function App(props: AppProps) {
586
645
  const operation =
587
646
  command.type === "resume"
588
647
  ? props.sessionController
589
- .resume(command.sessionId)
648
+ .resume(command.sessionId, beforeSessionCommit)
590
649
  .then(() => setNotice(`Resumed session ${command.sessionId}.`))
591
650
  : props.sessionController
592
651
  .delete(command.sessionId)
@@ -606,94 +665,122 @@ export function App(props: AppProps) {
606
665
  };
607
666
 
608
667
  return (
609
- <Box flexDirection="column">
610
- {fileView?.status === "loading" ? (
611
- <FileViewerLoading filePath={fileView.filePath} onCancel={closeFileView} />
612
- ) : fileView?.status === "ready" ? (
613
- <FileViewer file={fileView.file} onClose={closeFileView} />
614
- ) : memoryView !== undefined ? (
615
- <MemoryBrowser memories={memoryView} onClose={() => setMemoryView(undefined)} />
616
- ) : resumePicker?.status === "loading" ? (
617
- <ResumeSessionPickerLoading onCancel={closeResumePicker} />
618
- ) : resumePicker?.status === "ready" ? (
619
- <ResumeSessionPicker
620
- sessions={resumePicker.sessions}
621
- isResuming={resumePicker.isResuming}
622
- error={resumePicker.error}
623
- onCancel={closeResumePicker}
624
- onSelect={resumeSelectedSession}
625
- />
626
- ) : (
627
- <>
628
- <Header
629
- key={binding.sessionId}
630
- modelName={binding.modelName}
631
- workspaceRoot={binding.workspaceRoot}
632
- sessionId={binding.sessionId}
668
+ <AssistantMarkdownProvider>
669
+ <Box flexDirection="column">
670
+ <Static key={`${binding.sessionId}:${staticRenderEpoch}`} items={staticItems}>
671
+ {(item) =>
672
+ item === STATIC_HEADER ? (
673
+ <Header
674
+ key={`header-${binding.sessionId}`}
675
+ modelName={binding.modelName}
676
+ workspaceRoot={binding.workspaceRoot}
677
+ sessionId={binding.sessionId}
678
+ />
679
+ ) : (
680
+ <TimelineRow key={item.id} item={item} />
681
+ )
682
+ }
683
+ </Static>
684
+ {fileView?.status === "loading" ? (
685
+ <FileViewerLoading filePath={fileView.filePath} onCancel={closeFileView} />
686
+ ) : fileView?.status === "ready" ? (
687
+ <FileViewer file={fileView.file} onClose={closeFileView} />
688
+ ) : memoryView !== undefined ? (
689
+ <MemoryBrowser memories={memoryView} onClose={closeMemoryView} />
690
+ ) : activeResumePicker?.status === "loading" ? (
691
+ <ResumeSessionPickerLoading onCancel={closeResumePicker} />
692
+ ) : activeResumePicker?.status === "ready" ? (
693
+ <ResumeSessionPicker
694
+ sessions={activeResumePicker.sessions}
695
+ isResuming={activeResumePicker.isResuming}
696
+ error={activeResumePicker.error}
697
+ onCancel={closeResumePicker}
698
+ onSelect={resumeSelectedSession}
633
699
  />
634
- <Box marginTop={1} flexDirection="column">
635
- <Timeline items={visibleTimelineItems(state)} />
636
- </Box>
637
- {state.backgroundTasks.length === 0 ? null : (
638
- <Box marginTop={1}>
639
- <BackgroundTasks tasks={state.backgroundTasks} />
640
- </Box>
641
- )}
642
- {showStatus ? (
643
- <Box marginTop={1}>
644
- <ContextStatus state={state} />
700
+ ) : (
701
+ <Box
702
+ flexDirection="column"
703
+ maxHeight={
704
+ state.status === "running" ? Math.max(1, windowSize.rows - 1) : undefined
705
+ }
706
+ overflow={state.status === "running" ? "hidden" : "visible"}
707
+ >
708
+ <Box marginTop={1} flexDirection="column">
709
+ <Box
710
+ maxHeight={
711
+ state.backgroundTasks.length === 0
712
+ ? LIVE_TIMELINE_MAX_ROWS
713
+ : LIVE_TIMELINE_WITH_TASKS_MAX_ROWS
714
+ }
715
+ overflow="hidden"
716
+ >
717
+ <Timeline items={log.live} />
718
+ </Box>
645
719
  </Box>
646
- ) : null}
647
- {showSkills ? (
720
+ {state.backgroundTasks.length === 0 ? null : (
721
+ <Box
722
+ marginTop={1}
723
+ maxHeight={BACKGROUND_TASKS_MAX_ROWS}
724
+ overflow="hidden"
725
+ >
726
+ <BackgroundTasks tasks={state.backgroundTasks} />
727
+ </Box>
728
+ )}
729
+ {showStatus ? (
730
+ <Box marginTop={1}>
731
+ <ContextStatus state={state} />
732
+ </Box>
733
+ ) : null}
734
+ {showSkills ? (
735
+ <Box marginTop={1}>
736
+ <SkillsPanel snapshot={binding.skills()} />
737
+ </Box>
738
+ ) : null}
739
+ {showMcp ? (
740
+ <Box marginTop={1}>
741
+ <McpPanel snapshot={binding.mcp()} />
742
+ </Box>
743
+ ) : null}
648
744
  <Box marginTop={1}>
649
- <SkillsPanel snapshot={binding.skills()} />
745
+ <Footer
746
+ status={isCancelling ? "cancelling" : state.status}
747
+ workedForMs={state.workedForMs}
748
+ />
650
749
  </Box>
651
- ) : null}
652
- {showMcp ? (
653
- <Box marginTop={1}>
654
- <McpPanel snapshot={binding.mcp()} />
750
+ <Box marginTop={1} flexDirection="column">
751
+ {showModelPicker ? (
752
+ <ModelPicker
753
+ profiles={profileList}
754
+ currentProfileName={binding.profileName}
755
+ isSwitching={modelPickerState?.isSwitching}
756
+ error={modelPickerState?.error}
757
+ onCancel={closeModelPicker}
758
+ onSelect={doSwitchModel}
759
+ />
760
+ ) : (
761
+ <PromptInput
762
+ modelName={binding.modelName}
763
+ workspaceRoot={binding.workspaceRoot}
764
+ gitBranch={gitBranch}
765
+ contextUsage={state.contextUsage}
766
+ isDisabled={isRunning || isSessionOperation || isCopying}
767
+ history={props.history}
768
+ commands={availableCommands}
769
+ fileLister={props.fileLister}
770
+ importImage={binding.importImage}
771
+ verifyImageAssets={binding.verifyImageAssets}
772
+ onSubmit={onSubmit}
773
+ onMaintenance={onMaintenance}
774
+ placeholder='Enter a coding request, or "/" for commands'
775
+ />
776
+ )}
777
+ {viewError === undefined ? null : <Text color="red">{viewError}</Text>}
778
+ {notice === undefined ? null : <Text color="yellow">{notice}</Text>}
655
779
  </Box>
656
- ) : null}
657
- <Box marginTop={1}>
658
- <Footer
659
- status={isCancelling ? "cancelling" : state.status}
660
- workedForMs={state.workedForMs}
661
- elapsedMs={runningElapsedMs}
662
- />
663
780
  </Box>
664
- <Box marginTop={1} flexDirection="column">
665
- {showModelPicker ? (
666
- <ModelPicker
667
- profiles={profileList}
668
- currentProfileName={binding.profileName}
669
- isSwitching={modelPickerState?.isSwitching}
670
- error={modelPickerState?.error}
671
- onCancel={closeModelPicker}
672
- onSelect={doSwitchModel}
673
- />
674
- ) : (
675
- <PromptInput
676
- modelName={binding.modelName}
677
- workspaceRoot={binding.workspaceRoot}
678
- gitBranch={gitBranch}
679
- contextUsage={state.contextUsage}
680
- isDisabled={isRunning || isSessionOperation || isCopying}
681
- history={props.history}
682
- commands={availableCommands}
683
- fileLister={props.fileLister}
684
- importImage={binding.importImage}
685
- verifyImageAssets={binding.verifyImageAssets}
686
- onSubmit={onSubmit}
687
- onMaintenance={onMaintenance}
688
- placeholder='Enter a coding request, or "/" for commands'
689
- />
690
- )}
691
- {viewError === undefined ? null : <Text color="red">{viewError}</Text>}
692
- {notice === undefined ? null : <Text color="yellow">{notice}</Text>}
693
- </Box>
694
- </>
695
- )}
696
- </Box>
781
+ )}
782
+ </Box>
783
+ </AssistantMarkdownProvider>
697
784
  );
698
785
  }
699
786
 
@@ -701,42 +788,6 @@ function errorMessage(error: unknown): string {
701
788
  return error instanceof Error ? error.message : String(error);
702
789
  }
703
790
 
704
- const ELAPSED_TICK_MS = 1_000;
705
-
706
- // The wall clock, quantized to whole ticks so a render pass reads one stable
707
- // value and re-renders at most once per second.
708
- function readElapsedClockMs(): number {
709
- return Math.floor(Date.now() / ELAPSED_TICK_MS) * ELAPSED_TICK_MS;
710
- }
711
-
712
- // Counts up while a turn is active. The interval only exists while `startedAt`
713
- // is defined, so an idle TUI keeps no timer running.
714
- function useElapsedMs(startedAt: string | undefined): number | undefined {
715
- const subscribe = useCallback(
716
- (onClockTick: () => void) => {
717
- if (startedAt === undefined) {
718
- return () => undefined;
719
- }
720
-
721
- const timer = setInterval(onClockTick, ELAPSED_TICK_MS);
722
- return () => clearInterval(timer);
723
- },
724
- [startedAt],
725
- );
726
- const nowMs = useSyncExternalStore(subscribe, readElapsedClockMs, readElapsedClockMs);
727
-
728
- if (startedAt === undefined) {
729
- return undefined;
730
- }
731
-
732
- const startedAtMs = Date.parse(startedAt);
733
- if (!Number.isFinite(startedAtMs)) {
734
- throw new Error(`Invalid turn start timestamp: ${startedAt}`);
735
- }
736
-
737
- return Math.max(0, nowMs - startedAtMs);
738
- }
739
-
740
791
  export function formatContextCompactionNotice(result: ContextCompactionResult): string {
741
792
  if (result.status === "unchanged") {
742
793
  if (result.outcome === "below_target") {
@@ -1,38 +1,39 @@
1
+ import { createContext, memo, useContext, type ReactNode } from "react";
2
+ import { MarkdownText, type RenderOptions } from "@assistant-ui/react-ink-markdown";
1
3
  import {
2
- MarkdownText,
3
- type RenderOptions,
4
- useShikiHighlighter,
5
- } from "@assistant-ui/react-ink-markdown";
4
+ getPreparedShikiHighlighter,
5
+ type TuiShikiHighlighter,
6
+ } from "../shiki-highlighter";
6
7
 
7
8
  export type AssistantMarkdownProps = {
8
9
  text: string;
9
10
  };
10
11
 
11
- const highlightedLanguages = [
12
- "typescript",
13
- "javascript",
14
- "tsx",
15
- "jsx",
16
- "json",
17
- "bash",
18
- "shellscript",
19
- "python",
20
- "markdown",
21
- "html",
22
- "css",
23
- "yaml",
24
- "diff",
25
- ];
26
-
27
12
  const tableOptions = {
28
13
  tableTruncate: false,
29
14
  } satisfies Pick<RenderOptions, "tableTruncate">;
30
15
 
31
- export function AssistantMarkdown(props: AssistantMarkdownProps) {
32
- const highlighter = useShikiHighlighter({
33
- theme: "github-dark",
34
- langs: highlightedLanguages,
35
- });
16
+ const HighlighterContext = createContext<TuiShikiHighlighter | undefined>(undefined);
17
+
18
+ // The runner resolves Shiki initialization before mounting App, so immutable
19
+ // messages can safely move into Ink Static on their first rendered frame.
20
+ export function AssistantMarkdownProvider(props: { children: ReactNode }) {
21
+ const highlighter = getPreparedShikiHighlighter();
22
+ return (
23
+ <HighlighterContext.Provider value={highlighter}>
24
+ {props.children}
25
+ </HighlighterContext.Provider>
26
+ );
27
+ }
28
+
29
+ // Memoized on `text`: settled assistant messages are immutable, so an
30
+ // unchanged text guarantees an unchanged render and the whole markdown
31
+ // subtree (including the markdansi re-run inside MarkdownText) can be
32
+ // skipped for unrelated frames.
33
+ export const AssistantMarkdown = memo(function AssistantMarkdown(
34
+ props: AssistantMarkdownProps,
35
+ ) {
36
+ const highlighter = useContext(HighlighterContext);
36
37
 
37
38
  return (
38
39
  <MarkdownText
@@ -44,4 +45,4 @@ export function AssistantMarkdown(props: AssistantMarkdownProps) {
44
45
  tableBorder="unicode"
45
46
  />
46
47
  );
47
- }
48
+ });
@@ -2,9 +2,11 @@ import { Box, Text } from "ink";
2
2
  import type { ShellTaskSnapshot, ShellTaskStatus } from "../../tools/bash-task";
3
3
 
4
4
  export type BackgroundTasksProps = {
5
- tasks: ShellTaskSnapshot[];
5
+ tasks: readonly ShellTaskSnapshot[];
6
6
  };
7
7
 
8
+ const MAX_VISIBLE_TASKS = 5;
9
+
8
10
  export function BackgroundTasks(props: BackgroundTasksProps) {
9
11
  if (props.tasks.length === 0) {
10
12
  return null;
@@ -13,13 +15,15 @@ export function BackgroundTasks(props: BackgroundTasksProps) {
13
15
  const runningCount = props.tasks.filter(
14
16
  (task) => task.status === "running" || task.status === "stopping",
15
17
  ).length;
18
+ const visibleTasks = props.tasks.slice(0, MAX_VISIBLE_TASKS);
19
+ const omittedTaskCount = props.tasks.length - visibleTasks.length;
16
20
 
17
21
  return (
18
22
  <Box flexDirection="column">
19
23
  <Text bold>
20
24
  Background tasks · {runningCount} running / {props.tasks.length} total
21
25
  </Text>
22
- {props.tasks.map((task) => (
26
+ {visibleTasks.map((task) => (
23
27
  <Box key={task.taskId} flexDirection="column">
24
28
  <Text color={colorForStatus(task.status)}>
25
29
  {symbolForStatus(task.status)} {task.status} {taskDescription(task)}
@@ -28,6 +32,7 @@ export function BackgroundTasks(props: BackgroundTasksProps) {
28
32
  <Text dimColor>{taskTiming(task)}</Text>
29
33
  </Box>
30
34
  ))}
35
+ {omittedTaskCount === 0 ? null : <Text dimColor>+{omittedTaskCount} more</Text>}
31
36
  </Box>
32
37
  );
33
38
  }
@@ -18,7 +18,7 @@ export type FileViewerProps = {
18
18
  export function FileViewer(props: FileViewerProps) {
19
19
  const windowSize = useWindowSize();
20
20
  const { stdout } = useStdout();
21
- const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
21
+ const rows = Math.max(4, props.viewportRows ?? windowSize.rows - 1);
22
22
  const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
23
23
  const bodyRows = Math.max(1, rows - VIEWER_CHROME_ROWS);
24
24
  const normalizedLines = useMemo(
@@ -141,7 +141,7 @@ export function FileViewerLoading(props: {
141
141
  viewportColumns?: number;
142
142
  }) {
143
143
  const windowSize = useWindowSize();
144
- const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
144
+ const rows = Math.max(4, props.viewportRows ?? windowSize.rows - 1);
145
145
  const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
146
146
 
147
147
  useInput((_input, key) => {
@@ -1,10 +1,8 @@
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";
6
5
  workedForMs?: number;
7
- elapsedMs?: number;
8
6
  };
9
7
 
10
8
  export function Footer(props: FooterProps) {
@@ -25,11 +23,7 @@ export function Footer(props: FooterProps) {
25
23
  }
26
24
 
27
25
  if (props.status === "running") {
28
- if (props.elapsedMs === undefined) {
29
- return <Text color="yellow">• Running</Text>;
30
- }
31
-
32
- return <Text color="yellow">{`• Running ${formatDuration(props.elapsedMs)}`}</Text>;
26
+ return <Spinner label="Running" />;
33
27
  }
34
28
 
35
29
  if (props.status === "cancelling") {
@@ -20,7 +20,7 @@ export type MemoryBrowserProps = {
20
20
 
21
21
  export function MemoryBrowser(props: MemoryBrowserProps) {
22
22
  const windowSize = useWindowSize();
23
- const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
23
+ const rows = Math.max(4, props.viewportRows ?? windowSize.rows - 1);
24
24
  const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
25
25
  const bodyRows = Math.max(1, rows - BROWSER_CHROME_ROWS);
26
26
  const contentRef = useRef<DOMElement>(null);
@@ -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
 
@@ -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
+ }
@@ -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();