tinker-agent 1.6.0 → 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.
@@ -5,7 +5,7 @@ export type BackgroundTasksProps = {
5
5
  tasks: readonly ShellTaskSnapshot[];
6
6
  };
7
7
 
8
- const MAX_VISIBLE_TASKS = 5;
8
+ const MAX_VISIBLE_TASKS = 2;
9
9
 
10
10
  export function BackgroundTasks(props: BackgroundTasksProps) {
11
11
  if (props.tasks.length === 0) {
@@ -26,7 +26,8 @@ export function BackgroundTasks(props: BackgroundTasksProps) {
26
26
  {visibleTasks.map((task) => (
27
27
  <Box key={task.taskId} flexDirection="column">
28
28
  <Text color={colorForStatus(task.status)}>
29
- {symbolForStatus(task.status)} {task.status} {taskDescription(task)}
29
+ {symbolForStatus(task.status)} {task.status}
30
+ {task.tty ? " tty" : ""} {taskDescription(task)}
30
31
  {taskResult(task) === undefined ? "" : ` ${taskResult(task)}`}
31
32
  </Text>
32
33
  <Text dimColor>{taskTiming(task)}</Text>
@@ -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
  }
@@ -771,7 +771,11 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
771
771
  if (input.name === "WebFetch") {
772
772
  return `WebFetch ${toolUrl(input.args) ?? ""}`.trim();
773
773
  }
774
- if (input.name === "TaskOutput" || input.name === "TaskStop") {
774
+ if (
775
+ input.name === "TaskOutput" ||
776
+ input.name === "TaskInput" ||
777
+ input.name === "TaskStop"
778
+ ) {
775
779
  return `${input.name} ${toolTaskId(input.args) ?? ""}`.trim();
776
780
  }
777
781
  if (input.name === "TaskList") {
@@ -866,6 +870,10 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
866
870
  return `${base} -> ${raw.status}, ${raw.outputLines} line${raw.outputLines === 1 ? "" : "s"}`;
867
871
  }
868
872
  return base;
873
+ case "task_input":
874
+ return raw.ok
875
+ ? `${base} -> ${raw.status}, wrote ${raw.writtenBytes} byte${raw.writtenBytes === 1 ? "" : "s"}, ${raw.screenColumns}x${raw.screenRows}`
876
+ : base;
869
877
  case "task_stop": {
870
878
  if (raw.status === undefined) {
871
879
  return base;
@@ -911,6 +919,10 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
911
919
  const detail = bashResultDetail(raw);
912
920
  return detail === undefined ? {} : { bash: detail };
913
921
  }
922
+ case "task_input": {
923
+ const detail = bashResultDetail(raw);
924
+ return detail === undefined ? {} : { bash: detail };
925
+ }
914
926
  case "read":
915
927
  case "write":
916
928
  case "edit":
@@ -954,6 +966,7 @@ function toolRawResultDiff(
954
966
  case "bash":
955
967
  case "task_list":
956
968
  case "task_output":
969
+ case "task_input":
957
970
  case "task_stop":
958
971
  case "web_search":
959
972
  case "web_fetch":
@@ -96,7 +96,7 @@ export class DefaultTuiSessionController implements TuiSessionController {
96
96
  };
97
97
 
98
98
  listSessions(): Promise<readonly SessionSummary[]> {
99
- return this.catalog.list(this.binding.sessionId);
99
+ return this.catalog.listAll(this.binding.sessionId);
100
100
  }
101
101
 
102
102
  compact(): Promise<ContextCompactionResult> {