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.
- package/CHANGELOG.md +40 -1
- package/README.md +54 -0
- package/package.json +6 -1
- package/src/agent/runtime-session.ts +79 -0
- package/src/cli/config.ts +29 -0
- package/src/cli/model-profiles.ts +84 -1
- package/src/cli/public-config-contract.ts +82 -0
- package/src/cli/runner-dependencies.ts +8 -0
- package/src/cli/tui-memory.ts +67 -0
- package/src/cli/tui-runner.tsx +31 -0
- package/src/context/context-policy.ts +2 -2
- package/src/events/stdout-event-printer.ts +1 -0
- package/src/memory/contracts.ts +148 -0
- package/src/memory/embedding-client.ts +105 -0
- package/src/memory/memory-coordinator.ts +556 -0
- package/src/memory/memory-extractor.ts +231 -0
- package/src/memory/memory-log.ts +88 -0
- package/src/memory/memory-search-tool.ts +100 -0
- package/src/memory/memory-store.ts +687 -0
- package/src/memory/vector.ts +153 -0
- package/src/model/fake-model-client.ts +1068 -3
- package/src/observation/observation-builder.ts +20 -0
- package/src/session/session-store.ts +123 -0
- package/src/tools/registry.ts +4 -0
- package/src/tools/types.ts +16 -0
- package/src/tui/app.tsx +211 -93
- package/src/tui/clipboard.ts +22 -0
- package/src/tui/components/assistant-markdown.tsx +27 -26
- package/src/tui/components/background-tasks.tsx +7 -2
- package/src/tui/components/file-viewer.tsx +2 -2
- package/src/tui/components/footer.tsx +5 -6
- package/src/tui/components/memory-browser.tsx +151 -0
- package/src/tui/components/resume-session-picker.tsx +3 -1
- package/src/tui/components/timeline.tsx +9 -11
- package/src/tui/event-store.ts +22 -2
- package/src/tui/shiki-highlighter.ts +104 -0
- package/src/tui/slash-commands.ts +12 -0
- package/src/tui/tui-projection-store.ts +33 -0
- 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 {
|
|
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 {
|
|
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>(
|
|
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({
|
|
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
|
-
<
|
|
582
|
-
|
|
583
|
-
<
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
<
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
<
|
|
745
|
+
<Footer
|
|
746
|
+
status={isCancelling ? "cancelling" : state.status}
|
|
747
|
+
workedForMs={state.workedForMs}
|
|
748
|
+
/>
|
|
620
749
|
</Box>
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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
|
-
</
|
|
781
|
+
)}
|
|
782
|
+
</Box>
|
|
783
|
+
</AssistantMarkdownProvider>
|
|
666
784
|
);
|
|
667
785
|
}
|
|
668
786
|
|
package/src/tui/clipboard.ts
CHANGED
|
@@ -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
|
-
|
|
3
|
-
type
|
|
4
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
{
|
|
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) => {
|