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
@@ -1,25 +1,59 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { Box, Text, useInput, usePaste } from "ink";
4
- import { useState } from "react";
4
+ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
5
5
  import type { ContextUsageSnapshot } from "../../agent/context-meter";
6
+ import type { UserMessage } from "../../agent/types";
7
+ import { ModelRequestMediaAggregateError } from "../../model/model-client";
8
+ import { ImageNotRecognizedError } from "../../image/image-probe";
9
+ import type { ImportedImageAsset } from "../../image/image-asset-store";
10
+ import type { ImageAssetRef } from "../../image/image-types";
11
+ import { runtimeIdFactory, type RuntimeIdFactory } from "../../ids/runtime-id";
6
12
  import { formatContextUsageLine } from "../context-format";
7
13
  import {
8
- backspace,
9
- createLineEditorState,
10
- deleteForward,
11
- deleteToLineStart,
12
- insert,
13
- type LineEditorState,
14
- moveDown,
15
- moveLeft,
16
- moveRight,
17
- moveToLineEnd,
18
- moveToLineStart,
19
- moveUp,
20
- splitAtCursor,
21
- } from "../line-editor";
14
+ type FileMentionMatch,
15
+ findFileMention,
16
+ rankWorkspaceFiles,
17
+ } from "../file-mention";
18
+ import {
19
+ restorePromptHistoryEntry,
20
+ type LoadedPromptHistoryRecord,
21
+ } from "../prompt-history";
22
+ import {
23
+ backspaceDraft,
24
+ createPromptDraft,
25
+ deleteForwardDraft,
26
+ deleteToLineStartDraft,
27
+ draftSubmissionSnapshot,
28
+ insertDraftImage,
29
+ insertDraftText,
30
+ moveDraftDown,
31
+ moveDraftLeft,
32
+ moveDraftRight,
33
+ moveDraftToLineEnd,
34
+ moveDraftToLineStart,
35
+ moveDraftUp,
36
+ promptDraftChanged,
37
+ replaceDraftText,
38
+ type PromptDraft,
39
+ } from "../prompt-draft";
22
40
  import { matchSlashCommands, type SlashCommand } from "../slash-commands";
41
+ import { listWorkspaceFiles, type WorkspaceFileLister } from "../workspace-file-search";
42
+
43
+ export type PromptSubmission = {
44
+ readonly draft: PromptDraft;
45
+ readonly userMessage: UserMessage;
46
+ };
47
+
48
+ export type PromptMaintenanceAction = "compact" | "retire" | "new_session";
49
+
50
+ export type PromptSubmissionOutcome =
51
+ | boolean
52
+ | {
53
+ readonly kind: "maintenance_offer";
54
+ readonly reason: "budget" | "media_aggregate";
55
+ readonly message: string;
56
+ };
23
57
 
24
58
  export type PromptInputProps = {
25
59
  modelName: string;
@@ -28,181 +62,648 @@ export type PromptInputProps = {
28
62
  contextUsage?: ContextUsageSnapshot;
29
63
  isDisabled?: boolean;
30
64
  placeholder?: string;
31
- history?: { entries: readonly string[] };
65
+ history?: {
66
+ records?: readonly LoadedPromptHistoryRecord[];
67
+ entries?: readonly string[];
68
+ };
32
69
  commands?: readonly SlashCommand[];
33
- onSubmit: (value: string) => void;
70
+ fileLister?: WorkspaceFileLister;
71
+ idFactory?: Pick<RuntimeIdFactory, "createImageAttachmentId">;
72
+ importImage?: (
73
+ sourcePath: string,
74
+ signal: AbortSignal,
75
+ prospectiveMessageImageCount: number,
76
+ ) => Promise<ImportedImageAsset>;
77
+ verifyImageAssets?: (
78
+ assets: readonly ImageAssetRef[],
79
+ signal: AbortSignal,
80
+ ) => Promise<void>;
81
+ onSubmit: (
82
+ submission: PromptSubmission,
83
+ signal: AbortSignal,
84
+ ) => PromptSubmissionOutcome | Promise<PromptSubmissionOutcome>;
85
+ onMaintenance?: (
86
+ action: PromptMaintenanceAction,
87
+ signal: AbortSignal,
88
+ ) => void | Promise<void>;
34
89
  };
35
90
 
36
91
  type HistoryNavigation = {
37
92
  index: number;
38
- originalDraft: LineEditorState;
93
+ originalDraft: PromptDraft;
39
94
  browsing: boolean;
40
95
  };
41
96
 
97
+ type PromptInputPhase =
98
+ | { kind: "idle" }
99
+ | { kind: "attaching"; operationId: string }
100
+ | { kind: "restoring_history"; operationId: string; targetIndex: number }
101
+ | { kind: "admitting"; submissionId: string }
102
+ | {
103
+ kind: "maintenance_offer";
104
+ reason: "budget" | "media_aggregate";
105
+ operationId?: string;
106
+ action?: PromptMaintenanceAction;
107
+ };
108
+
42
109
  type PromptInputState = {
43
- editor: LineEditorState;
110
+ draft: PromptDraft;
44
111
  navigation?: HistoryNavigation;
45
112
  suggestionIndex: number;
46
113
  suggestionsDismissed: boolean;
114
+ phase: PromptInputPhase;
115
+ error?: string;
47
116
  };
48
117
 
118
+ type FileCatalogState =
119
+ | { status: "idle" }
120
+ | { status: "ready"; files: readonly string[] }
121
+ | { status: "error"; message: string };
122
+
49
123
  function createPromptInputState(value = ""): PromptInputState {
50
124
  return {
51
- editor: createLineEditorState(value),
125
+ draft: createPromptDraft(value),
52
126
  suggestionIndex: 0,
53
127
  suggestionsDismissed: false,
128
+ phase: { kind: "idle" },
54
129
  };
55
130
  }
56
131
 
57
132
  export function PromptInput(props: PromptInputProps) {
58
133
  const [state, setState] = useState<PromptInputState>(createPromptInputState);
134
+ const [fileCatalog, setFileCatalog] = useState<FileCatalogState>({
135
+ status: "idle",
136
+ });
137
+ const operation = useRef<AbortController | undefined>(undefined);
138
+ const idFactory = props.idFactory ?? runtimeIdFactory;
139
+ const locked = props.isDisabled === true || state.phase.kind !== "idle";
140
+
141
+ useEffect(
142
+ () => () => {
143
+ operation.current?.abort();
144
+ },
145
+ [],
146
+ );
59
147
 
60
- const suggestions = state.suggestionsDismissed
61
- ? []
62
- : matchSlashCommands(state.editor.value, props.commands);
63
- const selectedIndex =
64
- suggestions.length === 0
65
- ? 0
66
- : Math.min(state.suggestionIndex, suggestions.length - 1);
148
+ const fileMention =
149
+ state.suggestionsDismissed || state.navigation?.browsing === true
150
+ ? undefined
151
+ : findFileMention(state.draft.editor);
152
+ const filePopupActive = fileMention !== undefined && !locked;
153
+ const fileLister = props.fileLister ?? listWorkspaceFiles;
67
154
 
68
- const submit = (value: string) => {
69
- props.onSubmit(value);
70
- setState(createPromptInputState());
71
- };
155
+ useEffect(() => {
156
+ if (!filePopupActive) {
157
+ return;
158
+ }
159
+ const controller = new AbortController();
160
+ void fileLister(props.workspaceRoot, controller.signal).then(
161
+ (files) => {
162
+ if (!controller.signal.aborted) {
163
+ setFileCatalog({ status: "ready", files });
164
+ }
165
+ },
166
+ (error: unknown) => {
167
+ if (!controller.signal.aborted) {
168
+ setFileCatalog({ status: "error", message: errorMessage(error) });
169
+ }
170
+ },
171
+ );
172
+ return () => controller.abort();
173
+ }, [fileLister, filePopupActive, props.workspaceRoot]);
174
+
175
+ const fileQuery = fileMention?.query;
176
+ const fileMatches = useMemo(
177
+ () =>
178
+ fileQuery === undefined || fileCatalog.status !== "ready"
179
+ ? []
180
+ : rankWorkspaceFiles(fileCatalog.files, fileQuery),
181
+ [fileCatalog, fileQuery],
182
+ );
183
+ const suggestions =
184
+ state.suggestionsDismissed || filePopupActive || state.draft.attachments.length > 0
185
+ ? []
186
+ : matchSlashCommands(state.draft.editor.value, props.commands);
187
+ const suggestionCount = filePopupActive ? fileMatches.length : suggestions.length;
188
+ const selectedIndex =
189
+ suggestionCount === 0 ? 0 : Math.min(state.suggestionIndex, suggestionCount - 1);
72
190
 
73
- const updateEditor = (update: (editor: LineEditorState) => LineEditorState) => {
191
+ const updateDraft = (update: (draft: PromptDraft) => PromptDraft) => {
74
192
  setState((current) => {
75
- const editor = update(current.editor);
76
-
77
- if (!editorChanged(current.editor, editor)) {
193
+ if (current.phase.kind !== "idle") {
78
194
  return current;
79
195
  }
80
-
81
- return applyEditorChange(current, editor);
196
+ const draft = update(current.draft);
197
+ return promptDraftChanged(current.draft, draft)
198
+ ? applyDraftChange(current, draft)
199
+ : current;
82
200
  });
83
201
  };
84
202
 
85
- const navigateUp = () => {
203
+ const insertFilePath = (filePath: string) => {
86
204
  setState((current) => {
87
- const entries = props.history?.entries ?? [];
88
-
89
- if (current.navigation?.browsing === true) {
90
- return navigateHistoryUp(current, entries);
91
- }
92
-
93
- const editor = moveUp(current.editor);
94
- if (editorChanged(current.editor, editor)) {
95
- return applyEditorChange(current, editor);
205
+ const mention = findFileMention(current.draft.editor);
206
+ if (mention === undefined || current.phase.kind !== "idle") {
207
+ return current;
96
208
  }
97
-
98
- return navigateHistoryUp(current, entries);
209
+ return applyDraftChange(
210
+ current,
211
+ replaceDraftText(
212
+ current.draft,
213
+ { start: mention.start, end: mention.end },
214
+ fileMentionReplacement(current.draft.editor.value, mention.end, filePath),
215
+ ),
216
+ );
99
217
  });
100
218
  };
101
219
 
102
- const navigateDown = () => {
103
- setState((current) => {
104
- const entries = props.history?.entries ?? [];
220
+ const selectFile = (filePath: string) => {
221
+ const mention = findFileMention(state.draft.editor);
222
+ if (mention === undefined || locked) {
223
+ return;
224
+ }
225
+ if (props.importImage === undefined) {
226
+ insertFilePath(filePath);
227
+ return;
228
+ }
229
+ const controller = new AbortController();
230
+ const operationId = crypto.randomUUID();
231
+ operation.current?.abort();
232
+ operation.current = controller;
233
+ const captured = state.draft;
234
+ setState((current) => ({
235
+ ...current,
236
+ phase: { kind: "attaching", operationId },
237
+ error: undefined,
238
+ }));
239
+ void props
240
+ .importImage(filePath, controller.signal, captured.attachments.length + 1)
241
+ .then((imported) => {
242
+ setState((current) => {
243
+ if (
244
+ current.phase.kind !== "attaching" ||
245
+ current.phase.operationId !== operationId
246
+ ) {
247
+ return current;
248
+ }
249
+ if (controller.signal.aborted) {
250
+ return { ...current, phase: { kind: "idle" } };
251
+ }
252
+ try {
253
+ const draft = insertDraftImage(captured, {
254
+ replace: { start: mention.start, end: mention.end },
255
+ attachmentId: idFactory.createImageAttachmentId(),
256
+ imported,
257
+ });
258
+ return {
259
+ ...applyDraftChange(current, draft),
260
+ phase: { kind: "idle" },
261
+ error: undefined,
262
+ };
263
+ } catch (error) {
264
+ return {
265
+ ...current,
266
+ phase: { kind: "idle" },
267
+ error: errorMessage(error),
268
+ };
269
+ }
270
+ });
271
+ })
272
+ .catch((error: unknown) => {
273
+ if (controller.signal.aborted) {
274
+ setState((current) =>
275
+ current.phase.kind === "attaching" &&
276
+ current.phase.operationId === operationId
277
+ ? { ...current, phase: { kind: "idle" } }
278
+ : current,
279
+ );
280
+ return;
281
+ }
282
+ if (error instanceof ImageNotRecognizedError) {
283
+ setState((current) => {
284
+ if (
285
+ current.phase.kind !== "attaching" ||
286
+ current.phase.operationId !== operationId
287
+ ) {
288
+ return current;
289
+ }
290
+ return {
291
+ ...applyDraftChange(
292
+ current,
293
+ replaceDraftText(
294
+ captured,
295
+ { start: mention.start, end: mention.end },
296
+ fileMentionReplacement(captured.editor.value, mention.end, filePath),
297
+ ),
298
+ ),
299
+ phase: { kind: "idle" },
300
+ };
301
+ });
302
+ return;
303
+ }
304
+ if (error instanceof ModelRequestMediaAggregateError) {
305
+ setState((current) =>
306
+ current.phase.kind === "attaching" &&
307
+ current.phase.operationId === operationId
308
+ ? {
309
+ ...current,
310
+ phase: {
311
+ kind: "maintenance_offer",
312
+ reason: "media_aggregate",
313
+ },
314
+ error: error.message,
315
+ }
316
+ : current,
317
+ );
318
+ return;
319
+ }
320
+ setState((current) =>
321
+ current.phase.kind === "attaching" &&
322
+ current.phase.operationId === operationId
323
+ ? {
324
+ ...current,
325
+ phase: { kind: "idle" },
326
+ error: `Image attachment failed: ${errorMessage(error)}`,
327
+ }
328
+ : current,
329
+ );
330
+ })
331
+ .finally(() => {
332
+ if (operation.current === controller) {
333
+ operation.current = undefined;
334
+ }
335
+ });
336
+ };
105
337
 
106
- if (current.navigation?.browsing === true) {
107
- return navigateHistoryDown(current, entries);
108
- }
338
+ const submitDraft = (draft: PromptDraft) => {
339
+ if (locked) {
340
+ return;
341
+ }
342
+ let submission: PromptSubmission;
343
+ try {
344
+ const snapshot = draftSubmissionSnapshot(draft);
345
+ submission = { draft: snapshot.draft, userMessage: snapshot.userMessage };
346
+ } catch (error) {
347
+ setState((current) => ({ ...current, error: errorMessage(error) }));
348
+ return;
349
+ }
350
+ const controller = new AbortController();
351
+ const submissionId = crypto.randomUUID();
352
+ operation.current?.abort();
353
+ operation.current = controller;
354
+ setState((current) => ({
355
+ ...current,
356
+ phase: { kind: "admitting", submissionId },
357
+ error: undefined,
358
+ }));
359
+ void Promise.resolve(props.onSubmit(submission, controller.signal))
360
+ .then((outcome) => {
361
+ setState((current) => {
362
+ if (
363
+ current.phase.kind !== "admitting" ||
364
+ current.phase.submissionId !== submissionId
365
+ ) {
366
+ return current;
367
+ }
368
+ if (outcome === true) return createPromptInputState();
369
+ if (typeof outcome === "object" && outcome.kind === "maintenance_offer") {
370
+ return {
371
+ ...current,
372
+ phase: {
373
+ kind: "maintenance_offer",
374
+ reason: outcome.reason,
375
+ },
376
+ error: outcome.message,
377
+ };
378
+ }
379
+ return { ...current, phase: { kind: "idle" } };
380
+ });
381
+ })
382
+ .catch((error: unknown) => {
383
+ setState((current) =>
384
+ current.phase.kind === "admitting" &&
385
+ current.phase.submissionId === submissionId
386
+ ? {
387
+ ...current,
388
+ phase: { kind: "idle" },
389
+ error: errorMessage(error),
390
+ }
391
+ : current,
392
+ );
393
+ })
394
+ .finally(() => {
395
+ if (operation.current === controller) {
396
+ operation.current = undefined;
397
+ }
398
+ });
399
+ };
109
400
 
110
- const editor = moveDown(current.editor);
111
- return editorChanged(current.editor, editor)
112
- ? applyEditorChange(current, editor)
113
- : navigateHistoryDown(current, entries);
401
+ const navigateHistory = (direction: "up" | "down") => {
402
+ const records = historyRecords(props.history);
403
+ if (records.length === 0 || locked) {
404
+ return;
405
+ }
406
+ const currentNavigation = state.navigation;
407
+ if (direction === "down" && currentNavigation === undefined) {
408
+ return;
409
+ }
410
+ if (
411
+ direction === "down" &&
412
+ currentNavigation !== undefined &&
413
+ currentNavigation.index >= records.length - 1
414
+ ) {
415
+ setState({
416
+ ...state,
417
+ draft: currentNavigation.originalDraft,
418
+ navigation: undefined,
419
+ suggestionIndex: 0,
420
+ suggestionsDismissed: false,
421
+ error: undefined,
422
+ });
423
+ return;
424
+ }
425
+ const targetIndex =
426
+ currentNavigation === undefined
427
+ ? records.length - 1
428
+ : direction === "up"
429
+ ? Math.max(0, currentNavigation.index - 1)
430
+ : Math.min(records.length - 1, currentNavigation.index + 1);
431
+ const record = records[targetIndex];
432
+ if (record === undefined) {
433
+ return;
434
+ }
435
+ const navigation: HistoryNavigation = {
436
+ index: targetIndex,
437
+ originalDraft: currentNavigation?.originalDraft ?? state.draft,
438
+ browsing: true,
439
+ };
440
+ if (record.kind === "invalid") {
441
+ setState({
442
+ ...state,
443
+ navigation,
444
+ suggestionsDismissed: true,
445
+ error: `Prompt history line ${record.lineNumber} is invalid (${record.errorCode}).`,
446
+ });
447
+ return;
448
+ }
449
+ if (record.entry.version === 1) {
450
+ setState({
451
+ ...state,
452
+ draft: restorePromptHistoryEntry(record.entry, idFactory),
453
+ navigation,
454
+ suggestionsDismissed: true,
455
+ suggestionIndex: 0,
456
+ error: undefined,
457
+ });
458
+ return;
459
+ }
460
+ if (props.verifyImageAssets === undefined) {
461
+ setState({
462
+ ...state,
463
+ navigation,
464
+ error: "Current model profile cannot restore image Prompt history.",
465
+ });
466
+ return;
467
+ }
468
+ const controller = new AbortController();
469
+ const operationId = crypto.randomUUID();
470
+ operation.current?.abort();
471
+ operation.current = controller;
472
+ setState({
473
+ ...state,
474
+ phase: { kind: "restoring_history", operationId, targetIndex },
475
+ error: undefined,
114
476
  });
477
+ void props
478
+ .verifyImageAssets(
479
+ record.entry.attachments.map((attachment) => attachment.asset),
480
+ controller.signal,
481
+ )
482
+ .then(() => {
483
+ setState((current) => {
484
+ if (
485
+ current.phase.kind !== "restoring_history" ||
486
+ current.phase.operationId !== operationId
487
+ ) {
488
+ return current;
489
+ }
490
+ if (controller.signal.aborted) {
491
+ return { ...current, phase: { kind: "idle" } };
492
+ }
493
+ return {
494
+ ...current,
495
+ draft: restorePromptHistoryEntry(record.entry, idFactory),
496
+ navigation,
497
+ phase: { kind: "idle" },
498
+ suggestionsDismissed: true,
499
+ suggestionIndex: 0,
500
+ };
501
+ });
502
+ })
503
+ .catch((error: unknown) => {
504
+ setState((current) =>
505
+ current.phase.kind === "restoring_history" &&
506
+ current.phase.operationId === operationId
507
+ ? controller.signal.aborted
508
+ ? { ...current, phase: { kind: "idle" } }
509
+ : {
510
+ ...current,
511
+ navigation,
512
+ phase: { kind: "idle" },
513
+ error: `Prompt history restore failed: ${errorMessage(error)}`,
514
+ }
515
+ : current,
516
+ );
517
+ })
518
+ .finally(() => {
519
+ if (operation.current === controller) {
520
+ operation.current = undefined;
521
+ }
522
+ });
115
523
  };
116
524
 
117
525
  useInput(
118
526
  (input, key) => {
119
- const selected = suggestions[selectedIndex];
120
-
121
- if (key.return) {
122
- if (selected !== undefined) {
123
- submit(`/${selected.name}`);
527
+ if (state.phase.kind === "maintenance_offer") {
528
+ if (state.phase.operationId !== undefined) {
124
529
  return;
125
530
  }
126
-
127
- submit(state.editor.value);
531
+ if (key.escape) {
532
+ setState((current) => ({
533
+ ...current,
534
+ phase: { kind: "idle" },
535
+ error: undefined,
536
+ }));
537
+ return;
538
+ }
539
+ const action = maintenanceAction(input);
540
+ if (action === undefined) return;
541
+ if (props.onMaintenance === undefined) {
542
+ setState((current) => ({
543
+ ...current,
544
+ error: "Prompt maintenance actions are unavailable.",
545
+ }));
546
+ return;
547
+ }
548
+ const controller = new AbortController();
549
+ const operationId = crypto.randomUUID();
550
+ operation.current?.abort();
551
+ operation.current = controller;
552
+ setState((current) =>
553
+ current.phase.kind === "maintenance_offer"
554
+ ? {
555
+ ...current,
556
+ phase: {
557
+ ...current.phase,
558
+ operationId,
559
+ action,
560
+ },
561
+ error: undefined,
562
+ }
563
+ : current,
564
+ );
565
+ void Promise.resolve(props.onMaintenance(action, controller.signal))
566
+ .then(() => {
567
+ setState((current) =>
568
+ current.phase.kind === "maintenance_offer" &&
569
+ current.phase.operationId === operationId
570
+ ? { ...current, phase: { kind: "idle" } }
571
+ : current,
572
+ );
573
+ })
574
+ .catch((error: unknown) => {
575
+ setState((current) =>
576
+ current.phase.kind === "maintenance_offer" &&
577
+ current.phase.operationId === operationId
578
+ ? {
579
+ ...current,
580
+ phase: {
581
+ kind: "maintenance_offer",
582
+ reason: current.phase.reason,
583
+ },
584
+ error: `Maintenance failed: ${errorMessage(error)}`,
585
+ }
586
+ : current,
587
+ );
588
+ })
589
+ .finally(() => {
590
+ if (operation.current === controller) {
591
+ operation.current = undefined;
592
+ }
593
+ });
594
+ return;
595
+ }
596
+ if (state.phase.kind !== "idle") {
597
+ if (key.escape) {
598
+ operation.current?.abort();
599
+ }
600
+ return;
601
+ }
602
+ const selectedFile = fileMatches[selectedIndex];
603
+ const selectedCommand = suggestions[selectedIndex];
604
+ if (key.return) {
605
+ if (filePopupActive && selectedFile !== undefined) {
606
+ selectFile(selectedFile.path);
607
+ } else if (selectedCommand !== undefined) {
608
+ submitDraft(createPromptDraft(`/${selectedCommand.name}`));
609
+ } else {
610
+ submitDraft(state.draft);
611
+ }
128
612
  return;
129
613
  }
130
-
131
614
  if (key.tab) {
132
- if (selected !== undefined) {
133
- setState(createPromptInputState(`/${selected.name} `));
615
+ if (filePopupActive) {
616
+ if (selectedFile === undefined) {
617
+ setState((current) => ({ ...current, suggestionsDismissed: true }));
618
+ } else {
619
+ selectFile(selectedFile.path);
620
+ }
621
+ } else if (selectedCommand !== undefined) {
622
+ setState(createPromptInputState(`/${selectedCommand.name} `));
134
623
  }
135
624
  return;
136
625
  }
137
-
138
626
  if (key.escape) {
139
- if (suggestions.length > 0) {
627
+ if (filePopupActive || suggestions.length > 0) {
140
628
  setState((current) => ({ ...current, suggestionsDismissed: true }));
141
629
  }
142
630
  return;
143
631
  }
144
-
145
- if (key.upArrow) {
146
- if (suggestions.length > 0) {
632
+ if (key.upArrow && filePopupActive) {
633
+ if (fileMatches.length > 0) {
147
634
  setState((current) => ({
148
635
  ...current,
149
636
  suggestionIndex:
150
- (selectedIndex + suggestions.length - 1) % suggestions.length,
637
+ (selectedIndex + fileMatches.length - 1) % fileMatches.length,
151
638
  }));
152
- return;
153
639
  }
154
-
155
- navigateUp();
156
640
  return;
157
641
  }
158
-
159
- if (key.downArrow) {
160
- if (suggestions.length > 0) {
642
+ if (key.downArrow && filePopupActive) {
643
+ if (fileMatches.length > 0) {
161
644
  setState((current) => ({
162
645
  ...current,
163
- suggestionIndex: (selectedIndex + 1) % suggestions.length,
646
+ suggestionIndex: (selectedIndex + 1) % fileMatches.length,
164
647
  }));
165
- return;
166
648
  }
167
-
168
- navigateDown();
169
649
  return;
170
650
  }
171
-
172
- if (key.leftArrow) {
173
- updateEditor(moveLeft);
651
+ if (key.upArrow && suggestions.length > 0) {
652
+ setState((current) => ({
653
+ ...current,
654
+ suggestionIndex:
655
+ (selectedIndex + suggestions.length - 1) % suggestions.length,
656
+ }));
174
657
  return;
175
658
  }
176
-
177
- if (key.rightArrow) {
178
- updateEditor(moveRight);
659
+ if (key.downArrow && suggestions.length > 0) {
660
+ setState((current) => ({
661
+ ...current,
662
+ suggestionIndex: (selectedIndex + 1) % suggestions.length,
663
+ }));
179
664
  return;
180
665
  }
181
-
182
- if (key.backspace || key.delete) {
183
- updateEditor(backspace);
184
- return;
185
- }
186
-
187
- if (key.ctrl) {
188
- if (input === "a") {
189
- updateEditor(moveToLineStart);
190
- } else if (input === "e") {
191
- updateEditor(moveToLineEnd);
192
- } else if (input === "d") {
193
- updateEditor(deleteForward);
194
- } else if (input === "u") {
195
- updateEditor(deleteToLineStart);
666
+ if (key.upArrow) {
667
+ if (state.navigation !== undefined) {
668
+ navigateHistory("up");
669
+ return;
670
+ }
671
+ const moved = moveDraftUp(state.draft);
672
+ if (promptDraftChanged(state.draft, moved)) {
673
+ updateDraft(() => moved);
674
+ } else {
675
+ navigateHistory("up");
196
676
  }
197
677
  return;
198
678
  }
199
-
200
- if (key.meta || key.pageUp || key.pageDown) {
679
+ if (key.downArrow) {
680
+ if (state.navigation?.browsing === true) {
681
+ navigateHistory("down");
682
+ return;
683
+ }
684
+ const moved = moveDraftDown(state.draft);
685
+ if (promptDraftChanged(state.draft, moved)) {
686
+ updateDraft(() => moved);
687
+ } else {
688
+ navigateHistory("down");
689
+ }
201
690
  return;
202
691
  }
203
-
204
- if (input !== "") {
205
- updateEditor((editor) => insert(editor, normalizeLineBreaks(input)));
692
+ if (key.leftArrow) {
693
+ updateDraft(moveDraftLeft);
694
+ } else if (key.rightArrow) {
695
+ updateDraft(moveDraftRight);
696
+ } else if (key.backspace) {
697
+ updateDraft(backspaceDraft);
698
+ } else if (key.delete) {
699
+ updateDraft(deleteForwardDraft);
700
+ } else if (key.ctrl) {
701
+ if (input === "a") updateDraft(moveDraftToLineStart);
702
+ else if (input === "e") updateDraft(moveDraftToLineEnd);
703
+ else if (input === "d") updateDraft(deleteForwardDraft);
704
+ else if (input === "u") updateDraft(deleteToLineStartDraft);
705
+ } else if (!key.meta && !key.pageUp && !key.pageDown && input !== "") {
706
+ updateDraft((draft) => insertDraftText(draft, normalizeLineBreaks(input)));
206
707
  }
207
708
  },
208
709
  { isActive: props.isDisabled !== true },
@@ -210,23 +711,25 @@ export function PromptInput(props: PromptInputProps) {
210
711
 
211
712
  usePaste(
212
713
  (text) => {
213
- updateEditor((editor) => insert(editor, normalizeLineBreaks(text)));
714
+ updateDraft((draft) => insertDraftText(draft, normalizeLineBreaks(text)));
214
715
  },
215
- { isActive: props.isDisabled !== true },
716
+ { isActive: !locked },
216
717
  );
217
718
 
218
- const showSuggestions = suggestions.length > 0 && props.isDisabled !== true;
219
-
719
+ const showFileSuggestions = filePopupActive;
720
+ const showSlashSuggestions = suggestions.length > 0 && !locked;
721
+ const showSuggestions = showFileSuggestions || showSlashSuggestions;
220
722
  return (
221
723
  <Box flexDirection="column">
222
724
  <Box width="100%" borderStyle="single" borderLeft={false} borderRight={false}>
223
- {renderEditor(state.editor, props)}
725
+ {renderDraft(state.draft, props, locked)}
224
726
  </Box>
225
727
  {showSuggestions ? null : (
226
728
  <Box>
227
729
  <Text dimColor>
228
730
  {props.modelName} · {formatWorkspacePath(props.workspaceRoot)}
229
731
  {props.gitBranch === undefined ? null : ` · ${props.gitBranch}`}
732
+ {state.phase.kind === "idle" ? null : ` · ${phaseLabel(state.phase)}`}
230
733
  </Text>
231
734
  {props.contextUsage === undefined ? null : (
232
735
  <>
@@ -241,7 +744,9 @@ export function PromptInput(props: PromptInputProps) {
241
744
  )}
242
745
  </Box>
243
746
  )}
244
- {showSuggestions ? (
747
+ {showFileSuggestions ? (
748
+ renderFileSuggestions(fileCatalog, fileMatches, selectedIndex)
749
+ ) : showSlashSuggestions ? (
245
750
  <Box flexDirection="column">
246
751
  {suggestions.map((command, index) => (
247
752
  <Text key={command.name}>
@@ -251,47 +756,55 @@ export function PromptInput(props: PromptInputProps) {
251
756
  ))}
252
757
  </Box>
253
758
  ) : null}
759
+ {state.error === undefined ? null : <Text color="red">{state.error}</Text>}
760
+ {state.phase.kind === "maintenance_offer" ? (
761
+ <Text color="yellow">
762
+ {state.phase.operationId === undefined
763
+ ? "Context maintenance: c compact · r compact retire · n new session · Esc edit"
764
+ : `Running ${maintenanceLabel(state.phase.action)}…`}
765
+ </Text>
766
+ ) : null}
254
767
  </Box>
255
768
  );
256
769
  }
257
770
 
258
- function contextColor(
259
- pressure: ContextUsageSnapshot["pressure"],
260
- ): "yellow" | "red" | undefined {
261
- if (pressure === "blocked") {
262
- return "red";
263
- }
264
- return pressure === "triggered" ? "yellow" : undefined;
771
+ function applyDraftChange(
772
+ state: PromptInputState,
773
+ draft: PromptDraft,
774
+ ): PromptInputState {
775
+ const valueChanged = state.draft.editor.value !== draft.editor.value;
776
+ return {
777
+ ...state,
778
+ draft,
779
+ navigation: valueChanged
780
+ ? undefined
781
+ : state.navigation === undefined
782
+ ? undefined
783
+ : { ...state.navigation, browsing: false },
784
+ suggestionIndex: valueChanged ? 0 : state.suggestionIndex,
785
+ suggestionsDismissed: valueChanged ? false : state.suggestionsDismissed,
786
+ error: undefined,
787
+ };
265
788
  }
266
789
 
267
- function formatWorkspacePath(workspaceRoot: string): string {
268
- const home = os.homedir();
269
- const relative = path.relative(home, workspaceRoot);
270
-
271
- if (relative === "") {
272
- return home;
790
+ function historyRecords(
791
+ history: PromptInputProps["history"],
792
+ ): readonly LoadedPromptHistoryRecord[] {
793
+ if (history?.records !== undefined) {
794
+ return history.records;
273
795
  }
274
-
275
- const isInsideHome =
276
- relative !== ".." &&
277
- !relative.startsWith(`..${path.sep}`) &&
278
- !path.isAbsolute(relative);
279
-
280
- return isInsideHome ? `~${path.sep}${relative}` : workspaceRoot;
796
+ return (history?.entries ?? []).map((text, index) => ({
797
+ kind: "valid",
798
+ lineNumber: index + 1,
799
+ entry: { version: 1, text },
800
+ }));
281
801
  }
282
802
 
283
- function renderEditor(editor: LineEditorState, props: PromptInputProps) {
284
- if (editor.value === "") {
803
+ function renderDraft(draft: PromptDraft, props: PromptInputProps, disabled: boolean) {
804
+ if (draft.editor.value === "") {
285
805
  const placeholder = props.placeholder ?? "";
286
-
287
- if (props.isDisabled === true) {
288
- return <Text dimColor>{placeholder}</Text>;
289
- }
290
-
291
- if (placeholder === "") {
292
- return <Text inverse> </Text>;
293
- }
294
-
806
+ if (disabled) return <Text dimColor>{placeholder}</Text>;
807
+ if (placeholder === "") return <Text inverse> </Text>;
295
808
  return (
296
809
  <Text>
297
810
  <Text inverse>{placeholder.slice(0, 1)}</Text>
@@ -299,134 +812,154 @@ function renderEditor(editor: LineEditorState, props: PromptInputProps) {
299
812
  </Text>
300
813
  );
301
814
  }
302
-
303
- if (props.isDisabled === true) {
304
- return <Text>{editor.value}</Text>;
815
+ const chars = [...draft.editor.value];
816
+ const elements = new Map(
817
+ draft.elements.map((element) => [element.range.start, element]),
818
+ );
819
+ const nodes: React.ReactNode[] = [];
820
+ for (let index = 0; index <= chars.length; ) {
821
+ const element = elements.get(index);
822
+ if (element !== undefined) {
823
+ nodes.push(
824
+ <Text
825
+ key={`image-${element.attachmentId}`}
826
+ color="cyan"
827
+ bold
828
+ inverse={!disabled && draft.editor.cursor === element.range.start}
829
+ >
830
+ {element.label}
831
+ </Text>,
832
+ );
833
+ index = element.range.end;
834
+ continue;
835
+ }
836
+ if (index === chars.length) {
837
+ if (!disabled && draft.editor.cursor === index) {
838
+ nodes.push(
839
+ <Text key="cursor-end" inverse>
840
+ {" "}
841
+ </Text>,
842
+ );
843
+ }
844
+ break;
845
+ }
846
+ const char = chars[index];
847
+ if (!disabled && draft.editor.cursor === index) {
848
+ nodes.push(
849
+ <Fragment key={`cursor-${index}`}>
850
+ <Text inverse>{char === "\n" ? " " : char}</Text>
851
+ {char === "\n" ? "\n" : null}
852
+ </Fragment>,
853
+ );
854
+ } else {
855
+ nodes.push(<Fragment key={`char-${index}`}>{char}</Fragment>);
856
+ }
857
+ index += 1;
305
858
  }
859
+ return <Text>{nodes}</Text>;
860
+ }
306
861
 
307
- const { before, at, after } = splitAtCursor(editor);
308
- if (at === "\n") {
309
- return (
310
- <Text>
311
- {before}
312
- <Text inverse> </Text>
313
- {"\n"}
314
- {after}
315
- </Text>
316
- );
317
- }
862
+ function renderFileSuggestions(
863
+ catalog: FileCatalogState,
864
+ matches: readonly FileMentionMatch[],
865
+ selectedIndex: number,
866
+ ) {
867
+ if (catalog.status === "idle")
868
+ return <Text dimColor>Searching workspace files…</Text>;
869
+ if (catalog.status === "error") return <Text color="red">{catalog.message}</Text>;
870
+ if (matches.length === 0) return <Text dimColor>No matching files</Text>;
871
+ return (
872
+ <Box flexDirection="column">
873
+ {matches.map((match, index) => (
874
+ <Text key={match.path}>
875
+ {index === selectedIndex ? "❯ " : " "}
876
+ {renderMatchedPath(match)}
877
+ </Text>
878
+ ))}
879
+ </Box>
880
+ );
881
+ }
318
882
 
883
+ function renderMatchedPath(match: FileMentionMatch) {
884
+ const matchedIndices = new Set(match.indices);
319
885
  return (
320
886
  <Text>
321
- {before}
322
- <Text inverse>{at}</Text>
323
- {after}
887
+ {[...match.path].map((char, index) => (
888
+ <Text key={`${index}:${char}`} bold={matchedIndices.has(index)}>
889
+ {char}
890
+ </Text>
891
+ ))}
324
892
  </Text>
325
893
  );
326
894
  }
327
895
 
328
- function normalizeLineBreaks(value: string): string {
329
- return value.replace(/\r\n?/g, "\n");
896
+ function phaseLabel(phase: PromptInputPhase): string {
897
+ switch (phase.kind) {
898
+ case "idle":
899
+ return "ready";
900
+ case "attaching":
901
+ return "attaching image…";
902
+ case "restoring_history":
903
+ return "restoring history…";
904
+ case "admitting":
905
+ return "admitting turn…";
906
+ case "maintenance_offer":
907
+ return phase.operationId === undefined
908
+ ? "maintenance choice"
909
+ : `running ${maintenanceLabel(phase.action)}…`;
910
+ }
330
911
  }
331
912
 
332
- function editorChanged(before: LineEditorState, after: LineEditorState): boolean {
333
- return before.value !== after.value || before.cursor !== after.cursor;
913
+ function maintenanceAction(input: string): PromptMaintenanceAction | undefined {
914
+ if (input === "c") return "compact";
915
+ if (input === "r") return "retire";
916
+ if (input === "n") return "new_session";
917
+ return undefined;
334
918
  }
335
919
 
336
- function applyEditorChange(
337
- state: PromptInputState,
338
- editor: LineEditorState,
339
- ): PromptInputState {
340
- const valueChanged = state.editor.value !== editor.value;
341
- let navigation = state.navigation;
342
-
343
- if (valueChanged) {
344
- navigation = undefined;
345
- } else if (navigation !== undefined) {
346
- navigation = { ...navigation, browsing: false };
347
- }
348
-
349
- return {
350
- ...state,
351
- editor,
352
- navigation,
353
- suggestionIndex: valueChanged ? 0 : state.suggestionIndex,
354
- suggestionsDismissed: valueChanged ? false : state.suggestionsDismissed,
355
- };
920
+ function maintenanceLabel(action: PromptMaintenanceAction | undefined): string {
921
+ if (action === "compact") return "compact";
922
+ if (action === "retire") return "compact retire";
923
+ if (action === "new_session") return "new session";
924
+ return "maintenance";
356
925
  }
357
926
 
358
- function navigateHistoryUp(
359
- state: PromptInputState,
360
- entries: readonly string[],
361
- ): PromptInputState {
362
- if (entries.length === 0) {
363
- return state;
364
- }
365
-
366
- if (state.navigation === undefined) {
367
- const index = entries.length - 1;
368
- return showHistoryEntry(state, entries, {
369
- index,
370
- originalDraft: state.editor,
371
- browsing: true,
372
- });
373
- }
374
-
375
- if (state.navigation.index === 0) {
376
- return {
377
- ...state,
378
- navigation: { ...state.navigation, browsing: true },
379
- };
380
- }
381
-
382
- const navigation = {
383
- ...state.navigation,
384
- index: state.navigation.index - 1,
385
- browsing: true,
386
- };
387
- return showHistoryEntry(state, entries, navigation);
927
+ function normalizeLineBreaks(value: string): string {
928
+ return value.replace(/\r\n?/g, "\n");
388
929
  }
389
930
 
390
- function navigateHistoryDown(
391
- state: PromptInputState,
392
- entries: readonly string[],
393
- ): PromptInputState {
394
- const navigation = state.navigation;
395
- if (navigation === undefined) {
396
- return state;
397
- }
398
-
399
- if (navigation.index >= entries.length - 1) {
400
- return {
401
- editor: navigation.originalDraft,
402
- suggestionIndex: 0,
403
- suggestionsDismissed: false,
404
- };
405
- }
931
+ function fileMentionReplacement(
932
+ value: string,
933
+ mentionEnd: number,
934
+ filePath: string,
935
+ ): string {
936
+ const suffixStart = [...value][mentionEnd];
937
+ return suffixStart !== undefined && suffixStart !== "\n" && /\s/u.test(suffixStart)
938
+ ? filePath
939
+ : `${filePath} `;
940
+ }
406
941
 
407
- const nextNavigation = {
408
- ...navigation,
409
- index: navigation.index + 1,
410
- browsing: true,
411
- };
412
- return showHistoryEntry(state, entries, nextNavigation);
942
+ function contextColor(
943
+ pressure: ContextUsageSnapshot["pressure"],
944
+ ): "yellow" | "red" | undefined {
945
+ return pressure === "blocked"
946
+ ? "red"
947
+ : pressure === "triggered"
948
+ ? "yellow"
949
+ : undefined;
413
950
  }
414
951
 
415
- function showHistoryEntry(
416
- state: PromptInputState,
417
- entries: readonly string[],
418
- navigation: HistoryNavigation,
419
- ): PromptInputState {
420
- const entry = entries[navigation.index];
421
- if (entry === undefined) {
422
- throw new Error(`History entry ${navigation.index} does not exist`);
423
- }
952
+ function formatWorkspacePath(workspaceRoot: string): string {
953
+ const home = os.homedir();
954
+ const relative = path.relative(home, workspaceRoot);
955
+ if (relative === "") return home;
956
+ const isInsideHome =
957
+ relative !== ".." &&
958
+ !relative.startsWith(`..${path.sep}`) &&
959
+ !path.isAbsolute(relative);
960
+ return isInsideHome ? `~${path.sep}${relative}` : workspaceRoot;
961
+ }
424
962
 
425
- return {
426
- ...state,
427
- editor: createLineEditorState(entry),
428
- navigation,
429
- suggestionIndex: 0,
430
- suggestionsDismissed: true,
431
- };
963
+ function errorMessage(error: unknown): string {
964
+ return error instanceof Error ? error.message : String(error);
432
965
  }