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
package/src/tui/app.tsx CHANGED
@@ -1,6 +1,15 @@
1
- import { Box, Text, useApp, useInput } from "ink";
2
- import { 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";
12
+ import { boundedMemoryError, type StoredMemorySummary } from "../memory/contracts";
4
13
  import {
5
14
  ContextManagerError,
6
15
  type ContextCompactionResult,
@@ -10,14 +19,16 @@ import { ContextBudgetExceededError } from "../model/model-request-preflight";
10
19
  import { ModelRequestMediaAggregateError } from "../model/model-client";
11
20
  import type { SessionId } from "../ids/runtime-id";
12
21
  import { readLastAssistantResponse } from "../session/session-last-response-reader";
13
- import { visibleTimelineItems } from "./event-store";
22
+ import type { TimelineItem } from "./event-store";
14
23
  import type { PromptHistory } from "./prompt-history";
15
24
  import { Footer } from "./components/footer";
25
+ import { AssistantMarkdownProvider } from "./components/assistant-markdown";
16
26
  import { ContextStatus } from "./components/context-status";
17
27
  import { BackgroundTasks } from "./components/background-tasks";
18
28
  import { Header } from "./components/header";
19
29
  import { ModelPicker } from "./components/model-picker";
20
30
  import { FileViewer, FileViewerLoading } from "./components/file-viewer";
31
+ import { MemoryBrowser } from "./components/memory-browser";
21
32
  import {
22
33
  PromptInput,
23
34
  type PromptMaintenanceAction,
@@ -31,7 +42,7 @@ import {
31
42
  ResumeSessionPicker,
32
43
  ResumeSessionPickerLoading,
33
44
  } from "./components/resume-session-picker";
34
- import { Timeline } from "./components/timeline";
45
+ import { Timeline, TimelineRow } from "./components/timeline";
35
46
  import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands";
36
47
  import {
37
48
  resolveProjectSlashCommand,
@@ -59,12 +70,16 @@ export type AppProps = {
59
70
  ) => Promise<string | undefined>;
60
71
  writeClipboard?: (markdown: string) => Promise<void>;
61
72
  onQuit?: () => void;
73
+ initialNotice?: string;
74
+ listStoredMemories?: () => readonly StoredMemorySummary[];
75
+ memoryDisabledNotice?: string;
62
76
  };
63
77
 
64
78
  type ResumePickerState =
65
- | { status: "loading" }
79
+ | { status: "loading"; ownerSessionId: SessionId }
66
80
  | {
67
81
  status: "ready";
82
+ ownerSessionId: SessionId;
68
83
  sessions: readonly SessionSummary[];
69
84
  isResuming: boolean;
70
85
  error?: string;
@@ -79,8 +94,15 @@ type FileViewState =
79
94
  | { status: "loading"; filePath: string }
80
95
  | { status: "ready"; file: ViewFile };
81
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
+
82
102
  export function App(props: AppProps) {
83
103
  const { exit } = useApp();
104
+ const { write } = useStdout();
105
+ const windowSize = useWindowSize();
84
106
  const binding = useSyncExternalStore(
85
107
  props.sessionController.subscribe,
86
108
  props.sessionController.getBinding,
@@ -95,11 +117,16 @@ export function App(props: AppProps) {
95
117
  binding.projectionStore.getSnapshot,
96
118
  binding.projectionStore.getSnapshot,
97
119
  );
120
+ const log = useSyncExternalStore(
121
+ binding.projectionStore.subscribe,
122
+ binding.projectionStore.getLogSnapshot,
123
+ binding.projectionStore.getLogSnapshot,
124
+ );
98
125
  const [isRunning, setIsRunning] = useState(false);
99
126
  const [isSessionOperation, setIsSessionOperation] = useState(false);
100
127
  const [isCopying, setIsCopying] = useState(false);
101
128
  const [isCancelling, setIsCancelling] = useState(false);
102
- const [notice, setNotice] = useState<string | undefined>(undefined);
129
+ const [notice, setNotice] = useState<string | undefined>(props.initialNotice);
103
130
  const [showStatus, setShowStatus] = useState(false);
104
131
  const [showSkills, setShowSkills] = useState(false);
105
132
  const [showMcp, setShowMcp] = useState(false);
@@ -111,19 +138,36 @@ export function App(props: AppProps) {
111
138
  ModelPickerState | undefined
112
139
  >(undefined);
113
140
  const [fileView, setFileView] = useState<FileViewState | undefined>(undefined);
141
+ const [memoryView, setMemoryView] = useState<
142
+ readonly StoredMemorySummary[] | undefined
143
+ >(undefined);
114
144
  const [viewError, setViewError] = useState<string | undefined>(undefined);
145
+ const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
115
146
  const [gitBranch, setGitBranch] = useState<string | undefined>(undefined);
116
147
  const [gitBranchRefresh, setGitBranchRefresh] = useState(0);
117
148
  const gitBranchReadQueue = useRef<Promise<void>>(Promise.resolve());
118
149
  const activeController = useRef<AbortController | undefined>(undefined);
119
150
  const resumePickerRequest = useRef(0);
120
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
+ );
121
163
 
122
164
  const canSwitchModel =
123
165
  state.recentTurns.length === 0 &&
124
166
  state.activeTurn === undefined &&
125
167
  props.profiles !== undefined &&
126
168
  props.profiles.profiles.size > 1;
169
+ const activeResumePicker =
170
+ resumePicker?.ownerSessionId === binding.sessionId ? resumePicker : undefined;
127
171
 
128
172
  const builtInCommands = canSwitchModel
129
173
  ? SLASH_COMMANDS
@@ -176,17 +220,22 @@ export function App(props: AppProps) {
176
220
  );
177
221
 
178
222
  const closeResumePicker = () => {
223
+ const shouldRestoreViewport = activeResumePicker !== undefined;
179
224
  resumePickerRequest.current += 1;
180
225
  setResumePicker(undefined);
181
226
  setIsSessionOperation(false);
182
227
  setNotice(undefined);
228
+ if (shouldRestoreViewport) {
229
+ restoreStaticViewport();
230
+ }
183
231
  };
184
232
 
185
233
  const openResumePicker = () => {
186
234
  const requestId = resumePickerRequest.current + 1;
235
+ const ownerSessionId = binding.sessionId;
187
236
  resumePickerRequest.current = requestId;
188
237
  setNotice(undefined);
189
- setResumePicker({ status: "loading" });
238
+ setResumePicker({ status: "loading", ownerSessionId });
190
239
  setIsSessionOperation(true);
191
240
  void props.sessionController
192
241
  .listSessions()
@@ -197,14 +246,21 @@ export function App(props: AppProps) {
197
246
  if (sessions.length === 0) {
198
247
  setResumePicker(undefined);
199
248
  setNotice("No stored sessions found for this workspace.");
249
+ restoreStaticViewport();
200
250
  return;
201
251
  }
202
- setResumePicker({ status: "ready", sessions, isResuming: false });
252
+ setResumePicker({
253
+ status: "ready",
254
+ ownerSessionId,
255
+ sessions,
256
+ isResuming: false,
257
+ });
203
258
  })
204
259
  .catch((error: unknown) => {
205
260
  if (resumePickerRequest.current === requestId) {
206
261
  setResumePicker(undefined);
207
262
  setNotice(`Session operation failed: ${errorMessage(error)}`);
263
+ restoreStaticViewport();
208
264
  }
209
265
  })
210
266
  .finally(() => {
@@ -222,7 +278,7 @@ export function App(props: AppProps) {
222
278
  );
223
279
  setIsSessionOperation(true);
224
280
  void props.sessionController
225
- .resume(session.sessionId)
281
+ .resume(session.sessionId, beforeSessionCommit)
226
282
  .then(() => {
227
283
  setResumePicker(undefined);
228
284
  setNotice(`Resumed session ${session.sessionId}.`);
@@ -245,6 +301,7 @@ export function App(props: AppProps) {
245
301
  setShowModelPicker(false);
246
302
  setModelPickerState(undefined);
247
303
  setNotice(undefined);
304
+ restoreStaticViewport();
248
305
  };
249
306
 
250
307
  const doSwitchModel = (profile: ModelProfile) => {
@@ -253,7 +310,7 @@ export function App(props: AppProps) {
253
310
  setNotice(undefined);
254
311
  setIsSessionOperation(true);
255
312
  void props.sessionController
256
- .switchModel(profile)
313
+ .switchModel(profile, beforeSessionCommit)
257
314
  .then(async () => {
258
315
  setGitBranchRefresh((current) => current + 1);
259
316
  try {
@@ -266,6 +323,7 @@ export function App(props: AppProps) {
266
323
  }
267
324
  })
268
325
  .catch((error: unknown) => {
326
+ restoreStaticViewport();
269
327
  setNotice(`Model switch failed: ${errorMessage(error)}`);
270
328
  })
271
329
  .finally(() => setIsSessionOperation(false));
@@ -274,6 +332,26 @@ export function App(props: AppProps) {
274
332
  const closeFileView = () => {
275
333
  fileViewRequest.current += 1;
276
334
  setFileView(undefined);
335
+ restoreStaticViewport();
336
+ };
337
+
338
+ const closeMemoryView = () => {
339
+ setMemoryView(undefined);
340
+ restoreStaticViewport();
341
+ };
342
+
343
+ const openMemoryView = () => {
344
+ if (props.listStoredMemories === undefined) {
345
+ setNotice(props.memoryDisabledNotice ?? "memory disabled: not configured");
346
+ return;
347
+ }
348
+ try {
349
+ const snapshot = props.listStoredMemories();
350
+ setNotice(undefined);
351
+ setMemoryView(snapshot);
352
+ } catch (error) {
353
+ setNotice(`memory unavailable: ${boundedMemoryError(error)}`);
354
+ }
277
355
  };
278
356
 
279
357
  const openFileView = (filePath: string) => {
@@ -294,6 +372,7 @@ export function App(props: AppProps) {
294
372
  if (fileViewRequest.current === requestId) {
295
373
  setFileView(undefined);
296
374
  setViewError(`View failed: ${errorMessage(error)}`);
375
+ restoreStaticViewport();
297
376
  }
298
377
  });
299
378
  };
@@ -408,7 +487,7 @@ export function App(props: AppProps) {
408
487
  setNotice(formatContextRetirementNotice(result));
409
488
  return;
410
489
  }
411
- await props.sessionController.clear();
490
+ await props.sessionController.clear(beforeSessionCommit);
412
491
  signal.throwIfAborted();
413
492
  const sessionId = props.sessionController.getBinding().sessionId;
414
493
  setGitBranchRefresh((current) => current + 1);
@@ -426,10 +505,14 @@ export function App(props: AppProps) {
426
505
  ): PromptSubmissionOutcome | Promise<PromptSubmissionOutcome> => {
427
506
  const { userMessage } = submission;
428
507
  const trimmed = userMessage.content.trim();
508
+ const shouldRestoreViewport = showStatus || showSkills || showMcp;
429
509
  setShowStatus(false);
430
510
  setShowSkills(false);
431
511
  setShowMcp(false);
432
512
  setViewError(undefined);
513
+ if (shouldRestoreViewport) {
514
+ restoreStaticViewport();
515
+ }
433
516
 
434
517
  if (userMessage.attachments === undefined && trimmed.startsWith("/")) {
435
518
  try {
@@ -454,6 +537,10 @@ export function App(props: AppProps) {
454
537
  openFileView(command.filePath);
455
538
  return true;
456
539
  }
540
+ if (command.type === "memory") {
541
+ openMemoryView();
542
+ return true;
543
+ }
457
544
  if (command.type === "copy") {
458
545
  copyLastResponse();
459
546
  return true;
@@ -495,7 +582,7 @@ export function App(props: AppProps) {
495
582
  if (command.type === "clear") {
496
583
  setIsSessionOperation(true);
497
584
  void props.sessionController
498
- .clear()
585
+ .clear(beforeSessionCommit)
499
586
  .then(() => {
500
587
  const sessionId = props.sessionController.getBinding().sessionId;
501
588
  setGitBranchRefresh((current) => current + 1);
@@ -512,7 +599,7 @@ export function App(props: AppProps) {
512
599
  if (command.type === "fork") {
513
600
  setIsSessionOperation(true);
514
601
  void props.sessionController
515
- .fork()
602
+ .fork(beforeSessionCommit)
516
603
  .then((sessionId) => {
517
604
  setGitBranchRefresh((current) => current + 1);
518
605
  setNotice(
@@ -558,7 +645,7 @@ export function App(props: AppProps) {
558
645
  const operation =
559
646
  command.type === "resume"
560
647
  ? props.sessionController
561
- .resume(command.sessionId)
648
+ .resume(command.sessionId, beforeSessionCommit)
562
649
  .then(() => setNotice(`Resumed session ${command.sessionId}.`))
563
650
  : props.sessionController
564
651
  .delete(command.sessionId)
@@ -578,91 +665,122 @@ export function App(props: AppProps) {
578
665
  };
579
666
 
580
667
  return (
581
- <Box flexDirection="column">
582
- {fileView?.status === "loading" ? (
583
- <FileViewerLoading filePath={fileView.filePath} onCancel={closeFileView} />
584
- ) : fileView?.status === "ready" ? (
585
- <FileViewer file={fileView.file} onClose={closeFileView} />
586
- ) : resumePicker?.status === "loading" ? (
587
- <ResumeSessionPickerLoading onCancel={closeResumePicker} />
588
- ) : resumePicker?.status === "ready" ? (
589
- <ResumeSessionPicker
590
- sessions={resumePicker.sessions}
591
- isResuming={resumePicker.isResuming}
592
- error={resumePicker.error}
593
- onCancel={closeResumePicker}
594
- onSelect={resumeSelectedSession}
595
- />
596
- ) : (
597
- <>
598
- <Header
599
- key={binding.sessionId}
600
- modelName={binding.modelName}
601
- workspaceRoot={binding.workspaceRoot}
602
- 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}
603
699
  />
604
- <Box marginTop={1} flexDirection="column">
605
- <Timeline items={visibleTimelineItems(state)} />
606
- </Box>
607
- {state.backgroundTasks.length === 0 ? null : (
608
- <Box marginTop={1}>
609
- <BackgroundTasks tasks={state.backgroundTasks} />
610
- </Box>
611
- )}
612
- {showStatus ? (
613
- <Box marginTop={1}>
614
- <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>
615
719
  </Box>
616
- ) : null}
617
- {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}
618
744
  <Box marginTop={1}>
619
- <SkillsPanel snapshot={binding.skills()} />
745
+ <Footer
746
+ status={isCancelling ? "cancelling" : state.status}
747
+ workedForMs={state.workedForMs}
748
+ />
620
749
  </Box>
621
- ) : null}
622
- {showMcp ? (
623
- <Box marginTop={1}>
624
- <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>}
625
779
  </Box>
626
- ) : null}
627
- <Box marginTop={1}>
628
- <Footer
629
- status={isCancelling ? "cancelling" : state.status}
630
- workedForMs={state.workedForMs}
631
- />
632
- </Box>
633
- <Box marginTop={1} flexDirection="column">
634
- {showModelPicker ? (
635
- <ModelPicker
636
- profiles={profileList}
637
- currentProfileName={binding.profileName}
638
- isSwitching={modelPickerState?.isSwitching}
639
- error={modelPickerState?.error}
640
- onCancel={closeModelPicker}
641
- onSelect={doSwitchModel}
642
- />
643
- ) : (
644
- <PromptInput
645
- modelName={binding.modelName}
646
- workspaceRoot={binding.workspaceRoot}
647
- gitBranch={gitBranch}
648
- contextUsage={state.contextUsage}
649
- isDisabled={isRunning || isSessionOperation || isCopying}
650
- history={props.history}
651
- commands={availableCommands}
652
- fileLister={props.fileLister}
653
- importImage={binding.importImage}
654
- verifyImageAssets={binding.verifyImageAssets}
655
- onSubmit={onSubmit}
656
- onMaintenance={onMaintenance}
657
- placeholder='Enter a coding request, or "/" for commands'
658
- />
659
- )}
660
- {viewError === undefined ? null : <Text color="red">{viewError}</Text>}
661
- {notice === undefined ? null : <Text color="yellow">{notice}</Text>}
662
780
  </Box>
663
- </>
664
- )}
665
- </Box>
781
+ )}
782
+ </Box>
783
+ </AssistantMarkdownProvider>
666
784
  );
667
785
  }
668
786
 
@@ -1,5 +1,27 @@
1
1
  import clipboard from "clipboardy";
2
+ import { writeFile } from "node:fs/promises";
3
+ import path from "node:path";
2
4
 
3
5
  export async function writeClipboardText(text: string): Promise<void> {
4
6
  await clipboard.write(text);
5
7
  }
8
+
9
+ export function clipboardWriterForEnvironment(
10
+ env: NodeJS.ProcessEnv,
11
+ ): ((text: string) => Promise<void>) | undefined {
12
+ const filePath = env.TINKER_TEST_CLIPBOARD_FILE;
13
+ if (filePath === undefined || filePath === "") {
14
+ return undefined;
15
+ }
16
+ if (env.TINKER_TEST_FAKE_MODEL === undefined || env.TINKER_TEST_FAKE_MODEL === "") {
17
+ throw new Error("TINKER_TEST_CLIPBOARD_FILE requires TINKER_TEST_FAKE_MODEL.");
18
+ }
19
+ if (!path.isAbsolute(filePath)) {
20
+ throw new Error("TINKER_TEST_CLIPBOARD_FILE must be an absolute path.");
21
+ }
22
+ return (text) =>
23
+ writeFile(filePath, text, {
24
+ encoding: "utf8",
25
+ mode: 0o600,
26
+ });
27
+ }
@@ -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) => {