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.
- package/CHANGELOG.md +48 -1
- package/README.md +13 -5
- package/package.json +7 -5
- package/src/agent/assistant-text-delta.ts +10 -0
- package/src/agent/loop.ts +116 -22
- package/src/agent/runtime-session.ts +248 -1
- package/src/cli/command-line.ts +9 -1
- package/src/cli/config.ts +17 -4
- package/src/cli/main.ts +1 -0
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/public-config-contract.ts +25 -1
- package/src/cli/run-runner.ts +5 -0
- package/src/cli/tui-runner.tsx +21 -2
- package/src/events/observation-text-log.ts +21 -0
- package/src/events/stdout-event-printer.ts +11 -0
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +190 -0
- package/src/model/model-client.ts +3 -0
- package/src/model/openai-chat-model-client.ts +54 -15
- package/src/model/openai-chat-stream.ts +95 -72
- package/src/observation/observation-builder.ts +11 -0
- package/src/session/session-store.ts +1 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash.ts +31 -0
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +47 -3
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +13 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +301 -134
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/assistant-markdown.tsx +27 -26
- package/src/tui/components/background-tasks.tsx +7 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/file-viewer.tsx +2 -2
- package/src/tui/components/footer.tsx +9 -12
- package/src/tui/components/memory-browser.tsx +1 -1
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +3 -1
- package/src/tui/components/timeline.tsx +19 -11
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +75 -3
- package/src/tui/shiki-highlighter.ts +104 -0
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +277 -5
- 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 {
|
|
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 {
|
|
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({
|
|
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
|
-
<
|
|
610
|
-
|
|
611
|
-
<
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
<
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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.";
|