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.
- package/README.md +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- package/src/tui/view-file.ts +122 -0
package/src/tui/app.tsx
ADDED
|
@@ -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
|
+
}
|