@linxiraos/pi-tui 1.2.1 → 1.3.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 (274) hide show
  1. package/CHANGELOG.md +188 -125
  2. package/README.md +36 -0
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/app-keybindings.d.ts +18 -8
  5. package/dist/types/apps/git/diff-pane.d.ts +5 -0
  6. package/dist/types/apps/git/git-tui.d.ts +16 -2
  7. package/dist/types/apps/git/state.d.ts +11 -0
  8. package/dist/types/autocomplete.d.ts +2 -0
  9. package/dist/types/charts/chart-svg.d.ts +7 -13
  10. package/dist/types/chat/advisor-message.d.ts +4 -1
  11. package/dist/types/chat/assistant-message.d.ts +9 -4
  12. package/dist/types/chat/figure-markdown.d.ts +7 -2
  13. package/dist/types/chat/messages.d.ts +3 -1
  14. package/dist/types/chat/read-tool-group.d.ts +2 -2
  15. package/dist/types/chat/table-chart.d.ts +11 -15
  16. package/dist/types/chat/tool-execution.d.ts +32 -20
  17. package/dist/types/chat/user-message.d.ts +3 -2
  18. package/dist/types/chrome/countdown-timer.d.ts +3 -2
  19. package/dist/types/chrome/diff.d.ts +2 -3
  20. package/dist/types/chrome/transcript-container.d.ts +2 -0
  21. package/dist/types/components/image.d.ts +2 -0
  22. package/dist/types/components/markdown.d.ts +49 -3
  23. package/dist/types/components/text.d.ts +2 -0
  24. package/dist/types/debug-server.d.ts +2 -0
  25. package/dist/types/keybinding-matchers.d.ts +6 -0
  26. package/dist/types/multiplexers/tmux.d.ts +1 -0
  27. package/dist/types/multiplexers/types.d.ts +6 -0
  28. package/dist/types/native/backend.d.ts +5 -4
  29. package/dist/types/native/blobs.d.ts +24 -12
  30. package/dist/types/overlays/agent-hub-projection.d.ts +10 -5
  31. package/dist/types/overlays/annotation-overlay.d.ts +19 -0
  32. package/dist/types/overlays/approval-dialog.d.ts +79 -0
  33. package/dist/types/overlays/ask-dialog.d.ts +11 -6
  34. package/dist/types/overlays/btw-history-panel.d.ts +2 -6
  35. package/dist/types/overlays/hook-editor.d.ts +7 -2
  36. package/dist/types/overlays/hook-input.d.ts +12 -4
  37. package/dist/types/overlays/job-tree.d.ts +50 -0
  38. package/dist/types/overlays/jobs-panel.d.ts +25 -11
  39. package/dist/types/overlays/login-dialog.d.ts +8 -3
  40. package/dist/types/overlays/mcp-add-wizard.d.ts +9 -1
  41. package/dist/types/overlays/model-browser.d.ts +6 -1
  42. package/dist/types/overlays/model-picker.d.ts +2 -0
  43. package/dist/types/overlays/pause-screen.d.ts +5 -0
  44. package/dist/types/overlays/plan-review-overlay.d.ts +4 -0
  45. package/dist/types/overlays/report-panel.d.ts +5 -1
  46. package/dist/types/overlays/reset-usage-selector.d.ts +7 -5
  47. package/dist/types/overlays/session-account-selector.d.ts +4 -0
  48. package/dist/types/overlays/session-info-overlay.d.ts +7 -3
  49. package/dist/types/overlays/session-selector.d.ts +3 -1
  50. package/dist/types/overlays/usage-dashboard.d.ts +6 -6
  51. package/dist/types/prompt/composer.d.ts +21 -20
  52. package/dist/types/prompt/custom-editor.d.ts +20 -10
  53. package/dist/types/prompt/image-references.d.ts +22 -0
  54. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  55. package/dist/types/prompt/macos-spelling.d.ts +2 -1
  56. package/dist/types/prompt/prose-gate.d.ts +3 -0
  57. package/dist/types/prompt/queued-messages.d.ts +1 -1
  58. package/dist/types/prompt/typing-word.d.ts +25 -0
  59. package/dist/types/prompt/welcome.d.ts +26 -6
  60. package/dist/types/render/hanging-wrap.d.ts +7 -0
  61. package/dist/types/render/hyperlink.d.ts +2 -0
  62. package/dist/types/render/render-utils.d.ts +9 -1
  63. package/dist/types/render/utils.d.ts +1 -1
  64. package/dist/types/setup/scenes/splash.d.ts +6 -5
  65. package/dist/types/status-line/context-usage.d.ts +4 -1
  66. package/dist/types/status-line/host.d.ts +6 -4
  67. package/dist/types/status-line/startup.d.ts +6 -6
  68. package/dist/types/status-line/types.d.ts +3 -0
  69. package/dist/types/stdin-buffer.d.ts +7 -0
  70. package/dist/types/terminal-capabilities.d.ts +16 -1
  71. package/dist/types/terminal-multiplexer.d.ts +7 -0
  72. package/dist/types/terminal.d.ts +8 -4
  73. package/dist/types/theme/symbols.d.ts +1 -1
  74. package/dist/types/theme/theme.d.ts +11 -0
  75. package/dist/types/thinking.d.ts +5 -0
  76. package/dist/types/tmux.d.ts +39 -4
  77. package/dist/types/tools/ast-edit.d.ts +2 -2
  78. package/dist/types/tools/ast-grep.d.ts +2 -2
  79. package/dist/types/tools/bash.d.ts +6 -1
  80. package/dist/types/tools/edit.d.ts +2 -0
  81. package/dist/types/tools/eval.d.ts +53 -4
  82. package/dist/types/tools/find.d.ts +14 -4
  83. package/dist/types/tools/glob.d.ts +3 -3
  84. package/dist/types/tools/grep.d.ts +2 -2
  85. package/dist/types/tools/grouped-file-output.d.ts +9 -4
  86. package/dist/types/tools/native-view.d.ts +1 -1
  87. package/dist/types/tools/read.d.ts +32 -10
  88. package/dist/types/tools/renderer.d.ts +2 -0
  89. package/dist/types/tools/task.d.ts +31 -0
  90. package/dist/types/tools/write.d.ts +2 -0
  91. package/dist/types/tui.d.ts +20 -3
  92. package/package.json +10 -10
  93. package/src/app-keybindings.d.ts +18 -8
  94. package/src/app-keybindings.ts +20 -8
  95. package/src/apps/autoresearch-dashboard.ts +444 -27
  96. package/src/apps/debug/raw-sse.ts +2 -2
  97. package/src/apps/git/diff-pane.ts +39 -41
  98. package/src/apps/git/git-tui.ts +80 -14
  99. package/src/apps/git/sidebar.ts +10 -1
  100. package/src/apps/git/state.ts +12 -0
  101. package/src/autocomplete.d.ts +2 -0
  102. package/src/autocomplete.ts +21 -19
  103. package/src/bracketed-paste.ts +9 -2
  104. package/src/charts/chart-svg.d.ts +7 -13
  105. package/src/charts/chart-svg.ts +19 -22
  106. package/src/chat/advisor-message.d.ts +4 -1
  107. package/src/chat/advisor-message.ts +77 -24
  108. package/src/chat/assistant-message.d.ts +9 -4
  109. package/src/chat/assistant-message.ts +127 -31
  110. package/src/chat/bash-execution.ts +7 -2
  111. package/src/chat/figure-markdown.d.ts +7 -2
  112. package/src/chat/figure-markdown.ts +10 -3
  113. package/src/chat/image-loading.ts +9 -6
  114. package/src/chat/messages.d.ts +3 -1
  115. package/src/chat/messages.ts +4 -1
  116. package/src/chat/read-tool-group.d.ts +2 -2
  117. package/src/chat/read-tool-group.ts +97 -42
  118. package/src/chat/svg-figure.ts +3 -1
  119. package/src/chat/svg-source.ts +6 -3
  120. package/src/chat/table-chart.d.ts +11 -15
  121. package/src/chat/table-chart.ts +24 -57
  122. package/src/chat/tool-execution.d.ts +32 -20
  123. package/src/chat/tool-execution.ts +103 -36
  124. package/src/chat/user-message.d.ts +3 -2
  125. package/src/chat/user-message.ts +54 -34
  126. package/src/chrome/countdown-timer.ts +3 -3
  127. package/src/chrome/diff.d.ts +2 -3
  128. package/src/chrome/diff.ts +4 -9
  129. package/src/chrome/transcript-container.d.ts +2 -0
  130. package/src/chrome/transcript-container.ts +212 -28
  131. package/src/components/editor.ts +12 -12
  132. package/src/components/image.d.ts +2 -0
  133. package/src/components/image.ts +54 -20
  134. package/src/components/markdown.d.ts +49 -3
  135. package/src/components/markdown.ts +461 -11
  136. package/src/components/settings-list.ts +2 -4
  137. package/src/components/text.d.ts +2 -0
  138. package/src/components/text.ts +11 -1
  139. package/src/debug-server.d.ts +2 -0
  140. package/src/debug-server.ts +51 -69
  141. package/src/hotkeys-markdown.ts +2 -0
  142. package/src/keybinding-matchers.d.ts +6 -0
  143. package/src/keybinding-matchers.ts +19 -0
  144. package/src/multiplexers/tmux.d.ts +1 -0
  145. package/src/multiplexers/tmux.ts +3 -0
  146. package/src/multiplexers/types.d.ts +6 -0
  147. package/src/multiplexers/types.ts +6 -0
  148. package/src/native/backend.d.ts +5 -4
  149. package/src/native/backend.ts +13 -5
  150. package/src/native/blobs.d.ts +24 -12
  151. package/src/native/blobs.ts +56 -7
  152. package/src/native/reconcile.ts +43 -21
  153. package/src/overlays/advisor-config.ts +259 -8
  154. package/src/overlays/agent-hub-projection.d.ts +10 -5
  155. package/src/overlays/agent-hub-projection.ts +63 -12
  156. package/src/overlays/annotation-overlay.ts +422 -168
  157. package/src/overlays/approval-dialog.ts +389 -0
  158. package/src/overlays/ask-dialog.ts +244 -105
  159. package/src/overlays/btw-history-panel.ts +208 -147
  160. package/src/overlays/extensions/extension-list.ts +4 -6
  161. package/src/overlays/hook-editor.ts +90 -32
  162. package/src/overlays/hook-input.ts +105 -19
  163. package/src/overlays/job-tree.ts +177 -0
  164. package/src/overlays/jobs-panel.ts +222 -142
  165. package/src/overlays/login-dialog.ts +201 -91
  166. package/src/overlays/mcp-add-wizard.ts +562 -279
  167. package/src/overlays/model-browser.ts +32 -7
  168. package/src/overlays/model-hub.ts +4 -10
  169. package/src/overlays/model-picker.ts +58 -21
  170. package/src/overlays/pause-screen.ts +81 -23
  171. package/src/overlays/plan-review-overlay.ts +16 -0
  172. package/src/overlays/plugin-selector.ts +17 -1
  173. package/src/overlays/queue-mode-selector.ts +10 -0
  174. package/src/overlays/report-panel.ts +6 -2
  175. package/src/overlays/reset-usage-selector.ts +130 -21
  176. package/src/overlays/session-account-selector.ts +36 -0
  177. package/src/overlays/session-info-overlay.ts +199 -76
  178. package/src/overlays/session-selector.ts +15 -10
  179. package/src/overlays/settings-selector.ts +3 -0
  180. package/src/overlays/show-images-selector.ts +1 -0
  181. package/src/overlays/snapcompact-shape-preview.ts +13 -6
  182. package/src/overlays/theme-selector.ts +6 -1
  183. package/src/overlays/thinking-selector.ts +8 -4
  184. package/src/overlays/tree-selector.ts +117 -2
  185. package/src/overlays/usage-dashboard.ts +498 -144
  186. package/src/prompt/composer-cache.ts +1 -1
  187. package/src/prompt/composer.ts +194 -74
  188. package/src/prompt/custom-editor.ts +168 -65
  189. package/src/prompt/image-references.d.ts +22 -0
  190. package/src/prompt/image-references.ts +79 -2
  191. package/src/prompt/internal-url-autocomplete.ts +1 -0
  192. package/src/prompt/macos-spelling.ts +28 -5
  193. package/src/prompt/prose-gate.ts +3 -0
  194. package/src/prompt/queued-messages.ts +9 -2
  195. package/src/prompt/tips.txt +1 -2
  196. package/src/prompt/typing-word.ts +59 -0
  197. package/src/prompt/welcome.ts +97 -45
  198. package/src/render/code-cell.ts +18 -5
  199. package/src/render/hanging-wrap.d.ts +7 -0
  200. package/src/render/hanging-wrap.ts +58 -0
  201. package/src/render/hyperlink.d.ts +2 -0
  202. package/src/render/hyperlink.ts +5 -0
  203. package/src/render/render-utils.d.ts +9 -1
  204. package/src/render/render-utils.ts +16 -2
  205. package/src/render/utils.d.ts +1 -1
  206. package/src/render/utils.ts +1 -1
  207. package/src/render/width-aware-text.ts +6 -5
  208. package/src/setup/scenes/splash.ts +22 -8
  209. package/src/status-line/component.ts +23 -10
  210. package/src/status-line/context-usage.ts +176 -73
  211. package/src/status-line/footer.ts +18 -35
  212. package/src/status-line/host.ts +6 -1
  213. package/src/status-line/segments.ts +16 -5
  214. package/src/status-line/startup.ts +6 -6
  215. package/src/status-line/types.ts +1 -0
  216. package/src/stdin-buffer.d.ts +7 -0
  217. package/src/stdin-buffer.ts +11 -1
  218. package/src/terminal-capabilities.d.ts +16 -1
  219. package/src/terminal-capabilities.ts +66 -28
  220. package/src/terminal-multiplexer.d.ts +7 -0
  221. package/src/terminal-multiplexer.ts +14 -0
  222. package/src/terminal.d.ts +8 -4
  223. package/src/terminal.ts +160 -46
  224. package/src/theme/symbols.d.ts +1 -1
  225. package/src/theme/symbols.ts +1 -0
  226. package/src/theme/theme-class.ts +1 -0
  227. package/src/theme/theme.d.ts +11 -0
  228. package/src/theme/theme.ts +46 -14
  229. package/src/thinking.d.ts +5 -0
  230. package/src/thinking.ts +9 -1
  231. package/src/tmux.d.ts +39 -4
  232. package/src/tmux.ts +110 -16
  233. package/src/tools/ask.ts +20 -9
  234. package/src/tools/ast-edit.d.ts +2 -2
  235. package/src/tools/ast-edit.ts +30 -7
  236. package/src/tools/ast-grep.d.ts +2 -2
  237. package/src/tools/ast-grep.ts +39 -35
  238. package/src/tools/bash.d.ts +6 -1
  239. package/src/tools/bash.ts +15 -9
  240. package/src/tools/debug.ts +2 -1
  241. package/src/tools/default-renderer.ts +3 -2
  242. package/src/tools/edit.d.ts +2 -0
  243. package/src/tools/edit.ts +100 -21
  244. package/src/tools/eval.d.ts +53 -4
  245. package/src/tools/eval.ts +173 -137
  246. package/src/tools/find.d.ts +14 -4
  247. package/src/tools/find.ts +71 -18
  248. package/src/tools/glob.d.ts +3 -3
  249. package/src/tools/glob.ts +51 -17
  250. package/src/tools/grep.d.ts +2 -2
  251. package/src/tools/grep.ts +99 -22
  252. package/src/tools/grouped-file-output.d.ts +9 -4
  253. package/src/tools/grouped-file-output.ts +21 -6
  254. package/src/tools/lsp.ts +30 -18
  255. package/src/tools/mcp.ts +2 -2
  256. package/src/tools/native-view.d.ts +1 -1
  257. package/src/tools/native-view.ts +2 -2
  258. package/src/tools/read.d.ts +32 -10
  259. package/src/tools/read.ts +89 -43
  260. package/src/tools/renderer.d.ts +2 -0
  261. package/src/tools/renderer.ts +2 -0
  262. package/src/tools/task.d.ts +31 -0
  263. package/src/tools/task.ts +40 -7
  264. package/src/tools/todo.ts +3 -2
  265. package/src/tools/wait.ts +16 -30
  266. package/src/tools/web-search.ts +2 -1
  267. package/src/tools/write.d.ts +2 -0
  268. package/src/tools/write.ts +36 -12
  269. package/src/tools/yield.ts +2 -2
  270. package/src/tui.d.ts +20 -3
  271. package/src/tui.ts +140 -36
  272. package/src/utils.ts +5 -3
  273. package/dist/types/tools/eval-format/code-mode-declarations.d.ts +0 -5
  274. package/src/tools/eval-format/code-mode-declarations.ts +0 -61
@@ -39,6 +39,7 @@ import {
39
39
  skillToken,
40
40
  } from "./composer-attachments";
41
41
  import { type MacOSSpellingFeatures, MacOSSpellingProvider } from "./macos-spelling";
42
+ import { TypingWordTracker } from "./typing-word";
42
43
  import { hasMagicKeyword, highlightMagicKeywords, magicKeywordRanges } from "./magic-keywords";
43
44
  import type { TspEditorDecoration, TspText } from "@linxiraos/pi-wire";
44
45
  import { isNativeRendering } from "../native/state";
@@ -64,6 +65,8 @@ export interface ComposerNativeState {
64
65
  readonly rate?: number;
65
66
  /** A turn is running: the send keycap becomes a Stop button. */
66
67
  readonly running: boolean;
68
+ /** Native dictation control; undefined hides it for hosts without speech-to-text. */
69
+ readonly dictation?: "idle" | "starting" | "recording" | "transcribing";
67
70
  /** The viewed subagent's lineage, outermost first and the viewed agent last; undefined on the main session. */
68
71
  readonly viewing?: readonly string[];
69
72
  /** The session's title, quoted in italics as the TSP placeholder; undefined until it has one. */
@@ -75,12 +78,18 @@ const FOCUS_ACTION = "focus:";
75
78
 
76
79
  const IDLE_COMPOSER: ComposerNativeState = { running: false };
77
80
 
81
+ /** Recording and transcription still write into the draft, so sending it then would split the dictation. */
82
+ function dictationHoldsDraft(dictation: ComposerNativeState["dictation"]): boolean {
83
+ return dictation === "recording" || dictation === "transcribing";
84
+ }
85
+
78
86
  /** The composer's controls around the input, rebuilt when what they show changes; the bar adds the rate and facts. */
79
87
  interface ComposerControls {
80
88
  readonly focus: NativeNode | undefined;
81
89
  readonly mode: NativeNode | undefined;
82
90
  readonly model: NativeNode | undefined;
83
91
  readonly effort: NativeNode | undefined;
92
+ readonly dictation: NativeNode | undefined;
84
93
  readonly submit: NativeNode;
85
94
  }
86
95
 
@@ -453,6 +462,8 @@ export type ComposerChipDescriptor =
453
462
  */
454
463
  export class CustomEditor extends Editor {
455
464
  #spelling = new MacOSSpellingProvider();
465
+ /** The word being typed: its typo mark waits until the caret leaves it. */
466
+ #typing = new TypingWordTracker();
456
467
  #wordCompletion = new WordCompletionProvider();
457
468
  imageLinks?: readonly (string | undefined)[];
458
469
 
@@ -857,6 +868,9 @@ export class CustomEditor extends Editor {
857
868
  }
858
869
  // One string instance per revision: whole-buffer memos downstream hit on identity.
859
870
  const editorText = this.#decorationText;
871
+ const caret = this.getCursor();
872
+ this.#typing.update(this.textRevision, this.#decorationLines, caret.line, caret.col, this.focused);
873
+ const typing = this.#typing.word;
860
874
  const animated = this.focused && this.#shimmerEnabled() && hasMagicKeyword(editorText);
861
875
  const phase = animated ? (Date.now() % CustomEditor.SHIMMER_PERIOD_MS) / CustomEditor.SHIMMER_PERIOD_MS : 0;
862
876
  if (animated) this.#scheduleShimmerFrame();
@@ -879,6 +893,7 @@ export class CustomEditor extends Editor {
879
893
  lines: this.#decorationLines,
880
894
  line: context.line,
881
895
  startCol: context.startCol + sourceOffset,
896
+ typing,
882
897
  },
883
898
  span => highlightMagicKeywords(span, undefined, phase),
884
899
  );
@@ -926,6 +941,15 @@ export class CustomEditor extends Editor {
926
941
  );
927
942
  };
928
943
 
944
+ /** Catch the typed word up with the caret first: its leaving the word (or a blur) re-describes the typo marks. */
945
+ override describe(cx: DescribeContext): NativeNode {
946
+ const caret = this.getCursor();
947
+ if (this.#typing.update(this.textRevision, this.getLines(), caret.line, caret.col, this.focused)) {
948
+ this.invalidate();
949
+ }
950
+ return super.describe(cx);
951
+ }
952
+
929
953
  /**
930
954
  * TSP decorations: magic keywords shimmer (terminal-clocked) in the accent
931
955
  * color, chips and markers take their token colors, misspellings are marked,
@@ -966,6 +990,7 @@ export class CustomEditor extends Editor {
966
990
 
967
991
  const queueBody = parseQueueShorthand(text);
968
992
  const queueList = queueBody !== undefined && isQueuedMessageList(queueBody);
993
+ const typing = this.#typing.word;
969
994
  let lineStart = 0;
970
995
  for (let line = 0; line < lines.length; line++) {
971
996
  const value = lines[line] ?? "";
@@ -979,7 +1004,8 @@ export class CustomEditor extends Editor {
979
1004
  decor.push({ from: lineStart + indent, to: lineStart + marker[0].length, s: "accent" });
980
1005
  }
981
1006
  }
982
- for (const typo of this.#spelling.typoRanges(value, { editorText: text, lines, line, startCol: 0 })) {
1007
+ const context = { editorText: text, lines, line, startCol: 0, typing };
1008
+ for (const typo of this.#spelling.typoRanges(value, context)) {
983
1009
  decor.push({ from: lineStart + typo.start, to: lineStart + typo.start + typo.length, s: "typo" });
984
1010
  }
985
1011
  lineStart += value.length + 1;
@@ -1034,9 +1060,10 @@ export class CustomEditor extends Editor {
1034
1060
  }, CustomEditor.SHIMMER_FRAME_MS);
1035
1061
  this.#shimmerTimer.unref?.();
1036
1062
  }
1037
- /** Editing is available during bootstrap; atomic sends wait until submission is wired and enabled. */
1063
+ /** Editing is available during bootstrap; atomic sends wait until submission is wired and enabled
1064
+ * and no dictation is still writing into the draft. */
1038
1065
  protected override get nativeSendable(): boolean {
1039
- return this.onSubmit !== undefined && !this.disableSubmit;
1066
+ return this.onSubmit !== undefined && !this.disableSubmit && !dictationHoldsDraft(this.composerState().dictation);
1040
1067
  }
1041
1068
  /** The quoted-title placeholder, kept while the title stands so the editor node stays reused. */
1042
1069
  #titlePlaceholder: { title: string; text: TspText } | undefined;
@@ -1078,9 +1105,9 @@ export class CustomEditor extends Editor {
1078
1105
  * subagent is focused, the attachment chips, a `line` row of the
1079
1106
  * shell-mode chip and the input, and the `bar`: model chip, effort chip
1080
1107
  * (or the effort glyph as the model chip's icon), the generation rate,
1081
- * the other status facts, usage, then send (Stop while a turn runs).
1082
- * Clicks come back as `status.model`, `thinking.cycle`, `submit`,
1083
- * `interrupt` and `focus:<id>` actions.
1108
+ * the other status facts, usage, dictation, then send (Stop while a turn runs).
1109
+ * Clicks come back as `status.model`, `thinking.cycle`, `dictation.toggle`,
1110
+ * `submit`, `interrupt` and `focus:<id>` actions.
1084
1111
  */
1085
1112
  override describeLayout = (input: NativeNode, cx: DescribeContext): NativeEditorLayout => {
1086
1113
  const effortGlyph = cx.supports("effort");
@@ -1095,6 +1122,7 @@ export class CustomEditor extends Editor {
1095
1122
  shell?.excluded,
1096
1123
  state.thinking,
1097
1124
  state.running,
1125
+ state.dictation,
1098
1126
  thinkingKey,
1099
1127
  modelKey,
1100
1128
  interruptKey,
@@ -1138,6 +1166,7 @@ export class CustomEditor extends Editor {
1138
1166
  // The status facts are the bar's flexible space; without them a spacer keeps send at the end.
1139
1167
  facts?.extras ?? node("row", { grow: 1 }, [], "gap"),
1140
1168
  facts?.usage,
1169
+ controls.dictation,
1141
1170
  controls.submit,
1142
1171
  ]),
1143
1172
  { role: "zeta.composer.bar", gap: "sm", align: "center" },
@@ -1289,6 +1318,38 @@ export class CustomEditor extends Editor {
1289
1318
  "effort",
1290
1319
  )
1291
1320
  : undefined;
1321
+ const recording = state.dictation === "recording";
1322
+ const starting = state.dictation === "starting";
1323
+ const transcribing = state.dictation === "transcribing";
1324
+ const dictation =
1325
+ state.dictation === undefined
1326
+ ? undefined
1327
+ : node(
1328
+ "row",
1329
+ {
1330
+ role: "zeta.composer.dictation",
1331
+ gap: "xs",
1332
+ align: "center",
1333
+ tone: recording ? "error" : starting || transcribing ? "pending" : undefined,
1334
+ title: recording
1335
+ ? "Stop recording and transcribe"
1336
+ : starting
1337
+ ? "Cancel dictation"
1338
+ : transcribing
1339
+ ? "Transcribing dictation"
1340
+ : "Start dictation",
1341
+ // The model can take a while to load on first use, so a start stays cancellable.
1342
+ actions: transcribing ? undefined : { click: "dictation.toggle" },
1343
+ },
1344
+ starting || transcribing
1345
+ ? [node("spinner", { label: starting ? "Starting…" : "Transcribing…" }, undefined, "pending")]
1346
+ : compact([
1347
+ node("icon", { name: recording ? "stop" : "mic" }, undefined, "icon"),
1348
+ recording && node("text", { text: "Recording · Stop", wrap: "none" }, undefined, "label"),
1349
+ ]),
1350
+ "dictation",
1351
+ );
1352
+ const dictating = dictationHoldsDraft(state.dictation);
1292
1353
  const submit = state.running
1293
1354
  ? node(
1294
1355
  "text",
@@ -1307,13 +1368,14 @@ export class CustomEditor extends Editor {
1307
1368
  {
1308
1369
  role: "zeta.composer.send",
1309
1370
  keys: ["enter"],
1310
- title: `Send ${formatTooltipKey("enter")}`,
1311
- actions: { click: "submit" },
1371
+ title: dictating ? "Finish dictation before sending" : `Send ${formatTooltipKey("enter")}`,
1372
+ tone: dictating ? "muted" : undefined,
1373
+ actions: dictating ? undefined : { click: "submit" },
1312
1374
  },
1313
1375
  undefined,
1314
1376
  "send",
1315
1377
  );
1316
- if (!shell) return { focus, mode: undefined, model, effort, submit };
1378
+ if (!shell) return { focus, mode: undefined, model, effort, dictation, submit };
1317
1379
  const runs = shell.kind === "bash" ? "Runs in your shell" : "Runs in Python";
1318
1380
  const mode = keyed(
1319
1381
  row(
@@ -1336,15 +1398,15 @@ export class CustomEditor extends Editor {
1336
1398
  ),
1337
1399
  "mode",
1338
1400
  );
1339
- return { focus, mode, model, effort, submit };
1401
+ return { focus, mode, model, effort, dictation, submit };
1340
1402
  }
1341
1403
 
1342
1404
  /**
1343
- * Clicks on the composer's controls take the same paths as their keys: ⇧⇥,
1344
- * ⏎ and Esc; the viewing header's links (`focus:<id>`) go to the host,
1345
- * the status facts' clicks (`status.*`) to their source. Selection edits
1346
- * go to the buffer; `send` submits its own prompt after saving the old draft
1347
- * for recall and waiting for in-flight clipboard work.
1405
+ * Dictation clicks go directly to the host, independent of keybindings.
1406
+ * Other controls take the same paths as their keys; the viewing header's
1407
+ * links (`focus:<id>`) go to the host, the status facts' clicks to their source.
1408
+ * Selection edits go to the buffer; `send` submits its own prompt after
1409
+ * saving the old draft for recall and waiting for in-flight clipboard work.
1348
1410
  */
1349
1411
  override handleNativeEvent(event: NativeUiEvent): void {
1350
1412
  if (event.type === "send") {
@@ -1368,8 +1430,13 @@ export class CustomEditor extends Editor {
1368
1430
  case "thinking.cycle":
1369
1431
  this.onCycleThinkingLevel?.();
1370
1432
  return;
1433
+ case "dictation.toggle": {
1434
+ const { dictation } = this.composerState();
1435
+ if (dictation !== undefined && dictation !== "transcribing") this.onToggleDictation?.();
1436
+ return;
1437
+ }
1371
1438
  case "submit":
1372
- this.submit();
1439
+ if (this.nativeSendable) this.submit();
1373
1440
  return;
1374
1441
  case "interrupt":
1375
1442
  this.onEscape?.();
@@ -1404,6 +1471,8 @@ export class CustomEditor extends Editor {
1404
1471
  onRetry?: () => void;
1405
1472
  /** Called when Caps Lock is pressed. */
1406
1473
  onCapsLock?: () => void;
1474
+ /** Called directly by the native microphone/stop control, without synthesizing a keypress. */
1475
+ onToggleDictation?: () => void;
1407
1476
  /** Called when left-arrow is pressed while the editor is empty (cursor necessarily at start). */
1408
1477
  onLeftAtStart?: () => void;
1409
1478
  /** Called when the viewing header asks to view agent `id` ({@link MAIN_AGENT_ID}: the main session). */
@@ -1436,8 +1505,9 @@ export class CustomEditor extends Editor {
1436
1505
  * `pendingImages` (Codex PR #3602 review). */
1437
1506
  #pasteInFlight = 0;
1438
1507
  /** Input chunks and explicit prompts deferred behind an in-flight paste,
1439
- * drained in FIFO order once the paste count returns to zero. */
1440
- #pendingInput: (string | Extract<NativeUiEvent, { type: "send" }>)[] = [];
1508
+ * drained in FIFO order once the paste count returns to zero. A `draft` chunk
1509
+ * arrived through {@link handleDraftEdit} and drains back through it. */
1510
+ #pendingInput: (string | { draft: string } | Extract<NativeUiEvent, { type: "send" }>)[] = [];
1441
1511
  #actionKeys = new Map<ConfigurableEditorAction, KeyId[]>(
1442
1512
  Object.entries(DEFAULT_ACTION_KEYS).map(([action, keys]) => [action as ConfigurableEditorAction, [...keys]]),
1443
1513
  );
@@ -1516,6 +1586,7 @@ export class CustomEditor extends Editor {
1516
1586
  const drained = this.#pendingInput.splice(0);
1517
1587
  for (const input of drained) {
1518
1588
  if (typeof input === "string") this.handleInput(input);
1589
+ else if ("draft" in input) this.handleDraftEdit(input.draft);
1519
1590
  else this.handleNativeEvent(input);
1520
1591
  }
1521
1592
  }
@@ -1530,6 +1601,11 @@ export class CustomEditor extends Editor {
1530
1601
  void promise.then(this.#onPasteSettled, this.#onPasteSettled);
1531
1602
  }
1532
1603
 
1604
+ /** True while a clipboard-image or image-path paste is still landing; later keys queue behind it. */
1605
+ get pasteInFlight(): boolean {
1606
+ return this.#pasteInFlight > 0;
1607
+ }
1608
+
1533
1609
  capturesInput(data: string): boolean {
1534
1610
  return this.spaceHold.shouldRoute(data);
1535
1611
  }
@@ -1550,56 +1626,21 @@ export class CustomEditor extends Editor {
1550
1626
  this.onCapsLock();
1551
1627
  return;
1552
1628
  }
1553
-
1554
- // Bracketed-paste assembly. Some terminals fragment the start marker,
1555
- // the payload, and the end marker across separate stdin chunks
1556
- // (Windows Terminal under heavy load, certain SSH muxes, …); the
1557
- // inherited handler then sees a zero-length payload and silently
1558
- // drops it through the normal text-insert path. Running our own
1559
- // `BracketedPasteHandler` ahead of `super.handleInput` lets us route
1560
- // the assembled content regardless of chunk boundaries:
1561
- // - empty payload → `onPasteImage` (#3601: `Cmd+V`/`Ctrl+V` on an
1562
- // image-only macOS pasteboard the terminal stripped to `""` first);
1563
- // - explicit image-file paths → `onPasteImagePath` (#3506);
1564
- // - anything else → the base editor's `pasteText` so `[Paste #N]`
1565
- // markers, autocomplete, and undo state stay intact.
1566
- const paste = this.#pasteHandler.process(data);
1567
- if (paste.handled) {
1568
- if (paste.pasteContent === undefined) return; // still buffering — wait for end marker
1569
- const content = paste.pasteContent;
1570
- const remaining = paste.remaining;
1571
- // Queue any trailing bytes from the same read (typically a follow-up keystroke such as
1572
- // Enter that the user pressed right after Cmd+V) so they only fire *after* the paste
1573
- // completes — fixes the race where submit runs against an empty `pendingImages`.
1574
- if (remaining.length > 0) this.#pendingInput.push(remaining);
1575
- if (content.length === 0 && this.onPasteImage) {
1576
- this.#trackAsyncPaste(Promise.resolve(this.onPasteImage()));
1577
- return;
1578
- }
1579
- const attachmentPaths = extractImagePastePathsFromText(content);
1580
- if (attachmentPaths && this.onPasteImagePath) {
1581
- this.#trackAsyncPaste(
1582
- (async () => {
1583
- for (const p of attachmentPaths) await this.onPasteImagePath?.(p);
1584
- })(),
1585
- );
1586
- return;
1587
- }
1588
- // A submit key that shared the read (see `StdinBuffer`'s paste event) is
1589
- // about to be drained below, so a large-paste host must stage the paste
1590
- // synchronously instead of opening a menu the submit would land in.
1591
- if (this.#isSubmitKey(remaining)) this.pasteText(content, { submitAfterPaste: true });
1592
- else this.pasteText(content);
1593
- this.#collapseSkillTokens();
1594
- this.#collapseModelMentions();
1595
- // No async paste was started; drain the queued trailing bytes ourselves.
1596
- this.#drainPendingInput();
1597
- return;
1598
- }
1629
+ if (this.#routeBracketedPaste(data, false)) return;
1599
1630
 
1600
1631
  const parsedKey = parseKey(data);
1601
1632
  const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined;
1602
1633
 
1634
+ // Enter matches the disabled Send button while dictation still writes into the draft;
1635
+ // dictation's own submit trigger goes through submit(), not this key path.
1636
+ if (
1637
+ canonical !== undefined &&
1638
+ getKeybindings().matchesCanonical(canonical, "tui.input.submit") &&
1639
+ dictationHoldsDraft(this.composerState().dictation)
1640
+ ) {
1641
+ return;
1642
+ }
1643
+
1603
1644
  // Space-hold push-to-talk runs before editor shortcuts so a reserved binding can never
1604
1645
  // delete, move, submit, or invoke an app action while the gesture is enabled.
1605
1646
  const spaceHoldText = getSpaceHoldText(data, canonical);
@@ -1828,6 +1869,57 @@ export class CustomEditor extends Editor {
1828
1869
  this.#normalizeQueuePrefix(hadBareQueuePrefix);
1829
1870
  }
1830
1871
 
1872
+ /**
1873
+ * Bracketed-paste assembly. Some terminals fragment the start marker,
1874
+ * the payload, and the end marker across separate stdin chunks
1875
+ * (Windows Terminal under heavy load, certain SSH muxes, …); the
1876
+ * inherited handler then sees a zero-length payload and silently
1877
+ * drops it through the normal text-insert path. Running our own
1878
+ * `BracketedPasteHandler` ahead of `super.handleInput` lets us route
1879
+ * the assembled content regardless of chunk boundaries:
1880
+ * - empty payload → `onPasteImage` (#3601: `Cmd+V`/`Ctrl+V` on an
1881
+ * image-only macOS pasteboard the terminal stripped to `""` first);
1882
+ * - explicit image-file paths → `onPasteImagePath` (#3506);
1883
+ * - anything else → the base editor's `pasteText` so `[Paste #N]`
1884
+ * markers, autocomplete, and undo state stay intact.
1885
+ * True when `data` was consumed. `draft` queues trailing bytes back through
1886
+ * {@link handleDraftEdit} instead of {@link handleInput}.
1887
+ */
1888
+ #routeBracketedPaste(data: string, draft: boolean): boolean {
1889
+ const paste = this.#pasteHandler.process(data);
1890
+ if (!paste.handled) return false;
1891
+ if (paste.pasteContent === undefined) return true; // still buffering — wait for end marker
1892
+ const content = paste.pasteContent;
1893
+ const remaining = paste.remaining;
1894
+ // Queue any trailing bytes from the same read (typically a follow-up keystroke such as
1895
+ // Enter that the user pressed right after Cmd+V) so they only fire *after* the paste
1896
+ // completes — fixes the race where submit runs against an empty `pendingImages`.
1897
+ if (remaining.length > 0) this.#pendingInput.push(draft ? { draft: remaining } : remaining);
1898
+ if (content.length === 0 && this.onPasteImage) {
1899
+ this.#trackAsyncPaste(Promise.resolve(this.onPasteImage()));
1900
+ return true;
1901
+ }
1902
+ const attachmentPaths = extractImagePastePathsFromText(content);
1903
+ if (attachmentPaths && this.onPasteImagePath) {
1904
+ this.#trackAsyncPaste(
1905
+ (async () => {
1906
+ for (const p of attachmentPaths) await this.onPasteImagePath?.(p);
1907
+ })(),
1908
+ );
1909
+ return true;
1910
+ }
1911
+ // A submit key that shared the read (see `StdinBuffer`'s paste event) is
1912
+ // about to be drained below, so a large-paste host must stage the paste
1913
+ // synchronously instead of opening a menu the submit would land in.
1914
+ if (this.#isSubmitKey(remaining)) this.pasteText(content, { submitAfterPaste: true });
1915
+ else this.pasteText(content);
1916
+ this.#collapseSkillTokens();
1917
+ this.#collapseModelMentions();
1918
+ // No async paste was started; drain the queued trailing bytes ourselves.
1919
+ this.#drainPendingInput();
1920
+ return true;
1921
+ }
1922
+
1831
1923
  /** Promote a newly formed bare `->` / `=>` prefix to a reserved header line by opening the
1832
1924
  * queue body beneath it. `hadBareQueuePrefix` is the pre-edit state: a prompt that was
1833
1925
  * already just the prefix is left alone so the user can keep editing it. */
@@ -1846,9 +1938,16 @@ export class CustomEditor extends Editor {
1846
1938
  * an Ask dialog opened over a non-empty prompt — so finishing or submitting
1847
1939
  * the draft cannot fire an editor-slot shortcut that clears
1848
1940
  * `editorContainer` and orphans the overlay. Only text editing, cursor
1849
- * movement, submission, and the clear action reach the buffer.
1941
+ * movement, submission, the clear action, and pastes (clipboard images and
1942
+ * image paths attach as on the composer) reach the buffer.
1850
1943
  */
1851
1944
  handleDraftEdit(data: string): void {
1945
+ // Same serialization as handleInput: keys typed behind an image paste wait for it.
1946
+ if (this.#pasteInFlight > 0) {
1947
+ this.#pendingInput.push({ draft: data });
1948
+ return;
1949
+ }
1950
+ if (this.#routeBracketedPaste(data, true)) return;
1852
1951
  // The base editor reserves Ctrl+C for parent handling and returns without
1853
1952
  // touching the buffer, so the configured clear action must be dispatched
1854
1953
  // explicitly here — otherwise the guard's "finish or clear the prompt"
@@ -1862,6 +1961,10 @@ export class CustomEditor extends Editor {
1862
1961
  else this.setText("");
1863
1962
  return;
1864
1963
  }
1964
+ if (canonical !== undefined && this.#matchesAction(canonical, "app.clipboard.pasteImage") && this.onPasteImage) {
1965
+ this.#trackAsyncPaste(Promise.resolve(this.onPasteImage()));
1966
+ return;
1967
+ }
1865
1968
  this.#forwardInput(data);
1866
1969
  }
1867
1970
 
@@ -1,4 +1,5 @@
1
1
  import type { ImageContent } from "@linxiraos/pi-ai";
2
+ import type { TspSpan } from "@linxiraos/pi-wire";
2
3
  /** Materialized image destination returned by the host blob writer. */
3
4
  interface ImageBlobResult {
4
5
  displayPath: string;
@@ -23,4 +24,25 @@ type ImageBlobWriterSync = (data: Buffer, options?: {
23
24
  export declare function imageReferenceHyperlink(label: string, index: number, imageLinks: readonly (string | undefined)[] | undefined, renderLabel: (text: string) => string): string;
24
25
  export declare function materializeImageReferenceLinks(images: readonly ImageContent[] | undefined, putBlob: ImageBlobWriter): Promise<(string | undefined)[] | undefined>;
25
26
  export declare function materializeImageReferenceLinksSync(images: readonly ImageContent[] | undefined, putBlob: ImageBlobWriterSync): (string | undefined)[] | undefined;
27
+ /**
28
+ * Register the host's blob writer that backs links for images without a file of their
29
+ * own. Returns a disposer that unregisters it unless another writer replaced it since.
30
+ */
31
+ export declare function setImageBlobWriter(next: ImageBlobWriterSync): () => void;
32
+ /**
33
+ * Whether transcript images carry links under the current `tui.hyperlinks` policy: a
34
+ * native surface honours only an explicit `off`, text mode the resolved OSC 8 support.
35
+ * Renderers caching a linked image key their cache on it.
36
+ */
37
+ export declare function imageLinksEnabled(): boolean;
38
+ /**
39
+ * The file a transcript image opens: `sourcePath` when it was read from one, else a
40
+ * blob copy of its bytes written through the host. Undefined when links are disabled
41
+ * or no file exists, so a disabled policy never writes blobs.
42
+ */
43
+ export declare function transcriptImageLink(image: Pick<ImageContent, "data" | "mimeType">, sourcePath?: string): string | undefined;
44
+ /** `text` (an image's placeholder) as an OSC 8 link to the image's file; plain when hyperlinks are off or no file exists. */
45
+ export declare function linkedImageText(text: string, image: Pick<ImageContent, "data" | "mimeType">, sourcePath?: string): string;
46
+ /** Native counterpart of {@link linkedImageText}: a span linking to the image's file when one exists. */
47
+ export declare function linkedImageSpan(text: string, image: Pick<ImageContent, "data" | "mimeType">, sourcePath: string | undefined, s?: string): TspSpan;
26
48
  export {};
@@ -1,13 +1,15 @@
1
1
  import type { ImageContent } from "@linxiraos/pi-ai";
2
2
  import { logger } from "@linxiraos/pi-utils";
3
+ import type { TspSpan } from "@linxiraos/pi-wire";
4
+ import { span } from "../native/describe";
5
+ import { isNativeRendering } from "../native/state";
6
+ import { fileHyperlink, fileLinkSpan, isHyperlinkEnabled, isNativeLinkEnabled } from "../render/hyperlink";
3
7
  import { blobExtensionForImageMimeType } from "./image-format";
4
8
 
5
9
  /** Materialized image destination returned by the host blob writer. */
6
10
  interface ImageBlobResult {
7
11
  displayPath: string;
8
12
  }
9
- import { fileHyperlink } from "../render/hyperlink";
10
-
11
13
  /** Probed pixel dimensions riding on the draft image object itself; `null` records a failed
12
14
  * probe so the chips band never re-decodes a corrupt header every frame. */
13
15
  const kImageDims = Symbol("zeta.imageDimensions");
@@ -100,3 +102,78 @@ export function materializeImageReferenceLinksSync(
100
102
  const links = images.map((image, index) => materializeImageReferenceLink(image, index + 1, putBlob));
101
103
  return links.some(link => link !== undefined) ? links : undefined;
102
104
  }
105
+
106
+ let hostBlobWriter: ImageBlobWriterSync | undefined;
107
+ /** Blob paths already written for an image payload, keyed by mime type and content hash. */
108
+ const blobLinks = new Map<string, string>();
109
+ const BLOB_LINK_CACHE_LIMIT = 256;
110
+
111
+ /**
112
+ * Register the host's blob writer that backs links for images without a file of their
113
+ * own. Returns a disposer that unregisters it unless another writer replaced it since.
114
+ */
115
+ export function setImageBlobWriter(next: ImageBlobWriterSync): () => void {
116
+ hostBlobWriter = next;
117
+ blobLinks.clear();
118
+ return () => {
119
+ if (hostBlobWriter !== next) return;
120
+ hostBlobWriter = undefined;
121
+ blobLinks.clear();
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Whether transcript images carry links under the current `tui.hyperlinks` policy: a
127
+ * native surface honours only an explicit `off`, text mode the resolved OSC 8 support.
128
+ * Renderers caching a linked image key their cache on it.
129
+ */
130
+ export function imageLinksEnabled(): boolean {
131
+ return isNativeRendering() ? isNativeLinkEnabled() : isHyperlinkEnabled();
132
+ }
133
+
134
+ /**
135
+ * The file a transcript image opens: `sourcePath` when it was read from one, else a
136
+ * blob copy of its bytes written through the host. Undefined when links are disabled
137
+ * or no file exists, so a disabled policy never writes blobs.
138
+ */
139
+ export function transcriptImageLink(
140
+ image: Pick<ImageContent, "data" | "mimeType">,
141
+ sourcePath?: string,
142
+ ): string | undefined {
143
+ if (!imageLinksEnabled()) return undefined;
144
+ if (sourcePath) return sourcePath;
145
+ if (!hostBlobWriter || !image.data) return undefined;
146
+ const key = `${image.mimeType}:${Bun.hash(image.data)}`;
147
+ const cached = blobLinks.get(key);
148
+ if (cached) return cached;
149
+ const link = materializeImageReferenceLink({ type: "image", ...image }, 1, hostBlobWriter);
150
+ if (!link) return undefined;
151
+ if (blobLinks.size >= BLOB_LINK_CACHE_LIMIT) {
152
+ const oldest = blobLinks.keys().next();
153
+ if (!oldest.done) blobLinks.delete(oldest.value);
154
+ }
155
+ blobLinks.set(key, link);
156
+ return link;
157
+ }
158
+
159
+ /** `text` (an image's placeholder) as an OSC 8 link to the image's file; plain when hyperlinks are off or no file exists. */
160
+ export function linkedImageText(
161
+ text: string,
162
+ image: Pick<ImageContent, "data" | "mimeType">,
163
+ sourcePath?: string,
164
+ ): string {
165
+ if (!isHyperlinkEnabled()) return text;
166
+ const target = transcriptImageLink(image, sourcePath);
167
+ return target ? fileHyperlink(target, text) : text;
168
+ }
169
+
170
+ /** Native counterpart of {@link linkedImageText}: a span linking to the image's file when one exists. */
171
+ export function linkedImageSpan(
172
+ text: string,
173
+ image: Pick<ImageContent, "data" | "mimeType">,
174
+ sourcePath: string | undefined,
175
+ s?: string,
176
+ ): TspSpan {
177
+ const target = transcriptImageLink(image, sourcePath);
178
+ return target ? fileLinkSpan(target, text, s) : span(text, s);
179
+ }
@@ -76,6 +76,7 @@ export interface InternalUrlCallerContext {
76
76
  cwd?: string;
77
77
  sessionFile?: string;
78
78
  sessionId?: string;
79
+ seanceSourceArtifactsDir?: string;
79
80
  }
80
81
 
81
82
  /**
@@ -1,7 +1,7 @@
1
1
  import * as native from "@linxiraos/pi-natives";
2
2
  import { TERMINAL } from "../index";
3
3
  import type { EditorInlineReplacement, EditorTextAssistProvider, EditorWordReplacements } from "../components/editor";
4
- import { logger } from "@linxiraos/pi-utils";
4
+ import { logger, withTimeout } from "@linxiraos/pi-utils";
5
5
  import { isMagicKeyword } from "./magic-keywords";
6
6
  import { maskNonProse } from "./markdown-prose";
7
7
  import { isProseWord, lineContext, ProseSource, type SpellingDecorationContext } from "./prose-gate";
@@ -45,6 +45,12 @@ const NATIVE_BACKEND: SpellingBackend = {
45
45
  spellingGuesses: (text, start, length) => native.macOSSpellingGuesses(text, start, length),
46
46
  };
47
47
 
48
+ /**
49
+ * The native side awaits a `flume::bounded(1)` reply with no timeout, so a stuck call
50
+ * stays pending and never reaches the `catch` that disables the provider.
51
+ */
52
+ const BACKEND_TIMEOUT_MS = 2_000;
53
+
48
54
  /**
49
55
  * Bridges Apple's spelling service into the editor's typo and autocorrection
50
56
  * paths. Word completion is the cross-platform `WordCompletionProvider`
@@ -113,7 +119,8 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
113
119
 
114
120
  /**
115
121
  * Misspelled prose ranges in `text` (offsets into it), in order and
116
- * non-overlapping. Schedules the verification check for uncached text; its
122
+ * non-overlapping, except ones touching `context.typing` (the word still
123
+ * being typed). Schedules the verification check for uncached text; its
117
124
  * result arrives through {@link onUpdate}.
118
125
  */
119
126
  typoRanges(text: string, context: SpellingDecorationContext): readonly native.SpellingRange[] {
@@ -131,9 +138,13 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
131
138
  const ranges = cached ?? this.#projectTypoRanges(text);
132
139
  if (!ranges || ranges.length === 0) return [];
133
140
  const kept: native.SpellingRange[] = [];
141
+ const typing = context.typing?.line === context.line ? context.typing : undefined;
134
142
  let cursor = 0;
135
143
  for (const range of ranges) {
136
144
  const end = range.start + range.length;
145
+ if (typing && context.startCol + range.start <= typing.end && typing.start <= context.startCol + end) {
146
+ continue;
147
+ }
137
148
  // Overlapping or out-of-bounds ranges (stale native data, edit projection)
138
149
  // must never re-emit already-rendered text: that doubles it on screen and
139
150
  // desyncs the rendered width from the measured width (cursor drift).
@@ -167,7 +178,11 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
167
178
  const context = lineContext(lines, cursorLine);
168
179
  if (!this.#prose.isProse(context, start, start + word.length)) return null;
169
180
  try {
170
- const correction = await this.backend.autocorrectWord(textBeforeCursor, start, word.length);
181
+ const correction = await withTimeout(
182
+ this.backend.autocorrectWord(textBeforeCursor, start, word.length),
183
+ BACKEND_TIMEOUT_MS,
184
+ `macOS spelling service did not respond within ${BACKEND_TIMEOUT_MS}ms`,
185
+ );
171
186
  if (!correction || correction === word) return null;
172
187
  return { replaceLen: word.length + boundary.length, insert: correction + boundary };
173
188
  } catch (error) {
@@ -200,7 +215,11 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
200
215
  try {
201
216
  const seen = new Set<string>();
202
217
  const items: string[] = [];
203
- for (const guess of await this.backend.spellingGuesses(line, range.start, range.length)) {
218
+ for (const guess of await withTimeout(
219
+ this.backend.spellingGuesses(line, range.start, range.length),
220
+ BACKEND_TIMEOUT_MS,
221
+ `macOS spelling service did not respond within ${BACKEND_TIMEOUT_MS}ms`,
222
+ )) {
204
223
  if (!guess || seen.has(guess)) continue;
205
224
  seen.add(guess);
206
225
  items.push(guess);
@@ -305,7 +324,11 @@ export class MacOSSpellingProvider implements EditorTextAssistProvider {
305
324
  async #fetchTypoRanges(text: string, generation: number): Promise<readonly native.SpellingRange[]> {
306
325
  let checked: readonly native.SpellingRange[];
307
326
  try {
308
- checked = await this.backend.checkSpelling(text);
327
+ checked = await withTimeout(
328
+ this.backend.checkSpelling(text),
329
+ BACKEND_TIMEOUT_MS,
330
+ `macOS spelling service did not respond within ${BACKEND_TIMEOUT_MS}ms`,
331
+ );
309
332
  } catch (error) {
310
333
  this.#disable(error);
311
334
  return [];
@@ -1,4 +1,5 @@
1
1
  import { maskNonProse } from "./markdown-prose";
2
+ import type { TypingWord } from "./typing-word";
2
3
 
3
4
  /**
4
5
  * Client-side gates shared by the prose assistants (macOS typo/autocorrect and
@@ -19,6 +20,8 @@ export interface SpellingDecorationContext {
19
20
  lines: readonly string[];
20
21
  line: number;
21
22
  startCol: number;
23
+ /** The word being typed: typo marks touching it wait until the caret leaves it. */
24
+ typing?: TypingWord | undefined;
22
25
  }
23
26
 
24
27
  function tokenAt(text: string, start: number, end: number): string {