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.
Files changed (52) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +15 -7
  3. package/package.json +8 -7
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/runner-dependencies.ts +4 -1
  14. package/src/cli/tui-runner.tsx +17 -2
  15. package/src/events/bash-result-detail.ts +13 -6
  16. package/src/events/observation-text-log.ts +26 -1
  17. package/src/events/stdout-event-printer.ts +18 -2
  18. package/src/events/types.ts +14 -2
  19. package/src/model/fake-model-client.ts +177 -0
  20. package/src/model/model-client.ts +3 -0
  21. package/src/model/openai-chat-model-client.ts +54 -15
  22. package/src/model/openai-chat-stream.ts +95 -72
  23. package/src/observation/observation-builder.ts +54 -6
  24. package/src/session/session-catalog.ts +17 -11
  25. package/src/session/session-store.ts +2 -0
  26. package/src/tools/bash-guard.ts +131 -0
  27. package/src/tools/bash-task.ts +129 -90
  28. package/src/tools/bash.ts +75 -13
  29. package/src/tools/delete.ts +182 -0
  30. package/src/tools/edit.ts +68 -9
  31. package/src/tools/registry.ts +49 -3
  32. package/src/tools/shell-process.ts +296 -0
  33. package/src/tools/task-input.ts +229 -0
  34. package/src/tools/task-output-tool.ts +4 -1
  35. package/src/tools/terminal-screen.ts +105 -0
  36. package/src/tools/turn-undo-manager.ts +794 -0
  37. package/src/tools/types.ts +45 -0
  38. package/src/tools/write.ts +65 -14
  39. package/src/tui/app.tsx +161 -45
  40. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  41. package/src/tui/components/background-tasks.tsx +3 -2
  42. package/src/tui/components/bash-confirmation.tsx +27 -0
  43. package/src/tui/components/context-status.tsx +11 -1
  44. package/src/tui/components/footer.tsx +8 -5
  45. package/src/tui/components/prompt-input.tsx +13 -1
  46. package/src/tui/components/resume-session-picker.tsx +292 -46
  47. package/src/tui/components/timeline.tsx +10 -0
  48. package/src/tui/context-format.ts +17 -0
  49. package/src/tui/event-store.ts +76 -4
  50. package/src/tui/slash-commands.ts +28 -0
  51. package/src/tui/tui-projection-store.ts +246 -7
  52. 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: { state: TuiProjectionState }) {
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="Running" />;
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 { formatContextUsageLine } from "../context-format";
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 { useState } from "react";
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 PICKER_CHROME_ROWS = 3;
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 PickerPosition = {
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
- const visibleItemCount = Math.min(
36
- props.sessions.length,
37
- Math.max(
38
- 1,
39
- Math.floor(props.visibleItemCount ?? (rows - PICKER_CHROME_ROWS) / SESSION_ROWS),
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
- position.windowStart,
48
- position.selectedIndex,
173
+ state.windowStart,
174
+ selectedIndex,
49
175
  visibleItemCount,
50
- props.sessions.length,
176
+ displayedSessions.length,
51
177
  );
52
- const windowEnd = Math.min(props.sessions.length, windowStart + visibleItemCount);
53
- const selectedSession = props.sessions[position.selectedIndex];
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
- if (selectedSession !== undefined && isSessionSelectable(selectedSession)) {
64
- props.onSelect(selectedSession);
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 === 0) {
76
- return;
268
+ if (direction !== 0) {
269
+ dispatch({ type: "move_selection", direction });
77
270
  }
271
+ },
272
+ { isActive: props.isResuming !== true },
273
+ );
78
274
 
79
- setPosition((current) => {
80
- const selectedIndex = clamp(
81
- current.selectedIndex + direction,
82
- 0,
83
- props.sessions.length - 1,
84
- );
85
- const nextWindowStart = keepSelectionVisible(
86
- current.windowStart,
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
- : "↑/↓ or j/k to move · Enter to resume · Esc to cancel"}
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
- {props.sessions.slice(windowStart, windowEnd).map((session, offset) => (
300
+ {displayedSessions.slice(windowStart, windowEnd).map((session, offset) => (
113
301
  <SessionOption
114
302
  key={session.sessionId}
115
303
  session={session}
116
- isSelected={windowStart + offset === position.selectedIndex}
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
- ? formatWindowStatus(windowStart, windowEnd, props.sessions.length)
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<