tinker-agent 1.0.65

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 (110) hide show
  1. package/README.md +173 -0
  2. package/package.json +78 -0
  3. package/patches/markdansi@0.3.2.patch +37 -0
  4. package/src/agent/context-builder.ts +43 -0
  5. package/src/agent/context-meter.ts +310 -0
  6. package/src/agent/loop.ts +525 -0
  7. package/src/agent/runtime-session.ts +1212 -0
  8. package/src/agent/session-ledger.ts +828 -0
  9. package/src/agent/turn-cancellation.ts +44 -0
  10. package/src/agent/types.ts +77 -0
  11. package/src/cli/config.ts +283 -0
  12. package/src/cli/index.ts +29 -0
  13. package/src/cli/model-profiles.ts +289 -0
  14. package/src/cli/run-runner.ts +107 -0
  15. package/src/cli/tui-runner.tsx +290 -0
  16. package/src/context/compiled-context-hash.ts +138 -0
  17. package/src/context/compiled-context-validator.ts +209 -0
  18. package/src/context/context-manager.ts +362 -0
  19. package/src/context/context-policy.ts +8 -0
  20. package/src/context/context-protocol-validator.ts +463 -0
  21. package/src/context/context-revision-compiler.ts +281 -0
  22. package/src/context/context-revision.ts +111 -0
  23. package/src/context/context-source.ts +30 -0
  24. package/src/context/context-swap-renderer.ts +272 -0
  25. package/src/context/protocol-frame.ts +240 -0
  26. package/src/context/swap-planner.ts +725 -0
  27. package/src/events/append-private-file.ts +16 -0
  28. package/src/events/bash-result-detail.ts +70 -0
  29. package/src/events/composite-event-sink.ts +82 -0
  30. package/src/events/event-sink.ts +16 -0
  31. package/src/events/jsonl-event-log.ts +13 -0
  32. package/src/events/observation-text-log.ts +195 -0
  33. package/src/events/stdout-event-printer.ts +396 -0
  34. package/src/events/types.ts +263 -0
  35. package/src/ids/runtime-id.ts +68 -0
  36. package/src/ids/uuid-v7.ts +5 -0
  37. package/src/instructions/project-instructions.ts +242 -0
  38. package/src/mcp/mcp-config.ts +144 -0
  39. package/src/mcp/mcp-manager.ts +216 -0
  40. package/src/mcp/mcp-tool-executor.ts +178 -0
  41. package/src/model/committed-prefix-auditor.ts +68 -0
  42. package/src/model/fake-model-client.ts +280 -0
  43. package/src/model/model-client.ts +64 -0
  44. package/src/model/model-context-profile.ts +134 -0
  45. package/src/model/model-request-preflight.ts +120 -0
  46. package/src/model/openai-chat-mapping.ts +444 -0
  47. package/src/model/openai-chat-model-client.ts +190 -0
  48. package/src/model/prompt-prefix-hash.ts +47 -0
  49. package/src/model/token-estimator.ts +148 -0
  50. package/src/observation/observation-builder.ts +481 -0
  51. package/src/session/resume-projection.ts +616 -0
  52. package/src/session/session-catalog.ts +270 -0
  53. package/src/session/session-errors.ts +121 -0
  54. package/src/session/session-history-reader.ts +535 -0
  55. package/src/session/session-lock.ts +291 -0
  56. package/src/session/session-schema.ts +741 -0
  57. package/src/session/session-store.ts +3067 -0
  58. package/src/session/sqlite-session-ledger.ts +153 -0
  59. package/src/tools/bash-task.ts +617 -0
  60. package/src/tools/bash.ts +450 -0
  61. package/src/tools/cwd-state.ts +22 -0
  62. package/src/tools/edit.ts +428 -0
  63. package/src/tools/file-diff.ts +116 -0
  64. package/src/tools/glob.ts +202 -0
  65. package/src/tools/grep.ts +550 -0
  66. package/src/tools/hash.ts +9 -0
  67. package/src/tools/path-safety.ts +33 -0
  68. package/src/tools/read.ts +319 -0
  69. package/src/tools/recall.ts +400 -0
  70. package/src/tools/registry.ts +213 -0
  71. package/src/tools/ripgrep.ts +220 -0
  72. package/src/tools/task-list.ts +59 -0
  73. package/src/tools/task-output-snapshot.ts +47 -0
  74. package/src/tools/task-output-tool.ts +62 -0
  75. package/src/tools/task-output.ts +159 -0
  76. package/src/tools/task-stop.ts +59 -0
  77. package/src/tools/task-tool-args.ts +29 -0
  78. package/src/tools/types.ts +330 -0
  79. package/src/tools/web-fetch/backend.ts +27 -0
  80. package/src/tools/web-fetch/browser-backend.ts +126 -0
  81. package/src/tools/web-fetch/exa-backend.ts +172 -0
  82. package/src/tools/web-fetch/index.ts +298 -0
  83. package/src/tools/web-fetch/local-backend.ts +267 -0
  84. package/src/tools/web-fetch/refiner.ts +78 -0
  85. package/src/tools/web-fetch/route.ts +95 -0
  86. package/src/tools/web-search.ts +300 -0
  87. package/src/tools/write.ts +244 -0
  88. package/src/tui/app.tsx +497 -0
  89. package/src/tui/components/assistant-markdown.tsx +47 -0
  90. package/src/tui/components/background-tasks.tsx +92 -0
  91. package/src/tui/components/bash-result-view.tsx +47 -0
  92. package/src/tui/components/context-status.tsx +127 -0
  93. package/src/tui/components/diff-view.tsx +151 -0
  94. package/src/tui/components/file-viewer.tsx +212 -0
  95. package/src/tui/components/footer.tsx +60 -0
  96. package/src/tui/components/header.tsx +21 -0
  97. package/src/tui/components/model-picker.tsx +142 -0
  98. package/src/tui/components/prompt-input.tsx +432 -0
  99. package/src/tui/components/resume-session-picker.tsx +273 -0
  100. package/src/tui/components/timeline.tsx +121 -0
  101. package/src/tui/context-format.ts +24 -0
  102. package/src/tui/event-store.ts +865 -0
  103. package/src/tui/git-branch.ts +23 -0
  104. package/src/tui/line-editor.ts +157 -0
  105. package/src/tui/prompt-history.ts +94 -0
  106. package/src/tui/slash-commands.ts +126 -0
  107. package/src/tui/tui-projection-policy.ts +35 -0
  108. package/src/tui/tui-projection-store.ts +123 -0
  109. package/src/tui/tui-session-controller.ts +170 -0
  110. package/src/tui/view-file.ts +122 -0
@@ -0,0 +1,497 @@
1
+ import { Box, Text, useApp, useInput } from "ink";
2
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ import { TurnCancelledError } from "../agent/turn-cancellation";
4
+ import {
5
+ ContextManagerError,
6
+ type ContextCompactionResult,
7
+ } from "../context/context-manager";
8
+ import { ContextBudgetExceededError } from "../model/model-request-preflight";
9
+ import { visibleTimelineItems } from "./event-store";
10
+ import type { PromptHistory } from "./prompt-history";
11
+ import { Footer } from "./components/footer";
12
+ import { ContextStatus } from "./components/context-status";
13
+ import { BackgroundTasks } from "./components/background-tasks";
14
+ import { Header } from "./components/header";
15
+ import { ModelPicker } from "./components/model-picker";
16
+ import { FileViewer, FileViewerLoading } from "./components/file-viewer";
17
+ import { PromptInput } from "./components/prompt-input";
18
+ import {
19
+ ResumeSessionPicker,
20
+ ResumeSessionPickerLoading,
21
+ } from "./components/resume-session-picker";
22
+ import { Timeline } from "./components/timeline";
23
+ import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands";
24
+ import type { TuiSessionController } from "./tui-session-controller";
25
+ import type { SessionSummary } from "../session/session-catalog";
26
+ import type { ModelProfile, ModelProfiles } from "../cli/model-profiles";
27
+ import { loadViewFile, type ViewFile } from "./view-file";
28
+
29
+ export type AppProps = {
30
+ sessionController: TuiSessionController;
31
+ readGitBranch?: (workspaceRoot: string) => Promise<string | undefined>;
32
+ history?: PromptHistory;
33
+ profiles?: ModelProfiles;
34
+ persistDefaultProfile?: (profileName: string) => Promise<void>;
35
+ readViewFile?: (workspaceRoot: string, filePath: string) => Promise<ViewFile>;
36
+ onQuit?: () => void;
37
+ };
38
+
39
+ type ResumePickerState =
40
+ | { status: "loading" }
41
+ | {
42
+ status: "ready";
43
+ sessions: readonly SessionSummary[];
44
+ isResuming: boolean;
45
+ error?: string;
46
+ };
47
+
48
+ type ModelPickerState = {
49
+ isSwitching: boolean;
50
+ error?: string;
51
+ };
52
+
53
+ type FileViewState =
54
+ | { status: "loading"; filePath: string }
55
+ | { status: "ready"; file: ViewFile };
56
+
57
+ export function App(props: AppProps) {
58
+ const { exit } = useApp();
59
+ const binding = useSyncExternalStore(
60
+ props.sessionController.subscribe,
61
+ props.sessionController.getBinding,
62
+ props.sessionController.getBinding,
63
+ );
64
+ const { readGitBranch, workspaceRoot } = {
65
+ readGitBranch: props.readGitBranch,
66
+ workspaceRoot: binding.workspaceRoot,
67
+ };
68
+ const state = useSyncExternalStore(
69
+ binding.projectionStore.subscribe,
70
+ binding.projectionStore.getSnapshot,
71
+ binding.projectionStore.getSnapshot,
72
+ );
73
+ const [isRunning, setIsRunning] = useState(false);
74
+ const [isSessionOperation, setIsSessionOperation] = useState(false);
75
+ const [isCancelling, setIsCancelling] = useState(false);
76
+ const [notice, setNotice] = useState<string | undefined>(undefined);
77
+ const [showStatus, setShowStatus] = useState(false);
78
+ const [resumePicker, setResumePicker] = useState<ResumePickerState | undefined>(
79
+ undefined,
80
+ );
81
+ const [showModelPicker, setShowModelPicker] = useState(false);
82
+ const [modelPickerState, setModelPickerState] = useState<
83
+ ModelPickerState | undefined
84
+ >(undefined);
85
+ const [fileView, setFileView] = useState<FileViewState | undefined>(undefined);
86
+ const [viewError, setViewError] = useState<string | undefined>(undefined);
87
+ const [gitBranch, setGitBranch] = useState<string | undefined>(undefined);
88
+ const [gitBranchRefresh, setGitBranchRefresh] = useState(0);
89
+ const gitBranchReadQueue = useRef<Promise<void>>(Promise.resolve());
90
+ const activeController = useRef<AbortController | undefined>(undefined);
91
+ const resumePickerRequest = useRef(0);
92
+ const fileViewRequest = useRef(0);
93
+
94
+ const canSwitchModel =
95
+ state.recentTurns.length === 0 &&
96
+ state.activeTurn === undefined &&
97
+ props.profiles !== undefined &&
98
+ props.profiles.profiles.size > 1;
99
+
100
+ const availableCommands = canSwitchModel
101
+ ? undefined
102
+ : SLASH_COMMANDS.filter((cmd) => cmd.name !== "model");
103
+
104
+ const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
105
+
106
+ useEffect(() => {
107
+ if (readGitBranch === undefined) {
108
+ return;
109
+ }
110
+
111
+ let isCurrent = true;
112
+ const read = gitBranchReadQueue.current.then(() => readGitBranch(workspaceRoot));
113
+ gitBranchReadQueue.current = read.then(
114
+ (branch) => {
115
+ if (isCurrent) {
116
+ setGitBranch(branch);
117
+ }
118
+ },
119
+ () => {
120
+ if (isCurrent) {
121
+ setGitBranch(undefined);
122
+ }
123
+ },
124
+ );
125
+
126
+ return () => {
127
+ isCurrent = false;
128
+ };
129
+ }, [readGitBranch, workspaceRoot, gitBranchRefresh]);
130
+
131
+ useInput(
132
+ (_input, key) => {
133
+ if (!key.escape) {
134
+ return;
135
+ }
136
+
137
+ const controller = activeController.current;
138
+ if (controller === undefined || controller.signal.aborted) {
139
+ return;
140
+ }
141
+
142
+ controller.abort(new TurnCancelledError("user"));
143
+ setIsCancelling(true);
144
+ setNotice("Cancelling current turn...");
145
+ },
146
+ { isActive: isRunning },
147
+ );
148
+
149
+ const closeResumePicker = () => {
150
+ resumePickerRequest.current += 1;
151
+ setResumePicker(undefined);
152
+ setIsSessionOperation(false);
153
+ setNotice(undefined);
154
+ };
155
+
156
+ const openResumePicker = () => {
157
+ const requestId = resumePickerRequest.current + 1;
158
+ resumePickerRequest.current = requestId;
159
+ setNotice(undefined);
160
+ setResumePicker({ status: "loading" });
161
+ setIsSessionOperation(true);
162
+ void props.sessionController
163
+ .listSessions()
164
+ .then((sessions) => {
165
+ if (resumePickerRequest.current !== requestId) {
166
+ return;
167
+ }
168
+ if (sessions.length === 0) {
169
+ setResumePicker(undefined);
170
+ setNotice("No stored sessions found for this workspace.");
171
+ return;
172
+ }
173
+ setResumePicker({ status: "ready", sessions, isResuming: false });
174
+ })
175
+ .catch((error: unknown) => {
176
+ if (resumePickerRequest.current === requestId) {
177
+ setResumePicker(undefined);
178
+ setNotice(`Session operation failed: ${errorMessage(error)}`);
179
+ }
180
+ })
181
+ .finally(() => {
182
+ if (resumePickerRequest.current === requestId) {
183
+ setIsSessionOperation(false);
184
+ }
185
+ });
186
+ };
187
+
188
+ const resumeSelectedSession = (session: SessionSummary) => {
189
+ setResumePicker((current) =>
190
+ current?.status === "ready"
191
+ ? { ...current, isResuming: true, error: undefined }
192
+ : current,
193
+ );
194
+ setIsSessionOperation(true);
195
+ void props.sessionController
196
+ .resume(session.sessionId)
197
+ .then(() => {
198
+ setResumePicker(undefined);
199
+ setNotice(`Resumed session ${session.sessionId}.`);
200
+ })
201
+ .catch((error: unknown) => {
202
+ setResumePicker((current) =>
203
+ current?.status === "ready"
204
+ ? {
205
+ ...current,
206
+ isResuming: false,
207
+ error: errorMessage(error),
208
+ }
209
+ : current,
210
+ );
211
+ })
212
+ .finally(() => setIsSessionOperation(false));
213
+ };
214
+
215
+ const closeModelPicker = () => {
216
+ setShowModelPicker(false);
217
+ setModelPickerState(undefined);
218
+ setNotice(undefined);
219
+ };
220
+
221
+ const doSwitchModel = (profile: ModelProfile) => {
222
+ setShowModelPicker(false);
223
+ setModelPickerState(undefined);
224
+ setNotice(undefined);
225
+ setIsSessionOperation(true);
226
+ void props.sessionController
227
+ .switchModel(profile)
228
+ .then(async () => {
229
+ setGitBranchRefresh((current) => current + 1);
230
+ try {
231
+ await props.persistDefaultProfile?.(profile.name);
232
+ setNotice(`Switched to model profile "${profile.name}" (${profile.model}).`);
233
+ } catch (error) {
234
+ setNotice(
235
+ `Switched to model profile "${profile.name}" (${profile.model}), but failed to save it as the default: ${errorMessage(error)}`,
236
+ );
237
+ }
238
+ })
239
+ .catch((error: unknown) => {
240
+ setNotice(`Model switch failed: ${errorMessage(error)}`);
241
+ })
242
+ .finally(() => setIsSessionOperation(false));
243
+ };
244
+
245
+ const closeFileView = () => {
246
+ fileViewRequest.current += 1;
247
+ setFileView(undefined);
248
+ };
249
+
250
+ const openFileView = (filePath: string) => {
251
+ const requestId = fileViewRequest.current + 1;
252
+ fileViewRequest.current = requestId;
253
+ setNotice(undefined);
254
+ setViewError(undefined);
255
+ setFileView({ status: "loading", filePath });
256
+
257
+ void Promise.resolve()
258
+ .then(() => (props.readViewFile ?? loadViewFile)(workspaceRoot, filePath))
259
+ .then((file) => {
260
+ if (fileViewRequest.current === requestId) {
261
+ setFileView({ status: "ready", file });
262
+ }
263
+ })
264
+ .catch((error: unknown) => {
265
+ if (fileViewRequest.current === requestId) {
266
+ setFileView(undefined);
267
+ setViewError(`View failed: ${errorMessage(error)}`);
268
+ }
269
+ });
270
+ };
271
+
272
+ const onSubmit = (prompt: string) => {
273
+ const trimmed = prompt.trim();
274
+ setShowStatus(false);
275
+ setViewError(undefined);
276
+
277
+ if (trimmed.startsWith("/")) {
278
+ let command;
279
+ try {
280
+ command = parseSlashCommand(trimmed);
281
+ } catch (error) {
282
+ setNotice(errorMessage(error));
283
+ return;
284
+ }
285
+
286
+ setNotice(undefined);
287
+
288
+ if (command.type === "view") {
289
+ openFileView(command.filePath);
290
+ return;
291
+ }
292
+ if (command.type === "status") {
293
+ setShowStatus(true);
294
+ return;
295
+ }
296
+ if (command.type === "compact") {
297
+ setIsSessionOperation(true);
298
+ void props.sessionController
299
+ .compact()
300
+ .then((result) => setNotice(formatContextCompactionNotice(result)))
301
+ .catch((error: unknown) => {
302
+ setNotice(formatContextCompactionFailureNotice(error));
303
+ })
304
+ .finally(() => setIsSessionOperation(false));
305
+ return;
306
+ }
307
+ if (command.type === "quit") {
308
+ props.onQuit?.();
309
+ exit();
310
+ return;
311
+ }
312
+ if (command.type === "model" || command.type === "model_switch") {
313
+ if (!canSwitchModel) {
314
+ setNotice(
315
+ "Cannot switch models after the session has turns or while running.",
316
+ );
317
+ return;
318
+ }
319
+ if (command.type === "model_switch") {
320
+ const targetProfile = props.profiles?.profiles.get(command.profileName);
321
+ if (targetProfile === undefined) {
322
+ setNotice(`Unknown model profile: ${command.profileName}`);
323
+ return;
324
+ }
325
+ void doSwitchModel(targetProfile);
326
+ } else {
327
+ setModelPickerState({ isSwitching: false });
328
+ setShowModelPicker(true);
329
+ }
330
+ return;
331
+ }
332
+ if (command.type === "resume_list") {
333
+ openResumePicker();
334
+ return;
335
+ }
336
+ setIsSessionOperation(true);
337
+ const operation =
338
+ command.type === "resume"
339
+ ? props.sessionController
340
+ .resume(command.sessionId)
341
+ .then(() => setNotice(`Resumed session ${command.sessionId}.`))
342
+ : props.sessionController
343
+ .delete(command.sessionId)
344
+ .then(() => setNotice(`Deleted session ${command.sessionId}.`));
345
+ void operation
346
+ .catch((error: unknown) => {
347
+ setNotice(`Session operation failed: ${errorMessage(error)}`);
348
+ })
349
+ .finally(() => setIsSessionOperation(false));
350
+ return;
351
+ }
352
+
353
+ if (trimmed === "" || isRunning) {
354
+ return;
355
+ }
356
+
357
+ setNotice(undefined);
358
+ setIsRunning(true);
359
+ setIsCancelling(false);
360
+ const controller = new AbortController();
361
+ activeController.current = controller;
362
+ void props.history?.append(trimmed).catch(() => undefined);
363
+ let retainNotice = false;
364
+ void Promise.resolve()
365
+ .then(() => binding.executeTurn(trimmed, controller.signal))
366
+ .catch((error: unknown) => {
367
+ retainNotice = true;
368
+ setNotice(
369
+ error instanceof ContextBudgetExceededError
370
+ ? error.message
371
+ : `Runtime failed: ${errorMessage(error)}`,
372
+ );
373
+ })
374
+ .finally(() => {
375
+ if (activeController.current === controller) {
376
+ activeController.current = undefined;
377
+ setIsRunning(false);
378
+ setIsCancelling(false);
379
+ if (!retainNotice) {
380
+ setNotice(undefined);
381
+ }
382
+ setGitBranchRefresh((current) => current + 1);
383
+ }
384
+ });
385
+ };
386
+
387
+ return (
388
+ <Box flexDirection="column">
389
+ {fileView?.status === "loading" ? (
390
+ <FileViewerLoading filePath={fileView.filePath} onCancel={closeFileView} />
391
+ ) : fileView?.status === "ready" ? (
392
+ <FileViewer file={fileView.file} onClose={closeFileView} />
393
+ ) : resumePicker?.status === "loading" ? (
394
+ <ResumeSessionPickerLoading onCancel={closeResumePicker} />
395
+ ) : resumePicker?.status === "ready" ? (
396
+ <ResumeSessionPicker
397
+ sessions={resumePicker.sessions}
398
+ isResuming={resumePicker.isResuming}
399
+ error={resumePicker.error}
400
+ onCancel={closeResumePicker}
401
+ onSelect={resumeSelectedSession}
402
+ />
403
+ ) : (
404
+ <>
405
+ <Header
406
+ key={binding.sessionId}
407
+ modelName={binding.modelName}
408
+ workspaceRoot={binding.workspaceRoot}
409
+ sessionId={binding.sessionId}
410
+ />
411
+ <Box marginTop={1} flexDirection="column">
412
+ <Timeline items={visibleTimelineItems(state)} />
413
+ </Box>
414
+ {state.backgroundTasks.length === 0 ? null : (
415
+ <Box marginTop={1}>
416
+ <BackgroundTasks tasks={state.backgroundTasks} />
417
+ </Box>
418
+ )}
419
+ {showStatus ? (
420
+ <Box marginTop={1}>
421
+ <ContextStatus state={state} />
422
+ </Box>
423
+ ) : null}
424
+ <Box marginTop={1}>
425
+ <Footer
426
+ status={isCancelling ? "cancelling" : state.status}
427
+ workedForMs={state.workedForMs}
428
+ />
429
+ </Box>
430
+ <Box marginTop={1} flexDirection="column">
431
+ {showModelPicker ? (
432
+ <ModelPicker
433
+ profiles={profileList}
434
+ currentProfileName={binding.profileName}
435
+ isSwitching={modelPickerState?.isSwitching}
436
+ error={modelPickerState?.error}
437
+ onCancel={closeModelPicker}
438
+ onSelect={doSwitchModel}
439
+ />
440
+ ) : (
441
+ <PromptInput
442
+ modelName={binding.modelName}
443
+ workspaceRoot={binding.workspaceRoot}
444
+ gitBranch={gitBranch}
445
+ contextUsage={state.contextUsage}
446
+ isDisabled={isRunning || isSessionOperation}
447
+ history={props.history}
448
+ commands={availableCommands}
449
+ onSubmit={onSubmit}
450
+ placeholder='Enter a coding request, or "/" for commands'
451
+ />
452
+ )}
453
+ {viewError === undefined ? null : <Text color="red">{viewError}</Text>}
454
+ {notice === undefined ? null : <Text color="yellow">{notice}</Text>}
455
+ </Box>
456
+ </>
457
+ )}
458
+ </Box>
459
+ );
460
+ }
461
+
462
+ function errorMessage(error: unknown): string {
463
+ return error instanceof Error ? error.message : String(error);
464
+ }
465
+
466
+ export function formatContextCompactionNotice(result: ContextCompactionResult): string {
467
+ if (result.status === "unchanged") {
468
+ if (result.outcome === "below_target") {
469
+ return `Context is already below the compact target (${result.guardedTokensBefore.toLocaleString("en-US")} <= ${result.targetTokens.toLocaleString("en-US")} estimated tokens).`;
470
+ }
471
+ return "No eligible historical tool observations can be compacted.";
472
+ }
473
+ const before = result.guardedTokensBefore.toLocaleString("en-US");
474
+ const after = result.guardedTokensAfter.toLocaleString("en-US");
475
+ if (result.outcome === "insufficient_candidates") {
476
+ return `Context compacted: ${result.addedOverrideCount} observations swapped, ${before} -> ${after} estimated tokens; target ${result.targetTokens.toLocaleString("en-US")} was not reached.`;
477
+ }
478
+ const reduction =
479
+ result.guardedTokensBefore === 0
480
+ ? "0.0"
481
+ : (
482
+ ((result.guardedTokensBefore - result.guardedTokensAfter) /
483
+ result.guardedTokensBefore) *
484
+ 100
485
+ ).toFixed(1);
486
+ return `Context compacted: revision ${result.previousRevisionNumber} -> ${result.revisionNumber}, ${result.addedOverrideCount} observations swapped, ${before} -> ${after} estimated tokens (-${reduction}%).`;
487
+ }
488
+
489
+ export function formatContextCompactionFailureNotice(error: unknown): string {
490
+ if (!(error instanceof ContextManagerError)) {
491
+ return "Context compaction failed.";
492
+ }
493
+ const code = /^[A-Za-z0-9_]+$/.test(error.code)
494
+ ? error.code
495
+ : "CONTEXT_COMPACTION_FAILED";
496
+ return `Context compaction failed at ${error.stage} (${code}).`;
497
+ }
@@ -0,0 +1,47 @@
1
+ import {
2
+ MarkdownText,
3
+ type RenderOptions,
4
+ useShikiHighlighter,
5
+ } from "@assistant-ui/react-ink-markdown";
6
+
7
+ export type AssistantMarkdownProps = {
8
+ text: string;
9
+ };
10
+
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
+ const tableOptions = {
28
+ tableTruncate: false,
29
+ } satisfies Pick<RenderOptions, "tableTruncate">;
30
+
31
+ export function AssistantMarkdown(props: AssistantMarkdownProps) {
32
+ const highlighter = useShikiHighlighter({
33
+ theme: "github-dark",
34
+ langs: highlightedLanguages,
35
+ });
36
+
37
+ return (
38
+ <MarkdownText
39
+ {...tableOptions}
40
+ text={props.text}
41
+ highlighter={highlighter}
42
+ codeBox
43
+ codeWrap
44
+ tableBorder="unicode"
45
+ />
46
+ );
47
+ }
@@ -0,0 +1,92 @@
1
+ import { Box, Text } from "ink";
2
+ import type { ShellTaskSnapshot, ShellTaskStatus } from "../../tools/bash-task";
3
+
4
+ export type BackgroundTasksProps = {
5
+ tasks: ShellTaskSnapshot[];
6
+ };
7
+
8
+ export function BackgroundTasks(props: BackgroundTasksProps) {
9
+ if (props.tasks.length === 0) {
10
+ return null;
11
+ }
12
+
13
+ const runningCount = props.tasks.filter(
14
+ (task) => task.status === "running" || task.status === "stopping",
15
+ ).length;
16
+
17
+ return (
18
+ <Box flexDirection="column">
19
+ <Text bold>
20
+ Background tasks · {runningCount} running / {props.tasks.length} total
21
+ </Text>
22
+ {props.tasks.map((task) => (
23
+ <Box key={task.taskId} flexDirection="column">
24
+ <Text color={colorForStatus(task.status)}>
25
+ {symbolForStatus(task.status)} {task.status} {taskDescription(task)}
26
+ {taskResult(task) === undefined ? "" : ` ${taskResult(task)}`}
27
+ </Text>
28
+ <Text dimColor>{taskTiming(task)}</Text>
29
+ </Box>
30
+ ))}
31
+ </Box>
32
+ );
33
+ }
34
+
35
+ function taskDescription(task: ShellTaskSnapshot): string {
36
+ const description = task.description.trim();
37
+ return description === "" ? task.command.replace(/\s+/g, " ").trim() : description;
38
+ }
39
+
40
+ function taskResult(task: ShellTaskSnapshot): string | undefined {
41
+ if (task.signal !== undefined) {
42
+ return `signal=${task.signal}`;
43
+ }
44
+
45
+ if (task.exitCode !== undefined) {
46
+ return `exit=${task.exitCode}`;
47
+ }
48
+
49
+ return task.error;
50
+ }
51
+
52
+ function taskTiming(task: ShellTaskSnapshot): string {
53
+ const timing = [`id=${task.taskId}`, `started=${task.startedAt}`];
54
+ if (task.endedAt === undefined) {
55
+ return timing.join(" · ");
56
+ }
57
+
58
+ timing.push(`ended=${task.endedAt}`);
59
+ return timing.join(" · ");
60
+ }
61
+
62
+ function colorForStatus(status: ShellTaskStatus): string {
63
+ if (status === "completed") {
64
+ return "green";
65
+ }
66
+
67
+ if (status === "failed") {
68
+ return "red";
69
+ }
70
+
71
+ if (status === "killed") {
72
+ return "gray";
73
+ }
74
+
75
+ return "yellow";
76
+ }
77
+
78
+ function symbolForStatus(status: ShellTaskStatus): string {
79
+ if (status === "completed") {
80
+ return "✔";
81
+ }
82
+
83
+ if (status === "failed") {
84
+ return "✘";
85
+ }
86
+
87
+ if (status === "killed") {
88
+ return "■";
89
+ }
90
+
91
+ return "…";
92
+ }
@@ -0,0 +1,47 @@
1
+ import { Box, Text } from "ink";
2
+ import type { BashDisplayDetail } from "../../events/bash-result-detail";
3
+
4
+ export type BashResultViewProps = {
5
+ detail: BashDisplayDetail;
6
+ maxCommandLines?: number;
7
+ };
8
+
9
+ const DEFAULT_MAX_COMMAND_LINES = 2;
10
+
11
+ export function BashResultView(props: BashResultViewProps) {
12
+ const maxCommandLines = props.maxCommandLines ?? DEFAULT_MAX_COMMAND_LINES;
13
+ const commandLines = props.detail.command.split("\n");
14
+ const visibleCommandLines = commandLines.slice(0, maxCommandLines);
15
+ const hiddenCommandLines = commandLines.length - visibleCommandLines.length;
16
+ const outputLines = props.detail.outputPreview ?? [];
17
+ const omittedLines = props.detail.omittedOutputLines ?? 0;
18
+
19
+ return (
20
+ <Box flexDirection="column" paddingLeft={2}>
21
+ {visibleCommandLines.map((line, index) => (
22
+ <Text key={`command-${index}`} color="cyan" wrap="truncate-end">
23
+ {index === 0 ? "$ " : " "}
24
+ {line}
25
+ </Text>
26
+ ))}
27
+ {hiddenCommandLines > 0 ? (
28
+ <Text color="cyan">
29
+ {" "}… +{hiddenCommandLines} more line{hiddenCommandLines === 1 ? "" : "s"}
30
+ </Text>
31
+ ) : null}
32
+ {outputLines.map((line, index) => (
33
+ <Text key={`output-${index}`} color="gray" wrap="truncate-end">
34
+ {line === "" ? " " : line}
35
+ </Text>
36
+ ))}
37
+ {omittedLines > 0 ? (
38
+ <Text color="gray">
39
+ … +{omittedLines} line{omittedLines === 1 ? "" : "s"}
40
+ {props.detail.outputFilePath === undefined
41
+ ? ""
42
+ : ` (full output: ${props.detail.outputFilePath})`}
43
+ </Text>
44
+ ) : null}
45
+ </Box>
46
+ );
47
+ }