tinker-agent 2.10.0 → 2.12.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 +47 -1
  2. package/README.md +44 -67
  3. package/package.json +4 -3
  4. package/src/agent/loop.ts +50 -13
  5. package/src/agent/runtime-provider-retry.ts +115 -0
  6. package/src/agent/runtime-session-contracts.ts +13 -29
  7. package/src/agent/runtime-session.ts +42 -67
  8. package/src/cli/config.ts +0 -29
  9. package/src/cli/model-profiles.ts +0 -80
  10. package/src/cli/public-config-contract.ts +1 -82
  11. package/src/cli/tui-runner.tsx +3 -41
  12. package/src/events/observation-text-log.ts +7 -0
  13. package/src/events/types.ts +8 -0
  14. package/src/image/abortable-file-open.ts +54 -0
  15. package/src/image/image-asset-store.ts +7 -1
  16. package/src/memory/contracts.ts +0 -256
  17. package/src/memory/memory-create-tool.ts +3 -5
  18. package/src/memory/memory-files.ts +145 -0
  19. package/src/memory/memory-search-output.ts +23 -0
  20. package/src/memory/memory-search-tool.ts +79 -175
  21. package/src/memory/memory-search.ts +115 -0
  22. package/src/model/fake-model-client.ts +20 -1
  23. package/src/model/openai-model-utils.ts +45 -1
  24. package/src/model/openai-responses-mapping.ts +11 -0
  25. package/src/model/openai-responses-stream.ts +14 -0
  26. package/src/observation/observation-builder.ts +5 -1
  27. package/src/session/scoped-query-database.ts +27 -0
  28. package/src/session/session-history-access.ts +4 -3
  29. package/src/session/session-store-contracts.ts +0 -23
  30. package/src/session/session-store.ts +9 -106
  31. package/src/tools/grep.ts +12 -4
  32. package/src/tools/registry.ts +15 -18
  33. package/src/tools/types.ts +12 -0
  34. package/src/tui/app.tsx +51 -7
  35. package/src/tui/components/ask-user.tsx +15 -8
  36. package/src/tui/components/prompt-input.tsx +14 -6
  37. package/src/tui/components/timeline.tsx +17 -9
  38. package/src/tui/event-store.ts +13 -1
  39. package/src/tui/file-mention.ts +29 -5
  40. package/src/tui/tui-projection-store.ts +5 -2
  41. package/src/tui/tui-session-controller.ts +8 -0
  42. package/src/tui/workspace-file-search.ts +21 -0
  43. package/src/cli/tui-memory.ts +0 -69
  44. package/src/memory/embedding-client.ts +0 -105
  45. package/src/memory/memory-coordinator.ts +0 -1274
  46. package/src/memory/memory-delete-tool.ts +0 -88
  47. package/src/memory/memory-extractor.ts +0 -252
  48. package/src/memory/memory-get-tool.ts +0 -86
  49. package/src/memory/memory-log.ts +0 -88
  50. package/src/memory/memory-store.ts +0 -1133
  51. package/src/memory/memory-update-tool.ts +0 -142
  52. package/src/memory/vector.ts +0 -153
@@ -1,4 +1,6 @@
1
+ import { prepareSessionMemory } from "../memory/memory-files";
1
2
  import { Database } from "bun:sqlite";
3
+ import { ScopedQueryDatabase } from "./scoped-query-database";
2
4
  import { randomUUID } from "node:crypto";
3
5
  import { chmod, mkdir, open, readdir, rename, rmdir } from "node:fs/promises";
4
6
  import path from "node:path";
@@ -87,8 +89,6 @@ import {
87
89
  type CommitSurfaceRefreshOptions,
88
90
  type CommitSwapRevisionInput,
89
91
  type CommitSwapRevisionOptions,
90
- type CompletedTurnMessageSnapshot,
91
- type CompletedTurnSnapshot,
92
92
  type CreateNewSessionStoreInput,
93
93
  type OpenSessionStoreInput,
94
94
  type SessionCloseReason,
@@ -131,9 +131,7 @@ import { requireItem, requireSingleChange, runTransaction } from "./session-stor
131
131
  import { SessionStoreValidation } from "./session-store-validation";
132
132
  import {
133
133
  assertMeasuredContextAnchor,
134
- enumFromSql,
135
134
  nullableStringFromSql,
136
- nullableTextFromSql,
137
135
  numberFromSql,
138
136
  recordFromSql,
139
137
  stringFromSql,
@@ -885,105 +883,6 @@ export class SessionStore implements SessionLedgerCommitter {
885
883
  });
886
884
  }
887
885
 
888
- readCompletedTurnSnapshot(turnId: TurnId): CompletedTurnSnapshot {
889
- this.requireOpen();
890
- const turnRow = this.database
891
- .query("SELECT status FROM turns WHERE turn_id = ?")
892
- .get(turnId);
893
- const status = enumFromSql(
894
- recordFromSql(turnRow, "completed turn").status,
895
- ["open", "completed", "failed", "cancelled", "interrupted"] as const,
896
- "turn status",
897
- );
898
- if (status !== "completed") {
899
- throw new Error(`Turn ${turnId} is not completed.`);
900
- }
901
-
902
- const rows = this.database
903
- .query(
904
- `SELECT ordinal, role, content, reasoning_content,
905
- reasoning_content_present, name
906
- FROM messages
907
- WHERE turn_id = ?
908
- ORDER BY ordinal`,
909
- )
910
- .all(turnId);
911
- if (rows.length === 0) {
912
- throw new Error(`Completed turn ${turnId} has no messages.`);
913
- }
914
-
915
- let previousOrdinal = 0;
916
- const messages = rows.map((value): CompletedTurnMessageSnapshot => {
917
- const row = recordFromSql(value, "completed turn message");
918
- const ordinal = numberFromSql(row.ordinal, "completed turn ordinal");
919
- if (ordinal < 1 || ordinal <= previousOrdinal) {
920
- throw new Error("Completed turn message ordinals are invalid.");
921
- }
922
- previousOrdinal = ordinal;
923
- const role = enumFromSql(
924
- row.role,
925
- ["user", "assistant", "tool"] as const,
926
- "completed turn message role",
927
- );
928
- if (role === "user") {
929
- if (
930
- row.reasoning_content !== null ||
931
- numberFromSql(row.reasoning_content_present, "reasoning_content_present") !==
932
- 0 ||
933
- row.name !== null
934
- ) {
935
- throw new Error("Completed user message fields are invalid.");
936
- }
937
- return Object.freeze({
938
- ordinal,
939
- role,
940
- content: stringFromSql(row.content, "completed user content"),
941
- });
942
- }
943
- if (role === "assistant") {
944
- if (row.name !== null) {
945
- throw new Error("Completed assistant message name must be null.");
946
- }
947
- const reasoningPresent = numberFromSql(
948
- row.reasoning_content_present,
949
- "reasoning_content_present",
950
- );
951
- if (reasoningPresent !== 0 && reasoningPresent !== 1) {
952
- throw new Error("reasoning_content_present must be 0 or 1.");
953
- }
954
- if (reasoningPresent === 0 && row.reasoning_content !== null) {
955
- throw new Error("Absent assistant reasoning content must be null.");
956
- }
957
- return Object.freeze({
958
- ordinal,
959
- role,
960
- content: nullableTextFromSql(row.content, "completed assistant content"),
961
- ...(reasoningPresent === 0
962
- ? {}
963
- : {
964
- reasoningContent: nullableTextFromSql(
965
- row.reasoning_content,
966
- "completed assistant reasoning content",
967
- ),
968
- }),
969
- });
970
- }
971
- if (
972
- row.reasoning_content !== null ||
973
- numberFromSql(row.reasoning_content_present, "reasoning_content_present") !== 0
974
- ) {
975
- throw new Error("Completed tool message reasoning fields are invalid.");
976
- }
977
- return Object.freeze({
978
- ordinal,
979
- role,
980
- name: stringFromSql(row.name, "completed tool name"),
981
- content: stringFromSql(row.content, "completed tool content"),
982
- });
983
- });
984
- return Object.freeze({ messages: Object.freeze(messages) });
985
- }
986
-
987
886
  loadProtocolView(): ProtocolContextView {
988
887
  this.requireOpen();
989
888
  const imageAttachments = loadMessageImageAttachments(this.database);
@@ -1327,7 +1226,7 @@ export class SessionStore implements SessionLedgerCommitter {
1327
1226
  await chmod(stagingDatabasePath, 0o600);
1328
1227
  input.faultInjector?.("after_snapshot");
1329
1228
 
1330
- stagingDatabase = openWritableDatabase(stagingDatabasePath);
1229
+ stagingDatabase = openWritableDatabase(stagingDatabasePath, ScopedQueryDatabase);
1331
1230
  verifySessionSchema(stagingDatabase, this.sessionId);
1332
1231
  dropSessionCloneTriggers(stagingDatabase);
1333
1232
  input.faultInjector?.("after_trigger_drop");
@@ -1403,6 +1302,7 @@ export class SessionStore implements SessionLedgerCommitter {
1403
1302
  input.faultInjector?.("before_publish_rename");
1404
1303
  await rename(stagingDirectory, targetDirectory);
1405
1304
  published = true;
1305
+ await prepareSessionMemory(targetDirectory, input.targetSessionId, this.homeRoot);
1406
1306
  } finally {
1407
1307
  if (stagingDatabase !== undefined) {
1408
1308
  try {
@@ -1542,8 +1442,11 @@ export async function resolveSessionDatabasePath(
1542
1442
  );
1543
1443
  }
1544
1444
 
1545
- function openWritableDatabase(databasePath: string): Database {
1546
- const database = new Database(databasePath, {
1445
+ function openWritableDatabase(
1446
+ databasePath: string,
1447
+ DatabaseType: typeof Database = Database,
1448
+ ): Database {
1449
+ const database = new DatabaseType(databasePath, {
1547
1450
  create: false,
1548
1451
  readwrite: true,
1549
1452
  strict: true,
package/src/tools/grep.ts CHANGED
@@ -72,7 +72,7 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
72
72
  path: {
73
73
  type: "string",
74
74
  description:
75
- "Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd. For directories, ripgrep runs in that directory with . as its search path; explicitly selecting an excluded directory allows searching inside it. Files are passed as absolute paths: explicit files bypass ignore/glob/type filtering, and explicit symlink files are followed. Explicit binary files may yield matches but are not guaranteed to be searched completely.",
75
+ "Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd. For directories, ripgrep runs in that directory with . as its search path; explicitly selecting an excluded directory allows searching inside it. Files are passed as absolute paths: explicit files bypass ignore/glob/type filtering, and explicit symlink files are followed. Explicit binary files may yield matches but are not guaranteed to be searched completely, and reported line numbers may be inaccurate.",
76
76
  },
77
77
  glob: {
78
78
  type: "string",
@@ -202,7 +202,7 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
202
202
  absoluteSearchPath,
203
203
  mode,
204
204
  truncated: rg.truncated ? true : undefined,
205
- error: rg.error ?? "ripgrep failed.",
205
+ error: omitUnsupportedRegexHint(rg.error ?? "ripgrep failed."),
206
206
  });
207
207
  }
208
208
 
@@ -368,11 +368,19 @@ export function buildRipgrepArgs(
368
368
  function resolveGrepContext(input: GrepArgs) {
369
369
  const both = input.context ?? input.contextAlias;
370
370
  return {
371
- before: both ?? input.before ?? 0,
372
- after: both ?? input.after ?? 0,
371
+ before: input.before ?? both ?? 0,
372
+ after: input.after ?? both ?? 0,
373
373
  };
374
374
  }
375
375
 
376
+ function omitUnsupportedRegexHint(error: string): string {
377
+ // Grep does not expose rg's PCRE2 flag; preserve the diagnostic itself.
378
+ return error.replace(
379
+ /\n+Consider enabling PCRE2 with the --pcre2 flag, which can handle backreferences\s+and look-around\.\s*$/,
380
+ "",
381
+ );
382
+ }
383
+
376
384
  type ParsedGrepArgs =
377
385
  | { ok: true; value: GrepArgs }
378
386
  | { ok: false; error: string; pattern?: string; mode?: GrepOutputMode };
@@ -1,3 +1,5 @@
1
+ import { createMemorySearchToolExecutor } from "../memory/memory-search-tool";
2
+ import { createFileMemoryCreateToolExecutor } from "../memory/memory-files";
1
3
  import { createAskUserToolExecutor } from "./ask-user";
2
4
  import { createBashToolExecutor } from "./bash";
3
5
  import { ShellTaskManager } from "./bash-task";
@@ -182,10 +184,7 @@ export function createDefaultTooling(options: {
182
184
  skillCoordinator?: SkillActivationCoordinator;
183
185
  toolingConfig?: PublicToolingConfig;
184
186
  memorySearch?: ToolExecutor;
185
- memoryGet?: ToolExecutor;
186
187
  memoryCreate?: ToolExecutor;
187
- memoryUpdate?: ToolExecutor;
188
- memoryDelete?: ToolExecutor;
189
188
  enableTurnUndo?: boolean;
190
189
  imageAssetStore?: ImageAssetStore;
191
190
  supportsViewImage?: boolean;
@@ -259,21 +258,19 @@ export function createDefaultTooling(options: {
259
258
  registry.register(createContextStatusToolExecutor());
260
259
  registry.register(createContextSwapCandidatesToolExecutor());
261
260
  registry.register(createContextSwapToolExecutor());
262
- if (options.memorySearch !== undefined) {
263
- registry.register(options.memorySearch);
264
- }
265
- if (options.memoryGet !== undefined) {
266
- registry.register(options.memoryGet);
267
- }
268
- if (options.memoryCreate !== undefined) {
269
- registry.register(options.memoryCreate);
270
- }
271
- if (options.memoryUpdate !== undefined) {
272
- registry.register(options.memoryUpdate);
273
- }
274
- if (options.memoryDelete !== undefined) {
275
- registry.register(options.memoryDelete);
276
- }
261
+ registry.register(
262
+ options.memorySearch ??
263
+ createMemorySearchToolExecutor({
264
+ homeRoot: options.homeRoot,
265
+ }),
266
+ );
267
+ registry.register(
268
+ options.memoryCreate ??
269
+ createFileMemoryCreateToolExecutor({
270
+ workspaceRoot: options.workspaceRoot,
271
+ homeRoot: options.homeRoot,
272
+ }),
273
+ );
277
274
  if (options.skillCatalog !== undefined) {
278
275
  if (options.skillCatalog.skills.size === 0) {
279
276
  throw new Error("An empty Agent Skill catalog must not register tooling.");
@@ -427,7 +427,18 @@ export type ContextMaintenanceHandle = {
427
427
  ): Promise<ContextSwapRawResult>;
428
428
  };
429
429
 
430
+ export type MemorySearchLine = { lineNumber: number; match: boolean; text: string };
431
+ export type MemoryTextSearchResult = {
432
+ ok: true;
433
+ format: "text";
434
+ files: readonly { filePath: string; lines: readonly MemorySearchLine[] }[];
435
+ returnedResults: number;
436
+ hasMore: boolean;
437
+ nextOffset?: number;
438
+ };
439
+
430
440
  export type MemorySearchRawResult =
441
+ | MemoryTextSearchResult
431
442
  | {
432
443
  ok: true;
433
444
  degraded: "vector" | "fts" | null;
@@ -469,6 +480,7 @@ export type MemoryCreateRawResult =
469
480
  | {
470
481
  ok: true;
471
482
  status: "created" | "already_exists";
483
+ filePath?: string;
472
484
  memoryId: string;
473
485
  createdAt: string;
474
486
  }
package/src/tui/app.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { EMPTY_PROVIDER_RETRY } from "../agent/runtime-provider-retry";
1
2
  import { Box, Static, Text, useApp, useInput, useStdout, useWindowSize } from "ink";
2
3
  import {
3
4
  useCallback,
@@ -83,7 +84,9 @@ export type AppProps = {
83
84
  writeClipboard?: (markdown: string) => Promise<void>;
84
85
  onQuit?: () => void;
85
86
  initialNotice?: string;
86
- listStoredMemories?: () => readonly StoredMemorySummary[];
87
+ listStoredMemories?: () =>
88
+ | readonly StoredMemorySummary[]
89
+ | Promise<readonly StoredMemorySummary[]>;
87
90
  memoryDisabledNotice?: string;
88
91
  };
89
92
 
@@ -143,6 +146,12 @@ export function App(props: AppProps) {
143
146
  () => binding.bashGuard(),
144
147
  () => binding.bashGuard(),
145
148
  );
149
+ const providerRetry = useSyncExternalStore(
150
+ (listener) => binding.subscribeProviderRetry?.(listener) ?? (() => undefined),
151
+ () => binding.providerRetry?.() ?? EMPTY_PROVIDER_RETRY,
152
+ () => EMPTY_PROVIDER_RETRY,
153
+ );
154
+ const pendingProviderRetry = providerRetry.pending;
146
155
  const askUser = useSyncExternalStore(
147
156
  (listener) => binding.subscribeAskUser(listener),
148
157
  () => binding.askUser(),
@@ -282,7 +291,12 @@ export function App(props: AppProps) {
282
291
  setIsCancelling(true);
283
292
  setNotice("Cancelling current turn...");
284
293
  },
285
- { isActive: executionRunning && askUser.pending === undefined },
294
+ {
295
+ isActive:
296
+ executionRunning &&
297
+ askUser.pending === undefined &&
298
+ pendingProviderRetry === undefined,
299
+ },
286
300
  );
287
301
 
288
302
  const closeResumePicker = () => {
@@ -406,13 +420,13 @@ export function App(props: AppProps) {
406
420
  restoreStaticViewport();
407
421
  };
408
422
 
409
- const openMemoryView = () => {
423
+ const openMemoryView = async () => {
410
424
  if (props.listStoredMemories === undefined) {
411
425
  setNotice(props.memoryDisabledNotice ?? "memory disabled: not configured");
412
426
  return;
413
427
  }
414
428
  try {
415
- const snapshot = props.listStoredMemories();
429
+ const snapshot = await props.listStoredMemories();
416
430
  setNotice(undefined);
417
431
  setMemoryView(snapshot);
418
432
  } catch (error) {
@@ -634,7 +648,7 @@ export function App(props: AppProps) {
634
648
  return true;
635
649
  }
636
650
  if (command.type === "memory") {
637
- openMemoryView();
651
+ void openMemoryView();
638
652
  return true;
639
653
  }
640
654
  if (command.type === "copy") {
@@ -912,7 +926,8 @@ export function App(props: AppProps) {
912
926
  status={
913
927
  isCancelling
914
928
  ? "cancelling"
915
- : askUser.pending !== undefined
929
+ : askUser.pending !== undefined ||
930
+ pendingProviderRetry !== undefined
916
931
  ? "waiting_for_answer"
917
932
  : executionRunning
918
933
  ? "running"
@@ -924,7 +939,35 @@ export function App(props: AppProps) {
924
939
  />
925
940
  </Box>
926
941
  <Box marginTop={1} flexDirection="column" flexShrink={0}>
927
- {askUser.pending !== undefined ? (
942
+ {pendingProviderRetry !== undefined ? (
943
+ <AskUser
944
+ key={pendingProviderRetry.requestId}
945
+ title="Provider request failed"
946
+ question={`Automatic retries exhausted. ${pendingProviderRetry.failure.error.slice(0, 500)}`}
947
+ options={[
948
+ { description: "Retry again" },
949
+ { description: "End this turn" },
950
+ ]}
951
+ dismissLabel="end this turn"
952
+ onSelect={(index) => {
953
+ void binding
954
+ .resolveProviderRetry?.(
955
+ pendingProviderRetry.requestId,
956
+ index === 0 ? "retry" : "stop",
957
+ )
958
+ .catch((error: unknown) =>
959
+ setNotice(`Retry selection failed: ${errorMessage(error)}`),
960
+ );
961
+ }}
962
+ onDismiss={() => {
963
+ void binding
964
+ .resolveProviderRetry?.(pendingProviderRetry.requestId, "stop")
965
+ .catch((error: unknown) =>
966
+ setNotice(`Retry selection failed: ${errorMessage(error)}`),
967
+ );
968
+ }}
969
+ />
970
+ ) : askUser.pending !== undefined ? (
928
971
  <AskUser
929
972
  question={askUser.pending.question}
930
973
  options={askUser.pending.options}
@@ -977,6 +1020,7 @@ export function App(props: AppProps) {
977
1020
  isCopying ||
978
1021
  isCancelling ||
979
1022
  askUser.pending !== undefined ||
1023
+ pendingProviderRetry !== undefined ||
980
1024
  bashGuard.pending !== undefined
981
1025
  }
982
1026
  history={props.history}
@@ -1,8 +1,10 @@
1
1
  import { Box, Text, useInput } from "ink";
2
- import { useState } from "react";
2
+ import { useRef, useState } from "react";
3
3
 
4
4
  export type AskUserProps = {
5
5
  question: string;
6
+ title?: string;
7
+ dismissLabel?: string;
6
8
  options: readonly { readonly description: string }[];
7
9
  onSelect(selectedIndex: number): void;
8
10
  onDismiss(): void;
@@ -10,6 +12,12 @@ export type AskUserProps = {
10
12
 
11
13
  export function AskUser(props: AskUserProps) {
12
14
  const [selectedIndex, setSelectedIndex] = useState(0);
15
+ const selection = useRef(0);
16
+ const moveSelection = (offset: number) => {
17
+ selection.current =
18
+ (selection.current + offset + props.options.length) % props.options.length;
19
+ setSelectedIndex(selection.current);
20
+ };
13
21
 
14
22
  useInput((input, key) => {
15
23
  if (key.escape) {
@@ -17,17 +25,15 @@ export function AskUser(props: AskUserProps) {
17
25
  return;
18
26
  }
19
27
  if (key.upArrow) {
20
- setSelectedIndex((current) =>
21
- current === 0 ? props.options.length - 1 : current - 1,
22
- );
28
+ moveSelection(-1);
23
29
  return;
24
30
  }
25
31
  if (key.downArrow) {
26
- setSelectedIndex((current) => (current + 1) % props.options.length);
32
+ moveSelection(1);
27
33
  return;
28
34
  }
29
35
  if (key.return) {
30
- props.onSelect(selectedIndex);
36
+ props.onSelect(selection.current);
31
37
  return;
32
38
  }
33
39
  if (/^[1-6]$/.test(input)) {
@@ -41,7 +47,7 @@ export function AskUser(props: AskUserProps) {
41
47
  return (
42
48
  <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
43
49
  <Text color="cyan" bold>
44
- Tinker asks
50
+ {props.title ?? "Tinker asks"}
45
51
  </Text>
46
52
  <Text>{props.question}</Text>
47
53
  <Box flexDirection="column" marginTop={1}>
@@ -54,7 +60,8 @@ export function AskUser(props: AskUserProps) {
54
60
  ))}
55
61
  </Box>
56
62
  <Text dimColor>
57
- ↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc skip
63
+ ↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc{" "}
64
+ {props.dismissLabel ?? "skip"}
58
65
  </Text>
59
66
  </Box>
60
67
  );
@@ -41,7 +41,11 @@ import {
41
41
  type PromptDraft,
42
42
  } from "../prompt-draft";
43
43
  import { matchSlashCommands, type SlashCommand } from "../slash-commands";
44
- import { listWorkspaceFiles, type WorkspaceFileLister } from "../workspace-file-search";
44
+ import {
45
+ listWorkspaceFiles,
46
+ listWorkspaceFilesAndDirectories,
47
+ type WorkspaceFileLister,
48
+ } from "../workspace-file-search";
45
49
 
46
50
  export type PromptSubmission = {
47
51
  readonly draft: PromptDraft;
@@ -183,7 +187,10 @@ export function PromptInput(props: PromptInputProps) {
183
187
  () =>
184
188
  fileQuery === undefined || fileCatalog.status !== "ready"
185
189
  ? []
186
- : rankWorkspaceFiles(fileCatalog.files, fileQuery),
190
+ : rankWorkspaceFiles(
191
+ listWorkspaceFilesAndDirectories(fileCatalog.files),
192
+ fileQuery,
193
+ ),
187
194
  [fileCatalog, fileQuery],
188
195
  );
189
196
  const suggestions =
@@ -223,13 +230,14 @@ export function PromptInput(props: PromptInputProps) {
223
230
  });
224
231
  };
225
232
 
226
- const selectFile = (filePath: string) => {
233
+ const selectFile = (match: FileMentionMatch) => {
234
+ const filePath = match.path;
227
235
  const mention = findFileMention(state.draft.editor);
228
236
  if (mention === undefined || locked) {
229
237
  return;
230
238
  }
231
239
  const importImage = props.importImage;
232
- if (importImage === undefined) {
240
+ if (importImage === undefined || match.kind === "directory") {
233
241
  insertFilePath(filePath);
234
242
  return;
235
243
  }
@@ -616,7 +624,7 @@ export function PromptInput(props: PromptInputProps) {
616
624
  const selectedCommand = suggestions[selectedIndex];
617
625
  if (key.return) {
618
626
  if (filePopupActive && selectedFile !== undefined) {
619
- selectFile(selectedFile.path);
627
+ selectFile(selectedFile);
620
628
  } else if (selectedCommand !== undefined) {
621
629
  submitDraft(createPromptDraft(`/${selectedCommand.name}`));
622
630
  } else {
@@ -629,7 +637,7 @@ export function PromptInput(props: PromptInputProps) {
629
637
  if (selectedFile === undefined) {
630
638
  setState((current) => ({ ...current, suggestionsDismissed: true }));
631
639
  } else {
632
- selectFile(selectedFile.path);
640
+ selectFile(selectedFile);
633
641
  }
634
642
  } else if (selectedCommand !== undefined) {
635
643
  setState(createPromptInputState(`/${selectedCommand.name} `));
@@ -26,16 +26,16 @@ export function TimelineRow(props: { item: TimelineItem }) {
26
26
  if (item.label !== undefined) {
27
27
  if (item.label === "assistant") {
28
28
  return (
29
- <Fragment>
30
- <Text color="gray">- {item.label}</Text>
29
+ <Box flexDirection="column" marginTop={1}>
30
+ <TimelineLabel label={item.label} />
31
31
  <AssistantMarkdown text={item.text} />
32
- </Fragment>
32
+ </Box>
33
33
  );
34
34
  }
35
35
 
36
36
  return (
37
- <Fragment>
38
- <Text color="gray">- {item.label}</Text>
37
+ <Box flexDirection="column" marginY={1}>
38
+ <TimelineLabel label={item.label} />
39
39
  {item.userPrompt === undefined ? (
40
40
  <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
41
41
  ) : (
@@ -44,7 +44,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
44
44
  {renderItemBash(item)}
45
45
  {renderItemDiff(item)}
46
46
  {renderItemPlan(item)}
47
- </Fragment>
47
+ </Box>
48
48
  );
49
49
  }
50
50
 
@@ -60,10 +60,18 @@ export function TimelineRow(props: { item: TimelineItem }) {
60
60
 
61
61
  export function AssistantStreamSectionRow(props: { item: AssistantStreamSectionItem }) {
62
62
  return (
63
- <Fragment>
64
- {props.item.showAssistantLabel ? <Text color="gray">- assistant</Text> : null}
63
+ <Box flexDirection="column" marginTop={props.item.showAssistantLabel ? 1 : 0}>
64
+ {props.item.showAssistantLabel ? <TimelineLabel label="assistant" /> : null}
65
65
  <AssistantMarkdown text={props.item.markdown} />
66
- </Fragment>
66
+ </Box>
67
+ );
68
+ }
69
+
70
+ function TimelineLabel(props: { label: string }) {
71
+ return (
72
+ <Text color="cyan" bold>
73
+ - {props.label}
74
+ </Text>
67
75
  );
68
76
  }
69
77
 
@@ -205,6 +205,16 @@ export function reduceTuiProjection(
205
205
  status: "running",
206
206
  })),
207
207
  );
208
+ case "model.retry.requested":
209
+ return updateActiveTurn(state, event, policy, (turn) =>
210
+ updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
211
+ ...item,
212
+ text: `model iteration ${event.iterationNumber} · waiting for retry selection`,
213
+ status: "running",
214
+ })),
215
+ );
216
+ case "model.retry.resolved":
217
+ return state;
208
218
  case "model.request.finished":
209
219
  return updateActiveTurn(state, event, policy, (turn) =>
210
220
  updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
@@ -825,7 +835,7 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
825
835
  return `WebSearch ${toolQuery(input.args) ?? ""}`.trim();
826
836
  }
827
837
  if (input.name === "MemorySearch") {
828
- return `MemorySearch ${memorySearchDetail(input.args)}`.trim();
838
+ return `MemorySearch ${toolPattern(input.args) ?? memorySearchDetail(input.args)}`.trim();
829
839
  }
830
840
  if (input.name === "MemoryCreate") {
831
841
  return "MemoryCreate";
@@ -964,6 +974,8 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
964
974
  if (!raw.ok) {
965
975
  return base;
966
976
  }
977
+ if ("format" in raw)
978
+ return `${base} -> ${raw.returnedResults} matching lines in ${raw.files.length} files`;
967
979
  return `${base} -> ${raw.matches.length} derived memor${raw.matches.length === 1 ? "y" : "ies"}`;
968
980
  case "memory_get":
969
981
  if (!raw.ok) {