tinker-agent 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +48 -1
  2. package/README.md +13 -5
  3. package/package.json +7 -5
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/tui-runner.tsx +21 -2
  14. package/src/events/observation-text-log.ts +21 -0
  15. package/src/events/stdout-event-printer.ts +11 -0
  16. package/src/events/types.ts +14 -2
  17. package/src/model/fake-model-client.ts +190 -0
  18. package/src/model/model-client.ts +3 -0
  19. package/src/model/openai-chat-model-client.ts +54 -15
  20. package/src/model/openai-chat-stream.ts +95 -72
  21. package/src/observation/observation-builder.ts +11 -0
  22. package/src/session/session-store.ts +1 -0
  23. package/src/tools/bash-guard.ts +131 -0
  24. package/src/tools/bash.ts +31 -0
  25. package/src/tools/delete.ts +182 -0
  26. package/src/tools/edit.ts +68 -9
  27. package/src/tools/registry.ts +47 -3
  28. package/src/tools/turn-undo-manager.ts +794 -0
  29. package/src/tools/types.ts +13 -0
  30. package/src/tools/write.ts +65 -14
  31. package/src/tui/app.tsx +301 -134
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/assistant-markdown.tsx +27 -26
  34. package/src/tui/components/background-tasks.tsx +7 -2
  35. package/src/tui/components/bash-confirmation.tsx +27 -0
  36. package/src/tui/components/context-status.tsx +11 -1
  37. package/src/tui/components/file-viewer.tsx +2 -2
  38. package/src/tui/components/footer.tsx +9 -12
  39. package/src/tui/components/memory-browser.tsx +1 -1
  40. package/src/tui/components/prompt-input.tsx +13 -1
  41. package/src/tui/components/resume-session-picker.tsx +3 -1
  42. package/src/tui/components/timeline.tsx +19 -11
  43. package/src/tui/context-format.ts +17 -0
  44. package/src/tui/event-store.ts +75 -3
  45. package/src/tui/shiki-highlighter.ts +104 -0
  46. package/src/tui/slash-commands.ts +28 -0
  47. package/src/tui/tui-projection-store.ts +277 -5
  48. package/src/tui/tui-session-controller.ts +32 -8
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,11 +19,12 @@ 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";
15
22
  import type { PromptHistory } from "./prompt-history";
16
23
  import { Footer } from "./components/footer";
24
+ import { AssistantMarkdownProvider } from "./components/assistant-markdown";
17
25
  import { ContextStatus } from "./components/context-status";
18
26
  import { BackgroundTasks } from "./components/background-tasks";
27
+ import { BashConfirmation } from "./components/bash-confirmation";
19
28
  import { Header } from "./components/header";
20
29
  import { ModelPicker } from "./components/model-picker";
21
30
  import { FileViewer, FileViewerLoading } from "./components/file-viewer";
@@ -33,7 +42,11 @@ import {
33
42
  ResumeSessionPicker,
34
43
  ResumeSessionPickerLoading,
35
44
  } from "./components/resume-session-picker";
36
- import { Timeline } from "./components/timeline";
45
+ import {
46
+ AssistantStreamSectionRow,
47
+ Timeline,
48
+ TimelineRow,
49
+ } from "./components/timeline";
37
50
  import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands";
38
51
  import {
39
52
  resolveProjectSlashCommand,
@@ -45,6 +58,11 @@ import type { ModelProfile, ModelProfiles } from "../cli/model-profiles";
45
58
  import { loadViewFile, type ViewFile } from "./view-file";
46
59
  import { writeClipboardText } from "./clipboard";
47
60
  import type { WorkspaceFileLister } from "./workspace-file-search";
61
+ import type { TurnUndoBarrierReason, TurnUndoResult } from "../tools/turn-undo-manager";
62
+ import {
63
+ isAssistantStreamSectionItem,
64
+ type TuiCommittedItem,
65
+ } from "./tui-projection-store";
48
66
 
49
67
  export type AppProps = {
50
68
  sessionController: TuiSessionController;
@@ -67,9 +85,10 @@ export type AppProps = {
67
85
  };
68
86
 
69
87
  type ResumePickerState =
70
- | { status: "loading" }
88
+ | { status: "loading"; ownerSessionId: SessionId }
71
89
  | {
72
90
  status: "ready";
91
+ ownerSessionId: SessionId;
73
92
  sessions: readonly SessionSummary[];
74
93
  isResuming: boolean;
75
94
  error?: string;
@@ -84,8 +103,15 @@ type FileViewState =
84
103
  | { status: "loading"; filePath: string }
85
104
  | { status: "ready"; file: ViewFile };
86
105
 
106
+ const STATIC_HEADER = Symbol("tui-static-header");
107
+ const LIVE_TIMELINE_MAX_ROWS = 8;
108
+ const LIVE_TIMELINE_WITH_TASKS_MAX_ROWS = 3;
109
+ const BACKGROUND_TASKS_MAX_ROWS = 12;
110
+
87
111
  export function App(props: AppProps) {
88
112
  const { exit } = useApp();
113
+ const { write } = useStdout();
114
+ const windowSize = useWindowSize();
89
115
  const binding = useSyncExternalStore(
90
116
  props.sessionController.subscribe,
91
117
  props.sessionController.getBinding,
@@ -100,6 +126,16 @@ export function App(props: AppProps) {
100
126
  binding.projectionStore.getSnapshot,
101
127
  binding.projectionStore.getSnapshot,
102
128
  );
129
+ const log = useSyncExternalStore(
130
+ binding.projectionStore.subscribe,
131
+ binding.projectionStore.getLogSnapshot,
132
+ binding.projectionStore.getLogSnapshot,
133
+ );
134
+ const bashGuard = useSyncExternalStore(
135
+ (listener) => binding.subscribeBashGuard(listener),
136
+ () => binding.bashGuard(),
137
+ () => binding.bashGuard(),
138
+ );
103
139
  const [isRunning, setIsRunning] = useState(false);
104
140
  const [isSessionOperation, setIsSessionOperation] = useState(false);
105
141
  const [isCopying, setIsCopying] = useState(false);
@@ -120,18 +156,32 @@ export function App(props: AppProps) {
120
156
  readonly StoredMemorySummary[] | undefined
121
157
  >(undefined);
122
158
  const [viewError, setViewError] = useState<string | undefined>(undefined);
159
+ const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
123
160
  const [gitBranch, setGitBranch] = useState<string | undefined>(undefined);
124
161
  const [gitBranchRefresh, setGitBranchRefresh] = useState(0);
125
162
  const gitBranchReadQueue = useRef<Promise<void>>(Promise.resolve());
126
163
  const activeController = useRef<AbortController | undefined>(undefined);
127
164
  const resumePickerRequest = useRef(0);
128
165
  const fileViewRequest = useRef(0);
166
+ const beforeSessionCommit = useCallback(() => {
167
+ write(clearTerminal);
168
+ }, [write]);
169
+ const restoreStaticViewport = useCallback(() => {
170
+ write(clearTerminal);
171
+ setStaticRenderEpoch((current) => current + 1);
172
+ }, [write]);
173
+ const staticItems = useMemo<Array<typeof STATIC_HEADER | TuiCommittedItem>>(
174
+ () => [STATIC_HEADER, ...log.committed],
175
+ [log.committed],
176
+ );
129
177
 
130
178
  const canSwitchModel =
131
179
  state.recentTurns.length === 0 &&
132
180
  state.activeTurn === undefined &&
133
181
  props.profiles !== undefined &&
134
182
  props.profiles.profiles.size > 1;
183
+ const activeResumePicker =
184
+ resumePicker?.ownerSessionId === binding.sessionId ? resumePicker : undefined;
135
185
 
136
186
  const builtInCommands = canSwitchModel
137
187
  ? SLASH_COMMANDS
@@ -140,8 +190,6 @@ export function App(props: AppProps) {
140
190
 
141
191
  const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
142
192
 
143
- const runningElapsedMs = useElapsedMs(state.activeTurn?.startedAt);
144
-
145
193
  useEffect(() => {
146
194
  if (readGitBranch === undefined) {
147
195
  return;
@@ -186,17 +234,22 @@ export function App(props: AppProps) {
186
234
  );
187
235
 
188
236
  const closeResumePicker = () => {
237
+ const shouldRestoreViewport = activeResumePicker !== undefined;
189
238
  resumePickerRequest.current += 1;
190
239
  setResumePicker(undefined);
191
240
  setIsSessionOperation(false);
192
241
  setNotice(undefined);
242
+ if (shouldRestoreViewport) {
243
+ restoreStaticViewport();
244
+ }
193
245
  };
194
246
 
195
247
  const openResumePicker = () => {
196
248
  const requestId = resumePickerRequest.current + 1;
249
+ const ownerSessionId = binding.sessionId;
197
250
  resumePickerRequest.current = requestId;
198
251
  setNotice(undefined);
199
- setResumePicker({ status: "loading" });
252
+ setResumePicker({ status: "loading", ownerSessionId });
200
253
  setIsSessionOperation(true);
201
254
  void props.sessionController
202
255
  .listSessions()
@@ -207,14 +260,21 @@ export function App(props: AppProps) {
207
260
  if (sessions.length === 0) {
208
261
  setResumePicker(undefined);
209
262
  setNotice("No stored sessions found for this workspace.");
263
+ restoreStaticViewport();
210
264
  return;
211
265
  }
212
- setResumePicker({ status: "ready", sessions, isResuming: false });
266
+ setResumePicker({
267
+ status: "ready",
268
+ ownerSessionId,
269
+ sessions,
270
+ isResuming: false,
271
+ });
213
272
  })
214
273
  .catch((error: unknown) => {
215
274
  if (resumePickerRequest.current === requestId) {
216
275
  setResumePicker(undefined);
217
276
  setNotice(`Session operation failed: ${errorMessage(error)}`);
277
+ restoreStaticViewport();
218
278
  }
219
279
  })
220
280
  .finally(() => {
@@ -232,7 +292,7 @@ export function App(props: AppProps) {
232
292
  );
233
293
  setIsSessionOperation(true);
234
294
  void props.sessionController
235
- .resume(session.sessionId)
295
+ .resume(session.sessionId, beforeSessionCommit)
236
296
  .then(() => {
237
297
  setResumePicker(undefined);
238
298
  setNotice(`Resumed session ${session.sessionId}.`);
@@ -255,6 +315,7 @@ export function App(props: AppProps) {
255
315
  setShowModelPicker(false);
256
316
  setModelPickerState(undefined);
257
317
  setNotice(undefined);
318
+ restoreStaticViewport();
258
319
  };
259
320
 
260
321
  const doSwitchModel = (profile: ModelProfile) => {
@@ -263,7 +324,7 @@ export function App(props: AppProps) {
263
324
  setNotice(undefined);
264
325
  setIsSessionOperation(true);
265
326
  void props.sessionController
266
- .switchModel(profile)
327
+ .switchModel(profile, beforeSessionCommit)
267
328
  .then(async () => {
268
329
  setGitBranchRefresh((current) => current + 1);
269
330
  try {
@@ -276,6 +337,7 @@ export function App(props: AppProps) {
276
337
  }
277
338
  })
278
339
  .catch((error: unknown) => {
340
+ restoreStaticViewport();
279
341
  setNotice(`Model switch failed: ${errorMessage(error)}`);
280
342
  })
281
343
  .finally(() => setIsSessionOperation(false));
@@ -284,6 +346,12 @@ export function App(props: AppProps) {
284
346
  const closeFileView = () => {
285
347
  fileViewRequest.current += 1;
286
348
  setFileView(undefined);
349
+ restoreStaticViewport();
350
+ };
351
+
352
+ const closeMemoryView = () => {
353
+ setMemoryView(undefined);
354
+ restoreStaticViewport();
287
355
  };
288
356
 
289
357
  const openMemoryView = () => {
@@ -318,6 +386,7 @@ export function App(props: AppProps) {
318
386
  if (fileViewRequest.current === requestId) {
319
387
  setFileView(undefined);
320
388
  setViewError(`View failed: ${errorMessage(error)}`);
389
+ restoreStaticViewport();
321
390
  }
322
391
  });
323
392
  };
@@ -432,7 +501,7 @@ export function App(props: AppProps) {
432
501
  setNotice(formatContextRetirementNotice(result));
433
502
  return;
434
503
  }
435
- await props.sessionController.clear();
504
+ await props.sessionController.clear(beforeSessionCommit);
436
505
  signal.throwIfAborted();
437
506
  const sessionId = props.sessionController.getBinding().sessionId;
438
507
  setGitBranchRefresh((current) => current + 1);
@@ -450,10 +519,14 @@ export function App(props: AppProps) {
450
519
  ): PromptSubmissionOutcome | Promise<PromptSubmissionOutcome> => {
451
520
  const { userMessage } = submission;
452
521
  const trimmed = userMessage.content.trim();
522
+ const shouldRestoreViewport = showStatus || showSkills || showMcp;
453
523
  setShowStatus(false);
454
524
  setShowSkills(false);
455
525
  setShowMcp(false);
456
526
  setViewError(undefined);
527
+ if (shouldRestoreViewport) {
528
+ restoreStaticViewport();
529
+ }
457
530
 
458
531
  if (userMessage.attachments === undefined && trimmed.startsWith("/")) {
459
532
  try {
@@ -490,6 +563,19 @@ export function App(props: AppProps) {
490
563
  setShowStatus(true);
491
564
  return true;
492
565
  }
566
+ if (command.type === "yolo_status") {
567
+ setNotice(`Bash guard: ${bashGuard.mode} (source: ${bashGuard.source}).`);
568
+ return true;
569
+ }
570
+ if (command.type === "yolo") {
571
+ binding.setYoloMode(command.enabled);
572
+ setNotice(
573
+ command.enabled
574
+ ? "YOLO enabled for this session; dangerous Bash commands will run without confirmation."
575
+ : "YOLO disabled; dangerous Bash commands require confirmation.",
576
+ );
577
+ return true;
578
+ }
493
579
  if (command.type === "skills") {
494
580
  setShowSkills(true);
495
581
  return true;
@@ -520,10 +606,26 @@ export function App(props: AppProps) {
520
606
  .finally(() => setIsSessionOperation(false));
521
607
  return true;
522
608
  }
609
+ if (command.type === "undo") {
610
+ setIsSessionOperation(true);
611
+ void props.sessionController
612
+ .undo()
613
+ .then((result) => {
614
+ if (result.status === "restored" || result.status === "incomplete") {
615
+ setGitBranchRefresh((current) => current + 1);
616
+ }
617
+ setNotice(formatTurnUndoNotice(result));
618
+ })
619
+ .catch((error: unknown) => {
620
+ setNotice(errorMessage(error));
621
+ })
622
+ .finally(() => setIsSessionOperation(false));
623
+ return true;
624
+ }
523
625
  if (command.type === "clear") {
524
626
  setIsSessionOperation(true);
525
627
  void props.sessionController
526
- .clear()
628
+ .clear(beforeSessionCommit)
527
629
  .then(() => {
528
630
  const sessionId = props.sessionController.getBinding().sessionId;
529
631
  setGitBranchRefresh((current) => current + 1);
@@ -540,7 +642,7 @@ export function App(props: AppProps) {
540
642
  if (command.type === "fork") {
541
643
  setIsSessionOperation(true);
542
644
  void props.sessionController
543
- .fork()
645
+ .fork(beforeSessionCommit)
544
646
  .then((sessionId) => {
545
647
  setGitBranchRefresh((current) => current + 1);
546
648
  setNotice(
@@ -586,7 +688,7 @@ export function App(props: AppProps) {
586
688
  const operation =
587
689
  command.type === "resume"
588
690
  ? props.sessionController
589
- .resume(command.sessionId)
691
+ .resume(command.sessionId, beforeSessionCommit)
590
692
  .then(() => setNotice(`Resumed session ${command.sessionId}.`))
591
693
  : props.sessionController
592
694
  .delete(command.sessionId)
@@ -606,94 +708,145 @@ export function App(props: AppProps) {
606
708
  };
607
709
 
608
710
  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}
711
+ <AssistantMarkdownProvider>
712
+ <Box flexDirection="column">
713
+ <Static key={`${binding.sessionId}:${staticRenderEpoch}`} items={staticItems}>
714
+ {(item) =>
715
+ item === STATIC_HEADER ? (
716
+ <Header
717
+ key={`header-${binding.sessionId}`}
718
+ modelName={binding.modelName}
719
+ workspaceRoot={binding.workspaceRoot}
720
+ sessionId={binding.sessionId}
721
+ />
722
+ ) : isAssistantStreamSectionItem(item) ? (
723
+ <AssistantStreamSectionRow key={item.id} item={item} />
724
+ ) : (
725
+ <TimelineRow key={item.id} item={item} />
726
+ )
727
+ }
728
+ </Static>
729
+ {fileView?.status === "loading" ? (
730
+ <FileViewerLoading filePath={fileView.filePath} onCancel={closeFileView} />
731
+ ) : fileView?.status === "ready" ? (
732
+ <FileViewer file={fileView.file} onClose={closeFileView} />
733
+ ) : memoryView !== undefined ? (
734
+ <MemoryBrowser memories={memoryView} onClose={closeMemoryView} />
735
+ ) : activeResumePicker?.status === "loading" ? (
736
+ <ResumeSessionPickerLoading onCancel={closeResumePicker} />
737
+ ) : activeResumePicker?.status === "ready" ? (
738
+ <ResumeSessionPicker
739
+ sessions={activeResumePicker.sessions}
740
+ isResuming={activeResumePicker.isResuming}
741
+ error={activeResumePicker.error}
742
+ onCancel={closeResumePicker}
743
+ onSelect={resumeSelectedSession}
633
744
  />
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} />
745
+ ) : (
746
+ <Box
747
+ flexDirection="column"
748
+ maxHeight={Math.max(1, windowSize.rows - 1)}
749
+ overflow="hidden"
750
+ >
751
+ <Box flexDirection="column" flexShrink={1} minHeight={0} overflow="hidden">
752
+ <Box flexDirection="column" flexShrink={0}>
753
+ <Box marginTop={1} flexDirection="column" flexShrink={0}>
754
+ <Box
755
+ maxHeight={
756
+ state.backgroundTasks.length === 0
757
+ ? LIVE_TIMELINE_MAX_ROWS
758
+ : LIVE_TIMELINE_WITH_TASKS_MAX_ROWS
759
+ }
760
+ overflow="hidden"
761
+ >
762
+ <Timeline items={log.live} />
763
+ </Box>
764
+ </Box>
765
+ {state.backgroundTasks.length === 0 ? null : (
766
+ <Box
767
+ marginTop={1}
768
+ maxHeight={BACKGROUND_TASKS_MAX_ROWS}
769
+ overflow="hidden"
770
+ flexShrink={0}
771
+ >
772
+ <BackgroundTasks tasks={state.backgroundTasks} />
773
+ </Box>
774
+ )}
775
+ {showStatus ? (
776
+ <Box marginTop={1} flexShrink={0}>
777
+ <ContextStatus state={state} bashGuard={bashGuard} />
778
+ </Box>
779
+ ) : null}
780
+ {showSkills ? (
781
+ <Box marginTop={1} flexShrink={0}>
782
+ <SkillsPanel snapshot={binding.skills()} />
783
+ </Box>
784
+ ) : null}
785
+ {showMcp ? (
786
+ <Box marginTop={1} flexShrink={0}>
787
+ <McpPanel snapshot={binding.mcp()} />
788
+ </Box>
789
+ ) : null}
790
+ </Box>
645
791
  </Box>
646
- ) : null}
647
- {showSkills ? (
648
- <Box marginTop={1}>
649
- <SkillsPanel snapshot={binding.skills()} />
792
+ <Box marginTop={1} flexShrink={0}>
793
+ <Footer
794
+ status={isCancelling ? "cancelling" : state.status}
795
+ workedForMs={state.workedForMs}
796
+ yolo={bashGuard.mode === "yolo"}
797
+ />
650
798
  </Box>
651
- ) : null}
652
- {showMcp ? (
653
- <Box marginTop={1}>
654
- <McpPanel snapshot={binding.mcp()} />
799
+ <Box marginTop={1} flexDirection="column" flexShrink={0}>
800
+ {bashGuard.pending !== undefined ? (
801
+ <BashConfirmation
802
+ command={bashGuard.pending.command}
803
+ reason={bashGuard.pending.reason}
804
+ onDecision={(decision) => {
805
+ void binding
806
+ .resolveBashConfirmation(decision)
807
+ .catch((error: unknown) =>
808
+ setNotice(`Bash confirmation failed: ${errorMessage(error)}`),
809
+ );
810
+ }}
811
+ />
812
+ ) : showModelPicker ? (
813
+ <ModelPicker
814
+ profiles={profileList}
815
+ currentProfileName={binding.profileName}
816
+ isSwitching={modelPickerState?.isSwitching}
817
+ error={modelPickerState?.error}
818
+ onCancel={closeModelPicker}
819
+ onSelect={doSwitchModel}
820
+ />
821
+ ) : (
822
+ <PromptInput
823
+ modelName={binding.modelName}
824
+ workspaceRoot={binding.workspaceRoot}
825
+ gitBranch={gitBranch}
826
+ contextUsage={state.contextUsage}
827
+ isDisabled={
828
+ isRunning ||
829
+ isSessionOperation ||
830
+ isCopying ||
831
+ bashGuard.pending !== undefined
832
+ }
833
+ history={props.history}
834
+ commands={availableCommands}
835
+ fileLister={props.fileLister}
836
+ importImage={binding.importImage}
837
+ verifyImageAssets={binding.verifyImageAssets}
838
+ onSubmit={onSubmit}
839
+ onMaintenance={onMaintenance}
840
+ placeholder='Enter a coding request, or "/" for commands'
841
+ />
842
+ )}
843
+ {viewError === undefined ? null : <Text color="red">{viewError}</Text>}
844
+ {notice === undefined ? null : <Text color="yellow">{notice}</Text>}
655
845
  </Box>
656
- ) : null}
657
- <Box marginTop={1}>
658
- <Footer
659
- status={isCancelling ? "cancelling" : state.status}
660
- workedForMs={state.workedForMs}
661
- elapsedMs={runningElapsedMs}
662
- />
663
846
  </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>
847
+ )}
848
+ </Box>
849
+ </AssistantMarkdownProvider>
697
850
  );
698
851
  }
699
852
 
@@ -701,42 +854,6 @@ function errorMessage(error: unknown): string {
701
854
  return error instanceof Error ? error.message : String(error);
702
855
  }
703
856
 
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
857
  export function formatContextCompactionNotice(result: ContextCompactionResult): string {
741
858
  if (result.status === "unchanged") {
742
859
  if (result.outcome === "below_target") {
@@ -760,6 +877,56 @@ export function formatContextCompactionNotice(result: ContextCompactionResult):
760
877
  return `Context compacted: revision ${result.previousRevisionNumber} -> ${result.revisionNumber}, ${result.addedOverrideCount} observations swapped, ${before} -> ${after} estimated tokens (-${reduction}%).`;
761
878
  }
762
879
 
880
+ export function formatTurnUndoNotice(result: TurnUndoResult): string {
881
+ if (result.status === "nothing") {
882
+ return "Nothing to undo in this active session.";
883
+ }
884
+ if (result.status === "unavailable") {
885
+ return `Cannot undo turn ${result.turnNumber}: ${formatUndoBarrierReason(result.reason)}`;
886
+ }
887
+ if (result.status === "refused") {
888
+ const count = result.conflicts.length;
889
+ return [
890
+ `Undo refused: ${count} ${count === 1 ? "file" : "files"} changed after turn ${result.turnNumber}.`,
891
+ ...result.conflicts.map(
892
+ (conflict) => `- ${conflict.displayPath}: ${conflict.detail}`,
893
+ ),
894
+ ].join("\n");
895
+ }
896
+ if (result.status === "incomplete") {
897
+ return `Undo incomplete for turn ${result.turnNumber}: ${formatPartialUndoCount(result)} before ${result.failedPath} failed: ${sentenceDetail(result.detail)}\nRun /undo again to retry.`;
898
+ }
899
+ return `Restored workspace to before turn ${result.turnNumber}: ${formatFileCount(result.restoredFileCount)} restored, ${formatFileCount(result.deletedFileCount)} deleted.`;
900
+ }
901
+
902
+ function formatUndoBarrierReason(reason: TurnUndoBarrierReason): string {
903
+ if (reason.kind === "file-too-large" || reason.kind === "turn-too-large") {
904
+ return "undo snapshot capacity was exceeded.";
905
+ }
906
+ return `undo snapshot could not be captured for ${reason.displayPath}: ${sentenceDetail(reason.detail)}`;
907
+ }
908
+
909
+ function formatPartialUndoCount(
910
+ result: Extract<TurnUndoResult, { status: "incomplete" }>,
911
+ ): string {
912
+ const completed: string[] = [];
913
+ if (result.restoredFileCount > 0) {
914
+ completed.push(`${formatFileCount(result.restoredFileCount)} restored`);
915
+ }
916
+ if (result.deletedFileCount > 0) {
917
+ completed.push(`${formatFileCount(result.deletedFileCount)} deleted`);
918
+ }
919
+ return completed.length === 0 ? "no files restored" : completed.join(" and ");
920
+ }
921
+
922
+ function formatFileCount(count: number): string {
923
+ return `${count} ${count === 1 ? "file" : "files"}`;
924
+ }
925
+
926
+ function sentenceDetail(detail: string): string {
927
+ return /[.!?]$/.test(detail) ? detail : `${detail}.`;
928
+ }
929
+
763
930
  export function formatContextCompactionFailureNotice(error: unknown): string {
764
931
  if (!(error instanceof ContextManagerError)) {
765
932
  return "Context compaction failed.";