tinker-agent 2.9.0 → 2.11.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 (61) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/README.md +30 -1
  3. package/package.json +5 -3
  4. package/src/agent/loop.ts +50 -13
  5. package/src/agent/runtime-hosted-session.ts +443 -0
  6. package/src/agent/runtime-provider-retry.ts +115 -0
  7. package/src/agent/runtime-session-contracts.ts +11 -0
  8. package/src/agent/runtime-session.ts +29 -0
  9. package/src/cli/command-line.ts +26 -2
  10. package/src/cli/connect-runner.tsx +26 -0
  11. package/src/cli/main.ts +26 -0
  12. package/src/cli/output.ts +1 -1
  13. package/src/cli/public-cli-contract.ts +18 -0
  14. package/src/cli/public-config-contract.ts +1 -1
  15. package/src/cli/serve-runner.ts +45 -0
  16. package/src/cli/serve-runtime.ts +100 -0
  17. package/src/cli/tui-runner.tsx +1 -0
  18. package/src/context/context-swap-renderer.ts +14 -0
  19. package/src/events/types.ts +8 -0
  20. package/src/image/abortable-file-open.ts +54 -0
  21. package/src/image/image-asset-store.ts +7 -1
  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 +87 -37
  27. package/src/remote/client.ts +350 -0
  28. package/src/remote/config.ts +95 -0
  29. package/src/remote/http-server.ts +240 -0
  30. package/src/remote/protocol.ts +228 -0
  31. package/src/remote/service-store.ts +175 -0
  32. package/src/remote/service.ts +219 -0
  33. package/src/remote/sync-hub.ts +95 -0
  34. package/src/session/remote-history-reader.ts +143 -0
  35. package/src/session/scoped-query-database.ts +27 -0
  36. package/src/session/session-history-access.ts +4 -3
  37. package/src/session/session-store.ts +7 -3
  38. package/src/tools/bash-task.ts +26 -16
  39. package/src/tools/bash.ts +44 -2
  40. package/src/tools/glob.ts +107 -19
  41. package/src/tools/grep-output.ts +130 -0
  42. package/src/tools/grep-pagination.ts +73 -0
  43. package/src/tools/grep-path.ts +11 -0
  44. package/src/tools/grep-snippets.ts +111 -0
  45. package/src/tools/grep.ts +148 -155
  46. package/src/tools/read.ts +0 -9
  47. package/src/tools/ripgrep.ts +19 -26
  48. package/src/tools/shell-process.ts +30 -4
  49. package/src/tools/task-stop.ts +2 -1
  50. package/src/tools/terminal-screen.ts +11 -2
  51. package/src/tools/types.ts +30 -2
  52. package/src/tui/app.tsx +45 -3
  53. package/src/tui/components/ask-user.tsx +15 -8
  54. package/src/tui/components/prompt-input.tsx +14 -6
  55. package/src/tui/components/timeline.tsx +17 -9
  56. package/src/tui/event-store.ts +25 -2
  57. package/src/tui/file-mention.ts +29 -5
  58. package/src/tui/remote-app.tsx +210 -0
  59. package/src/tui/tui-projection-store.ts +5 -2
  60. package/src/tui/tui-session-controller.ts +8 -0
  61. package/src/tui/workspace-file-search.ts +21 -0
@@ -14,6 +14,7 @@ export type ShellProcessHandle = {
14
14
  readonly mode: ShellProcessMode;
15
15
  readonly exitCode: number | null;
16
16
  readonly signalCode: NodeJS.Signals | null;
17
+ readonly outputClosed: boolean;
17
18
  wait(): Promise<ProcessExitResult>;
18
19
  waitForOutputClose(): Promise<void>;
19
20
  write?(chars: string): Promise<number>;
@@ -35,6 +36,8 @@ export async function spawnShellProcess(input: {
35
36
  command: string;
36
37
  cwd: string;
37
38
  cwdFilePath: string;
39
+ cols?: number;
40
+ rows?: number;
38
41
  onOutput(bytes: Uint8Array): void;
39
42
  }): Promise<ShellProcessHandle> {
40
43
  const env = {
@@ -65,7 +68,16 @@ async function spawnPipeShellProcess(input: {
65
68
  pipeOutput(child.stderr, (bytes) => input.onOutput(bytes));
66
69
 
67
70
  const exit = waitForNodeProcessExit(child);
68
- const close = waitForNodeProcessClose(child);
71
+ let outputClosed = false;
72
+ let resolveOutputClose: () => void;
73
+ const close = new Promise<void>((resolve) => {
74
+ resolveOutputClose = resolve;
75
+ });
76
+ const settleOutputClose = () => {
77
+ outputClosed = true;
78
+ resolveOutputClose();
79
+ };
80
+ void waitForNodeProcessClose(child).then(settleOutputClose);
69
81
  if (child.pid === undefined) {
70
82
  const result = await exit;
71
83
  throw new Error(result.error ?? "Bash process failed to start.");
@@ -80,15 +92,25 @@ async function spawnPipeShellProcess(input: {
80
92
  get signalCode() {
81
93
  return child.signalCode;
82
94
  },
95
+ get outputClosed() {
96
+ return outputClosed;
97
+ },
83
98
  wait: () => exit,
84
99
  waitForOutputClose: () => close,
85
- close() {},
100
+ close() {
101
+ child.stdin.destroy();
102
+ child.stdout.destroy();
103
+ child.stderr.destroy();
104
+ settleOutputClose();
105
+ },
86
106
  };
87
107
  }
88
108
 
89
109
  function spawnPtyShellProcess(input: {
90
110
  cwd: string;
91
111
  env: NodeJS.ProcessEnv;
112
+ cols?: number;
113
+ rows?: number;
92
114
  onOutput(bytes: Uint8Array): void;
93
115
  }): ShellProcessHandle {
94
116
  let terminalEnded = false;
@@ -125,8 +147,8 @@ function spawnPtyShellProcess(input: {
125
147
  GIT_PAGER: "cat",
126
148
  },
127
149
  terminal: {
128
- cols: TERMINAL_SCREEN_COLUMNS,
129
- rows: TERMINAL_SCREEN_ROWS,
150
+ cols: input.cols ?? TERMINAL_SCREEN_COLUMNS,
151
+ rows: input.rows ?? TERMINAL_SCREEN_ROWS,
130
152
  name: "xterm-256color",
131
153
  data(_terminal, bytes) {
132
154
  input.onOutput(new Uint8Array(bytes));
@@ -205,6 +227,9 @@ function spawnPtyShellProcess(input: {
205
227
  get signalCode() {
206
228
  return subprocess.signalCode;
207
229
  },
230
+ get outputClosed() {
231
+ return terminalEnded;
232
+ },
208
233
  wait: () => exit,
209
234
  waitForOutputClose: () => terminalExit,
210
235
  write,
@@ -212,6 +237,7 @@ function spawnPtyShellProcess(input: {
212
237
  if (!terminal.closed) {
213
238
  terminal.close();
214
239
  }
240
+ settleTerminalExit();
215
241
  },
216
242
  };
217
243
  }
@@ -37,12 +37,13 @@ export function createTaskStopToolExecutor(options: {
37
37
  try {
38
38
  const result = await options.taskManager.stopTask(parsed.taskId, "tool");
39
39
  return {
40
- ok: true,
40
+ ok: result.task.error === undefined,
41
41
  taskId: parsed.taskId,
42
42
  task: result.task,
43
43
  status: result.task.status,
44
44
  requestedSignal: result.requestedSignal,
45
45
  escalated: result.escalated,
46
+ ...(result.task.error === undefined ? {} : { error: result.task.error }),
46
47
  };
47
48
  } catch (error) {
48
49
  const inspection = options.taskManager.inspectTask(parsed.taskId);
@@ -3,16 +3,25 @@ import { Terminal } from "@xterm/headless";
3
3
 
4
4
  export const TERMINAL_SCREEN_ROWS = 24;
5
5
  export const TERMINAL_SCREEN_COLUMNS = 80;
6
+ export const MIN_TERMINAL_COLUMNS = 2;
7
+ export const MAX_TERMINAL_DIMENSION = 1_000;
6
8
 
7
9
  export type TerminalScreen = {
10
+ readonly rows: number;
11
+ readonly columns: number;
8
12
  write(bytes: Uint8Array): Promise<void>;
9
13
  flush(): Promise<void>;
10
14
  text(): string;
11
15
  dispose(): void;
12
16
  };
13
17
 
14
- export function createTerminalScreen(): TerminalScreen {
15
- return new HeadlessTerminalScreen(TERMINAL_SCREEN_ROWS, TERMINAL_SCREEN_COLUMNS);
18
+ export function createTerminalScreen(
19
+ options: { cols?: number; rows?: number } = {},
20
+ ): TerminalScreen {
21
+ return new HeadlessTerminalScreen(
22
+ options.rows ?? TERMINAL_SCREEN_ROWS,
23
+ options.cols ?? TERMINAL_SCREEN_COLUMNS,
24
+ );
16
25
  }
17
26
 
18
27
  export class HeadlessTerminalScreen implements TerminalScreen {
@@ -92,16 +92,27 @@ export type DeleteFileRawResult = {
92
92
 
93
93
  export type GlobRawResult = {
94
94
  ok: boolean;
95
- pattern: string;
95
+ pattern?: string;
96
96
  searchPath: string;
97
97
  absoluteSearchPath?: string;
98
98
  matches?: string[];
99
+ /** Number of paths returned, including in legacy unpaginated results. */
99
100
  matchCount?: number;
101
+ /** Exact total before pagination; absent in legacy results. */
102
+ totalMatches?: number;
103
+ returnedCount?: number;
104
+ appliedOffset?: number;
105
+ hasMore?: boolean;
106
+ nextOffset?: number;
100
107
  ignored?: string[];
101
108
  error?: string;
102
109
  };
103
110
 
104
- export type GrepOutputMode = "content" | "files_with_matches" | "count";
111
+ export type GrepOutputMode =
112
+ | "content"
113
+ | "files_with_matches"
114
+ | "count"
115
+ | "count-matches";
105
116
 
106
117
  export type GrepRawResult = {
107
118
  ok: boolean;
@@ -113,10 +124,27 @@ export type GrepRawResult = {
113
124
  numFiles: number;
114
125
  content?: string;
115
126
  numLines?: number;
127
+ /** Structured count records; paths are unescaped and never parsed from display text. */
128
+ counts?: { filePath: string; count: number }[];
129
+ /** Sum on this page: matching lines for count, individual matches for count-matches. */
116
130
  numMatches?: number;
131
+ /** Collected pagination units; legacy content results counted all output lines. Not a global total if searchIncomplete. */
132
+ totalResults?: number;
133
+ /** Selected pagination units, excluding context and nearby matches shown as context. */
134
+ returnedResults?: number;
135
+ paginationUnit?: "matching_lines" | "match_events" | "files";
136
+ /** More collected pagination units are available, independently of search completeness. */
137
+ hasMore?: boolean;
138
+ nextOffset?: number;
117
139
  appliedLimit?: number;
118
140
  appliedOffset?: number;
141
+ /** True for interrupted searches (e.g. timeout or output buffer limit), not pagination. */
142
+ searchIncomplete?: boolean;
143
+ /** The search stopped early, so requested context cannot be guaranteed complete. */
144
+ contextMayBeIncomplete?: boolean;
145
+ /** Legacy default exclusions, not observed skips. New Grep results omit this field. */
119
146
  ignored?: string[];
147
+ /** Compatibility flag combining pagination and interrupted search; prefer explicit fields above. */
120
148
  truncated?: boolean;
121
149
  error?: string;
122
150
  };
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,
@@ -143,6 +144,12 @@ export function App(props: AppProps) {
143
144
  () => binding.bashGuard(),
144
145
  () => binding.bashGuard(),
145
146
  );
147
+ const providerRetry = useSyncExternalStore(
148
+ (listener) => binding.subscribeProviderRetry?.(listener) ?? (() => undefined),
149
+ () => binding.providerRetry?.() ?? EMPTY_PROVIDER_RETRY,
150
+ () => EMPTY_PROVIDER_RETRY,
151
+ );
152
+ const pendingProviderRetry = providerRetry.pending;
146
153
  const askUser = useSyncExternalStore(
147
154
  (listener) => binding.subscribeAskUser(listener),
148
155
  () => binding.askUser(),
@@ -282,7 +289,12 @@ export function App(props: AppProps) {
282
289
  setIsCancelling(true);
283
290
  setNotice("Cancelling current turn...");
284
291
  },
285
- { isActive: executionRunning && askUser.pending === undefined },
292
+ {
293
+ isActive:
294
+ executionRunning &&
295
+ askUser.pending === undefined &&
296
+ pendingProviderRetry === undefined,
297
+ },
286
298
  );
287
299
 
288
300
  const closeResumePicker = () => {
@@ -912,7 +924,8 @@ export function App(props: AppProps) {
912
924
  status={
913
925
  isCancelling
914
926
  ? "cancelling"
915
- : askUser.pending !== undefined
927
+ : askUser.pending !== undefined ||
928
+ pendingProviderRetry !== undefined
916
929
  ? "waiting_for_answer"
917
930
  : executionRunning
918
931
  ? "running"
@@ -924,7 +937,35 @@ export function App(props: AppProps) {
924
937
  />
925
938
  </Box>
926
939
  <Box marginTop={1} flexDirection="column" flexShrink={0}>
927
- {askUser.pending !== undefined ? (
940
+ {pendingProviderRetry !== undefined ? (
941
+ <AskUser
942
+ key={pendingProviderRetry.requestId}
943
+ title="Provider request failed"
944
+ question={`Automatic retries exhausted. ${pendingProviderRetry.failure.error.slice(0, 500)}`}
945
+ options={[
946
+ { description: "Retry again" },
947
+ { description: "End this turn" },
948
+ ]}
949
+ dismissLabel="end this turn"
950
+ onSelect={(index) => {
951
+ void binding
952
+ .resolveProviderRetry?.(
953
+ pendingProviderRetry.requestId,
954
+ index === 0 ? "retry" : "stop",
955
+ )
956
+ .catch((error: unknown) =>
957
+ setNotice(`Retry selection failed: ${errorMessage(error)}`),
958
+ );
959
+ }}
960
+ onDismiss={() => {
961
+ void binding
962
+ .resolveProviderRetry?.(pendingProviderRetry.requestId, "stop")
963
+ .catch((error: unknown) =>
964
+ setNotice(`Retry selection failed: ${errorMessage(error)}`),
965
+ );
966
+ }}
967
+ />
968
+ ) : askUser.pending !== undefined ? (
928
969
  <AskUser
929
970
  question={askUser.pending.question}
930
971
  options={askUser.pending.options}
@@ -977,6 +1018,7 @@ export function App(props: AppProps) {
977
1018
  isCopying ||
978
1019
  isCancelling ||
979
1020
  askUser.pending !== undefined ||
1021
+ pendingProviderRetry !== undefined ||
980
1022
  bashGuard.pending !== undefined
981
1023
  }
982
1024
  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) => ({
@@ -873,6 +883,9 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
873
883
  ? base
874
884
  : `${base} -> ${raw.asset.mimeType}, ${raw.asset.width}x${raw.asset.height}, ${raw.asset.byteLength} bytes`;
875
885
  case "glob":
886
+ if (raw.ok && raw.totalMatches !== undefined) {
887
+ return `${base} -> ${raw.returnedCount ?? raw.matches?.length ?? 0} of ${raw.totalMatches} matches`;
888
+ }
876
889
  return raw.ok && raw.matchCount !== undefined
877
890
  ? `${base} -> ${raw.matchCount} match${raw.matchCount === 1 ? "" : "es"}`
878
891
  : base;
@@ -881,11 +894,21 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
881
894
  return `${base} -> ${raw.numLines} line${raw.numLines === 1 ? "" : "s"}`;
882
895
  }
883
896
  if (
884
- raw.mode === "count" &&
897
+ (raw.mode === "count" || raw.mode === "count-matches") &&
885
898
  raw.numMatches !== undefined &&
886
899
  raw.numFiles !== undefined
887
900
  ) {
888
- return `${base} -> ${raw.numMatches} match${raw.numMatches === 1 ? "" : "es"} across ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}`;
901
+ const unit =
902
+ raw.mode === "count"
903
+ ? `matching line${raw.numMatches === 1 ? "" : "s"}`
904
+ : `match${raw.numMatches === 1 ? "" : "es"}`;
905
+ const scope =
906
+ raw.appliedLimit !== undefined || (raw.appliedOffset ?? 0) > 0
907
+ ? " (this page)"
908
+ : raw.truncated
909
+ ? " (partial results)"
910
+ : "";
911
+ return `${base} -> ${raw.numMatches} ${unit} across ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}${scope}`;
889
912
  }
890
913
  return `${base} -> ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}`;
891
914
  case "read":
@@ -12,6 +12,7 @@ export type FileMentionMatch = {
12
12
  path: string;
13
13
  indices: readonly number[];
14
14
  score: number;
15
+ kind: "file" | "directory";
15
16
  };
16
17
 
17
18
  export function findFileMention(editor: LineEditorState): FileMention | undefined {
@@ -76,8 +77,13 @@ export function rankWorkspaceFiles(
76
77
  ): FileMentionMatch[] {
77
78
  if (query === "") {
78
79
  return files
79
- .map((filePath) => ({ path: filePath, indices: [], score: 0 }))
80
- .sort((left, right) => compareShallowPaths(left.path, right.path))
80
+ .map((filePath) => ({
81
+ path: filePath,
82
+ indices: [],
83
+ score: 0,
84
+ kind: fileMentionKind(filePath),
85
+ }))
86
+ .sort((left, right) => compareShallowPaths(left, right))
81
87
  .slice(0, limit);
82
88
  }
83
89
 
@@ -104,6 +110,7 @@ function fuzzyMatchPath(filePath: string, query: string): FileMentionMatch | und
104
110
  path: filePath,
105
111
  indices: basenameMatch.indices,
106
112
  score: basenameMatch.score + 200,
113
+ kind: fileMentionKind(filePath),
107
114
  };
108
115
  }
109
116
 
@@ -115,6 +122,7 @@ function fuzzyMatchPath(filePath: string, query: string): FileMentionMatch | und
115
122
  path: filePath,
116
123
  indices: fullPathMatch.indices,
117
124
  score: fullPathMatch.score,
125
+ kind: fileMentionKind(filePath),
118
126
  };
119
127
  }
120
128
 
@@ -167,6 +175,11 @@ function compareFileMatches(left: FileMentionMatch, right: FileMentionMatch): nu
167
175
  return right.score - left.score;
168
176
  }
169
177
 
178
+ const kindDifference = left.kind === right.kind ? 0 : left.kind === "file" ? -1 : 1;
179
+ if (kindDifference !== 0) {
180
+ return kindDifference;
181
+ }
182
+
170
183
  const depthDifference = pathDepth(left.path) - pathDepth(right.path);
171
184
  if (depthDifference !== 0) {
172
185
  return depthDifference;
@@ -178,9 +191,16 @@ function compareFileMatches(left: FileMentionMatch, right: FileMentionMatch): nu
178
191
  : lengthDifference;
179
192
  }
180
193
 
181
- function compareShallowPaths(left: string, right: string): number {
182
- const depthDifference = pathDepth(left) - pathDepth(right);
183
- return depthDifference === 0 ? comparePathText(left, right) : depthDifference;
194
+ function compareShallowPaths(left: FileMentionMatch, right: FileMentionMatch): number {
195
+ const kindDifference = left.kind === right.kind ? 0 : left.kind === "file" ? -1 : 1;
196
+ if (kindDifference !== 0) {
197
+ return kindDifference;
198
+ }
199
+
200
+ const depthDifference = pathDepth(left.path) - pathDepth(right.path);
201
+ return depthDifference === 0
202
+ ? comparePathText(left.path, right.path)
203
+ : depthDifference;
184
204
  }
185
205
 
186
206
  function comparePathText(left: string, right: string): number {
@@ -196,6 +216,10 @@ function comparePathText(left: string, right: string): number {
196
216
  return left < right ? -1 : left > right ? 1 : 0;
197
217
  }
198
218
 
219
+ function fileMentionKind(filePath: string): FileMentionMatch["kind"] {
220
+ return filePath.endsWith("/") || filePath.endsWith("\\") ? "directory" : "file";
221
+ }
222
+
199
223
  function pathDepth(filePath: string): number {
200
224
  return [...filePath].filter((char) => char === "/" || char === "\\").length;
201
225
  }