tinker-agent 1.0.65 → 1.2.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 (208) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +90 -9
  5. package/bin/tinker.js +39 -0
  6. package/node_modules/ansi-regex/index.d.ts +33 -0
  7. package/node_modules/ansi-regex/index.js +14 -0
  8. package/node_modules/ansi-regex/license +9 -0
  9. package/node_modules/ansi-regex/package.json +61 -0
  10. package/node_modules/ansi-regex/readme.md +66 -0
  11. package/node_modules/ansi-styles/index.d.ts +236 -0
  12. package/node_modules/ansi-styles/index.js +223 -0
  13. package/node_modules/ansi-styles/license +9 -0
  14. package/node_modules/ansi-styles/package.json +54 -0
  15. package/node_modules/ansi-styles/readme.md +173 -0
  16. package/node_modules/chalk/license +9 -0
  17. package/node_modules/chalk/package.json +83 -0
  18. package/node_modules/chalk/readme.md +297 -0
  19. package/node_modules/chalk/source/index.d.ts +325 -0
  20. package/node_modules/chalk/source/index.js +225 -0
  21. package/node_modules/chalk/source/utilities.js +33 -0
  22. package/node_modules/chalk/source/vendor/ansi-styles/index.d.ts +236 -0
  23. package/node_modules/chalk/source/vendor/ansi-styles/index.js +223 -0
  24. package/node_modules/chalk/source/vendor/supports-color/browser.d.ts +1 -0
  25. package/node_modules/chalk/source/vendor/supports-color/browser.js +34 -0
  26. package/node_modules/chalk/source/vendor/supports-color/index.d.ts +55 -0
  27. package/node_modules/chalk/source/vendor/supports-color/index.js +190 -0
  28. package/node_modules/character-entities/index.d.ts +6 -0
  29. package/node_modules/character-entities/index.js +2132 -0
  30. package/node_modules/character-entities/license +22 -0
  31. package/node_modules/character-entities/package.json +78 -0
  32. package/node_modules/character-entities/readme.md +152 -0
  33. package/node_modules/decode-named-character-reference/index.d.ts +13 -0
  34. package/node_modules/decode-named-character-reference/index.d.ts.map +1 -0
  35. package/node_modules/decode-named-character-reference/index.dom.d.ts +6 -0
  36. package/node_modules/decode-named-character-reference/index.dom.d.ts.map +1 -0
  37. package/node_modules/decode-named-character-reference/index.dom.js +32 -0
  38. package/node_modules/decode-named-character-reference/index.js +19 -0
  39. package/node_modules/decode-named-character-reference/license +22 -0
  40. package/node_modules/decode-named-character-reference/package.json +90 -0
  41. package/node_modules/decode-named-character-reference/readme.md +136 -0
  42. package/node_modules/get-east-asian-width/index.d.ts +60 -0
  43. package/node_modules/get-east-asian-width/index.js +30 -0
  44. package/node_modules/get-east-asian-width/license +9 -0
  45. package/node_modules/get-east-asian-width/lookup-data.js +21 -0
  46. package/node_modules/get-east-asian-width/lookup.js +138 -0
  47. package/node_modules/get-east-asian-width/package.json +71 -0
  48. package/node_modules/get-east-asian-width/readme.md +65 -0
  49. package/node_modules/get-east-asian-width/utilities.js +24 -0
  50. package/node_modules/has-flag/index.d.ts +39 -0
  51. package/node_modules/has-flag/index.js +8 -0
  52. package/node_modules/has-flag/license +9 -0
  53. package/node_modules/has-flag/package.json +49 -0
  54. package/node_modules/has-flag/readme.md +74 -0
  55. package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
  56. package/node_modules/is-fullwidth-code-point/index.js +12 -0
  57. package/node_modules/is-fullwidth-code-point/license +9 -0
  58. package/node_modules/is-fullwidth-code-point/package.json +53 -0
  59. package/node_modules/is-fullwidth-code-point/readme.md +31 -0
  60. package/node_modules/markdansi/LICENSE +21 -0
  61. package/node_modules/markdansi/README.md +167 -0
  62. package/node_modules/markdansi/dist/ast.d.ts +99 -0
  63. package/node_modules/markdansi/dist/ast.js +1 -0
  64. package/node_modules/markdansi/dist/cli.d.ts +17 -0
  65. package/node_modules/markdansi/dist/cli.js +208 -0
  66. package/node_modules/markdansi/dist/hyperlink.d.ts +9 -0
  67. package/node_modules/markdansi/dist/hyperlink.js +26 -0
  68. package/node_modules/markdansi/dist/index.d.ts +10 -0
  69. package/node_modules/markdansi/dist/index.js +16 -0
  70. package/node_modules/markdansi/dist/parser.d.ts +2 -0
  71. package/node_modules/markdansi/dist/parser.js +205 -0
  72. package/node_modules/markdansi/dist/render.d.ts +9 -0
  73. package/node_modules/markdansi/dist/render.js +680 -0
  74. package/node_modules/markdansi/dist/stream.d.ts +39 -0
  75. package/node_modules/markdansi/dist/stream.js +192 -0
  76. package/node_modules/markdansi/dist/theme.d.ts +18 -0
  77. package/node_modules/markdansi/dist/theme.js +105 -0
  78. package/node_modules/markdansi/dist/types.d.ts +58 -0
  79. package/node_modules/markdansi/dist/types.js +1 -0
  80. package/node_modules/markdansi/dist/wrap.d.ts +10 -0
  81. package/node_modules/markdansi/dist/wrap.js +73 -0
  82. package/node_modules/markdansi/docs/spec.md +110 -0
  83. package/node_modules/markdansi/package.json +78 -0
  84. package/node_modules/markdansi/tsconfig.json +18 -0
  85. package/node_modules/marked/LICENSE +44 -0
  86. package/node_modules/marked/README.md +115 -0
  87. package/node_modules/marked/bin/main.js +284 -0
  88. package/node_modules/marked/bin/marked.js +16 -0
  89. package/node_modules/marked/lib/marked.d.ts +760 -0
  90. package/node_modules/marked/lib/marked.esm.js +77 -0
  91. package/node_modules/marked/lib/marked.esm.js.map +7 -0
  92. package/node_modules/marked/lib/marked.umd.js +79 -0
  93. package/node_modules/marked/lib/marked.umd.js.map +7 -0
  94. package/node_modules/marked/man/marked.1 +113 -0
  95. package/node_modules/marked/man/marked.1.md +93 -0
  96. package/node_modules/marked/package.json +103 -0
  97. package/node_modules/slice-ansi/index.d.ts +19 -0
  98. package/node_modules/slice-ansi/index.js +317 -0
  99. package/node_modules/slice-ansi/license +10 -0
  100. package/node_modules/slice-ansi/package.json +59 -0
  101. package/node_modules/slice-ansi/readme.md +55 -0
  102. package/node_modules/slice-ansi/tokenize-ansi.js +752 -0
  103. package/node_modules/string-width/index.d.ts +39 -0
  104. package/node_modules/string-width/index.js +203 -0
  105. package/node_modules/string-width/license +9 -0
  106. package/node_modules/string-width/package.json +65 -0
  107. package/node_modules/string-width/readme.md +66 -0
  108. package/node_modules/strip-ansi/index.d.ts +15 -0
  109. package/node_modules/strip-ansi/index.js +19 -0
  110. package/node_modules/strip-ansi/license +9 -0
  111. package/node_modules/strip-ansi/package.json +59 -0
  112. package/node_modules/strip-ansi/readme.md +37 -0
  113. package/node_modules/supports-color/browser.d.ts +1 -0
  114. package/node_modules/supports-color/browser.js +35 -0
  115. package/node_modules/supports-color/index.d.ts +55 -0
  116. package/node_modules/supports-color/index.js +202 -0
  117. package/node_modules/supports-color/license +9 -0
  118. package/node_modules/supports-color/package.json +64 -0
  119. package/node_modules/supports-color/readme.md +75 -0
  120. package/node_modules/supports-hyperlinks/browser.js +10 -0
  121. package/node_modules/supports-hyperlinks/index.d.ts +25 -0
  122. package/node_modules/supports-hyperlinks/index.js +163 -0
  123. package/node_modules/supports-hyperlinks/license +10 -0
  124. package/node_modules/supports-hyperlinks/package.json +47 -0
  125. package/node_modules/supports-hyperlinks/readme.md +45 -0
  126. package/package.json +41 -10
  127. package/patches/markdansi@0.3.2.patch +22 -2
  128. package/src/agent/context-builder.ts +24 -10
  129. package/src/agent/context-meter.ts +89 -5
  130. package/src/agent/loop.ts +107 -10
  131. package/src/agent/runtime-session.ts +1437 -82
  132. package/src/agent/session-ledger.ts +169 -29
  133. package/src/agent/types.ts +12 -1
  134. package/src/agent/user-prompt-projection.ts +62 -0
  135. package/src/cli/config.ts +57 -14
  136. package/src/cli/model-profiles.ts +124 -0
  137. package/src/cli/run-runner.ts +4 -1
  138. package/src/cli/tui-runner.tsx +68 -30
  139. package/src/context/compiled-context-hash.ts +1 -1
  140. package/src/context/compiled-context-validator.ts +92 -15
  141. package/src/context/context-automation-policy.ts +121 -0
  142. package/src/context/context-manager.ts +390 -11
  143. package/src/context/context-policy.ts +8 -0
  144. package/src/context/context-protocol-validator.ts +11 -1
  145. package/src/context/context-revision-compiler.ts +190 -27
  146. package/src/context/context-revision.ts +105 -19
  147. package/src/context/context-surface.ts +329 -0
  148. package/src/context/prefix-retirement-planner.ts +570 -0
  149. package/src/context/protocol-frame.ts +30 -3
  150. package/src/context/recall-retirement-contract.ts +27 -0
  151. package/src/context/swap-planner.ts +34 -10
  152. package/src/events/observation-text-log.ts +3 -3
  153. package/src/events/stdout-event-printer.ts +39 -3
  154. package/src/events/types.ts +172 -35
  155. package/src/ids/runtime-id.ts +6 -0
  156. package/src/ids/uuid-v7.ts +7 -0
  157. package/src/image/image-asset-store.ts +319 -0
  158. package/src/image/image-input-policy.ts +14 -0
  159. package/src/image/image-probe.ts +233 -0
  160. package/src/image/image-types.ts +262 -0
  161. package/src/instructions/project-instructions.ts +4 -1
  162. package/src/mcp/mcp-manager.ts +83 -19
  163. package/src/model/fake-model-client.ts +9 -1
  164. package/src/model/input-token-estimator.ts +25 -0
  165. package/src/model/model-client.ts +60 -0
  166. package/src/model/model-request-preflight.ts +1 -0
  167. package/src/model/moonshot-input-token-estimator.ts +107 -0
  168. package/src/model/openai-chat-mapping.ts +77 -1
  169. package/src/model/openai-chat-model-client.ts +389 -59
  170. package/src/model/openai-chat-stream.ts +272 -0
  171. package/src/model/prompt-prefix-hash.ts +2 -4
  172. package/src/model/token-estimator.ts +4 -0
  173. package/src/observation/observation-builder.ts +29 -0
  174. package/src/session/resume-projection.ts +94 -34
  175. package/src/session/session-catalog.ts +4 -4
  176. package/src/session/session-errors.ts +1 -1
  177. package/src/session/session-history-reader.ts +151 -4
  178. package/src/session/session-last-response-reader.ts +189 -0
  179. package/src/session/session-schema.ts +349 -35
  180. package/src/session/session-store.ts +3266 -255
  181. package/src/session/sqlite-session-ledger.ts +9 -4
  182. package/src/skills/skill-catalog.ts +182 -0
  183. package/src/skills/skill-context.ts +364 -0
  184. package/src/skills/skill-loader.ts +779 -0
  185. package/src/skills/skill-tool.ts +297 -0
  186. package/src/tools/edit.ts +51 -64
  187. package/src/tools/ensure-parent-directory.ts +6 -0
  188. package/src/tools/read.ts +2 -2
  189. package/src/tools/recall.ts +43 -40
  190. package/src/tools/registry.ts +24 -3
  191. package/src/tools/ripgrep.ts +3 -2
  192. package/src/tools/types.ts +43 -4
  193. package/src/tools/write.ts +20 -31
  194. package/src/tui/app.tsx +333 -96
  195. package/src/tui/clipboard.ts +5 -0
  196. package/src/tui/components/mcp-panel.tsx +28 -0
  197. package/src/tui/components/prompt-input.tsx +790 -257
  198. package/src/tui/components/skills-panel.tsx +31 -0
  199. package/src/tui/components/timeline.tsx +30 -1
  200. package/src/tui/event-store.ts +85 -2
  201. package/src/tui/file-mention.ts +234 -0
  202. package/src/tui/line-editor.ts +1 -0
  203. package/src/tui/project-slash-commands.ts +229 -0
  204. package/src/tui/prompt-draft.ts +510 -0
  205. package/src/tui/prompt-history.ts +294 -22
  206. package/src/tui/slash-commands.ts +46 -2
  207. package/src/tui/tui-session-controller.ts +110 -48
  208. package/src/tui/workspace-file-search.ts +94 -0
@@ -0,0 +1,31 @@
1
+ import { Box, Text } from "ink";
2
+ import type { RuntimeSkillsSnapshot } from "../../agent/runtime-session";
3
+
4
+ export function SkillsPanel(props: { snapshot: RuntimeSkillsSnapshot }) {
5
+ return (
6
+ <Box flexDirection="column">
7
+ <Text bold>Agent Skills</Text>
8
+ {props.snapshot.skills.length === 0 ? (
9
+ <Text color="yellow"> no skills available</Text>
10
+ ) : (
11
+ props.snapshot.skills.map((skill) => (
12
+ <Box key={skill.name} flexDirection="column">
13
+ <Text>
14
+ {" "}
15
+ <Text bold>{skill.name}</Text>
16
+ {` (${skill.scope}${skill.active ? ", active" : ""})`}
17
+ </Text>
18
+ <Text dimColor> {skill.description}</Text>
19
+ </Box>
20
+ ))
21
+ )}
22
+ {props.snapshot.shadowedNames.length === 0 ? null : (
23
+ <>
24
+ <Text> </Text>
25
+ <Text bold>Shadowed user skills</Text>
26
+ <Text> {props.snapshot.shadowedNames.join(", ")}</Text>
27
+ </>
28
+ )}
29
+ </Box>
30
+ );
31
+ }
@@ -36,7 +36,11 @@ function renderTimelineItem(item: TimelineItem) {
36
36
  return (
37
37
  <Fragment key={item.id}>
38
38
  <Text color="gray">- {item.label}</Text>
39
- <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
39
+ {item.userPrompt === undefined ? (
40
+ <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
41
+ ) : (
42
+ renderUserPrompt(item.userPrompt)
43
+ )}
40
44
  {renderItemBash(item)}
41
45
  {renderItemDiff(item)}
42
46
  </Fragment>
@@ -52,6 +56,31 @@ function renderTimelineItem(item: TimelineItem) {
52
56
  );
53
57
  }
54
58
 
59
+ function renderUserPrompt(prompt: NonNullable<TimelineItem["userPrompt"]>) {
60
+ const chars = [...prompt.text];
61
+ const fragments: React.ReactNode[] = [];
62
+ let offset = 0;
63
+ for (const image of prompt.images) {
64
+ fragments.push(chars.slice(offset, image.range.start).join(""));
65
+ fragments.push(
66
+ <Text key={`${image.range.start}:${image.label}`} color="cyan" bold>
67
+ {chars.slice(image.range.start, image.range.end).join("")}
68
+ <Text dimColor> ({image.originalName})</Text>
69
+ </Text>,
70
+ );
71
+ offset = image.range.end;
72
+ }
73
+ fragments.push(chars.slice(offset).join(""));
74
+ if (prompt.omittedImageCount > 0) {
75
+ fragments.push(
76
+ <Text key="omitted-images" dimColor>
77
+ {` [${prompt.omittedImageCount} image(s) omitted]`}
78
+ </Text>,
79
+ );
80
+ }
81
+ return <Text color="white">{fragments}</Text>;
82
+ }
83
+
55
84
  function renderItemDiff(item: TimelineItem) {
56
85
  if (item.diff === undefined || item.diff.length === 0) {
57
86
  return null;
@@ -17,6 +17,11 @@ import {
17
17
  type TuiProjectionPolicy,
18
18
  validateTuiProjectionPolicy,
19
19
  } from "./tui-projection-policy";
20
+ import {
21
+ MAX_TIMELINE_PROMPT_CODE_POINTS,
22
+ truncateUserPromptProjection,
23
+ type UserPromptProjection,
24
+ } from "../agent/user-prompt-projection";
20
25
 
21
26
  export type TimelineItem = {
22
27
  id: string;
@@ -27,6 +32,7 @@ export type TimelineItem = {
27
32
  diff?: DiffHunk[];
28
33
  diffTruncated?: boolean;
29
34
  bash?: BashDisplayDetail;
35
+ userPrompt?: UserPromptProjection;
30
36
  };
31
37
 
32
38
  export type TuiTurnProjection = {
@@ -96,6 +102,11 @@ export function reduceTuiProjection(
96
102
  contextBudget: event.data.contextBudget,
97
103
  };
98
104
  case "session.resumed":
105
+ return {
106
+ ...state,
107
+ contextProfile: event.data.contextProfile,
108
+ contextBudget: event.data.contextBudget,
109
+ };
99
110
  case "session.interrupted_frame_recovered":
100
111
  return state;
101
112
  case "context.usage.updated":
@@ -112,6 +123,10 @@ export function reduceTuiProjection(
112
123
  "turn.started turnNumber",
113
124
  );
114
125
  parseEventTimestamp(event.timestamp);
126
+ const userPrompt = truncateUserPromptProjection(
127
+ event.data.userPrompt,
128
+ MAX_TIMELINE_PROMPT_CODE_POINTS,
129
+ );
115
130
  return {
116
131
  ...state,
117
132
  status: "running",
@@ -127,7 +142,8 @@ export function reduceTuiProjection(
127
142
  {
128
143
  id: `turn-${turnId}-prompt-${event.eventSequence}`,
129
144
  label: "prompt",
130
- text: event.data.userPrompt,
145
+ text: userPrompt.text,
146
+ userPrompt,
131
147
  status: "text",
132
148
  },
133
149
  ],
@@ -223,6 +239,47 @@ export function reduceTuiProjection(
223
239
  },
224
240
  policy.sessionNoticeLimit,
225
241
  );
242
+ case "skills.catalog.loaded":
243
+ return event.data.shadowedNames.length === 0
244
+ ? state
245
+ : appendNotice(
246
+ state,
247
+ {
248
+ id: `notice-skills-catalog-${event.eventSequence}`,
249
+ label: "skills",
250
+ text: `project skills shadowed user skills -> ${event.data.shadowedNames.join(", ")}`,
251
+ status: "info",
252
+ },
253
+ policy.sessionNoticeLimit,
254
+ );
255
+ case "skills.updated": {
256
+ const changes = [
257
+ event.data.activated.length === 0
258
+ ? undefined
259
+ : `activated ${event.data.activated.join(", ")}`,
260
+ event.data.refreshed.length === 0
261
+ ? undefined
262
+ : `refreshed ${event.data.refreshed.join(", ")}`,
263
+ event.data.deactivated.length === 0
264
+ ? undefined
265
+ : `deactivated ${event.data.deactivated.join(", ")}`,
266
+ event.data.unavailable.length === 0
267
+ ? undefined
268
+ : `unavailable ${event.data.unavailable.join(", ")}`,
269
+ ].filter((entry): entry is string => entry !== undefined);
270
+ return changes.length === 0
271
+ ? state
272
+ : appendNotice(
273
+ state,
274
+ {
275
+ id: `notice-skills-updated-${event.eventSequence}`,
276
+ label: "skills",
277
+ text: `skills updated -> ${changes.join("; ")}`,
278
+ status: "info",
279
+ },
280
+ policy.sessionNoticeLimit,
281
+ );
282
+ }
226
283
  case "diagnostic.sink_failed":
227
284
  return appendNotice(
228
285
  state,
@@ -301,15 +358,31 @@ export function reduceTuiProjection(
301
358
  case "context.shadow.planned":
302
359
  case "context.shadow.failed":
303
360
  case "context.revision.started":
304
- case "context.revision.finished":
305
361
  case "context.revision.failed":
306
362
  case "tool.observation":
307
363
  return state;
364
+ case "context.revision.finished":
365
+ return event.data.strategy === "surface_refresh"
366
+ ? appendNotice(
367
+ state,
368
+ {
369
+ id: `notice-context-refresh-${event.eventSequence}`,
370
+ label: "context",
371
+ text: `runtime context refreshed on resume -> ${event.data.changed.map(formatSurfaceComponent).join(", ")}`,
372
+ status: "info",
373
+ },
374
+ policy.sessionNoticeLimit,
375
+ )
376
+ : state;
308
377
  default:
309
378
  return assertNever(event);
310
379
  }
311
380
  }
312
381
 
382
+ function formatSurfaceComponent(component: string): string {
383
+ return component.replaceAll("_", " ");
384
+ }
385
+
313
386
  export const applyAgentEvent = reduceTuiProjection;
314
387
 
315
388
  export function visibleTimelineItems(state: TuiProjectionState): TimelineItem[] {
@@ -619,6 +692,9 @@ function toolCallSummary(input: { name: string; args: unknown }): string {
619
692
  if (input.name === "TaskList") {
620
693
  return "TaskList";
621
694
  }
695
+ if (input.name === "Skill") {
696
+ return `Skill ${stringProperty(asRecord(input.args), "name") ?? ""}`.trim();
697
+ }
622
698
 
623
699
  const filePath = toolPath(input.args);
624
700
  return `${input.name}${filePath === undefined ? "" : ` ${filePath}`}`;
@@ -686,6 +762,11 @@ function toolRawResultSummary(name: string, args: unknown, raw: ToolRawResult):
686
762
  return raw.mode === "search"
687
763
  ? `${base} -> ${raw.page.hits.length} historical match${raw.page.hits.length === 1 ? "" : "es"}`
688
764
  : `${base} -> ${raw.page.returnedBytes} historical bytes`;
765
+ case "skill":
766
+ if (!raw.ok) {
767
+ return `${base} failed -> ${boundedToolError(raw.error)}`;
768
+ }
769
+ return `skill ${raw.name} ${raw.status.replaceAll("_", " ")}`;
689
770
  case "task_list":
690
771
  return `${base} -> ${raw.tasks.length} task${raw.tasks.length === 1 ? "" : "s"}, ${raw.runningCount} running`;
691
772
  case "task_output":
@@ -748,6 +829,7 @@ function toolRawResultBashDetail(raw: ToolRawResult): Pick<TimelineItem, "bash">
748
829
  case "web_search":
749
830
  case "web_fetch":
750
831
  case "recall":
832
+ case "skill":
751
833
  case "mcp":
752
834
  case "generic":
753
835
  return {};
@@ -781,6 +863,7 @@ function toolRawResultDiff(
781
863
  case "web_search":
782
864
  case "web_fetch":
783
865
  case "recall":
866
+ case "skill":
784
867
  case "mcp":
785
868
  case "generic":
786
869
  return {};
@@ -0,0 +1,234 @@
1
+ import type { LineEditorState } from "./line-editor";
2
+
3
+ export const MAX_FILE_MENTION_RESULTS = 8;
4
+
5
+ export type FileMention = {
6
+ start: number;
7
+ end: number;
8
+ query: string;
9
+ };
10
+
11
+ export type FileMentionMatch = {
12
+ path: string;
13
+ indices: readonly number[];
14
+ score: number;
15
+ };
16
+
17
+ export function findFileMention(editor: LineEditorState): FileMention | undefined {
18
+ const chars = [...editor.value];
19
+ let anchor: number;
20
+
21
+ if (editor.cursor < chars.length && !isWhitespace(chars[editor.cursor])) {
22
+ anchor = editor.cursor;
23
+ } else if (editor.cursor > 0 && !isWhitespace(chars[editor.cursor - 1])) {
24
+ anchor = editor.cursor - 1;
25
+ } else {
26
+ return undefined;
27
+ }
28
+
29
+ let start = anchor;
30
+ while (start > 0 && !isWhitespace(chars[start - 1])) {
31
+ start -= 1;
32
+ }
33
+
34
+ if (chars[start] !== "@") {
35
+ return undefined;
36
+ }
37
+
38
+ let end = anchor + 1;
39
+ while (end < chars.length && !isWhitespace(chars[end])) {
40
+ end += 1;
41
+ }
42
+
43
+ return {
44
+ start,
45
+ end,
46
+ query: chars.slice(start + 1, end).join(""),
47
+ };
48
+ }
49
+
50
+ export function replaceFileMention(
51
+ editor: LineEditorState,
52
+ mention: FileMention,
53
+ filePath: string,
54
+ ): LineEditorState {
55
+ const chars = [...editor.value];
56
+ const replacement = [...filePath];
57
+ const suffix = chars.slice(mention.end);
58
+ const reusesSeparator = isHorizontalWhitespace(suffix[0]);
59
+ const separator = reusesSeparator ? [] : [" "];
60
+
61
+ return {
62
+ value: [
63
+ ...chars.slice(0, mention.start),
64
+ ...replacement,
65
+ ...separator,
66
+ ...suffix,
67
+ ].join(""),
68
+ cursor: mention.start + replacement.length + 1,
69
+ };
70
+ }
71
+
72
+ export function rankWorkspaceFiles(
73
+ files: readonly string[],
74
+ query: string,
75
+ limit = MAX_FILE_MENTION_RESULTS,
76
+ ): FileMentionMatch[] {
77
+ if (query === "") {
78
+ return files
79
+ .map((filePath) => ({ path: filePath, indices: [], score: 0 }))
80
+ .sort((left, right) => compareShallowPaths(left.path, right.path))
81
+ .slice(0, limit);
82
+ }
83
+
84
+ return files
85
+ .map((filePath) => fuzzyMatchPath(filePath, query))
86
+ .filter((match): match is FileMentionMatch => match !== undefined)
87
+ .sort(compareFileMatches)
88
+ .slice(0, limit);
89
+ }
90
+
91
+ function fuzzyMatchPath(filePath: string, query: string): FileMentionMatch | undefined {
92
+ const pathChars = [...filePath];
93
+ const queryChars = [...query];
94
+ const fullPathMatch = fuzzySubsequence(pathChars, queryChars, 0);
95
+ const basenameStart = findBasenameStart(pathChars);
96
+ const basenameMatch = fuzzySubsequence(
97
+ pathChars.slice(basenameStart),
98
+ queryChars,
99
+ basenameStart,
100
+ );
101
+
102
+ if (basenameMatch !== undefined) {
103
+ return {
104
+ path: filePath,
105
+ indices: basenameMatch.indices,
106
+ score: basenameMatch.score + 200,
107
+ };
108
+ }
109
+
110
+ if (fullPathMatch === undefined) {
111
+ return undefined;
112
+ }
113
+
114
+ return {
115
+ path: filePath,
116
+ indices: fullPathMatch.indices,
117
+ score: fullPathMatch.score,
118
+ };
119
+ }
120
+
121
+ function fuzzySubsequence(
122
+ candidate: readonly string[],
123
+ query: readonly string[],
124
+ indexOffset: number,
125
+ ): { indices: number[]; score: number } | undefined {
126
+ const indices: number[] = [];
127
+ let candidateIndex = 0;
128
+ let previousIndex = -1;
129
+ let score = 0;
130
+
131
+ for (const queryChar of query) {
132
+ const normalizedQueryChar = queryChar.toLowerCase();
133
+ let matchIndex = -1;
134
+
135
+ while (candidateIndex < candidate.length) {
136
+ if (candidate[candidateIndex]?.toLowerCase() === normalizedQueryChar) {
137
+ matchIndex = candidateIndex;
138
+ break;
139
+ }
140
+ candidateIndex += 1;
141
+ }
142
+
143
+ if (matchIndex === -1) {
144
+ return undefined;
145
+ }
146
+
147
+ score += 100;
148
+ if (previousIndex >= 0) {
149
+ const gap = matchIndex - previousIndex - 1;
150
+ score += gap === 0 ? 40 : -gap;
151
+ }
152
+ if (isBoundary(candidate, matchIndex)) {
153
+ score += 30;
154
+ }
155
+
156
+ indices.push(matchIndex + indexOffset);
157
+ previousIndex = matchIndex;
158
+ candidateIndex = matchIndex + 1;
159
+ }
160
+
161
+ score -= candidate.length - query.length;
162
+ return { indices, score };
163
+ }
164
+
165
+ function compareFileMatches(left: FileMentionMatch, right: FileMentionMatch): number {
166
+ if (left.score !== right.score) {
167
+ return right.score - left.score;
168
+ }
169
+
170
+ const depthDifference = pathDepth(left.path) - pathDepth(right.path);
171
+ if (depthDifference !== 0) {
172
+ return depthDifference;
173
+ }
174
+
175
+ const lengthDifference = [...left.path].length - [...right.path].length;
176
+ return lengthDifference === 0
177
+ ? comparePathText(left.path, right.path)
178
+ : lengthDifference;
179
+ }
180
+
181
+ function compareShallowPaths(left: string, right: string): number {
182
+ const depthDifference = pathDepth(left) - pathDepth(right);
183
+ return depthDifference === 0 ? comparePathText(left, right) : depthDifference;
184
+ }
185
+
186
+ function comparePathText(left: string, right: string): number {
187
+ const normalizedLeft = left.toLowerCase();
188
+ const normalizedRight = right.toLowerCase();
189
+
190
+ if (normalizedLeft < normalizedRight) {
191
+ return -1;
192
+ }
193
+ if (normalizedLeft > normalizedRight) {
194
+ return 1;
195
+ }
196
+ return left < right ? -1 : left > right ? 1 : 0;
197
+ }
198
+
199
+ function pathDepth(filePath: string): number {
200
+ return [...filePath].filter((char) => char === "/" || char === "\\").length;
201
+ }
202
+
203
+ function findBasenameStart(chars: readonly string[]): number {
204
+ for (let index = chars.length - 1; index >= 0; index -= 1) {
205
+ if (chars[index] === "/" || chars[index] === "\\") {
206
+ return index + 1;
207
+ }
208
+ }
209
+ return 0;
210
+ }
211
+
212
+ function isBoundary(chars: readonly string[], index: number): boolean {
213
+ if (index === 0) {
214
+ return true;
215
+ }
216
+
217
+ const previous = chars[index - 1];
218
+ return (
219
+ previous === "/" ||
220
+ previous === "\\" ||
221
+ previous === "." ||
222
+ previous === "_" ||
223
+ previous === "-" ||
224
+ isWhitespace(previous)
225
+ );
226
+ }
227
+
228
+ function isHorizontalWhitespace(char: string | undefined): boolean {
229
+ return char !== undefined && char !== "\n" && char !== "\r" && isWhitespace(char);
230
+ }
231
+
232
+ function isWhitespace(char: string | undefined): boolean {
233
+ return char !== undefined && /\s/u.test(char);
234
+ }
@@ -1,4 +1,5 @@
1
1
  export type LineEditorState = {
2
+ /** Cursor offsets are Unicode code-point indices, never UTF-16 offsets. */
2
3
  value: string;
3
4
  cursor: number;
4
5
  preferredColumn?: number;