tinker-agent 1.5.1 → 1.7.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 +52 -1
- package/README.md +15 -7
- package/package.json +8 -7
- 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/runner-dependencies.ts +4 -1
- package/src/cli/tui-runner.tsx +17 -2
- package/src/events/bash-result-detail.ts +13 -6
- package/src/events/observation-text-log.ts +26 -1
- package/src/events/stdout-event-printer.ts +18 -2
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +177 -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 +54 -6
- package/src/session/session-catalog.ts +17 -11
- package/src/session/session-store.ts +2 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash-task.ts +129 -90
- package/src/tools/bash.ts +75 -13
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +49 -3
- package/src/tools/shell-process.ts +296 -0
- package/src/tools/task-input.ts +229 -0
- package/src/tools/task-output-tool.ts +4 -1
- package/src/tools/terminal-screen.ts +105 -0
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +45 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +161 -45
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/background-tasks.tsx +3 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/footer.tsx +8 -5
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +292 -46
- package/src/tui/components/timeline.tsx +10 -0
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +76 -4
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +246 -7
- package/src/tui/tui-session-controller.ts +19 -1
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
|
+
import type { BashGuardSnapshot } from "../../agent/runtime-session";
|
|
2
3
|
import type { TuiProjectionState } from "../event-store";
|
|
3
4
|
import { formatContextUsageLine, formatTokenCount } from "../context-format";
|
|
4
5
|
|
|
5
|
-
export function ContextStatus(props: {
|
|
6
|
+
export function ContextStatus(props: {
|
|
7
|
+
state: TuiProjectionState;
|
|
8
|
+
bashGuard: BashGuardSnapshot;
|
|
9
|
+
}) {
|
|
6
10
|
const usage = props.state.contextUsage;
|
|
7
11
|
const profile = props.state.contextProfile;
|
|
8
12
|
const budget = props.state.contextBudget;
|
|
@@ -14,6 +18,12 @@ export function ContextStatus(props: { state: TuiProjectionState }) {
|
|
|
14
18
|
<Text> model: {props.state.modelName}</Text>
|
|
15
19
|
<Text> workspace: {props.state.workspaceRoot}</Text>
|
|
16
20
|
<Text> </Text>
|
|
21
|
+
<Text bold>Bash guard</Text>
|
|
22
|
+
<Text>
|
|
23
|
+
{" mode: "}
|
|
24
|
+
{props.bashGuard.mode} (source: {props.bashGuard.source})
|
|
25
|
+
</Text>
|
|
26
|
+
<Text> </Text>
|
|
17
27
|
<Text bold>Context</Text>
|
|
18
28
|
{usage === undefined || profile === undefined || budget === undefined ? (
|
|
19
29
|
<Text color="yellow"> measurement unavailable</Text>
|
|
@@ -3,9 +3,11 @@ import { Spinner, StatusMessage } from "@inkjs/ui";
|
|
|
3
3
|
export type FooterProps = {
|
|
4
4
|
status: "idle" | "running" | "cancelling" | "cancelled" | "done" | "failed";
|
|
5
5
|
workedForMs?: number;
|
|
6
|
+
yolo?: boolean;
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
export function Footer(props: FooterProps) {
|
|
10
|
+
const suffix = props.yolo ? " · yolo" : "";
|
|
9
11
|
if (props.status === "done") {
|
|
10
12
|
if (props.workedForMs === undefined) {
|
|
11
13
|
throw new Error("Done footer requires workedForMs");
|
|
@@ -14,27 +16,28 @@ export function Footer(props: FooterProps) {
|
|
|
14
16
|
return (
|
|
15
17
|
<StatusMessage variant="success">
|
|
16
18
|
Worked for {formatDuration(props.workedForMs)}
|
|
19
|
+
{suffix}
|
|
17
20
|
</StatusMessage>
|
|
18
21
|
);
|
|
19
22
|
}
|
|
20
23
|
|
|
21
24
|
if (props.status === "failed") {
|
|
22
|
-
return <StatusMessage variant="error">failed</StatusMessage>;
|
|
25
|
+
return <StatusMessage variant="error">failed{suffix}</StatusMessage>;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
if (props.status === "running") {
|
|
26
|
-
return <Spinner label=
|
|
29
|
+
return <Spinner label={`Running${suffix}`} />;
|
|
27
30
|
}
|
|
28
31
|
|
|
29
32
|
if (props.status === "cancelling") {
|
|
30
|
-
return <StatusMessage variant="info">cancelling</StatusMessage>;
|
|
33
|
+
return <StatusMessage variant="info">cancelling{suffix}</StatusMessage>;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
if (props.status === "cancelled") {
|
|
34
|
-
return <StatusMessage variant="info">cancelled</StatusMessage>;
|
|
37
|
+
return <StatusMessage variant="info">cancelled{suffix}</StatusMessage>;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
return <StatusMessage variant="info">idle</StatusMessage>;
|
|
40
|
+
return <StatusMessage variant="info">idle{suffix}</StatusMessage>;
|
|
38
41
|
}
|
|
39
42
|
|
|
40
43
|
function formatDuration(durationMs: number): string {
|
|
@@ -9,7 +9,10 @@ import { ImageNotRecognizedError } from "../../image/image-probe";
|
|
|
9
9
|
import type { ImportedImageAsset } from "../../image/image-asset-store";
|
|
10
10
|
import type { ImageAssetRef } from "../../image/image-types";
|
|
11
11
|
import { runtimeIdFactory, type RuntimeIdFactory } from "../../ids/runtime-id";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
formatContextUsageLine,
|
|
14
|
+
formatLatestProviderCacheRate,
|
|
15
|
+
} from "../context-format";
|
|
13
16
|
import {
|
|
14
17
|
type FileMentionMatch,
|
|
15
18
|
findFileMention,
|
|
@@ -722,6 +725,9 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
722
725
|
const showFileSuggestions = filePopupActive;
|
|
723
726
|
const showSlashSuggestions = suggestions.length > 0 && !locked;
|
|
724
727
|
const showSuggestions = showFileSuggestions || showSlashSuggestions;
|
|
728
|
+
const cacheRate = formatLatestProviderCacheRate(
|
|
729
|
+
props.contextUsage?.lastProviderUsage,
|
|
730
|
+
);
|
|
725
731
|
return (
|
|
726
732
|
<Box flexDirection="column">
|
|
727
733
|
<Box width="100%" borderStyle="single" borderLeft={false} borderRight={false}>
|
|
@@ -745,6 +751,12 @@ export function PromptInput(props: PromptInputProps) {
|
|
|
745
751
|
</Text>
|
|
746
752
|
</>
|
|
747
753
|
)}
|
|
754
|
+
{cacheRate === undefined ? null : (
|
|
755
|
+
<>
|
|
756
|
+
<Text dimColor> · </Text>
|
|
757
|
+
<Text dimColor>{cacheRate}</Text>
|
|
758
|
+
</>
|
|
759
|
+
)}
|
|
748
760
|
</Box>
|
|
749
761
|
)}
|
|
750
762
|
{showFileSuggestions ? (
|
|
@@ -1,9 +1,24 @@
|
|
|
1
|
-
import { Box, Text, useInput, useWindowSize } from "ink";
|
|
2
|
-
import {
|
|
1
|
+
import { Box, Text, useInput, usePaste, useWindowSize } from "ink";
|
|
2
|
+
import { useReducer, useRef } from "react";
|
|
3
3
|
import type { SessionSummary } from "../../session/session-catalog";
|
|
4
|
+
import {
|
|
5
|
+
backspace,
|
|
6
|
+
createLineEditorState,
|
|
7
|
+
deleteForward,
|
|
8
|
+
deleteToLineStart,
|
|
9
|
+
insert,
|
|
10
|
+
moveLeft,
|
|
11
|
+
moveRight,
|
|
12
|
+
moveToLineEnd,
|
|
13
|
+
moveToLineStart,
|
|
14
|
+
splitAtCursor,
|
|
15
|
+
type LineEditorState,
|
|
16
|
+
} from "../line-editor";
|
|
4
17
|
|
|
5
18
|
const SESSION_ROWS = 3;
|
|
6
|
-
const
|
|
19
|
+
const BROWSE_CHROME_ROWS = 3;
|
|
20
|
+
const SEARCH_CHROME_ROWS = 4;
|
|
21
|
+
const MAX_DISPLAYED_SESSIONS = 20;
|
|
7
22
|
|
|
8
23
|
export type ResumeSessionPickerProps = {
|
|
9
24
|
sessions: readonly SessionSummary[];
|
|
@@ -16,11 +31,20 @@ export type ResumeSessionPickerProps = {
|
|
|
16
31
|
onSelect: (session: SessionSummary) => void;
|
|
17
32
|
};
|
|
18
33
|
|
|
19
|
-
type
|
|
34
|
+
type PickerState = {
|
|
35
|
+
mode: "browse" | "search";
|
|
36
|
+
editor: LineEditorState;
|
|
20
37
|
selectedIndex: number;
|
|
21
38
|
windowStart: number;
|
|
22
39
|
};
|
|
23
40
|
|
|
41
|
+
type PickerAction =
|
|
42
|
+
| { type: "enter_search" }
|
|
43
|
+
| { type: "clear_search" }
|
|
44
|
+
| { type: "move_selection"; direction: -1 | 1 }
|
|
45
|
+
| { type: "move_editor_cursor"; update: (editor: LineEditorState) => LineEditorState }
|
|
46
|
+
| { type: "change_query"; update: (editor: LineEditorState) => LineEditorState };
|
|
47
|
+
|
|
24
48
|
export function ResumeSessionPicker(props: ResumeSessionPickerProps) {
|
|
25
49
|
if (props.sessions.length === 0) {
|
|
26
50
|
throw new Error("ResumeSessionPicker requires at least one session.");
|
|
@@ -32,37 +56,206 @@ export function ResumeSessionPicker(props: ResumeSessionPickerProps) {
|
|
|
32
56
|
function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
33
57
|
const windowSize = useWindowSize();
|
|
34
58
|
const rows = props.viewportRows ?? windowSize.rows - 1;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
|
|
60
|
+
const displayedFor = (value: string): readonly SessionSummary[] => {
|
|
61
|
+
const nextCandidates =
|
|
62
|
+
normalizeSearchText(value) === ""
|
|
63
|
+
? props.sessions
|
|
64
|
+
: props.sessions.filter((session) => matchesSessionPreview(session, value));
|
|
65
|
+
return nextCandidates.slice(0, MAX_DISPLAYED_SESSIONS);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const visibleCountFor = (mode: PickerState["mode"], displayedCount: number) => {
|
|
69
|
+
const chromeRows = mode === "search" ? SEARCH_CHROME_ROWS : BROWSE_CHROME_ROWS;
|
|
70
|
+
return Math.min(
|
|
71
|
+
Math.max(displayedCount, 1),
|
|
72
|
+
Math.max(
|
|
73
|
+
1,
|
|
74
|
+
Math.floor(props.visibleItemCount ?? (rows - chromeRows) / SESSION_ROWS),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const reduce = (state: PickerState, action: PickerAction): PickerState => {
|
|
80
|
+
switch (action.type) {
|
|
81
|
+
case "enter_search": {
|
|
82
|
+
if (state.mode === "search") {
|
|
83
|
+
return state;
|
|
84
|
+
}
|
|
85
|
+
return { ...state, mode: "search" };
|
|
86
|
+
}
|
|
87
|
+
case "clear_search": {
|
|
88
|
+
const displayed = displayedFor("");
|
|
89
|
+
return {
|
|
90
|
+
mode: "browse",
|
|
91
|
+
editor: createLineEditorState(),
|
|
92
|
+
selectedIndex: initialSelectedIndex(displayed),
|
|
93
|
+
windowStart: 0,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
case "move_selection": {
|
|
97
|
+
const query = state.mode === "search" ? state.editor.value : "";
|
|
98
|
+
const displayed = displayedFor(query);
|
|
99
|
+
if (displayed.length === 0) {
|
|
100
|
+
return state;
|
|
101
|
+
}
|
|
102
|
+
const selectedIndex = clamp(
|
|
103
|
+
state.selectedIndex + action.direction,
|
|
104
|
+
0,
|
|
105
|
+
displayed.length - 1,
|
|
106
|
+
);
|
|
107
|
+
const windowStart = keepSelectionVisible(
|
|
108
|
+
state.windowStart,
|
|
109
|
+
selectedIndex,
|
|
110
|
+
visibleCountFor(state.mode, displayed.length),
|
|
111
|
+
displayed.length,
|
|
112
|
+
);
|
|
113
|
+
if (
|
|
114
|
+
selectedIndex === state.selectedIndex &&
|
|
115
|
+
windowStart === state.windowStart
|
|
116
|
+
) {
|
|
117
|
+
return state;
|
|
118
|
+
}
|
|
119
|
+
return { ...state, selectedIndex, windowStart };
|
|
120
|
+
}
|
|
121
|
+
case "move_editor_cursor": {
|
|
122
|
+
const editor = action.update(state.editor);
|
|
123
|
+
return editor === state.editor ? state : { ...state, editor };
|
|
124
|
+
}
|
|
125
|
+
case "change_query": {
|
|
126
|
+
const editor = action.update(state.editor);
|
|
127
|
+
if (editor === state.editor) {
|
|
128
|
+
return state;
|
|
129
|
+
}
|
|
130
|
+
const displayed = displayedFor(editor.value);
|
|
131
|
+
return {
|
|
132
|
+
mode: "search",
|
|
133
|
+
editor,
|
|
134
|
+
selectedIndex: displayed.length === 0 ? 0 : initialSelectedIndex(displayed),
|
|
135
|
+
windowStart: 0,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const [state, baseDispatch] = useReducer(reduce, undefined, () => ({
|
|
142
|
+
mode: "browse" as const,
|
|
143
|
+
editor: createLineEditorState(),
|
|
144
|
+
selectedIndex: initialSelectedIndex(
|
|
145
|
+
props.sessions.slice(0, MAX_DISPLAYED_SESSIONS),
|
|
40
146
|
),
|
|
41
|
-
);
|
|
42
|
-
const [position, setPosition] = useState<PickerPosition>(() => ({
|
|
43
|
-
selectedIndex: initialSelectedIndex(props.sessions),
|
|
44
147
|
windowStart: 0,
|
|
45
148
|
}));
|
|
149
|
+
// Input events can arrive faster than React re-renders. Every state change
|
|
150
|
+
// goes through dispatch, which keeps this ref in sync with the exact action
|
|
151
|
+
// fold, so handlers always read and reduce the latest state instead of a
|
|
152
|
+
// stale render closure.
|
|
153
|
+
const stateRef = useRef(state);
|
|
154
|
+
const dispatch = (action: PickerAction) => {
|
|
155
|
+
stateRef.current = reduce(stateRef.current, action);
|
|
156
|
+
baseDispatch(action);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const query = state.mode === "search" ? state.editor.value : "";
|
|
160
|
+
const searching = normalizeSearchText(query) !== "";
|
|
161
|
+
const candidates = searching
|
|
162
|
+
? props.sessions.filter((session) => matchesSessionPreview(session, query))
|
|
163
|
+
: props.sessions;
|
|
164
|
+
const matchCount = candidates.length;
|
|
165
|
+
const displayedSessions = candidates.slice(0, MAX_DISPLAYED_SESSIONS);
|
|
166
|
+
const visibleItemCount = visibleCountFor(state.mode, displayedSessions.length);
|
|
167
|
+
const selectedIndex = clamp(
|
|
168
|
+
state.selectedIndex,
|
|
169
|
+
0,
|
|
170
|
+
Math.max(displayedSessions.length - 1, 0),
|
|
171
|
+
);
|
|
46
172
|
const windowStart = keepSelectionVisible(
|
|
47
|
-
|
|
48
|
-
|
|
173
|
+
state.windowStart,
|
|
174
|
+
selectedIndex,
|
|
49
175
|
visibleItemCount,
|
|
50
|
-
|
|
176
|
+
displayedSessions.length,
|
|
51
177
|
);
|
|
52
|
-
const windowEnd = Math.min(
|
|
53
|
-
const selectedSession =
|
|
178
|
+
const windowEnd = Math.min(displayedSessions.length, windowStart + visibleItemCount);
|
|
179
|
+
const selectedSession = displayedSessions[selectedIndex];
|
|
180
|
+
|
|
181
|
+
const selectCurrent = () => {
|
|
182
|
+
const current = stateRef.current;
|
|
183
|
+
const currentQuery = current.mode === "search" ? current.editor.value : "";
|
|
184
|
+
const displayed = displayedFor(currentQuery);
|
|
185
|
+
const session =
|
|
186
|
+
displayed[clamp(current.selectedIndex, 0, Math.max(displayed.length - 1, 0))];
|
|
187
|
+
if (session !== undefined && isSessionSelectable(session)) {
|
|
188
|
+
props.onSelect(session);
|
|
189
|
+
}
|
|
190
|
+
};
|
|
54
191
|
|
|
55
192
|
useInput(
|
|
56
193
|
(input, key) => {
|
|
194
|
+
if (stateRef.current.mode === "search") {
|
|
195
|
+
if (key.escape) {
|
|
196
|
+
dispatch({ type: "clear_search" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (key.return) {
|
|
200
|
+
selectCurrent();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (key.upArrow) {
|
|
204
|
+
dispatch({ type: "move_selection", direction: -1 });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (key.downArrow) {
|
|
208
|
+
dispatch({ type: "move_selection", direction: 1 });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (key.leftArrow) {
|
|
212
|
+
dispatch({ type: "move_editor_cursor", update: moveLeft });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (key.rightArrow) {
|
|
216
|
+
dispatch({ type: "move_editor_cursor", update: moveRight });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (key.backspace) {
|
|
220
|
+
dispatch({ type: "change_query", update: backspace });
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (key.delete) {
|
|
224
|
+
dispatch({ type: "change_query", update: deleteForward });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (key.ctrl) {
|
|
228
|
+
if (input === "a") {
|
|
229
|
+
dispatch({ type: "move_editor_cursor", update: moveToLineStart });
|
|
230
|
+
} else if (input === "e") {
|
|
231
|
+
dispatch({ type: "move_editor_cursor", update: moveToLineEnd });
|
|
232
|
+
} else if (input === "u") {
|
|
233
|
+
dispatch({ type: "change_query", update: deleteToLineStart });
|
|
234
|
+
} else if (input === "d") {
|
|
235
|
+
dispatch({ type: "change_query", update: deleteForward });
|
|
236
|
+
}
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (key.meta || key.pageUp || key.pageDown || input === "") {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
dispatch({
|
|
243
|
+
type: "change_query",
|
|
244
|
+
update: (editor) => insert(editor, normalizeQueryInput(input)),
|
|
245
|
+
});
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
57
249
|
if (key.escape) {
|
|
58
250
|
props.onCancel();
|
|
59
251
|
return;
|
|
60
252
|
}
|
|
61
|
-
|
|
62
253
|
if (key.return) {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
254
|
+
selectCurrent();
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (input === "/" && !key.ctrl && !key.meta) {
|
|
258
|
+
dispatch({ type: "enter_search" });
|
|
66
259
|
return;
|
|
67
260
|
}
|
|
68
261
|
|
|
@@ -72,29 +265,21 @@ function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
|
72
265
|
: key.downArrow || (input === "j" && !key.ctrl && !key.meta)
|
|
73
266
|
? 1
|
|
74
267
|
: 0;
|
|
75
|
-
if (direction
|
|
76
|
-
|
|
268
|
+
if (direction !== 0) {
|
|
269
|
+
dispatch({ type: "move_selection", direction });
|
|
77
270
|
}
|
|
271
|
+
},
|
|
272
|
+
{ isActive: props.isResuming !== true },
|
|
273
|
+
);
|
|
78
274
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
selectedIndex,
|
|
88
|
-
visibleItemCount,
|
|
89
|
-
props.sessions.length,
|
|
90
|
-
);
|
|
91
|
-
if (
|
|
92
|
-
selectedIndex === current.selectedIndex &&
|
|
93
|
-
nextWindowStart === current.windowStart
|
|
94
|
-
) {
|
|
95
|
-
return current;
|
|
96
|
-
}
|
|
97
|
-
return { selectedIndex, windowStart: nextWindowStart };
|
|
275
|
+
usePaste(
|
|
276
|
+
(text) => {
|
|
277
|
+
if (stateRef.current.mode !== "search") {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
dispatch({
|
|
281
|
+
type: "change_query",
|
|
282
|
+
update: (editor) => insert(editor, normalizeQueryInput(text)),
|
|
98
283
|
});
|
|
99
284
|
},
|
|
100
285
|
{ isActive: props.isResuming !== true },
|
|
@@ -104,16 +289,19 @@ function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
|
104
289
|
return (
|
|
105
290
|
<Box flexDirection="column">
|
|
106
291
|
<Text bold>Resume session</Text>
|
|
292
|
+
{state.mode === "search" ? <SearchLine editor={state.editor} /> : null}
|
|
107
293
|
<Text dimColor>
|
|
108
294
|
{props.isResuming === true
|
|
109
295
|
? `Resuming ${shortSessionId(selectedSession?.sessionId ?? "")}`
|
|
110
|
-
:
|
|
296
|
+
: state.mode === "search"
|
|
297
|
+
? "↑/↓ to move · Enter to resume · Esc to clear search"
|
|
298
|
+
: "↑/↓ or j/k to move · / to search · Enter to resume · Esc to cancel"}
|
|
111
299
|
</Text>
|
|
112
|
-
{
|
|
300
|
+
{displayedSessions.slice(windowStart, windowEnd).map((session, offset) => (
|
|
113
301
|
<SessionOption
|
|
114
302
|
key={session.sessionId}
|
|
115
303
|
session={session}
|
|
116
|
-
isSelected={windowStart + offset ===
|
|
304
|
+
isSelected={windowStart + offset === selectedIndex}
|
|
117
305
|
now={now}
|
|
118
306
|
/>
|
|
119
307
|
))}
|
|
@@ -123,13 +311,31 @@ function ResumeSessionPickerContent(props: ResumeSessionPickerProps) {
|
|
|
123
311
|
wrap="truncate-end"
|
|
124
312
|
>
|
|
125
313
|
{props.error === undefined
|
|
126
|
-
?
|
|
314
|
+
? formatFooter({
|
|
315
|
+
searching,
|
|
316
|
+
query,
|
|
317
|
+
matchCount,
|
|
318
|
+
windowStart,
|
|
319
|
+
windowEnd,
|
|
320
|
+
totalCount: props.sessions.length,
|
|
321
|
+
})
|
|
127
322
|
: `Resume failed: ${singleLine(props.error)}`}
|
|
128
323
|
</Text>
|
|
129
324
|
</Box>
|
|
130
325
|
);
|
|
131
326
|
}
|
|
132
327
|
|
|
328
|
+
function SearchLine(props: { editor: LineEditorState }) {
|
|
329
|
+
const { before, at, after } = splitAtCursor(props.editor);
|
|
330
|
+
return (
|
|
331
|
+
<Text wrap="truncate-end">
|
|
332
|
+
Search: {before}
|
|
333
|
+
<Text inverse>{at}</Text>
|
|
334
|
+
{after}
|
|
335
|
+
</Text>
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
|
|
133
339
|
export function ResumeSessionPickerLoading(props: { onCancel: () => void }) {
|
|
134
340
|
useInput((_input, key) => {
|
|
135
341
|
if (key.escape) {
|
|
@@ -145,6 +351,19 @@ export function ResumeSessionPickerLoading(props: { onCancel: () => void }) {
|
|
|
145
351
|
);
|
|
146
352
|
}
|
|
147
353
|
|
|
354
|
+
export function normalizeSearchText(value: string): string {
|
|
355
|
+
return value.normalize("NFKC").replace(/\s+/g, " ").trim().toLowerCase();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function matchesSessionPreview(session: SessionSummary, query: string): boolean {
|
|
359
|
+
const terms = normalizeSearchText(query).split(" ").filter(Boolean);
|
|
360
|
+
if (terms.length === 0) {
|
|
361
|
+
return true;
|
|
362
|
+
}
|
|
363
|
+
const preview = normalizeSearchText(session.firstUserPromptPreview ?? "");
|
|
364
|
+
return preview !== "" && terms.every((term) => preview.includes(term));
|
|
365
|
+
}
|
|
366
|
+
|
|
148
367
|
function SessionOption(props: {
|
|
149
368
|
session: SessionSummary;
|
|
150
369
|
isSelected: boolean;
|
|
@@ -255,6 +474,29 @@ function shortSessionId(sessionId: string): string {
|
|
|
255
474
|
return `${sessionId.slice(0, 8)}…`;
|
|
256
475
|
}
|
|
257
476
|
|
|
477
|
+
function formatFooter(input: {
|
|
478
|
+
searching: boolean;
|
|
479
|
+
query: string;
|
|
480
|
+
matchCount: number;
|
|
481
|
+
windowStart: number;
|
|
482
|
+
windowEnd: number;
|
|
483
|
+
totalCount: number;
|
|
484
|
+
}): string {
|
|
485
|
+
if (input.searching) {
|
|
486
|
+
if (input.matchCount === 0) {
|
|
487
|
+
return `No sessions match "${singleLine(input.query)}" · Esc to clear search`;
|
|
488
|
+
}
|
|
489
|
+
if (input.matchCount > MAX_DISPLAYED_SESSIONS) {
|
|
490
|
+
return `Showing ${input.windowStart + 1}–${input.windowEnd} / ${MAX_DISPLAYED_SESSIONS} results · ${input.matchCount} matches total`;
|
|
491
|
+
}
|
|
492
|
+
return `${input.matchCount} ${input.matchCount === 1 ? "match" : "matches"}`;
|
|
493
|
+
}
|
|
494
|
+
if (input.totalCount > MAX_DISPLAYED_SESSIONS) {
|
|
495
|
+
return `Showing ${input.windowStart + 1}–${input.windowEnd} / ${MAX_DISPLAYED_SESSIONS} recent · ${input.totalCount} sessions total`;
|
|
496
|
+
}
|
|
497
|
+
return formatWindowStatus(input.windowStart, input.windowEnd, input.totalCount);
|
|
498
|
+
}
|
|
499
|
+
|
|
258
500
|
function formatWindowStatus(start: number, end: number, total: number): string {
|
|
259
501
|
if (start === 0 && end === total) {
|
|
260
502
|
return `${total} ${total === 1 ? "session" : "sessions"}`;
|
|
@@ -262,6 +504,10 @@ function formatWindowStatus(start: number, end: number, total: number): string {
|
|
|
262
504
|
return `Showing ${start + 1}–${end} / ${total}${start > 0 ? " · ↑ more above" : ""}${end < total ? " · ↓ more below" : ""}`;
|
|
263
505
|
}
|
|
264
506
|
|
|
507
|
+
function normalizeQueryInput(value: string): string {
|
|
508
|
+
return value.replace(/\s+/g, " ");
|
|
509
|
+
}
|
|
510
|
+
|
|
265
511
|
function clamp(value: number, minimum: number, maximum: number): number {
|
|
266
512
|
return Math.min(Math.max(value, minimum), maximum);
|
|
267
513
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Box, Text } from "ink";
|
|
2
2
|
import { Fragment } from "react";
|
|
3
3
|
import type { TimelineItem } from "../event-store";
|
|
4
|
+
import type { AssistantStreamSectionItem } from "../tui-projection-store";
|
|
4
5
|
import { AssistantMarkdown } from "./assistant-markdown";
|
|
5
6
|
import { BashResultView } from "./bash-result-view";
|
|
6
7
|
import { DiffView } from "./diff-view";
|
|
@@ -54,6 +55,15 @@ export function TimelineRow(props: { item: TimelineItem }) {
|
|
|
54
55
|
);
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
export function AssistantStreamSectionRow(props: { item: AssistantStreamSectionItem }) {
|
|
59
|
+
return (
|
|
60
|
+
<Fragment>
|
|
61
|
+
{props.item.showAssistantLabel ? <Text color="gray">- assistant</Text> : null}
|
|
62
|
+
<AssistantMarkdown text={props.item.markdown} />
|
|
63
|
+
</Fragment>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
57
67
|
function renderUserPrompt(prompt: NonNullable<TimelineItem["userPrompt"]>) {
|
|
58
68
|
const chars = [...prompt.text];
|
|
59
69
|
const fragments: React.ReactNode[] = [];
|
|
@@ -1,4 +1,21 @@
|
|
|
1
1
|
import type { ContextUsageSnapshot } from "../agent/context-meter";
|
|
2
|
+
import type { ModelUsage } from "../model/model-client";
|
|
3
|
+
|
|
4
|
+
export function formatLatestProviderCacheRate(
|
|
5
|
+
usage: ModelUsage | undefined,
|
|
6
|
+
): string | undefined {
|
|
7
|
+
const hit = usage?.promptCacheHitTokens;
|
|
8
|
+
const miss = usage?.promptCacheMissTokens;
|
|
9
|
+
if (hit === undefined || miss === undefined || hit + miss === 0) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
// Floor instead of round: an append turn is never a true 100% hit, and
|
|
13
|
+
// rounding would display 99.5%+ as a misleading "cache 100%". A genuine
|
|
14
|
+
// full hit (miss === 0, e.g. an identical resent request) still floors to
|
|
15
|
+
// exactly 100. The min() guards against float rounding when miss > 0.
|
|
16
|
+
const percent = Math.min(99, Math.floor((hit / (hit + miss)) * 100));
|
|
17
|
+
return `cache ${miss === 0 ? 100 : percent}%`;
|
|
18
|
+
}
|
|
2
19
|
|
|
3
20
|
export function formatContextUsageLine(
|
|
4
21
|
usage: Pick<
|