tinker-agent 1.5.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 +18 -1
- package/package.json +2 -1
- package/src/cli/tui-runner.tsx +4 -0
- package/src/model/fake-model-client.ts +97 -0
- package/src/tui/app.tsx +184 -133
- 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 +2 -8
- package/src/tui/components/memory-browser.tsx +1 -1
- package/src/tui/components/resume-session-picker.tsx +3 -1
- package/src/tui/components/timeline.tsx +9 -11
- package/src/tui/event-store.ts +13 -0
- package/src/tui/shiki-highlighter.ts +104 -0
- package/src/tui/tui-projection-store.ts +33 -0
- package/src/tui/tui-session-controller.ts +14 -8
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.5.1] - 2026-07-29
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Keep settled TUI history outside recurring live renders and reuse prepared
|
|
13
|
+
Markdown highlighting, reducing redraw work during long interactive sessions.
|
|
14
|
+
- Stop repainting the TUI once per second solely to update a running elapsed-time
|
|
15
|
+
counter; completed turns still show their final duration.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Restore the visible history tail after closing `/view`, `/memory`, `/resume`,
|
|
20
|
+
and other temporary TUI panels instead of leaving a mostly blank viewport.
|
|
21
|
+
- Keep the `/resume` picker bound to the session that opened it, preventing stale
|
|
22
|
+
picker state from affecting a newly activated session.
|
|
23
|
+
|
|
8
24
|
## [1.5.0] - 2026-07-27
|
|
9
25
|
|
|
10
26
|
### Added
|
|
@@ -84,7 +100,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
|
|
|
84
100
|
- First formal npm release under the `tinker-agent` package name with the `tinker`
|
|
85
101
|
executable.
|
|
86
102
|
|
|
87
|
-
[Unreleased]: https://github.com/ishowshao/tinker/compare/v1.5.
|
|
103
|
+
[Unreleased]: https://github.com/ishowshao/tinker/compare/v1.5.1...HEAD
|
|
104
|
+
[1.5.1]: https://github.com/ishowshao/tinker/releases/tag/v1.5.1
|
|
88
105
|
[1.5.0]: https://github.com/ishowshao/tinker/releases/tag/v1.5.0
|
|
89
106
|
[1.4.0]: https://github.com/ishowshao/tinker/releases/tag/v1.4.0
|
|
90
107
|
[1.3.0]: https://github.com/ishowshao/tinker/releases/tag/v1.3.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tinker-agent",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "A personal coding agent with an interactive TUI and one-shot CLI.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
81
81
|
"@mozilla/readability": "^0.6.0",
|
|
82
82
|
"@vscode/ripgrep": "1.18.0",
|
|
83
|
+
"ansi-escapes": "^7.3.0",
|
|
83
84
|
"bun": "1.3.14",
|
|
84
85
|
"clipboardy": "^5.3.1",
|
|
85
86
|
"commander": "^14.0.3",
|
package/src/cli/tui-runner.tsx
CHANGED
|
@@ -43,6 +43,7 @@ import { loadProjectSlashCommands } from "../tui/project-slash-commands";
|
|
|
43
43
|
import { createWorkspaceFileLister } from "../tui/workspace-file-search";
|
|
44
44
|
import { clipboardWriterForEnvironment } from "../tui/clipboard";
|
|
45
45
|
import { initializeTuiMemory } from "./tui-memory";
|
|
46
|
+
import { prepareShikiHighlighter } from "../tui/shiki-highlighter";
|
|
46
47
|
|
|
47
48
|
export type RunTuiOptions = {
|
|
48
49
|
readonly publicConfig: ResolvedPublicConfig;
|
|
@@ -51,6 +52,7 @@ export type RunTuiOptions = {
|
|
|
51
52
|
};
|
|
52
53
|
|
|
53
54
|
export async function runTui(options: RunTuiOptions): Promise<void> {
|
|
55
|
+
const shikiPreparation = prepareShikiHighlighter();
|
|
54
56
|
const profiles =
|
|
55
57
|
options.publicConfig.mode === "profile" ? options.publicConfig.profiles : undefined;
|
|
56
58
|
const config = options.initialRunnerConfig;
|
|
@@ -256,6 +258,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
|
|
|
256
258
|
createFreshSession,
|
|
257
259
|
);
|
|
258
260
|
|
|
261
|
+
await shikiPreparation;
|
|
259
262
|
instance = render(
|
|
260
263
|
<App
|
|
261
264
|
sessionController={controller}
|
|
@@ -285,6 +288,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
|
|
|
285
288
|
: () => memoryCoordinator.listStoredMemories()
|
|
286
289
|
}
|
|
287
290
|
/>,
|
|
291
|
+
{ incrementalRendering: true },
|
|
288
292
|
);
|
|
289
293
|
await instance.waitUntilExit();
|
|
290
294
|
} catch (error) {
|
|
@@ -226,6 +226,12 @@ export class FakeModelClient implements ModelClient {
|
|
|
226
226
|
if (this.mode === "pty-echo-history") {
|
|
227
227
|
return this.ptyEchoHistory(input, prepared);
|
|
228
228
|
}
|
|
229
|
+
if (this.mode === "pty-static-history") {
|
|
230
|
+
return this.ptyStaticHistory(input, prepared, options);
|
|
231
|
+
}
|
|
232
|
+
if (this.mode === "pty-resume-layout") {
|
|
233
|
+
return this.ptyResumeLayout(input, prepared, options);
|
|
234
|
+
}
|
|
229
235
|
if (this.mode === "pty-cancel-then-echo") {
|
|
230
236
|
return this.ptyCancelThenEcho(input, prepared, options);
|
|
231
237
|
}
|
|
@@ -307,6 +313,97 @@ export class FakeModelClient implements ModelClient {
|
|
|
307
313
|
throw new Error(`Unexpected pty-echo-history prompt: ${JSON.stringify(prompt)}.`);
|
|
308
314
|
}
|
|
309
315
|
|
|
316
|
+
private async ptyStaticHistory(
|
|
317
|
+
input: ModelRequestInput,
|
|
318
|
+
prepared: PreparedModelRequest,
|
|
319
|
+
options: ModelRequestOptions,
|
|
320
|
+
): Promise<ModelRequestOutput> {
|
|
321
|
+
const prompt = lastUserMessage(input.messages);
|
|
322
|
+
const historyMatch = /^PTY_STATIC_HISTORY_([1-4])$/u.exec(prompt);
|
|
323
|
+
if (historyMatch !== null) {
|
|
324
|
+
const turn = historyMatch[1];
|
|
325
|
+
return textOutput(
|
|
326
|
+
prepared,
|
|
327
|
+
[
|
|
328
|
+
turn === "1"
|
|
329
|
+
? "PTY_STATIC_HISTORY_EARLY_SENTINEL"
|
|
330
|
+
: `PTY_STATIC_HISTORY_${turn}`,
|
|
331
|
+
...Array.from(
|
|
332
|
+
{ length: 8 },
|
|
333
|
+
(_, index) => `- settled PTY history ${turn}.${index + 1}`,
|
|
334
|
+
),
|
|
335
|
+
`PTY_STATIC_HISTORY_${turn}_DONE`,
|
|
336
|
+
].join("\n"),
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
if (prompt !== "PTY_STATIC_LIVE") {
|
|
340
|
+
throw new Error(
|
|
341
|
+
`Unexpected pty-static-history prompt: ${JSON.stringify(prompt)}.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
requireTools(input, ["Bash"]);
|
|
346
|
+
const bash = toolMessagesAfterLastUser(input.messages).find(
|
|
347
|
+
(message) => message.name === "Bash",
|
|
348
|
+
);
|
|
349
|
+
await Bun.sleep(200);
|
|
350
|
+
options.signal.throwIfAborted();
|
|
351
|
+
if (bash === undefined) {
|
|
352
|
+
return toolCallOutput(prepared, options, "Bash", {
|
|
353
|
+
command:
|
|
354
|
+
'index=1; while [ "$index" -le 20 ]; do printf \'PTY_STATIC_LIVE_LINE_%s\\n\' "$index"; index=$((index + 1)); done; sleep 0.2',
|
|
355
|
+
description: "Exercise static history live tail",
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (!bash.content.includes("PTY_STATIC_LIVE_LINE_20")) {
|
|
359
|
+
throw new Error("PTY static-history Bash output was incomplete.");
|
|
360
|
+
}
|
|
361
|
+
return textOutput(prepared, "PTY_STATIC_LIVE_DONE");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
private ptyResumeLayout(
|
|
365
|
+
input: ModelRequestInput,
|
|
366
|
+
prepared: PreparedModelRequest,
|
|
367
|
+
options: ModelRequestOptions,
|
|
368
|
+
): ModelRequestOutput {
|
|
369
|
+
const prompt = lastUserMessage(input.messages);
|
|
370
|
+
if (/^PTY_RESUME_LAYOUT_PAD_\d+$/u.test(prompt)) {
|
|
371
|
+
return textOutput(prepared, `${prompt}_DONE`);
|
|
372
|
+
}
|
|
373
|
+
const match = /^PTY_RESUME_LAYOUT_([1-3])$/u.exec(prompt);
|
|
374
|
+
if (match === null) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`Unexpected pty-resume-layout prompt: ${JSON.stringify(prompt)}.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
requireTools(input, ["Read"]);
|
|
381
|
+
const turn = Number(match[1]);
|
|
382
|
+
const targetToolCount = [8, 17, 4][turn - 1];
|
|
383
|
+
const finalLineCount = [31, 47, 8][turn - 1];
|
|
384
|
+
if (targetToolCount === undefined || finalLineCount === undefined) {
|
|
385
|
+
throw new Error(`Invalid pty-resume-layout turn: ${turn}.`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const completedReads = toolMessagesAfterLastUser(input.messages).filter(
|
|
389
|
+
(message) => message.name === "Read",
|
|
390
|
+
).length;
|
|
391
|
+
if (completedReads < targetToolCount) {
|
|
392
|
+
return toolCallOutput(prepared, options, "Read", {
|
|
393
|
+
file_path: "resume-layout.txt",
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return textOutput(
|
|
398
|
+
prepared,
|
|
399
|
+
Array.from(
|
|
400
|
+
{ length: finalLineCount },
|
|
401
|
+
(_, index) =>
|
|
402
|
+
`PTY_RESUME_LAYOUT_${turn}_FINAL_${String(index + 1).padStart(2, "0")}`,
|
|
403
|
+
).join("\n"),
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
310
407
|
private ptyCancelThenEcho(
|
|
311
408
|
input: ModelRequestInput,
|
|
312
409
|
prepared: PreparedModelRequest,
|
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,9 +19,10 @@ 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 {
|
|
22
|
+
import type { TimelineItem } from "./event-store";
|
|
15
23
|
import type { PromptHistory } from "./prompt-history";
|
|
16
24
|
import { Footer } from "./components/footer";
|
|
25
|
+
import { AssistantMarkdownProvider } from "./components/assistant-markdown";
|
|
17
26
|
import { ContextStatus } from "./components/context-status";
|
|
18
27
|
import { BackgroundTasks } from "./components/background-tasks";
|
|
19
28
|
import { Header } from "./components/header";
|
|
@@ -33,7 +42,7 @@ import {
|
|
|
33
42
|
ResumeSessionPicker,
|
|
34
43
|
ResumeSessionPickerLoading,
|
|
35
44
|
} from "./components/resume-session-picker";
|
|
36
|
-
import { Timeline } from "./components/timeline";
|
|
45
|
+
import { Timeline, TimelineRow } from "./components/timeline";
|
|
37
46
|
import { parseSlashCommand, SLASH_COMMANDS } from "./slash-commands";
|
|
38
47
|
import {
|
|
39
48
|
resolveProjectSlashCommand,
|
|
@@ -67,9 +76,10 @@ export type AppProps = {
|
|
|
67
76
|
};
|
|
68
77
|
|
|
69
78
|
type ResumePickerState =
|
|
70
|
-
| { status: "loading" }
|
|
79
|
+
| { status: "loading"; ownerSessionId: SessionId }
|
|
71
80
|
| {
|
|
72
81
|
status: "ready";
|
|
82
|
+
ownerSessionId: SessionId;
|
|
73
83
|
sessions: readonly SessionSummary[];
|
|
74
84
|
isResuming: boolean;
|
|
75
85
|
error?: string;
|
|
@@ -84,8 +94,15 @@ type FileViewState =
|
|
|
84
94
|
| { status: "loading"; filePath: string }
|
|
85
95
|
| { status: "ready"; file: ViewFile };
|
|
86
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
|
+
|
|
87
102
|
export function App(props: AppProps) {
|
|
88
103
|
const { exit } = useApp();
|
|
104
|
+
const { write } = useStdout();
|
|
105
|
+
const windowSize = useWindowSize();
|
|
89
106
|
const binding = useSyncExternalStore(
|
|
90
107
|
props.sessionController.subscribe,
|
|
91
108
|
props.sessionController.getBinding,
|
|
@@ -100,6 +117,11 @@ export function App(props: AppProps) {
|
|
|
100
117
|
binding.projectionStore.getSnapshot,
|
|
101
118
|
binding.projectionStore.getSnapshot,
|
|
102
119
|
);
|
|
120
|
+
const log = useSyncExternalStore(
|
|
121
|
+
binding.projectionStore.subscribe,
|
|
122
|
+
binding.projectionStore.getLogSnapshot,
|
|
123
|
+
binding.projectionStore.getLogSnapshot,
|
|
124
|
+
);
|
|
103
125
|
const [isRunning, setIsRunning] = useState(false);
|
|
104
126
|
const [isSessionOperation, setIsSessionOperation] = useState(false);
|
|
105
127
|
const [isCopying, setIsCopying] = useState(false);
|
|
@@ -120,18 +142,32 @@ export function App(props: AppProps) {
|
|
|
120
142
|
readonly StoredMemorySummary[] | undefined
|
|
121
143
|
>(undefined);
|
|
122
144
|
const [viewError, setViewError] = useState<string | undefined>(undefined);
|
|
145
|
+
const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
|
|
123
146
|
const [gitBranch, setGitBranch] = useState<string | undefined>(undefined);
|
|
124
147
|
const [gitBranchRefresh, setGitBranchRefresh] = useState(0);
|
|
125
148
|
const gitBranchReadQueue = useRef<Promise<void>>(Promise.resolve());
|
|
126
149
|
const activeController = useRef<AbortController | undefined>(undefined);
|
|
127
150
|
const resumePickerRequest = useRef(0);
|
|
128
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
|
+
);
|
|
129
163
|
|
|
130
164
|
const canSwitchModel =
|
|
131
165
|
state.recentTurns.length === 0 &&
|
|
132
166
|
state.activeTurn === undefined &&
|
|
133
167
|
props.profiles !== undefined &&
|
|
134
168
|
props.profiles.profiles.size > 1;
|
|
169
|
+
const activeResumePicker =
|
|
170
|
+
resumePicker?.ownerSessionId === binding.sessionId ? resumePicker : undefined;
|
|
135
171
|
|
|
136
172
|
const builtInCommands = canSwitchModel
|
|
137
173
|
? SLASH_COMMANDS
|
|
@@ -140,8 +176,6 @@ export function App(props: AppProps) {
|
|
|
140
176
|
|
|
141
177
|
const profileList = props.profiles ? [...props.profiles.profiles.values()] : [];
|
|
142
178
|
|
|
143
|
-
const runningElapsedMs = useElapsedMs(state.activeTurn?.startedAt);
|
|
144
|
-
|
|
145
179
|
useEffect(() => {
|
|
146
180
|
if (readGitBranch === undefined) {
|
|
147
181
|
return;
|
|
@@ -186,17 +220,22 @@ export function App(props: AppProps) {
|
|
|
186
220
|
);
|
|
187
221
|
|
|
188
222
|
const closeResumePicker = () => {
|
|
223
|
+
const shouldRestoreViewport = activeResumePicker !== undefined;
|
|
189
224
|
resumePickerRequest.current += 1;
|
|
190
225
|
setResumePicker(undefined);
|
|
191
226
|
setIsSessionOperation(false);
|
|
192
227
|
setNotice(undefined);
|
|
228
|
+
if (shouldRestoreViewport) {
|
|
229
|
+
restoreStaticViewport();
|
|
230
|
+
}
|
|
193
231
|
};
|
|
194
232
|
|
|
195
233
|
const openResumePicker = () => {
|
|
196
234
|
const requestId = resumePickerRequest.current + 1;
|
|
235
|
+
const ownerSessionId = binding.sessionId;
|
|
197
236
|
resumePickerRequest.current = requestId;
|
|
198
237
|
setNotice(undefined);
|
|
199
|
-
setResumePicker({ status: "loading" });
|
|
238
|
+
setResumePicker({ status: "loading", ownerSessionId });
|
|
200
239
|
setIsSessionOperation(true);
|
|
201
240
|
void props.sessionController
|
|
202
241
|
.listSessions()
|
|
@@ -207,14 +246,21 @@ export function App(props: AppProps) {
|
|
|
207
246
|
if (sessions.length === 0) {
|
|
208
247
|
setResumePicker(undefined);
|
|
209
248
|
setNotice("No stored sessions found for this workspace.");
|
|
249
|
+
restoreStaticViewport();
|
|
210
250
|
return;
|
|
211
251
|
}
|
|
212
|
-
setResumePicker({
|
|
252
|
+
setResumePicker({
|
|
253
|
+
status: "ready",
|
|
254
|
+
ownerSessionId,
|
|
255
|
+
sessions,
|
|
256
|
+
isResuming: false,
|
|
257
|
+
});
|
|
213
258
|
})
|
|
214
259
|
.catch((error: unknown) => {
|
|
215
260
|
if (resumePickerRequest.current === requestId) {
|
|
216
261
|
setResumePicker(undefined);
|
|
217
262
|
setNotice(`Session operation failed: ${errorMessage(error)}`);
|
|
263
|
+
restoreStaticViewport();
|
|
218
264
|
}
|
|
219
265
|
})
|
|
220
266
|
.finally(() => {
|
|
@@ -232,7 +278,7 @@ export function App(props: AppProps) {
|
|
|
232
278
|
);
|
|
233
279
|
setIsSessionOperation(true);
|
|
234
280
|
void props.sessionController
|
|
235
|
-
.resume(session.sessionId)
|
|
281
|
+
.resume(session.sessionId, beforeSessionCommit)
|
|
236
282
|
.then(() => {
|
|
237
283
|
setResumePicker(undefined);
|
|
238
284
|
setNotice(`Resumed session ${session.sessionId}.`);
|
|
@@ -255,6 +301,7 @@ export function App(props: AppProps) {
|
|
|
255
301
|
setShowModelPicker(false);
|
|
256
302
|
setModelPickerState(undefined);
|
|
257
303
|
setNotice(undefined);
|
|
304
|
+
restoreStaticViewport();
|
|
258
305
|
};
|
|
259
306
|
|
|
260
307
|
const doSwitchModel = (profile: ModelProfile) => {
|
|
@@ -263,7 +310,7 @@ export function App(props: AppProps) {
|
|
|
263
310
|
setNotice(undefined);
|
|
264
311
|
setIsSessionOperation(true);
|
|
265
312
|
void props.sessionController
|
|
266
|
-
.switchModel(profile)
|
|
313
|
+
.switchModel(profile, beforeSessionCommit)
|
|
267
314
|
.then(async () => {
|
|
268
315
|
setGitBranchRefresh((current) => current + 1);
|
|
269
316
|
try {
|
|
@@ -276,6 +323,7 @@ export function App(props: AppProps) {
|
|
|
276
323
|
}
|
|
277
324
|
})
|
|
278
325
|
.catch((error: unknown) => {
|
|
326
|
+
restoreStaticViewport();
|
|
279
327
|
setNotice(`Model switch failed: ${errorMessage(error)}`);
|
|
280
328
|
})
|
|
281
329
|
.finally(() => setIsSessionOperation(false));
|
|
@@ -284,6 +332,12 @@ export function App(props: AppProps) {
|
|
|
284
332
|
const closeFileView = () => {
|
|
285
333
|
fileViewRequest.current += 1;
|
|
286
334
|
setFileView(undefined);
|
|
335
|
+
restoreStaticViewport();
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
const closeMemoryView = () => {
|
|
339
|
+
setMemoryView(undefined);
|
|
340
|
+
restoreStaticViewport();
|
|
287
341
|
};
|
|
288
342
|
|
|
289
343
|
const openMemoryView = () => {
|
|
@@ -318,6 +372,7 @@ export function App(props: AppProps) {
|
|
|
318
372
|
if (fileViewRequest.current === requestId) {
|
|
319
373
|
setFileView(undefined);
|
|
320
374
|
setViewError(`View failed: ${errorMessage(error)}`);
|
|
375
|
+
restoreStaticViewport();
|
|
321
376
|
}
|
|
322
377
|
});
|
|
323
378
|
};
|
|
@@ -432,7 +487,7 @@ export function App(props: AppProps) {
|
|
|
432
487
|
setNotice(formatContextRetirementNotice(result));
|
|
433
488
|
return;
|
|
434
489
|
}
|
|
435
|
-
await props.sessionController.clear();
|
|
490
|
+
await props.sessionController.clear(beforeSessionCommit);
|
|
436
491
|
signal.throwIfAborted();
|
|
437
492
|
const sessionId = props.sessionController.getBinding().sessionId;
|
|
438
493
|
setGitBranchRefresh((current) => current + 1);
|
|
@@ -450,10 +505,14 @@ export function App(props: AppProps) {
|
|
|
450
505
|
): PromptSubmissionOutcome | Promise<PromptSubmissionOutcome> => {
|
|
451
506
|
const { userMessage } = submission;
|
|
452
507
|
const trimmed = userMessage.content.trim();
|
|
508
|
+
const shouldRestoreViewport = showStatus || showSkills || showMcp;
|
|
453
509
|
setShowStatus(false);
|
|
454
510
|
setShowSkills(false);
|
|
455
511
|
setShowMcp(false);
|
|
456
512
|
setViewError(undefined);
|
|
513
|
+
if (shouldRestoreViewport) {
|
|
514
|
+
restoreStaticViewport();
|
|
515
|
+
}
|
|
457
516
|
|
|
458
517
|
if (userMessage.attachments === undefined && trimmed.startsWith("/")) {
|
|
459
518
|
try {
|
|
@@ -523,7 +582,7 @@ export function App(props: AppProps) {
|
|
|
523
582
|
if (command.type === "clear") {
|
|
524
583
|
setIsSessionOperation(true);
|
|
525
584
|
void props.sessionController
|
|
526
|
-
.clear()
|
|
585
|
+
.clear(beforeSessionCommit)
|
|
527
586
|
.then(() => {
|
|
528
587
|
const sessionId = props.sessionController.getBinding().sessionId;
|
|
529
588
|
setGitBranchRefresh((current) => current + 1);
|
|
@@ -540,7 +599,7 @@ export function App(props: AppProps) {
|
|
|
540
599
|
if (command.type === "fork") {
|
|
541
600
|
setIsSessionOperation(true);
|
|
542
601
|
void props.sessionController
|
|
543
|
-
.fork()
|
|
602
|
+
.fork(beforeSessionCommit)
|
|
544
603
|
.then((sessionId) => {
|
|
545
604
|
setGitBranchRefresh((current) => current + 1);
|
|
546
605
|
setNotice(
|
|
@@ -586,7 +645,7 @@ export function App(props: AppProps) {
|
|
|
586
645
|
const operation =
|
|
587
646
|
command.type === "resume"
|
|
588
647
|
? props.sessionController
|
|
589
|
-
.resume(command.sessionId)
|
|
648
|
+
.resume(command.sessionId, beforeSessionCommit)
|
|
590
649
|
.then(() => setNotice(`Resumed session ${command.sessionId}.`))
|
|
591
650
|
: props.sessionController
|
|
592
651
|
.delete(command.sessionId)
|
|
@@ -606,94 +665,122 @@ export function App(props: AppProps) {
|
|
|
606
665
|
};
|
|
607
666
|
|
|
608
667
|
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
|
-
|
|
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}
|
|
633
699
|
/>
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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>
|
|
645
719
|
</Box>
|
|
646
|
-
|
|
647
|
-
|
|
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}
|
|
648
744
|
<Box marginTop={1}>
|
|
649
|
-
<
|
|
745
|
+
<Footer
|
|
746
|
+
status={isCancelling ? "cancelling" : state.status}
|
|
747
|
+
workedForMs={state.workedForMs}
|
|
748
|
+
/>
|
|
650
749
|
</Box>
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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>}
|
|
655
779
|
</Box>
|
|
656
|
-
) : null}
|
|
657
|
-
<Box marginTop={1}>
|
|
658
|
-
<Footer
|
|
659
|
-
status={isCancelling ? "cancelling" : state.status}
|
|
660
|
-
workedForMs={state.workedForMs}
|
|
661
|
-
elapsedMs={runningElapsedMs}
|
|
662
|
-
/>
|
|
663
780
|
</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>
|
|
781
|
+
)}
|
|
782
|
+
</Box>
|
|
783
|
+
</AssistantMarkdownProvider>
|
|
697
784
|
);
|
|
698
785
|
}
|
|
699
786
|
|
|
@@ -701,42 +788,6 @@ function errorMessage(error: unknown): string {
|
|
|
701
788
|
return error instanceof Error ? error.message : String(error);
|
|
702
789
|
}
|
|
703
790
|
|
|
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
791
|
export function formatContextCompactionNotice(result: ContextCompactionResult): string {
|
|
741
792
|
if (result.status === "unchanged") {
|
|
742
793
|
if (result.outcome === "below_target") {
|
|
@@ -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) => {
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { StatusMessage } from "@inkjs/ui";
|
|
2
|
-
import { Text } from "ink";
|
|
1
|
+
import { Spinner, StatusMessage } from "@inkjs/ui";
|
|
3
2
|
|
|
4
3
|
export type FooterProps = {
|
|
5
4
|
status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
|
|
6
5
|
workedForMs?: number;
|
|
7
|
-
elapsedMs?: number;
|
|
8
6
|
};
|
|
9
7
|
|
|
10
8
|
export function Footer(props: FooterProps) {
|
|
@@ -25,11 +23,7 @@ export function Footer(props: FooterProps) {
|
|
|
25
23
|
}
|
|
26
24
|
|
|
27
25
|
if (props.status === "running") {
|
|
28
|
-
|
|
29
|
-
return <Text color="yellow">• Running</Text>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return <Text color="yellow">{`• Running ${formatDuration(props.elapsedMs)}`}</Text>;
|
|
26
|
+
return <Spinner label="Running" />;
|
|
33
27
|
}
|
|
34
28
|
|
|
35
29
|
if (props.status === "cancelling") {
|
|
@@ -20,7 +20,7 @@ export type MemoryBrowserProps = {
|
|
|
20
20
|
|
|
21
21
|
export function MemoryBrowser(props: MemoryBrowserProps) {
|
|
22
22
|
const windowSize = useWindowSize();
|
|
23
|
-
const rows = Math.max(4, props.viewportRows ?? windowSize.rows);
|
|
23
|
+
const rows = Math.max(4, props.viewportRows ?? windowSize.rows - 1);
|
|
24
24
|
const columns = Math.max(20, props.viewportColumns ?? windowSize.columns);
|
|
25
25
|
const bodyRows = Math.max(1, rows - BROWSER_CHROME_ROWS);
|
|
26
26
|
const contentRef = useRef<DOMElement>(null);
|
|
@@ -10,6 +10,7 @@ export type ResumeSessionPickerProps = {
|
|
|
10
10
|
isResuming?: boolean;
|
|
11
11
|
error?: string;
|
|
12
12
|
now?: Date;
|
|
13
|
+
viewportRows?: number;
|
|
13
14
|
visibleItemCount?: number;
|
|
14
15
|
onCancel: () => void;
|
|
15
16
|
onSelect: (session: SessionSummary) => void;
|
|
@@ -29,7 +30,8 @@ export function ResumeSessionPicker(props: ResumeSessionPickerProps) {
|
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
32
|
-
const
|
|
33
|
+
const windowSize = useWindowSize();
|
|
34
|
+
const rows = props.viewportRows ?? windowSize.rows - 1;
|
|
33
35
|
const visibleItemCount = Math.min(
|
|
34
36
|
props.sessions.length,
|
|
35
37
|
Math.max(
|
|
@@ -6,27 +6,25 @@ import { BashResultView } from "./bash-result-view";
|
|
|
6
6
|
import { DiffView } from "./diff-view";
|
|
7
7
|
|
|
8
8
|
export type TimelineProps = {
|
|
9
|
-
items: TimelineItem[];
|
|
9
|
+
items: readonly TimelineItem[];
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
export function Timeline(props: TimelineProps) {
|
|
13
13
|
return (
|
|
14
14
|
<Box flexDirection="column">
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
) : (
|
|
19
|
-
props.items.map((item) => renderTimelineItem(item))
|
|
20
|
-
)}
|
|
15
|
+
{props.items.map((item) => (
|
|
16
|
+
<TimelineRow key={item.id} item={item} />
|
|
17
|
+
))}
|
|
21
18
|
</Box>
|
|
22
19
|
);
|
|
23
20
|
}
|
|
24
21
|
|
|
25
|
-
function
|
|
22
|
+
export function TimelineRow(props: { item: TimelineItem }) {
|
|
23
|
+
const { item } = props;
|
|
26
24
|
if (item.label !== undefined) {
|
|
27
25
|
if (item.label === "assistant") {
|
|
28
26
|
return (
|
|
29
|
-
<Fragment
|
|
27
|
+
<Fragment>
|
|
30
28
|
<Text color="gray">- {item.label}</Text>
|
|
31
29
|
<AssistantMarkdown text={item.text} />
|
|
32
30
|
</Fragment>
|
|
@@ -34,7 +32,7 @@ function renderTimelineItem(item: TimelineItem) {
|
|
|
34
32
|
}
|
|
35
33
|
|
|
36
34
|
return (
|
|
37
|
-
<Fragment
|
|
35
|
+
<Fragment>
|
|
38
36
|
<Text color="gray">- {item.label}</Text>
|
|
39
37
|
{item.userPrompt === undefined ? (
|
|
40
38
|
<Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
|
|
@@ -48,7 +46,7 @@ function renderTimelineItem(item: TimelineItem) {
|
|
|
48
46
|
}
|
|
49
47
|
|
|
50
48
|
return (
|
|
51
|
-
<Fragment
|
|
49
|
+
<Fragment>
|
|
52
50
|
<Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
|
|
53
51
|
{renderItemBash(item)}
|
|
54
52
|
{renderItemDiff(item)}
|
package/src/tui/event-store.ts
CHANGED
|
@@ -393,6 +393,19 @@ function formatSurfaceComponent(component: string): string {
|
|
|
393
393
|
|
|
394
394
|
export const applyAgentEvent = reduceTuiProjection;
|
|
395
395
|
|
|
396
|
+
export function timelineStreamItems(state: TuiProjectionState): TimelineItem[] {
|
|
397
|
+
return [
|
|
398
|
+
...state.notices,
|
|
399
|
+
...state.recentTurns.flatMap((turn) => turn.items),
|
|
400
|
+
...(state.activeTurn?.items ?? []),
|
|
401
|
+
];
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
export function firstRunningIndex(items: readonly TimelineItem[]): number {
|
|
405
|
+
const index = items.findIndex((item) => item.status === "running");
|
|
406
|
+
return index === -1 ? items.length : index;
|
|
407
|
+
}
|
|
408
|
+
|
|
396
409
|
export function visibleTimelineItems(state: TuiProjectionState): TimelineItem[] {
|
|
397
410
|
const items = [...state.notices];
|
|
398
411
|
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { BundledLanguage, BundledTheme } from "shiki";
|
|
2
|
+
|
|
3
|
+
export type TuiShikiHighlighter = (code: string, language?: string) => string;
|
|
4
|
+
|
|
5
|
+
type ShikiToken = {
|
|
6
|
+
readonly content: string;
|
|
7
|
+
readonly color?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type TuiShikiTokenizer = {
|
|
11
|
+
codeToTokensBase(
|
|
12
|
+
code: string,
|
|
13
|
+
options: { readonly lang: string; readonly theme: string },
|
|
14
|
+
): readonly (readonly ShikiToken[])[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const THEME = "github-dark";
|
|
18
|
+
const HIGHLIGHTED_LANGUAGES = [
|
|
19
|
+
"typescript",
|
|
20
|
+
"javascript",
|
|
21
|
+
"tsx",
|
|
22
|
+
"jsx",
|
|
23
|
+
"json",
|
|
24
|
+
"bash",
|
|
25
|
+
"shellscript",
|
|
26
|
+
"python",
|
|
27
|
+
"markdown",
|
|
28
|
+
"html",
|
|
29
|
+
"css",
|
|
30
|
+
"yaml",
|
|
31
|
+
"diff",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
let preparation: Promise<void> | undefined;
|
|
35
|
+
let highlighter: TuiShikiHighlighter | undefined;
|
|
36
|
+
|
|
37
|
+
export function prepareShikiHighlighter(): Promise<void> {
|
|
38
|
+
preparation ??= createTuiShikiHighlighter(async () => {
|
|
39
|
+
const { createHighlighter } = await import("shiki");
|
|
40
|
+
const tokenizer = await createHighlighter({
|
|
41
|
+
themes: [THEME],
|
|
42
|
+
langs: [...HIGHLIGHTED_LANGUAGES],
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
codeToTokensBase: (code, options) =>
|
|
46
|
+
tokenizer.codeToTokensBase(code, {
|
|
47
|
+
lang: options.lang as BundledLanguage,
|
|
48
|
+
theme: options.theme as BundledTheme,
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}).then((prepared) => {
|
|
52
|
+
highlighter = prepared;
|
|
53
|
+
});
|
|
54
|
+
return preparation;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getPreparedShikiHighlighter(): TuiShikiHighlighter | undefined {
|
|
58
|
+
return highlighter;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function createTuiShikiHighlighter(
|
|
62
|
+
createTokenizer: () => Promise<TuiShikiTokenizer>,
|
|
63
|
+
): Promise<TuiShikiHighlighter | undefined> {
|
|
64
|
+
try {
|
|
65
|
+
const tokenizer = await createTokenizer();
|
|
66
|
+
return (code, language) => {
|
|
67
|
+
if (language === undefined || language === "") {
|
|
68
|
+
return code;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
return tokenizer
|
|
72
|
+
.codeToTokensBase(code, { lang: language, theme: THEME })
|
|
73
|
+
.map((line) =>
|
|
74
|
+
line
|
|
75
|
+
.map((token) => {
|
|
76
|
+
const ansi = tokenColorToAnsi(token.color);
|
|
77
|
+
return ansi === undefined
|
|
78
|
+
? token.content
|
|
79
|
+
: `${ansi}${token.content}\u001b[39m`;
|
|
80
|
+
})
|
|
81
|
+
.join(""),
|
|
82
|
+
)
|
|
83
|
+
.join("\n");
|
|
84
|
+
} catch {
|
|
85
|
+
return code;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function tokenColorToAnsi(color: string | undefined): string | undefined {
|
|
94
|
+
if (color === undefined || !color.startsWith("#") || color.length < 7) {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
const red = Number.parseInt(color.slice(1, 3), 16);
|
|
98
|
+
const green = Number.parseInt(color.slice(3, 5), 16);
|
|
99
|
+
const blue = Number.parseInt(color.slice(5, 7), 16);
|
|
100
|
+
if ([red, green, blue].some((component) => Number.isNaN(component))) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
return `\u001b[38;2;${red};${green};${blue}m`;
|
|
104
|
+
}
|
|
@@ -2,7 +2,11 @@ import type { EventSink } from "../events/event-sink";
|
|
|
2
2
|
import type { AgentEvent } from "../events/types";
|
|
3
3
|
import {
|
|
4
4
|
createInitialTuiProjectionState,
|
|
5
|
+
firstRunningIndex,
|
|
5
6
|
reduceTuiProjection,
|
|
7
|
+
timelineStreamItems,
|
|
8
|
+
visibleTimelineItems,
|
|
9
|
+
type TimelineItem,
|
|
6
10
|
type TuiProjectionState,
|
|
7
11
|
} from "./event-store";
|
|
8
12
|
import {
|
|
@@ -19,11 +23,18 @@ export type TuiProjectionStoreInput = {
|
|
|
19
23
|
initialSnapshot?: TuiProjectionState;
|
|
20
24
|
};
|
|
21
25
|
|
|
26
|
+
export type TuiTimelineLog = Readonly<{
|
|
27
|
+
committed: readonly TimelineItem[];
|
|
28
|
+
live: readonly TimelineItem[];
|
|
29
|
+
}>;
|
|
30
|
+
|
|
22
31
|
export class TuiProjectionStore implements EventSink {
|
|
23
32
|
readonly name = "tui-projection-store";
|
|
24
33
|
private readonly listeners = new Set<() => void>();
|
|
25
34
|
private readonly policy: TuiProjectionPolicy;
|
|
35
|
+
private readonly printed = new Set<string>();
|
|
26
36
|
private snapshot: TuiProjectionState;
|
|
37
|
+
private log: TuiTimelineLog = { committed: [], live: [] };
|
|
27
38
|
|
|
28
39
|
constructor(input: TuiProjectionStoreInput) {
|
|
29
40
|
this.policy = validateTuiProjectionPolicy(
|
|
@@ -33,10 +44,15 @@ export class TuiProjectionStore implements EventSink {
|
|
|
33
44
|
input.initialSnapshot === undefined
|
|
34
45
|
? createInitialTuiProjectionState(input)
|
|
35
46
|
: validateInitialSnapshot(input, input.initialSnapshot, this.policy);
|
|
47
|
+
if (input.initialSnapshot !== undefined) {
|
|
48
|
+
this.refreshLog(visibleTimelineItems(this.snapshot));
|
|
49
|
+
}
|
|
36
50
|
}
|
|
37
51
|
|
|
38
52
|
readonly getSnapshot = (): TuiProjectionState => this.snapshot;
|
|
39
53
|
|
|
54
|
+
readonly getLogSnapshot = (): TuiTimelineLog => this.log;
|
|
55
|
+
|
|
40
56
|
readonly subscribe = (listener: () => void): (() => void) => {
|
|
41
57
|
this.listeners.add(listener);
|
|
42
58
|
return () => {
|
|
@@ -51,6 +67,7 @@ export class TuiProjectionStore implements EventSink {
|
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
this.snapshot = next;
|
|
70
|
+
this.refreshLog();
|
|
54
71
|
for (const listener of this.listeners) {
|
|
55
72
|
listener();
|
|
56
73
|
}
|
|
@@ -73,10 +90,26 @@ export class TuiProjectionStore implements EventSink {
|
|
|
73
90
|
snapshot,
|
|
74
91
|
this.policy,
|
|
75
92
|
);
|
|
93
|
+
this.refreshLog(visibleTimelineItems(this.snapshot));
|
|
76
94
|
for (const listener of this.listeners) {
|
|
77
95
|
listener();
|
|
78
96
|
}
|
|
79
97
|
}
|
|
98
|
+
|
|
99
|
+
private refreshLog(stream = timelineStreamItems(this.snapshot)): void {
|
|
100
|
+
const settledEnd = firstRunningIndex(stream);
|
|
101
|
+
const pending = stream
|
|
102
|
+
.slice(0, settledEnd)
|
|
103
|
+
.filter((item) => !this.printed.has(item.id));
|
|
104
|
+
for (const item of pending) {
|
|
105
|
+
this.printed.add(item.id);
|
|
106
|
+
}
|
|
107
|
+
this.log = {
|
|
108
|
+
committed:
|
|
109
|
+
pending.length === 0 ? this.log.committed : [...this.log.committed, ...pending],
|
|
110
|
+
live: stream.slice(settledEnd),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
80
113
|
}
|
|
81
114
|
|
|
82
115
|
function validateInitialSnapshot(
|
|
@@ -47,11 +47,11 @@ export type TuiSessionController = {
|
|
|
47
47
|
listSessions: () => Promise<readonly SessionSummary[]>;
|
|
48
48
|
compact: () => Promise<ContextCompactionResult>;
|
|
49
49
|
retire: () => Promise<ContextRetirementResult>;
|
|
50
|
-
fork: () => Promise<SessionId>;
|
|
51
|
-
clear: () => Promise<void>;
|
|
52
|
-
resume: (sessionId: SessionId) => Promise<void>;
|
|
50
|
+
fork: (beforeCommit?: () => void) => Promise<SessionId>;
|
|
51
|
+
clear: (beforeCommit?: () => void) => Promise<void>;
|
|
52
|
+
resume: (sessionId: SessionId, beforeCommit?: () => void) => Promise<void>;
|
|
53
53
|
delete: (sessionId: SessionId) => Promise<void>;
|
|
54
|
-
switchModel: (profile: ModelProfile) => Promise<void>;
|
|
54
|
+
switchModel: (profile: ModelProfile, beforeCommit?: () => void) => Promise<void>;
|
|
55
55
|
};
|
|
56
56
|
|
|
57
57
|
export type ManagedTuiSessionBinding = TuiSessionBinding & {
|
|
@@ -100,7 +100,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
100
100
|
return this.serialize(() => this.binding.runtimeSession.retireContext());
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
fork(): Promise<SessionId> {
|
|
103
|
+
fork(beforeCommit?: () => void): Promise<SessionId> {
|
|
104
104
|
return this.serialize(async () => {
|
|
105
105
|
const targetSessionId = createUuidV7() as SessionId;
|
|
106
106
|
await this.replaceSession(
|
|
@@ -109,21 +109,23 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
109
109
|
await current.runtimeSession.cloneSession(targetSessionId);
|
|
110
110
|
return this.openSession(targetSessionId);
|
|
111
111
|
},
|
|
112
|
+
beforeCommit,
|
|
112
113
|
);
|
|
113
114
|
return targetSessionId;
|
|
114
115
|
});
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
clear(): Promise<void> {
|
|
118
|
+
clear(beforeCommit?: () => void): Promise<void> {
|
|
118
119
|
return this.serialize(() =>
|
|
119
120
|
this.replaceSession(
|
|
120
121
|
"Cannot clear the session while a turn, context operation, or background task is active.",
|
|
121
122
|
(current) => this.createFreshSession(current),
|
|
123
|
+
beforeCommit,
|
|
122
124
|
),
|
|
123
125
|
);
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
resume(sessionId: SessionId): Promise<void> {
|
|
128
|
+
resume(sessionId: SessionId, beforeCommit?: () => void): Promise<void> {
|
|
127
129
|
return this.serialize(async () => {
|
|
128
130
|
if (sessionId === this.binding.sessionId) {
|
|
129
131
|
throw new Error(`Session ${sessionId} is already current.`);
|
|
@@ -131,6 +133,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
131
133
|
await this.replaceSession(
|
|
132
134
|
"Cannot switch sessions while a turn or background task is active.",
|
|
133
135
|
() => this.openSession(sessionId),
|
|
136
|
+
beforeCommit,
|
|
134
137
|
);
|
|
135
138
|
});
|
|
136
139
|
}
|
|
@@ -139,11 +142,12 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
139
142
|
return this.serialize(() => this.catalog.delete(sessionId, this.binding.sessionId));
|
|
140
143
|
}
|
|
141
144
|
|
|
142
|
-
switchModel(profile: ModelProfile): Promise<void> {
|
|
145
|
+
switchModel(profile: ModelProfile, beforeCommit?: () => void): Promise<void> {
|
|
143
146
|
return this.serialize(() =>
|
|
144
147
|
this.replaceSession(
|
|
145
148
|
"Cannot switch models while a turn or background task is active.",
|
|
146
149
|
() => this.createSessionWithProfile(profile),
|
|
150
|
+
beforeCommit,
|
|
147
151
|
),
|
|
148
152
|
);
|
|
149
153
|
}
|
|
@@ -157,6 +161,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
157
161
|
createTarget: (
|
|
158
162
|
current: ManagedTuiSessionBinding,
|
|
159
163
|
) => Promise<ManagedTuiSessionBinding>,
|
|
164
|
+
beforeCommit?: () => void,
|
|
160
165
|
): Promise<void> {
|
|
161
166
|
const current = this.binding;
|
|
162
167
|
if (!current.runtimeSession.canSwitchSession()) {
|
|
@@ -172,6 +177,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
|
|
|
172
177
|
.catch(() => undefined);
|
|
173
178
|
throw error;
|
|
174
179
|
}
|
|
180
|
+
beforeCommit?.();
|
|
175
181
|
this.binding = target;
|
|
176
182
|
for (const listener of this.listeners) {
|
|
177
183
|
listener();
|