@oh-my-pi/pi-tui 18.13.2 → 18.14.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 (245) hide show
  1. package/CHANGELOG.md +40 -54
  2. package/README.md +34 -34
  3. package/dist/types/app-keybindings.d.ts +15 -0
  4. package/dist/types/apps/session-picker.d.ts +5 -0
  5. package/dist/types/charts/chart-plan.d.ts +9 -7
  6. package/dist/types/chat/assistant-message.d.ts +15 -3
  7. package/dist/types/chat/bash-execution.d.ts +2 -0
  8. package/dist/types/chat/display-preferences.d.ts +2 -0
  9. package/dist/types/chat/extension-types.d.ts +31 -0
  10. package/dist/types/chat/reply-write.d.ts +48 -0
  11. package/dist/types/chat/table-chart.d.ts +4 -3
  12. package/dist/types/chat/tool-execution.d.ts +1 -2
  13. package/dist/types/chat/transcript-outline.d.ts +7 -0
  14. package/dist/types/chat/transcript-render-helpers.d.ts +2 -0
  15. package/dist/types/chrome/index.d.ts +1 -0
  16. package/dist/types/chrome/transcript-notice.d.ts +35 -0
  17. package/dist/types/chrome/transcript-status.d.ts +20 -8
  18. package/dist/types/components/editor.d.ts +8 -0
  19. package/dist/types/components/markdown.d.ts +7 -0
  20. package/dist/types/native/blobs.d.ts +2 -1
  21. package/dist/types/native/picker.d.ts +3 -3
  22. package/dist/types/overlays/annotation-overlay.d.ts +7 -1
  23. package/dist/types/overlays/annotation-types.d.ts +28 -0
  24. package/dist/types/overlays/login-dialog.d.ts +5 -1
  25. package/dist/types/overlays/model-browser.d.ts +10 -1
  26. package/dist/types/overlays/oauth-selector.d.ts +23 -0
  27. package/dist/types/overlays/plan-review-overlay.d.ts +2 -0
  28. package/dist/types/overlays/rewind-selector.d.ts +1 -1
  29. package/dist/types/overlays/session-selector.d.ts +22 -2
  30. package/dist/types/prompt/composer.d.ts +41 -4
  31. package/dist/types/prompt/interactive-context-helpers.d.ts +10 -3
  32. package/dist/types/prompt/welcome.d.ts +6 -2
  33. package/dist/types/render/extension-links.d.ts +65 -0
  34. package/dist/types/render/hyperlink.d.ts +25 -7
  35. package/dist/types/render/index.d.ts +1 -0
  36. package/dist/types/setup/scenes/model.d.ts +1 -1
  37. package/dist/types/setup/scenes/outro.d.ts +5 -4
  38. package/dist/types/setup/scenes/sign-in.d.ts +13 -8
  39. package/dist/types/setup/scenes/splash.d.ts +4 -4
  40. package/dist/types/setup/scenes/types.d.ts +16 -1
  41. package/dist/types/setup/startup-splash.d.ts +1 -1
  42. package/dist/types/setup/wizard-overlay.d.ts +11 -7
  43. package/dist/types/setup/wizard.d.ts +5 -1
  44. package/dist/types/status-line/component.d.ts +1 -1
  45. package/dist/types/status-line/context-usage.d.ts +28 -6
  46. package/dist/types/status-line/host.d.ts +6 -2
  47. package/dist/types/theme/theme.d.ts +0 -23
  48. package/dist/types/tools/bash-interactive.d.ts +6 -0
  49. package/dist/types/tools/bash.d.ts +2 -0
  50. package/dist/types/tools/default-renderer.d.ts +6 -4
  51. package/dist/types/tools/irc.d.ts +6 -0
  52. package/dist/types/tools/json-tree.d.ts +23 -4
  53. package/dist/types/tools/lsp.d.ts +2 -0
  54. package/dist/types/tools/mcp.d.ts +6 -5
  55. package/dist/types/tools/native-view.d.ts +1 -1
  56. package/dist/types/tools/renderer.d.ts +2 -0
  57. package/dist/types/tools/streaming-output.d.ts +11 -0
  58. package/dist/types/tools/task.d.ts +9 -1
  59. package/dist/types/tools/url-scheme-host.d.ts +17 -0
  60. package/dist/types/tools/yield.d.ts +1 -1
  61. package/dist/types/tui.d.ts +7 -2
  62. package/package.json +10 -15
  63. package/src/app-keybindings.ts +15 -0
  64. package/src/apps/session-picker.ts +7 -0
  65. package/src/charts/chart-plan.ts +20 -50
  66. package/src/chat/advisor-message.ts +2 -0
  67. package/src/chat/assistant-message.ts +137 -19
  68. package/src/chat/background-tan-message.ts +4 -0
  69. package/src/chat/bash-execution.ts +6 -0
  70. package/src/chat/chat-transcript-builder.ts +58 -28
  71. package/src/chat/compaction-summary-message.ts +2 -1
  72. package/src/chat/display-preferences.ts +3 -0
  73. package/src/chat/extension-types.ts +34 -0
  74. package/src/chat/reply-write.ts +127 -0
  75. package/src/chat/table-chart.ts +10 -4
  76. package/src/chat/tool-execution.ts +20 -13
  77. package/src/chat/transcript-outline.ts +27 -0
  78. package/src/chat/transcript-render-helpers.ts +66 -24
  79. package/src/chrome/index.ts +1 -0
  80. package/src/chrome/transcript-notice.ts +83 -0
  81. package/src/chrome/transcript-status.ts +29 -29
  82. package/src/components/editor.ts +10 -1
  83. package/src/components/markdown.ts +116 -6
  84. package/src/hotkeys-markdown.ts +1 -0
  85. package/src/native/backend.ts +5 -66
  86. package/src/native/blobs.ts +3 -2
  87. package/src/native/picker.ts +3 -3
  88. package/src/overlays/advisor-config.ts +1 -10
  89. package/src/overlays/annotation-overlay.ts +125 -37
  90. package/src/overlays/annotation-types.ts +24 -0
  91. package/src/overlays/hook-selector.ts +2 -2
  92. package/src/overlays/login-dialog.ts +31 -5
  93. package/src/overlays/model-browser.ts +37 -0
  94. package/src/overlays/model-hub.ts +1 -7
  95. package/src/overlays/model-picker.ts +1 -10
  96. package/src/overlays/oauth-selector.ts +44 -6
  97. package/src/overlays/plan-review-overlay.ts +13 -2
  98. package/src/overlays/rewind-selector.ts +29 -12
  99. package/src/overlays/session-selector.ts +305 -39
  100. package/src/overlays/usage-dashboard.ts +5 -12
  101. package/src/prompt/composer.ts +213 -5
  102. package/src/prompt/custom-editor.ts +6 -1
  103. package/src/prompt/emoji-autocomplete.ts +0 -2
  104. package/src/prompt/interactive-context-helpers.ts +29 -4
  105. package/src/prompt/welcome.ts +31 -45
  106. package/src/render/extension-links.ts +214 -0
  107. package/src/render/hyperlink.ts +65 -9
  108. package/src/render/index.ts +1 -0
  109. package/src/setup/scenes/composer.ts +2 -0
  110. package/src/setup/scenes/glyph.ts +2 -0
  111. package/src/setup/scenes/model.ts +192 -29
  112. package/src/setup/scenes/outro.ts +30 -17
  113. package/src/setup/scenes/sign-in.ts +196 -400
  114. package/src/setup/scenes/splash.ts +25 -21
  115. package/src/setup/scenes/theme.ts +2 -0
  116. package/src/setup/scenes/types.ts +18 -1
  117. package/src/setup/startup-splash.ts +8 -7
  118. package/src/setup/wizard-overlay.ts +164 -33
  119. package/src/setup/wizard.ts +11 -2
  120. package/src/status-line/component.ts +4 -10
  121. package/src/status-line/context-usage.ts +72 -21
  122. package/src/status-line/host.ts +6 -2
  123. package/src/terminal.ts +24 -2
  124. package/src/theme/defaults.json +1 -0
  125. package/src/theme/loader.ts +1 -1
  126. package/src/theme/theme.ts +2 -75
  127. package/src/tools/bash-interactive.ts +6 -1
  128. package/src/tools/bash.ts +2 -0
  129. package/src/tools/default-renderer.ts +9 -6
  130. package/src/tools/eval.ts +14 -4
  131. package/src/tools/irc.ts +6 -0
  132. package/src/tools/job-widget.ts +13 -1
  133. package/src/tools/json-tree.ts +132 -42
  134. package/src/tools/lsp.ts +36 -15
  135. package/src/tools/mcp.ts +20 -12
  136. package/src/tools/native-view.ts +1 -1
  137. package/src/tools/renderer.ts +2 -0
  138. package/src/tools/streaming-output.ts +41 -0
  139. package/src/tools/task.ts +59 -17
  140. package/src/tools/url-scheme-host.ts +21 -0
  141. package/src/tools/yield.ts +18 -9
  142. package/src/tui.ts +43 -15
  143. package/THIRD-PARTY-NOTICES.txt +0 -22901
  144. package/dist/types/theme/defaults/index.d.ts +0 -10289
  145. package/src/theme/defaults/alabaster.json +0 -103
  146. package/src/theme/defaults/amethyst.json +0 -106
  147. package/src/theme/defaults/anthracite.json +0 -103
  148. package/src/theme/defaults/basalt.json +0 -101
  149. package/src/theme/defaults/birch.json +0 -105
  150. package/src/theme/defaults/dark-abyss.json +0 -101
  151. package/src/theme/defaults/dark-arctic.json +0 -114
  152. package/src/theme/defaults/dark-aurora.json +0 -105
  153. package/src/theme/defaults/dark-catppuccin.json +0 -117
  154. package/src/theme/defaults/dark-cavern.json +0 -101
  155. package/src/theme/defaults/dark-celestial.json +0 -116
  156. package/src/theme/defaults/dark-copper.json +0 -105
  157. package/src/theme/defaults/dark-cosmos.json +0 -100
  158. package/src/theme/defaults/dark-cyberpunk.json +0 -112
  159. package/src/theme/defaults/dark-dracula.json +0 -108
  160. package/src/theme/defaults/dark-eclipse.json +0 -101
  161. package/src/theme/defaults/dark-ember.json +0 -105
  162. package/src/theme/defaults/dark-equinox.json +0 -100
  163. package/src/theme/defaults/dark-forest.json +0 -106
  164. package/src/theme/defaults/dark-github.json +0 -115
  165. package/src/theme/defaults/dark-gruvbox.json +0 -122
  166. package/src/theme/defaults/dark-lavender.json +0 -105
  167. package/src/theme/defaults/dark-lunar.json +0 -99
  168. package/src/theme/defaults/dark-midnight.json +0 -105
  169. package/src/theme/defaults/dark-monochrome.json +0 -104
  170. package/src/theme/defaults/dark-monokai.json +0 -108
  171. package/src/theme/defaults/dark-nebula.json +0 -100
  172. package/src/theme/defaults/dark-neon-noir.json +0 -110
  173. package/src/theme/defaults/dark-nord.json +0 -108
  174. package/src/theme/defaults/dark-ocean.json +0 -111
  175. package/src/theme/defaults/dark-one.json +0 -110
  176. package/src/theme/defaults/dark-poimandres.json +0 -125
  177. package/src/theme/defaults/dark-rainforest.json +0 -101
  178. package/src/theme/defaults/dark-reef.json +0 -101
  179. package/src/theme/defaults/dark-retro.json +0 -102
  180. package/src/theme/defaults/dark-rose-pine.json +0 -106
  181. package/src/theme/defaults/dark-sakura.json +0 -105
  182. package/src/theme/defaults/dark-slate.json +0 -105
  183. package/src/theme/defaults/dark-solarized.json +0 -107
  184. package/src/theme/defaults/dark-solstice.json +0 -100
  185. package/src/theme/defaults/dark-starfall.json +0 -101
  186. package/src/theme/defaults/dark-sunset.json +0 -109
  187. package/src/theme/defaults/dark-swamp.json +0 -100
  188. package/src/theme/defaults/dark-synthwave.json +0 -113
  189. package/src/theme/defaults/dark-taiga.json +0 -101
  190. package/src/theme/defaults/dark-terminal.json +0 -105
  191. package/src/theme/defaults/dark-tokyo-night.json +0 -111
  192. package/src/theme/defaults/dark-tundra.json +0 -101
  193. package/src/theme/defaults/dark-twilight.json +0 -101
  194. package/src/theme/defaults/dark-volcanic.json +0 -101
  195. package/src/theme/defaults/graphite.json +0 -102
  196. package/src/theme/defaults/index.ts +0 -203
  197. package/src/theme/defaults/light-arctic.json +0 -117
  198. package/src/theme/defaults/light-aurora-day.json +0 -101
  199. package/src/theme/defaults/light-canyon.json +0 -101
  200. package/src/theme/defaults/light-catppuccin.json +0 -116
  201. package/src/theme/defaults/light-cirrus.json +0 -100
  202. package/src/theme/defaults/light-coral.json +0 -105
  203. package/src/theme/defaults/light-cyberpunk.json +0 -106
  204. package/src/theme/defaults/light-dawn.json +0 -100
  205. package/src/theme/defaults/light-dunes.json +0 -101
  206. package/src/theme/defaults/light-eucalyptus.json +0 -105
  207. package/src/theme/defaults/light-forest.json +0 -110
  208. package/src/theme/defaults/light-frost.json +0 -105
  209. package/src/theme/defaults/light-github.json +0 -125
  210. package/src/theme/defaults/light-glacier.json +0 -101
  211. package/src/theme/defaults/light-gruvbox.json +0 -118
  212. package/src/theme/defaults/light-haze.json +0 -100
  213. package/src/theme/defaults/light-honeycomb.json +0 -105
  214. package/src/theme/defaults/light-lagoon.json +0 -101
  215. package/src/theme/defaults/light-lavender.json +0 -105
  216. package/src/theme/defaults/light-meadow.json +0 -101
  217. package/src/theme/defaults/light-mint.json +0 -105
  218. package/src/theme/defaults/light-monochrome.json +0 -111
  219. package/src/theme/defaults/light-ocean.json +0 -109
  220. package/src/theme/defaults/light-one.json +0 -109
  221. package/src/theme/defaults/light-opal.json +0 -101
  222. package/src/theme/defaults/light-orchard.json +0 -101
  223. package/src/theme/defaults/light-paper.json +0 -105
  224. package/src/theme/defaults/light-poimandres.json +0 -125
  225. package/src/theme/defaults/light-prism.json +0 -100
  226. package/src/theme/defaults/light-retro.json +0 -108
  227. package/src/theme/defaults/light-sand.json +0 -105
  228. package/src/theme/defaults/light-savanna.json +0 -101
  229. package/src/theme/defaults/light-solarized.json +0 -112
  230. package/src/theme/defaults/light-soleil.json +0 -100
  231. package/src/theme/defaults/light-sunset.json +0 -109
  232. package/src/theme/defaults/light-synthwave.json +0 -108
  233. package/src/theme/defaults/light-tokyo-night.json +0 -121
  234. package/src/theme/defaults/light-wetland.json +0 -101
  235. package/src/theme/defaults/light-zenith.json +0 -99
  236. package/src/theme/defaults/limestone.json +0 -104
  237. package/src/theme/defaults/mahogany.json +0 -107
  238. package/src/theme/defaults/marble.json +0 -103
  239. package/src/theme/defaults/obsidian.json +0 -101
  240. package/src/theme/defaults/onyx.json +0 -101
  241. package/src/theme/defaults/pearl.json +0 -103
  242. package/src/theme/defaults/porcelain.json +0 -101
  243. package/src/theme/defaults/quartz.json +0 -106
  244. package/src/theme/defaults/sandstone.json +0 -105
  245. package/src/theme/defaults/titanium.json +0 -100
@@ -59,10 +59,13 @@ interface AppKeybindings {
59
59
  "app.session.rename": true;
60
60
  "app.session.delete": true;
61
61
  "app.session.deleteNoninvasive": true;
62
+ "app.session.pin": true;
63
+ "app.session.unpin": true;
62
64
  "app.tree.previousBranch": true;
63
65
  "app.tree.nextBranch": true;
64
66
  "app.plan.toggle": true;
65
67
  "app.history.search": true;
68
+ "app.links.hint": true;
66
69
  "app.stt.pushToTalk": true;
67
70
  "app.stt.toggle": true;
68
71
  "app.live.toggle": true;
@@ -239,6 +242,14 @@ export const KEYBINDINGS = {
239
242
  defaultKeys: "ctrl+backspace",
240
243
  description: "Delete session (non-invasive)",
241
244
  },
245
+ "app.session.pin": {
246
+ defaultKeys: "alt+k",
247
+ description: "Pin the selected session in the resume picker",
248
+ },
249
+ "app.session.unpin": {
250
+ defaultKeys: "alt+shift+k",
251
+ description: "Unpin the selected session in the resume picker",
252
+ },
242
253
  "app.tree.previousBranch": {
243
254
  defaultKeys: [],
244
255
  description: "Jump to the previous branch in /tree",
@@ -255,6 +266,10 @@ export const KEYBINDINGS = {
255
266
  defaultKeys: "ctrl+r",
256
267
  description: "Search history",
257
268
  },
269
+ "app.links.hint": {
270
+ defaultKeys: "alt+o",
271
+ description: "Pick an extension link on screen by its hint tag",
272
+ },
258
273
  "app.stt.pushToTalk": {
259
274
  defaultKeys: "space",
260
275
  description: "Hold to record speech-to-text",
@@ -11,6 +11,9 @@ export interface SessionPickerHost<T extends SessionSelectorEntry = SessionSelec
11
11
  loadPinnedIds?(): Promise<ReadonlySet<string>>;
12
12
  loadHistoryMatcher?(): SessionHistoryMatcher;
13
13
  deleteSession?(session: T): Promise<boolean>;
14
+ /** Rename the session and resolve with the title as stored, or null when it did not happen. */
15
+ renameSession?(session: T, title: string): Promise<string | null>;
16
+ setSessionPinned?(session: T, pinned: boolean): Promise<void>;
14
17
  loadAllSessions?(): Promise<T[]>;
15
18
  }
16
19
 
@@ -21,6 +24,8 @@ export interface SessionPickerOptions<T extends SessionSelectorEntry = SessionSe
21
24
  scopeLabel?: string | false;
22
25
  showCwd?: boolean;
23
26
  allowDelete?: boolean;
27
+ /** Rename the selected session; resolve with the title as stored, or null when it did not happen. */
28
+ onRename?: (session: T, title: string) => Promise<string | null>;
24
29
  allowGlobalScope?: boolean;
25
30
  historySearch?: boolean;
26
31
  pinnedIds?: ReadonlySet<string>;
@@ -63,6 +68,8 @@ export async function selectSession<T extends SessionSelectorEntry>(
63
68
  },
64
69
  {
65
70
  onDelete: options.allowDelete === false ? undefined : host.deleteSession,
71
+ onRename: options.onRename ?? host.renameSession,
72
+ onSetPinned: host.setSessionPinned,
66
73
  historyMatcher,
67
74
  loadAllSessions: options.allowGlobalScope === false ? undefined : host.loadAllSessions,
68
75
  allSessions: options.allSessions,
@@ -33,7 +33,6 @@ import {
33
33
  isNumber,
34
34
  type NumberCell,
35
35
  parseCell,
36
- plainCell,
37
36
  type Status,
38
37
  type TableAnalysis,
39
38
  type TableColumn,
@@ -536,20 +535,6 @@ const NEST_SEPARATORS = [" — ", " – ", ": ", " · ", ", ", " ", "/", "-", "_
536
535
  /** Where a long row name's description starts (`Payroll (10 people…)`, `@watzon — #19, #53`). */
537
536
  const DESCRIPTION = / [—–] | - |: | \(/;
538
537
 
539
- /**
540
- * Measures that improve upward, by header: pass and success rates, retrieval
541
- * scores, throughput, speedups and savings.
542
- */
543
- const HIGHER_BETTER =
544
- /(?:^|[^\w])(?:pass(?:ed|es|ing)?|pass ?%|pass ?rate|success(?:es|ful)?|succeeded|accuracy|acc|recall|precision|prec|f1|em|exact[_ ]?match|scores?|wins?|win ?rate|hit ?rate|throughput|qps|rps|ops\/s|req\/s|tok\/s|tokens\/s|speedup|faster|improvement|savings?|saved|gain|coverage|quality|relevance|correct|solved|ok|renders|completion ?rate|uptime|performance|profits?|roi|pnl|growth|per \$|\/\$)(?![\w])/i;
545
- /** Measures that improve downward, by header: cost, time, failures. */
546
- const LOWER_BETTER =
547
- /(?:^|[^\w])(?:cost|costs|price|bill|spend|usd|latency|time|duration|wall(?: ?clock)?|elapsed|errors?|err|fail(?:s|ed|ures?)?|timeouts?|miss(?:es)?|crash(?:es)?|regressions?|bugs?|warnings?|retries|turns|p\d{2}|blockers|violations|leaks?|stalls?|load|\$\/\w+)(?![\w])/i;
548
- /** Resources a before/after change aims to shrink: lower is better only beside a baseline. */
549
- const LOWER_IN_CHANGE =
550
- /(?:^|[^\w])(?:tokens?|tok|rss|memory|mem|heap|allocs?|allocations?|clones|size|bytes|cpu|gc|input|output)(?![\w])/i;
551
- /** Names of shares and counts that describe rather than score: no polarity whatever else they say. */
552
- const NEUTRAL = /(?:^|[^\w])(?:share|shares|% of|portion|fraction|distribution|breakdown)(?![\w])/i;
553
538
  /** Label headers naming rival options a table compares (models, configs, arms). */
554
539
  const RIVALS =
555
540
  /(?:^|[^\w])(?:models?|config(?:uration)?s?|arms?|variants?|approach(?:es)?|options?|plans?|polic(?:y|ies)|providers?|modes?|strateg(?:y|ies)|candidates?|settings?|shapes?|backends?|engines?|librar(?:y|ies)|implementations?|vendors?|tools?|methods?|algorithms?|setups?|runs?)(?![\w])/i;
@@ -606,13 +591,16 @@ export function planChart(table: TableAnalysis): ChartPlan | undefined {
606
591
 
607
592
  /**
608
593
  * Whether a model should pick `table`'s chart (`tui.autoGraph: smart`):
609
- * multi-series tables, and tables whose rows may be named by a number the
610
- * `guess` plots (`Thread | State`, `Finding | Current`), where only meaning
611
- * tells a name from a measure.
594
+ * multi-series tables, tables whose rows may be named by a number the
595
+ * `guess` plots (`Thread | State`, `Finding | Current`), and any other chart
596
+ * the guess would draw — only meaning tells a measure from page references,
597
+ * step numbers or figures quoted in prose, which the judge charts as none.
612
598
  */
613
599
  export function shouldJudge(table: TableAnalysis, guess: ChartPlan | undefined): boolean {
614
600
  if (!guess || table.rows.length < 3) return false;
615
- return table.measures.length >= 2 || leadingMeasures(table, guess).length > 0;
601
+ if (table.measures.length >= 2 || leadingMeasures(table, guess).length > 0) return true;
602
+ const spec = buildChart(table, guess);
603
+ return spec !== undefined && worthCharting(spec);
616
604
  }
617
605
 
618
606
  /** Measure columns before `guess`'s text label column: numbers that may only name their rows. */
@@ -696,38 +684,33 @@ export function drawnNames(spec: ChartSpec): Set<string> {
696
684
  }
697
685
 
698
686
  /**
699
- * The local guess at what `plan`'s rows and measures mean: header keywords
700
- * for each measure's {@link Polarity} (resources like tokens or memory only
701
- * beside a baseline), rivals from the label's header, focus from bolded or
702
- * `(current)` row names, and a status column whose cells vary.
687
+ * The local guess at what `plan`'s rows and measures mean: each measure's
688
+ * {@link Polarity} as far as its unit says ({@link dimPolarity}; what a name
689
+ * means — `Memory` as a capacity or a cost — is the judge's call), rivals from
690
+ * the label's header, focus from bolded or `(current)` row names, and a status
691
+ * column whose cells vary.
703
692
  */
704
693
  export function guessMeaning(
705
694
  table: TableAnalysis,
706
695
  plan: ChartPlan,
707
696
  ): Pick<ChartPlan, "polarity" | "rowPolarity" | "rivals" | "focus" | "status"> {
708
697
  const label = plan.label === undefined ? undefined : table.columns[plan.label];
709
- // A comparison of two conditions reads resources (tokens, memory) the way a change does.
698
+ // A comparison of two conditions reads money and sizes the way a change does: as what it cuts.
710
699
  const change =
711
700
  plan.kind === "change" || plan.kind === "stat" || table.columns.some(column => BEFORE.test(column.header));
712
701
  const polarity: Record<number, Polarity> = {};
713
702
  for (const column of table.columns) {
714
- if (column === label) continue;
715
703
  // A signed delta's sign convention (saved vs. spent) is not in its unit.
716
- const delta = DELTA.test(column.header) || column.signedShare >= 0.5;
717
- const guessed =
718
- headerPolarity(column.header, change) ??
719
- (column.role === "measure" && !delta
720
- ? dimPolarity(column.dim, column.unit, change, column.scores)
721
- : undefined);
704
+ if (column === label || column.role !== "measure" || DELTA.test(column.header) || column.signedShare >= 0.5)
705
+ continue;
706
+ const guessed = dimPolarity(column.dim, column.unit, change, column.scores);
722
707
  if (guessed) polarity[column.index] = guessed;
723
708
  }
724
709
  const rowPolarity: Record<number, Polarity> = {};
725
710
  if (label && rowsAreMetrics(table, plan))
726
711
  for (const row of [...table.rows, ...table.totals]) {
727
712
  const first = plan.series.map(index => table.columns[index]!.cells[row]).find(isNumber);
728
- const guessed =
729
- headerPolarity(label.cells[row]!.text, true) ??
730
- (first ? dimPolarity(first.dim, first.unit, true, false) : undefined);
713
+ const guessed = first && dimPolarity(first.dim, first.unit, change, false);
731
714
  if (guessed) rowPolarity[row] = guessed;
732
715
  }
733
716
  return {
@@ -745,23 +728,10 @@ export function rowsAreMetrics(table: TableAnalysis, plan: ChartPlan): boolean {
745
728
  }
746
729
 
747
730
  /**
748
- * A measure's {@link Polarity} from its name (`undefined` when it names
749
- * none); `change` admits resources a change aims to shrink.
750
- */
751
- function headerPolarity(name: string, change: boolean): Polarity | undefined {
752
- // `gtTagRelevance`: camelCase words read apart.
753
- const plain = plainCell(name).text.replace(/([a-z])([A-Z])/g, "$1 $2");
754
- if (NEUTRAL.test(plain)) return undefined;
755
- if (HIGHER_BETTER.test(plain)) return "higher";
756
- if (LOWER_BETTER.test(plain)) return "lower";
757
- if (change && LOWER_IN_CHANGE.test(plain)) return "lower";
758
- return undefined;
759
- }
760
-
761
- /**
762
- * A measure's {@link Polarity} from its unit when its name gives none:
731
+ * A measure's {@link Polarity} from the unit its values are written in:
763
732
  * scores out of a total and rates per second climb, durations shrink, and
764
- * money and sizes shrink beside a baseline.
733
+ * money and sizes shrink beside a baseline (`change`). Elsewhere a size may
734
+ * be a capacity (`L2 cache`, `Memory`) and money a budget: no polarity.
765
735
  */
766
736
  function dimPolarity(dim: Dimension | undefined, unit: string, change: boolean, scores: boolean): Polarity | undefined {
767
737
  if (scores) return "higher";
@@ -2,6 +2,7 @@ import { type Component } from "../tui";
2
2
  import { Disclosure } from "../components/disclosure";
3
3
  import { visibleWidth } from "../utils";
4
4
  import type { AdvisorMessageDetails, AdvisorNote, AdvisorSeverity } from "./messages";
5
+ import { chatTranscriptDisplayPreferences } from "./display-preferences";
5
6
  import { formatBadge, replaceTabs, type ToolUIColor, wrapTextWithAnsi } from "../render/render-utils";
6
7
  import { Ellipsis, truncateToWidth } from "../render";
7
8
  import type { Theme } from "../theme";
@@ -274,6 +275,7 @@ export function createAdvisorMessageCard(
274
275
  body: () => new AdvisorNotes(notes, 0, uiTheme),
275
276
  expanded: getExpanded(),
276
277
  paddingX: 1,
278
+ maxCollapsedRows: chatTranscriptDisplayPreferences.advisorCollapsedLines || undefined,
277
279
  });
278
280
  // The tool-output toggle owns expansion state; synchronize the controlled
279
281
  // disclosure from the callback on every render.
@@ -5,6 +5,7 @@ import { Image, type ImageBudget } from "../components/image";
5
5
  import { ImageProtocol, TERMINAL } from "../terminal-capabilities";
6
6
  import {
7
7
  type DefaultTextStyle,
8
+ endsInsideOpenFence,
8
9
  holdPartialTableRow,
9
10
  Markdown,
10
11
  type MarkdownTheme,
@@ -14,10 +15,10 @@ import {
14
15
  } from "../components/markdown";
15
16
  import { Spacer } from "../components/spacer";
16
17
  import { Text } from "../components/text";
17
- import { formatDuration, formatNumber } from "@oh-my-pi/pi-utils";
18
+ import { formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
18
19
  import chalk from "@oh-my-pi/pi-utils/chalk";
19
20
  import { LRUCache } from "@oh-my-pi/pi-utils/lru";
20
- import type { AssistantThinkingRenderer } from "./extension-types";
21
+ import type { AssistantTextRenderer, AssistantTextRendering, AssistantThinkingRenderer } from "./extension-types";
21
22
  import { ensureThemeSync, getMarkdownTheme, getMarkdownThemeWithLinkTargets, getThemeEpoch, theme } from "../theme";
22
23
  import { card, col, elapsed, node, span, text } from "../native/describe";
23
24
  import { hasTranscriptActions, runTranscriptAction } from "./transcript-actions";
@@ -51,6 +52,8 @@ import { chartFence, hasChartTable, lookupTableChart, splitTableCharts } from ".
51
52
  */
52
53
  const MAX_TRANSCRIPT_ERROR_ROWS = 8;
53
54
  const EMPTY_STABLE_RENDER: readonly string[] = [];
55
+ /** Text renderers that threw once; each failure is logged once per renderer, not per frame. */
56
+ const failedTextRenderers = new WeakSet<AssistantTextRenderer>();
54
57
 
55
58
  /** The native head of a finished thinking block: "Thought for 12s", or "Thought" when it was never seen streaming. */
56
59
  function thoughtLabel(clock: { start: number; end?: number } | undefined): string {
@@ -245,6 +248,19 @@ function lerpHex(from: string, to: string, t: number): string {
245
248
  return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
246
249
  }
247
250
 
251
+ /**
252
+ * Drop the closing border the renderer draws for a source that ends inside an
253
+ * open fence. That row is not part of the message yet: the live block reaches
254
+ * it only when the closer arrives, so publishing it would make the settled
255
+ * prefix disagree with the block render and freeze mid-stream retirement.
256
+ */
257
+ function trimFabricatedCloser(rows: readonly string[], source: string): readonly string[] {
258
+ if (!endsInsideOpenFence(source)) return rows;
259
+ let last = rows.length;
260
+ while (last > 0 && rows[last - 1]!.trim() === "") last--;
261
+ return last > 0 ? rows.slice(0, last - 1) : rows;
262
+ }
263
+
248
264
  /**
249
265
  * Renders an assistant message; streaming content remains mutable until the
250
266
  * provider finalizes it because later deltas can revise earlier Markdown.
@@ -390,8 +406,10 @@ export class AssistantMessageComponent extends Container {
390
406
  #reactionTarget: ReactionTarget | undefined;
391
407
  /** Reaction lifted from the reply's opening emoji, once resolved. */
392
408
  #reaction: string | undefined;
393
- /** Display form of {@link #lastMessage} (reaction handled) the native description is built from. */
409
+ /** Display form of {@link #lastMessage} (reaction handled, extension text renderers applied) the native description is built from. */
394
410
  #displayedMessage: AssistantMessage | undefined;
411
+ /** {@link #displayedMessage} before extension text renderers: the reply as written, which thinking state and the native Copy follow. */
412
+ #answerMessage: AssistantMessage | undefined;
395
413
  /** Thinking-extension components per content index, recorded when the slow path mounts them. */
396
414
  #thinkingExtensions = new Map<number, Component[]>();
397
415
  /** Collapse state of thinking sections toggled in the terminal, by content index; cleared by {@link setHideThinkingBlock}. */
@@ -523,9 +541,65 @@ export class AssistantMessageComponent extends Container {
523
541
  content[index] = { ...block, text };
524
542
  return { ...message, content };
525
543
  }
544
+
545
+ /**
546
+ * Display form with the extension text renderers applied, in registration order: each gets the
547
+ * previous one's output. A renderer that throws or returns a non-string leaves its input drawn;
548
+ * a throw is logged once per renderer. A block whose text and `streaming` flag are unchanged
549
+ * since its last render reuses that output: a block sealed at a tool call keeps receiving
550
+ * updates while the call's arguments stream. {@link rerenderMessage} and
551
+ * {@link setTextRenderers} drop the reused output.
552
+ */
553
+ #renderText(message: AssistantMessage, streaming: boolean): AssistantMessage {
554
+ const rendering = this.#textRendering;
555
+ if (!rendering) return message;
556
+ // Renderers are display-only: hand them a deep copy so no edit, however nested, reaches the stored message.
557
+ let view: AssistantMessage | undefined;
558
+ let content: AssistantMessage["content"] | undefined;
559
+ // Blocks stream in order: only the last one can still grow.
560
+ const tail = message.content.length - 1;
561
+ for (let index = 0; index < message.content.length; index++) {
562
+ const block = message.content[index]!;
563
+ if (block.type !== "text" || block.text.length === 0) continue;
564
+ const blockStreaming = streaming && index === tail;
565
+ const cached = this.#renderedText.get(index);
566
+ let text: string;
567
+ if (cached?.input === block.text && cached.streaming === blockStreaming) {
568
+ text = cached.output;
569
+ } else {
570
+ view ??= structuredClone(message);
571
+ text = block.text;
572
+ for (const renderer of rendering.renderers) {
573
+ try {
574
+ const rendered = renderer({
575
+ message: view,
576
+ contentIndex: rendering.contentOffset + index,
577
+ text,
578
+ streaming: blockStreaming,
579
+ });
580
+ if (typeof rendered === "string") text = rendered;
581
+ } catch (error) {
582
+ if (failedTextRenderers.has(renderer)) continue;
583
+ failedTextRenderers.add(renderer);
584
+ logger.warn("Assistant text renderer failed; drawing its input", { error: String(error) });
585
+ }
586
+ }
587
+ this.#renderedText.set(index, { input: block.text, streaming: blockStreaming, output: text });
588
+ }
589
+ if (text === block.text) continue;
590
+ content ??= message.content.slice();
591
+ content[index] = { ...block, text };
592
+ }
593
+ return content ? { ...message, content } : message;
594
+ }
526
595
  #hideThinkingBlock: boolean;
527
596
  readonly #onImageUpdate?: () => void;
528
597
  readonly #thinkingRenderers: readonly AssistantThinkingRenderer[];
598
+ #textRendering: AssistantTextRendering | undefined;
599
+ /** Index in the full reply's `content` of this component's first block, kept across renderer swaps. */
600
+ readonly #textContentOffset: number;
601
+ /** Last renderer output per content index, reused while the block's input and `streaming` flag hold; see {@link #renderText}. */
602
+ readonly #renderedText = new Map<number, { input: string; streaming: boolean; output: string }>();
529
603
  readonly #imageBudget?: ImageBudget;
530
604
  #proseOnlyThinking: boolean;
531
605
  #expandThinkingBlocks: boolean;
@@ -539,11 +613,14 @@ export class AssistantMessageComponent extends Container {
539
613
  proseOnlyThinking = true,
540
614
  linkTargets?: ReadonlyMap<string, string>,
541
615
  expandThinkingBlocks = false,
616
+ textRendering?: AssistantTextRendering,
542
617
  ) {
543
618
  super();
544
619
  this.#hideThinkingBlock = hideThinkingBlock;
545
620
  this.#onImageUpdate = onImageUpdate;
546
621
  this.#thinkingRenderers = thinkingRenderers;
622
+ this.#textRendering = textRendering?.renderers.length ? textRendering : undefined;
623
+ this.#textContentOffset = textRendering?.contentOffset ?? 0;
547
624
  this.#imageBudget = imageBudget;
548
625
  this.#proseOnlyThinking = proseOnlyThinking;
549
626
  this.#expandThinkingBlocks = expandThinkingBlocks;
@@ -792,8 +869,9 @@ export class AssistantMessageComponent extends Container {
792
869
  return this.#transcriptBlockFinalized;
793
870
  }
794
871
 
795
- /** Whether a figure still waits for the raster of its final source; the transcript holds retirement meanwhile. */
872
+ /** Hold image and figure rendering until the completed block can enter immutable scrollback. */
796
873
  isTranscriptBlockPending(): boolean {
874
+ if (this.#showImages && this.#kittyConversionsAwaited.size > 0) return true;
797
875
  for (const block of this.#figureBlocks.values()) if (block.pending) return true;
798
876
  return false;
799
877
  }
@@ -815,7 +893,7 @@ export class AssistantMessageComponent extends Container {
815
893
  */
816
894
  override describe(cx?: DescribeContext): NativeNode {
817
895
  const media = cx?.supports("media") === true;
818
- const tail = this.#displayedMessage ? this.#thinkingTailIndex(this.#displayedMessage) : undefined;
896
+ const tail = this.#answerMessage ? this.#thinkingTailIndex(this.#answerMessage) : undefined;
819
897
  const rate =
820
898
  tail !== undefined && this.#lastUpdateTransient && this.#thinkingRateLive
821
899
  ? Math.round(Math.min(SPEED_MAX, sharedSpeedTracker.getSpeed()) * 10) / 10
@@ -886,13 +964,15 @@ export class AssistantMessageComponent extends Container {
886
964
  let answered = false;
887
965
  if (message) {
888
966
  const live = !this.#transcriptBlockFinalized && this.#lastUpdateTransient;
967
+ // Which block streams, and whether there is an answer to copy, follow the reply as written:
968
+ // text renderers change only how it is drawn (one may hide all of it).
969
+ const answer = this.#answerMessage ?? message;
889
970
  let tailIndex = -1;
890
- for (let index = 0; index < message.content.length; index++) {
891
- const content = message.content[index]!;
892
- if (
893
- (content.type === "text" && canonicalizeMessage(content.text)) ||
894
- (content.type === "thinking" && canonicalizeMessage(content.thinking))
895
- ) {
971
+ for (let index = 0; index < answer.content.length; index++) {
972
+ const content = answer.content[index]!;
973
+ const answerText = content.type === "text" && canonicalizeMessage(content.text) !== "";
974
+ answered ||= answerText;
975
+ if (answerText || (content.type === "thinking" && canonicalizeMessage(content.thinking))) {
896
976
  tailIndex = index;
897
977
  }
898
978
  }
@@ -900,7 +980,6 @@ export class AssistantMessageComponent extends Container {
900
980
  const content = message.content[index]!;
901
981
  const streaming = live && index === tailIndex;
902
982
  if (content.type === "text" && canonicalizeMessage(content.text)) {
903
- answered = true;
904
983
  // A native terminal sizes table columns by every row it gets: send a growing row once
905
984
  // its line ends. Hold before trimming, which would drop the newline ending a finished row.
906
985
  const source = (streaming ? holdPartialTableRow(content.text) : content.text).trim();
@@ -1152,7 +1231,7 @@ export class AssistantMessageComponent extends Container {
1152
1231
  /** The answer as the transcript shows it: its text blocks' markdown, a blank line apart, without thinking or tool calls. */
1153
1232
  #answerText(): string {
1154
1233
  const blocks: string[] = [];
1155
- for (const content of this.#displayedMessage?.content ?? []) {
1234
+ for (const content of this.#answerMessage?.content ?? []) {
1156
1235
  if (content.type === "text" && canonicalizeMessage(content.text)) blocks.push(content.text.trim());
1157
1236
  }
1158
1237
  return blocks.join("\n\n");
@@ -1214,7 +1293,8 @@ export class AssistantMessageComponent extends Container {
1214
1293
  * every guard skips publication and nothing ever retracts it.
1215
1294
  */
1216
1295
  #publishStableSnapshot(rendered: readonly string[], width: number): void {
1217
- if (!this.#midStreamPublication) return;
1296
+ // Extension text renderers may rewrite text they already drew, and published rows never retract.
1297
+ if (!this.#midStreamPublication || this.#textRendering) return;
1218
1298
  const parts = this.#currentStableSnapshot();
1219
1299
  if (!parts) return;
1220
1300
  const last = parts.at(-1);
@@ -1359,12 +1439,12 @@ export class AssistantMessageComponent extends Container {
1359
1439
  const renderer = role === "head" ? head : role === "replay" ? replay : head?.index === index ? head : replay;
1360
1440
  if (renderer?.index === index && renderer.kind === kind) {
1361
1441
  renderer.md.setText(trimmed);
1362
- return renderer.md.render(width);
1442
+ return trimFabricatedCloser(renderer.md.render(width), trimmed);
1363
1443
  }
1364
1444
  const md = this.#createMarkdown(kind, trimmed);
1365
1445
  if (role === "head") this.#stableHeadRenderer = { index, kind, md };
1366
1446
  else if (role === "replay") this.#stableReplayRenderer = { index, kind, md };
1367
- return md.render(width);
1447
+ return trimFabricatedCloser(md.render(width), trimmed);
1368
1448
  }
1369
1449
 
1370
1450
  /** Markdown for a text or thinking block; live children and stable-row renders share it so stable rows prefix the block render. */
@@ -1491,11 +1571,30 @@ export class AssistantMessageComponent extends Container {
1491
1571
  this.#stopThinkingAnimation();
1492
1572
  // If the live pulse was on screen when the block sealed, drop the fast path
1493
1573
  // and rebuild so the placeholder is removed — finalized blocks never animate.
1574
+ // A sealed block's text is also complete and can retire to scrollback before the
1575
+ // reply's final update reaches it: give the text renderers their final call now
1576
+ // (sealed renders pass `streaming: false`).
1577
+ const sealRenderers = this.#textRendering !== undefined && this.#lastUpdateTransient;
1494
1578
  if (this.#thinkingDots) {
1495
1579
  this.#fastPathKey = undefined;
1496
1580
  this.#fastPathItems = undefined;
1497
- if (this.#lastMessage) this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
1498
1581
  }
1582
+ if ((this.#thinkingDots || sealRenderers) && this.#lastMessage) {
1583
+ this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
1584
+ }
1585
+ }
1586
+
1587
+ /**
1588
+ * Swap the extension text renderers (the setting enabling them changed) and redraw with them.
1589
+ * Enabling them also drops rows already published as stable, since a renderer may rewrite them;
1590
+ * callers MUST pair this with a scrollback-clearing display reset, as for
1591
+ * {@link resetTranscriptStableRows}.
1592
+ */
1593
+ setTextRenderers(renderers: readonly AssistantTextRenderer[]): void {
1594
+ this.#textRendering = renderers.length ? { renderers, contentOffset: this.#textContentOffset } : undefined;
1595
+ this.#renderedText.clear();
1596
+ if (this.#textRendering) this.resetTranscriptStableRows();
1597
+ if (this.#lastMessage) this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
1499
1598
  }
1500
1599
 
1501
1600
  applyRetryRecovery(retryRecovery: AssistantMessage["retryRecovery"]): void {
@@ -1504,6 +1603,18 @@ export class AssistantMessageComponent extends Container {
1504
1603
  this.updateContent({ ...this.#lastMessage, retryRecovery });
1505
1604
  }
1506
1605
 
1606
+ /**
1607
+ * Draw the current message again when it is the reply sent at `timestamp`, re-running the
1608
+ * extension text renderers; returns whether it was. The caller schedules the frame.
1609
+ */
1610
+ rerenderMessage(timestamp: number): boolean {
1611
+ if (this.#lastMessage?.timestamp !== timestamp) return false;
1612
+ // The renderers' state may have changed: run them again rather than reuse their last output.
1613
+ this.#renderedText.clear();
1614
+ this.updateContent(this.#lastMessage, { transient: this.#lastUpdateTransient });
1615
+ return true;
1616
+ }
1617
+
1507
1618
  messagePersistenceKey(): string | undefined {
1508
1619
  if (!this.#lastMessage) return undefined;
1509
1620
  return [
@@ -1615,6 +1726,7 @@ export class AssistantMessageComponent extends Container {
1615
1726
  })
1616
1727
  .catch(() => {
1617
1728
  this.#kittyConversionsAwaited.delete(key);
1729
+ this.#onImageUpdate?.();
1618
1730
  });
1619
1731
  }
1620
1732
  }
@@ -1806,6 +1918,12 @@ export class AssistantMessageComponent extends Container {
1806
1918
  // verbatim message so re-renders re-derive the reaction deterministically.
1807
1919
  // Narration the provider returned as thinking renders as answer text.
1808
1920
  message = this.#displayMessage(presentNarrationAsAnswer(message), this.#lastUpdateTransient);
1921
+ this.#answerMessage = message;
1922
+ // A sealed block (closed at a tool call) keeps receiving transient updates; its text is final.
1923
+ // Thinking state below reads `answer`: renderers transform display only, so one that hides the
1924
+ // streaming text must not make the closed thought before it look live again.
1925
+ const answer = message;
1926
+ message = this.#renderText(message, this.#lastUpdateTransient && !this.#transcriptBlockFinalized);
1809
1927
  this.#displayedMessage = message;
1810
1928
 
1811
1929
  // Streaming-speed gauge: only a live, in-flight render of the single
@@ -1823,7 +1941,7 @@ export class AssistantMessageComponent extends Container {
1823
1941
  // Native terminals show the live rate on visible thinking too, not only the hidden pulse.
1824
1942
  const isThinkingNow =
1825
1943
  this.#lastUpdateTransient &&
1826
- (isNativeRendering() ? this.#thinkingTailIndex(message) !== undefined : this.#shouldAnimateThinking(message));
1944
+ (isNativeRendering() ? this.#thinkingTailIndex(answer) !== undefined : this.#shouldAnimateThinking(answer));
1827
1945
  if (isThinkingNow) {
1828
1946
  const currentTokens = message.usage.reasoningTokens ?? message.usage.output;
1829
1947
  this.#thinkingTokens = currentTokens;
@@ -1926,7 +2044,7 @@ export class AssistantMessageComponent extends Container {
1926
2044
  if (this.#figureBlocks.get(index) !== block) block.dispose();
1927
2045
  }
1928
2046
 
1929
- if (this.#shouldAnimateThinking(message)) {
2047
+ if (this.#shouldAnimateThinking(answer)) {
1930
2048
  if (hasVisibleContent) this.#contentContainer.addChild(new Spacer(1));
1931
2049
  this.#thinkingDots = new Text(this.#thinkingDotsLabel(), 1, 0);
1932
2050
  this.#contentContainer.addChild(this.#thinkingDots);
@@ -3,6 +3,7 @@ import { replaceTabs } from "../render/render-utils";
3
3
  import { theme } from "../theme";
4
4
  import type { TranscriptBlock } from "../chrome/transcript-container";
5
5
  import { TranscriptStatusBlock } from "../chrome/transcript-status";
6
+ import { span } from "../native/describe";
6
7
 
7
8
  const TAN_WORK_PREVIEW_LENGTH = 56;
8
9
 
@@ -22,6 +23,8 @@ export function createBackgroundTanDispatchBlock(message: CustomMessage<unknown>
22
23
  const details = (message as CustomMessage<Partial<BackgroundTanDispatchDetails>>).details;
23
24
  const jobId = details?.jobId ?? "unknown";
24
25
  const work = details?.work ? previewWork(details.work) : undefined;
26
+ const spans = [span("Tangent dispatched"), span(" · ", "dim"), span(`task ${jobId}`, "dim")];
27
+ if (work) spans.push(span(" · ", "dim"), span(work, "dim"));
25
28
  return new TranscriptStatusBlock([
26
29
  {
27
30
  parts: [
@@ -30,6 +33,7 @@ export function createBackgroundTanDispatchBlock(message: CustomMessage<unknown>
30
33
  { text: jobId, color: "accent" },
31
34
  work ? { text: `${theme.format.dash} ${work}`, color: "dim" } : undefined,
32
35
  ],
36
+ native: { tone: "info", icon: "output", message: spans },
33
37
  },
34
38
  ]);
35
39
  }
@@ -16,6 +16,7 @@ import type { TruncationMeta } from "../tools/output-meta";
16
16
  import { resolveImageOptions } from "../render/render-utils";
17
17
  import { OutputPane } from "../render/output-pane";
18
18
  import { loadXtermTerminal, readTerminalRows, styleTerminalRow } from "../tools/terminal-output";
19
+ import { normalizeInputForPty } from "../tools/bash-interactive";
19
20
  import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../render/sixel";
20
21
  import {
21
22
  buildExecutionFrame,
@@ -286,6 +287,11 @@ export class BashExecutionComponent extends Container {
286
287
  this.#drainPtyQueue();
287
288
  }
288
289
 
290
+ /** Bytes a terminal would send for a key event, honoring the command's cursor-key mode; "" for key releases. */
291
+ encodePtyInput(data: string): string {
292
+ return normalizeInputForPty(data, this.#ptyTerminal?.modes.applicationCursorKeysMode ?? false);
293
+ }
294
+
289
295
  #drainPtyQueue(): void {
290
296
  const terminal = this.#ptyTerminal;
291
297
  if (!terminal || this.#ptyWriting) return;