gloomberb 0.16.2 → 0.16.4

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 (527) hide show
  1. package/package.json +4 -1
  2. package/src/api-client/askg.ts +43 -0
  3. package/src/api-client/auth.ts +15 -0
  4. package/src/api-client/beneficial-owners.ts +4 -0
  5. package/src/api-client/chat.ts +19 -0
  6. package/src/api-client/company-kpis.ts +14 -2
  7. package/src/api-client/data.ts +34 -16
  8. package/src/api-client/index.ts +12 -0
  9. package/src/api-client/normalizers.ts +27 -0
  10. package/src/api-client/paths.ts +88 -23
  11. package/src/api-client/request.ts +2 -1
  12. package/src/api-client/research-activity.ts +31 -0
  13. package/src/api-client/socket.ts +39 -8
  14. package/src/api-client/types.ts +57 -1
  15. package/src/api-client/upgrade-experiment.ts +90 -0
  16. package/src/app/global-shortcuts.ts +11 -1
  17. package/src/app/keybindings/actions.ts +10 -0
  18. package/src/app/pane-runtime/index.tsx +8 -0
  19. package/src/app/pane-runtime/pane-template-runtime.ts +5 -0
  20. package/src/app/pane-runtime/plugin-bindings.ts +65 -25
  21. package/src/app/pane-runtime/ticker-inspector-runtime.ts +12 -0
  22. package/src/app/pane-runtime/ticker-open-runtime.ts +18 -1
  23. package/src/app/runtime/desktop-deeplink.ts +5 -0
  24. package/src/app/star-prompt/model.ts +98 -0
  25. package/src/app/star-prompt/runtime.ts +41 -0
  26. package/src/app/star-prompt/status-line.tsx +190 -0
  27. package/src/app.tsx +13 -7
  28. package/src/architecture/core-builtin-allowlist.json +70 -0
  29. package/src/brokers/account-cache.ts +45 -0
  30. package/src/brokers/signed-in/adapter.ts +33 -2
  31. package/src/brokers/signed-in/client.ts +10 -0
  32. package/src/brokers/signed-in/sign-in-dialog.tsx +23 -9
  33. package/src/brokers/signed-in/sign-in.ts +62 -5
  34. package/src/capabilities/registry.ts +8 -1
  35. package/src/capabilities/types.ts +2 -0
  36. package/src/cli/command-options.ts +74 -0
  37. package/src/cli/commands/command-utils.ts +29 -0
  38. package/src/cli/commands/crypto-hints.ts +12 -4
  39. package/src/cli/commands/market.ts +524 -148
  40. package/src/cli/commands/options-chain.ts +172 -0
  41. package/src/cli/commands/options-leaps.ts +422 -0
  42. package/src/cli/commands/overview.ts +223 -42
  43. package/src/cli/commands/search.ts +29 -8
  44. package/src/cli/commands/system.ts +99 -19
  45. package/src/cli/commands/themes.ts +64 -0
  46. package/src/cli/commands/ticker.ts +200 -73
  47. package/src/cli/desktop-pane-shot.ts +100 -16
  48. package/src/cli/errors.ts +25 -2
  49. package/src/cli/freshness.ts +7 -4
  50. package/src/cli/fx-pair.ts +57 -0
  51. package/src/cli/help.ts +40 -7
  52. package/src/cli/history-rows.ts +187 -15
  53. package/src/cli/index-roots.ts +51 -0
  54. package/src/cli/index.ts +21 -13
  55. package/src/cli/listing-arg.ts +211 -3
  56. package/src/cli/not-a-ticker.ts +37 -0
  57. package/src/cli/options.ts +55 -7
  58. package/src/cli/pane-functions/capabilities.ts +13 -2
  59. package/src/cli/pane-functions/catalog.ts +13 -2
  60. package/src/cli/pane-functions/data.ts +36 -5
  61. package/src/cli/pane-functions/dom.ts +89 -28
  62. package/src/cli/pane-functions/freshness.ts +205 -26
  63. package/src/cli/pane-functions/function-help.ts +71 -24
  64. package/src/cli/pane-functions/headless.ts +168 -35
  65. package/src/cli/pane-functions/index.ts +12 -2
  66. package/src/cli/pane-functions/market-session.ts +133 -0
  67. package/src/cli/pane-functions/options.ts +15 -4
  68. package/src/cli/pane-functions/report-notices.ts +63 -0
  69. package/src/cli/pane-functions/report.ts +12 -1
  70. package/src/cli/pane-functions/resolver.ts +32 -8
  71. package/src/cli/pane-functions/screenshot-evidence.ts +2 -0
  72. package/src/cli/pane-functions/screenshot.ts +109 -23
  73. package/src/cli/registry.ts +8 -4
  74. package/src/cli/report-tables.ts +460 -0
  75. package/src/cli/result.ts +63 -16
  76. package/src/cli/row-window.ts +76 -0
  77. package/src/cli/scroll-truncation.ts +34 -0
  78. package/src/components/chart/composite/composite-chart.tsx +43 -4
  79. package/src/components/chart/composite/legend.tsx +39 -6
  80. package/src/components/chart/composite/rasterizer.ts +64 -4
  81. package/src/components/chart/composite/series-cues.ts +71 -0
  82. package/src/components/chart/composite/text-renderer.ts +16 -8
  83. package/src/components/chart/composite/types.ts +3 -0
  84. package/src/components/command-bar/commands/direct/index.ts +9 -1
  85. package/src/components/command-bar/commands/plugin/items.ts +3 -1
  86. package/src/components/command-bar/commands/registry.ts +7 -0
  87. package/src/components/command-bar/layout-items/focused-pane.ts +38 -0
  88. package/src/components/command-bar/pane-templates/workflow.ts +5 -0
  89. package/src/components/command-bar/routes/root/command-items.ts +2 -0
  90. package/src/components/command-bar/routes/root/plugin-turn-on.ts +92 -0
  91. package/src/components/command-bar/routes/root/results.ts +40 -10
  92. package/src/components/command-bar/routes/root/runtime.ts +8 -0
  93. package/src/components/command-bar/routes/root/search-providers.ts +97 -14
  94. package/src/components/command-bar/surface/index.tsx +18 -4
  95. package/src/components/command-bar/theme-picker.tsx +48 -13
  96. package/src/components/command-bar/workflow/ops.ts +5 -3
  97. package/src/components/data-table/export.ts +6 -51
  98. package/src/components/data-table/view.tsx +31 -10
  99. package/src/components/layout/floating-pane.tsx +6 -2
  100. package/src/components/layout/market-summary.ts +23 -9
  101. package/src/components/layout/pane/content.tsx +46 -29
  102. package/src/components/layout/pane/cycle-order.ts +2 -11
  103. package/src/components/layout/pane/header.tsx +2 -1
  104. package/src/components/layout/pane/title.ts +1 -1
  105. package/src/components/layout/presentation.ts +22 -0
  106. package/src/components/layout/shell/active-drag.ts +67 -35
  107. package/src/components/layout/shell/cursor-occlusion.ts +6 -5
  108. package/src/components/layout/shell/drag/index.ts +16 -7
  109. package/src/components/layout/shell/drag/live.ts +193 -0
  110. package/src/components/layout/shell/drag/overlays.tsx +179 -35
  111. package/src/components/layout/shell/drag/runtime.ts +62 -50
  112. package/src/components/layout/shell/drag/slide.ts +10 -0
  113. package/src/components/layout/shell/index.tsx +84 -57
  114. package/src/components/layout/shell/layout-state.ts +5 -11
  115. package/src/components/layout/shell/menu.ts +28 -1
  116. package/src/components/layout/shell/native/pointer-runtime.ts +13 -13
  117. package/src/components/layout/shell/native/surfaces.ts +30 -14
  118. package/src/components/layout/shell/pane/actions.ts +2 -1
  119. package/src/components/layout/shell/pane/layers.tsx +461 -180
  120. package/src/components/layout/shell/pane/management-shortcuts.ts +5 -0
  121. package/src/components/layout/shell/pane/new-layout.ts +116 -0
  122. package/src/components/layout/shell/shortcuts.ts +5 -2
  123. package/src/components/layout/shell/terminal-pointer-runtime.ts +18 -18
  124. package/src/components/layout/shell/visible-layout.ts +44 -1
  125. package/src/components/layout/shell/window-mode/overlays.tsx +12 -4
  126. package/src/components/layout/status-bar.tsx +4 -1
  127. package/src/components/markdown-text.tsx +9 -5
  128. package/src/components/metric-treemap/heat-surface.tsx +27 -8
  129. package/src/components/metric-treemap/heat-tiles.ts +48 -17
  130. package/src/components/onboarding/onboarding-frame.tsx +33 -10
  131. package/src/components/onboarding/onboarding-wizard.tsx +49 -40
  132. package/src/components/onboarding/portfolio-step/positions-panel.tsx +5 -2
  133. package/src/components/onboarding/pro-step-layout.ts +86 -0
  134. package/src/components/onboarding/use-onboarding-upgrade.ts +6 -2
  135. package/src/components/ui/button.tsx +10 -0
  136. package/src/components/ui/data-table/index.tsx +38 -0
  137. package/src/components/ui/data-table/types.ts +8 -1
  138. package/src/components/ui/data-table/visible-range.ts +2 -0
  139. package/src/components/ui/icon.tsx +11 -2
  140. package/src/components/ui/index.ts +1 -1
  141. package/src/core/connection-health.ts +1 -1
  142. package/src/core/state/app/layout-reducer.ts +89 -3
  143. package/src/core/state/app/state.ts +8 -5
  144. package/src/core/state/app/types.ts +13 -2
  145. package/src/data/config/layout.ts +55 -21
  146. package/src/data/config/store/fold-panes.ts +149 -0
  147. package/src/data/config/store/migrations.ts +7 -0
  148. package/src/data/config/store/node.ts +27 -6
  149. package/src/data/config/store/normalize.ts +37 -3
  150. package/src/data/json-persistence.ts +17 -3
  151. package/src/data/json-storage.ts +2 -0
  152. package/src/data/memory-resource-store.ts +46 -0
  153. package/src/i18n/es.ts +14 -1
  154. package/src/i18n/ja.ts +14 -1
  155. package/src/i18n/ko.ts +14 -1
  156. package/src/i18n/zh-cn.ts +14 -1
  157. package/src/i18n/zh-tw.ts +14 -1
  158. package/src/layout/desks.ts +1 -2
  159. package/src/layout/pane-layout-move.ts +264 -0
  160. package/src/layout/pane-manager/hidden-panes.ts +160 -0
  161. package/src/layout/pane-manager/index.ts +1 -0
  162. package/src/market-data/coordinator/entries.ts +7 -1
  163. package/src/market-data/coordinator/quotes.ts +2 -2
  164. package/src/market-data/market/freshness.ts +156 -11
  165. package/src/market-data/market/status.ts +40 -0
  166. package/src/market-data/options-alternatives.ts +69 -0
  167. package/src/market-data/quotes/contributions.ts +19 -2
  168. package/src/market-data/quotes/freshness.ts +5 -0
  169. package/src/market-data/quotes/metadata.ts +16 -0
  170. package/src/market-data/quotes/screener-live-quotes.ts +56 -5
  171. package/src/plugins/builtin/account-management/keyboard.ts +3 -0
  172. package/src/plugins/builtin/account-management/model.ts +1 -0
  173. package/src/plugins/builtin/account-management/navigation.ts +1 -1
  174. package/src/plugins/builtin/account-management/pane.tsx +31 -4
  175. package/src/plugins/builtin/alerts/quotes.ts +3 -0
  176. package/src/plugins/builtin/analytics/pane-model.ts +10 -6
  177. package/src/plugins/builtin/analytics/risk-headless.ts +3 -1
  178. package/src/plugins/builtin/analytics/risk-model.ts +37 -8
  179. package/src/plugins/builtin/analytics/risk-pane.tsx +8 -4
  180. package/src/plugins/builtin/attention/pane.tsx +1 -1
  181. package/src/plugins/builtin/backtest/pane.tsx +1 -1
  182. package/src/plugins/builtin/broker-manager/detail.tsx +4 -0
  183. package/src/plugins/builtin/broker-manager/index.tsx +7 -3
  184. package/src/plugins/builtin/broker-manager/model.ts +8 -0
  185. package/src/plugins/builtin/broker-manager/pane-actions.tsx +8 -2
  186. package/src/plugins/builtin/broker-manager/sign-in-end.ts +82 -0
  187. package/src/plugins/builtin/broker-manager/table.ts +5 -0
  188. package/src/plugins/builtin/builtin-plugin-meta.ts +121 -11
  189. package/src/plugins/builtin/catalysts/detail.tsx +1 -1
  190. package/src/plugins/builtin/chart-composer/pane.tsx +1 -1
  191. package/src/plugins/builtin/chat/channels.ts +34 -36
  192. package/src/plugins/builtin/chat/command-bar.ts +138 -0
  193. package/src/plugins/builtin/chat/composer-commands.ts +21 -1
  194. package/src/plugins/builtin/chat/content/composer-runtime.ts +11 -1
  195. package/src/plugins/builtin/chat/content/footer.tsx +5 -3
  196. package/src/plugins/builtin/chat/content/index.tsx +21 -1
  197. package/src/plugins/builtin/chat/content/mentions.ts +2 -1
  198. package/src/plugins/builtin/chat/content/user-map.ts +3 -1
  199. package/src/plugins/builtin/chat/controller/index.ts +48 -0
  200. package/src/plugins/builtin/chat/conversations.ts +256 -0
  201. package/src/plugins/builtin/chat/discord-command.ts +91 -0
  202. package/src/plugins/builtin/chat/edit-window.ts +12 -6
  203. package/src/plugins/builtin/chat/ghost-user.ts +14 -0
  204. package/src/plugins/builtin/chat/index.tsx +40 -2
  205. package/src/plugins/builtin/chat/layout.ts +2 -0
  206. package/src/plugins/builtin/chat/message/desktop.tsx +2 -1
  207. package/src/plugins/builtin/chat/message/header.tsx +24 -15
  208. package/src/plugins/builtin/chat/message/profile-popover.tsx +2 -1
  209. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  210. package/src/plugins/builtin/chat/pane-requests.ts +60 -0
  211. package/src/plugins/builtin/chat/pane-state.ts +32 -2
  212. package/src/plugins/builtin/chat/pane.tsx +8 -0
  213. package/src/plugins/builtin/cloud/askg/controller.ts +112 -4
  214. package/src/plugins/builtin/cloud/askg/conversation-store.ts +21 -0
  215. package/src/plugins/builtin/cloud/askg/manifest.ts +1 -0
  216. package/src/plugins/builtin/cloud/askg/model.ts +129 -70
  217. package/src/plugins/builtin/cloud/askg/pane.tsx +772 -377
  218. package/src/plugins/builtin/cloud/askg/protocol.ts +40 -2
  219. package/src/plugins/builtin/cloud/askg/tool-display.ts +437 -0
  220. package/src/plugins/builtin/cloud/askg/tool-timeline.tsx +403 -0
  221. package/src/plugins/builtin/cloud/company-picker.tsx +1 -9
  222. package/src/plugins/builtin/cloud/mcp-connect/command.ts +24 -0
  223. package/src/plugins/builtin/cloud/mcp-connect/dialog.tsx +360 -0
  224. package/src/plugins/builtin/cloud/mcp-connect/model.ts +135 -0
  225. package/src/plugins/builtin/cloud/mcp-connect/sections.ts +62 -0
  226. package/src/plugins/builtin/cloud/plugin.tsx +36 -1
  227. package/src/plugins/builtin/cloud/starter-symbols.ts +8 -0
  228. package/src/plugins/builtin/cloud/terminal-relay/acm-tab.tsx +89 -0
  229. package/src/plugins/builtin/cloud/terminal-relay/client.ts +97 -0
  230. package/src/plugins/builtin/cloud/terminal-relay/connect-section.tsx +57 -0
  231. package/src/plugins/builtin/cloud/terminal-relay/engine.ts +666 -0
  232. package/src/plugins/builtin/cloud/terminal-relay/grants.ts +137 -0
  233. package/src/plugins/builtin/cloud/terminal-relay/host.tsx +81 -0
  234. package/src/plugins/builtin/cloud/terminal-relay/indicator.tsx +82 -0
  235. package/src/plugins/builtin/cloud/terminal-relay/manifest.ts +234 -0
  236. package/src/plugins/builtin/cloud/terminal-relay/policy.ts +153 -0
  237. package/src/plugins/builtin/cloud/terminal-relay/prompts.tsx +168 -0
  238. package/src/plugins/builtin/cloud/terminal-relay/redact.ts +73 -0
  239. package/src/plugins/builtin/cloud/terminal-relay/summary.ts +62 -0
  240. package/src/plugins/builtin/cloud/upgrade-dialog.tsx +26 -2
  241. package/src/plugins/builtin/cloud/upgrade-personalization.ts +57 -0
  242. package/src/plugins/builtin/cloud/upgrade-tickers.ts +124 -0
  243. package/src/plugins/builtin/comment-letters/client.ts +1 -3
  244. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  245. package/src/plugins/builtin/company-kpis/client.ts +23 -8
  246. package/src/plugins/builtin/company-kpis/headless.ts +3 -1
  247. package/src/plugins/builtin/company-kpis/pane.tsx +15 -7
  248. package/src/plugins/builtin/company-kpis/screenshot-evidence.ts +10 -3
  249. package/src/plugins/builtin/composite-plugins.ts +107 -64
  250. package/src/plugins/builtin/credit-boards/cdx-pane.tsx +4 -4
  251. package/src/plugins/builtin/credit-boards/index.tsx +21 -21
  252. package/src/plugins/builtin/credit-boards/model.ts +0 -1
  253. package/src/plugins/builtin/credit-boards/pane.tsx +30 -0
  254. package/src/plugins/builtin/credit-boards/sovr-pane.tsx +4 -4
  255. package/src/plugins/builtin/debt-maturities/client.ts +37 -9
  256. package/src/plugins/builtin/debt-maturities/headless.ts +3 -1
  257. package/src/plugins/builtin/debt-maturities/pane.tsx +5 -4
  258. package/src/plugins/builtin/dividend-yield/index.tsx +1 -1
  259. package/src/plugins/builtin/dividend-yield/pane.tsx +1 -1
  260. package/src/plugins/builtin/earnings-calls/data.ts +28 -7
  261. package/src/plugins/builtin/earnings-calls/headless.ts +3 -2
  262. package/src/plugins/builtin/earnings-calls/pane.tsx +10 -5
  263. package/src/plugins/builtin/econ/calendar-model.ts +42 -17
  264. package/src/plugins/builtin/econ/detail-view.tsx +2 -2
  265. package/src/plugins/builtin/econ/index.tsx +68 -22
  266. package/src/plugins/builtin/econ-statistics/headless.ts +12 -2
  267. package/src/plugins/builtin/econ-statistics/pane.tsx +1 -1
  268. package/src/plugins/builtin/econ-statistics/view.ts +17 -0
  269. package/src/plugins/builtin/executives/data.ts +17 -6
  270. package/src/plugins/builtin/executives/pane.tsx +16 -7
  271. package/src/plugins/builtin/filing-events/pane.tsx +22 -5
  272. package/src/plugins/builtin/fx-matrix/freshness.ts +65 -0
  273. package/src/plugins/builtin/fx-matrix/index.tsx +22 -1
  274. package/src/plugins/builtin/fx-matrix/pairs.ts +1 -1
  275. package/src/plugins/builtin/fx-matrix/settings.ts +42 -2
  276. package/src/plugins/builtin/gpu/headless.ts +9 -3
  277. package/src/plugins/builtin/gpu/model.ts +14 -5
  278. package/src/plugins/builtin/help/index.tsx +1 -0
  279. package/src/plugins/builtin/holders/beneficial-client.ts +29 -7
  280. package/src/plugins/builtin/holders/beneficial-view.tsx +7 -3
  281. package/src/plugins/builtin/holders/client.ts +3 -6
  282. package/src/plugins/builtin/holders/format.ts +31 -1
  283. package/src/plugins/builtin/holders/headless.ts +32 -6
  284. package/src/plugins/builtin/holders/pane.tsx +25 -13
  285. package/src/plugins/builtin/holders/report-header.ts +46 -5
  286. package/src/plugins/builtin/insider/index.tsx +4 -4
  287. package/src/plugins/builtin/iv-history/charts.tsx +21 -7
  288. package/src/plugins/builtin/iv-history/client.ts +12 -2
  289. package/src/plugins/builtin/iv-history/format.ts +33 -2
  290. package/src/plugins/builtin/iv-history/headless.ts +22 -10
  291. package/src/plugins/builtin/iv-history/index.tsx +3 -3
  292. package/src/plugins/builtin/iv-history/model.ts +61 -13
  293. package/src/plugins/builtin/iv-history/pane.tsx +11 -3
  294. package/src/plugins/builtin/iv-history/screen-pane.tsx +42 -18
  295. package/src/plugins/builtin/jobs/pane.tsx +1 -1
  296. package/src/plugins/builtin/kelly-sizer/headless.ts +149 -0
  297. package/src/plugins/builtin/kelly-sizer/history.ts +80 -0
  298. package/src/plugins/builtin/kelly-sizer/index.tsx +2 -0
  299. package/src/plugins/builtin/kelly-sizer/pane.tsx +121 -45
  300. package/src/plugins/builtin/kelly-sizer/portfolio.ts +25 -0
  301. package/src/plugins/builtin/kelly-sizer/sections.tsx +14 -0
  302. package/src/plugins/builtin/kelly-sizer/types.ts +9 -1
  303. package/src/plugins/builtin/layout-manager/index.tsx +9 -1
  304. package/src/plugins/builtin/macro-day/client.ts +24 -0
  305. package/src/plugins/builtin/macro-day/headless.ts +11 -8
  306. package/src/plugins/builtin/macro-day/index.tsx +3 -0
  307. package/src/plugins/builtin/macro-day/model.ts +11 -6
  308. package/src/plugins/builtin/macro-day/pane.tsx +12 -8
  309. package/src/plugins/builtin/macro-day/release-table.ts +40 -0
  310. package/src/plugins/builtin/macro-day/releases.ts +62 -44
  311. package/src/plugins/builtin/market-heatmap/data.ts +25 -5
  312. package/src/plugins/builtin/market-heatmap/evidence.ts +90 -0
  313. package/src/plugins/builtin/market-heatmap/index.tsx +144 -63
  314. package/src/plugins/builtin/market-heatmap/model.ts +94 -3
  315. package/src/plugins/builtin/market-heatmap/portfolio.ts +12 -2
  316. package/src/plugins/builtin/market-movers/headless.ts +31 -4
  317. package/src/plugins/builtin/market-movers/index.tsx +8 -1
  318. package/src/plugins/builtin/market-movers/model.ts +71 -17
  319. package/src/plugins/builtin/market-movers/table.ts +50 -23
  320. package/src/plugins/builtin/members/pane.tsx +10 -0
  321. package/src/plugins/builtin/news/headless.ts +9 -2
  322. package/src/plugins/builtin/news/wire/news/footer.ts +10 -0
  323. package/src/plugins/builtin/openfda/client.ts +1 -1
  324. package/src/plugins/builtin/options/carry.ts +95 -0
  325. package/src/plugins/builtin/options/index.tsx +58 -5
  326. package/src/plugins/builtin/options/strike-window.ts +183 -0
  327. package/src/plugins/builtin/options/table.ts +55 -3
  328. package/src/plugins/builtin/options/types.ts +8 -1
  329. package/src/plugins/builtin/options/view.tsx +161 -12
  330. package/src/plugins/builtin/options-positioning/headless.ts +4 -2
  331. package/src/plugins/builtin/options-scenario/charts.tsx +2 -2
  332. package/src/plugins/builtin/options-scenario/client.ts +73 -17
  333. package/src/plugins/builtin/options-scenario/headless.ts +71 -24
  334. package/src/plugins/builtin/options-scenario/hedge.ts +284 -0
  335. package/src/plugins/builtin/options-scenario/index.tsx +9 -2
  336. package/src/plugins/builtin/options-scenario/model.ts +33 -0
  337. package/src/plugins/builtin/options-scenario/pane.tsx +40 -16
  338. package/src/plugins/builtin/pe-band/model.ts +2 -2
  339. package/src/plugins/builtin/pe-band/report-dates.ts +11 -7
  340. package/src/plugins/builtin/perps/equity-row.tsx +8 -5
  341. package/src/plugins/builtin/perps/model.ts +16 -5
  342. package/src/plugins/builtin/perps/pane.tsx +1 -1
  343. package/src/plugins/builtin/plugin-marketplace/model.ts +64 -21
  344. package/src/plugins/builtin/plugin-marketplace/packs.ts +118 -0
  345. package/src/plugins/builtin/plugin-marketplace/pane.tsx +344 -32
  346. package/src/plugins/builtin/plugin-marketplace/store.ts +7 -1
  347. package/src/plugins/builtin/plugin-module.ts +87 -4
  348. package/src/plugins/builtin/portfolio-list/account-metrics.ts +16 -1
  349. package/src/plugins/builtin/portfolio-list/allocation.ts +3 -0
  350. package/src/plugins/builtin/portfolio-list/cached-account.ts +18 -9
  351. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +71 -36
  352. package/src/plugins/builtin/portfolio-list/cli/render.ts +89 -18
  353. package/src/plugins/builtin/portfolio-list/cli/shared.ts +2 -16
  354. package/src/plugins/builtin/portfolio-list/headless.ts +40 -7
  355. package/src/plugins/builtin/portfolio-list/pane/index.tsx +7 -4
  356. package/src/plugins/builtin/portfolio-list/summary/index.tsx +22 -4
  357. package/src/plugins/builtin/portfolio-list/summary/live-accounts.ts +11 -1
  358. package/src/plugins/builtin/realized-vol/headless.ts +3 -0
  359. package/src/plugins/builtin/realized-vol/pane.tsx +1 -1
  360. package/src/plugins/builtin/research/corporate-actions-pane.tsx +2 -2
  361. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +1 -1
  362. package/src/plugins/builtin/research/relative-valuation-model.ts +7 -3
  363. package/src/plugins/builtin/revenue-breakdown/client.ts +13 -6
  364. package/src/plugins/builtin/revenue-breakdown/headless.ts +4 -1
  365. package/src/plugins/builtin/revenue-breakdown/pane.tsx +7 -4
  366. package/src/plugins/builtin/reverse-dcf/headless.ts +5 -1
  367. package/src/plugins/builtin/reverse-dcf/model.ts +2 -1
  368. package/src/plugins/builtin/reverse-dcf/pane.tsx +1 -1
  369. package/src/plugins/builtin/risk-factors/data.ts +23 -9
  370. package/src/plugins/builtin/risk-factors/headless.ts +5 -2
  371. package/src/plugins/builtin/risk-factors/pane.tsx +6 -5
  372. package/src/plugins/builtin/seasonality/pane.tsx +1 -1
  373. package/src/plugins/builtin/sec/index.tsx +5 -5
  374. package/src/plugins/builtin/sectors/client.ts +10 -2
  375. package/src/plugins/builtin/sectors/headless.ts +35 -6
  376. package/src/plugins/builtin/sectors/index.tsx +18 -1
  377. package/src/plugins/builtin/sectors/sector-model.ts +150 -41
  378. package/src/plugins/builtin/shared/command-tab.ts +51 -0
  379. package/src/plugins/builtin/shared/headless-market-data.ts +33 -1
  380. package/src/plugins/builtin/shared/members-menu.tsx +260 -0
  381. package/src/plugins/builtin/shared/report-freshness.ts +50 -2
  382. package/src/plugins/builtin/shared/ticker-request.ts +25 -1
  383. package/src/plugins/builtin/shared/use-quote-board.ts +25 -5
  384. package/src/plugins/builtin/shared/volatility/options.ts +1 -46
  385. package/src/plugins/builtin/short-interest/index.tsx +19 -4
  386. package/src/plugins/builtin/short-interest/surface.tsx +4 -4
  387. package/src/plugins/builtin/short-volume/index.tsx +1 -10
  388. package/src/plugins/builtin/short-volume/pane.tsx +1 -1
  389. package/src/plugins/builtin/social-mentions/pane.tsx +1 -1
  390. package/src/plugins/builtin/supply-chain/model.ts +1 -1
  391. package/src/plugins/builtin/themes/pane.tsx +3 -0
  392. package/src/plugins/builtin/ticker-detail/financials/aggregation.ts +4 -0
  393. package/src/plugins/builtin/ticker-detail/financials/model.ts +25 -2
  394. package/src/plugins/builtin/ticker-detail/headless.ts +63 -25
  395. package/src/plugins/builtin/ticker-detail/overview/model.ts +9 -4
  396. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +3 -2
  397. package/src/plugins/builtin/ticker-detail/pane.tsx +4 -5
  398. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +94 -6
  399. package/src/plugins/builtin/treasury-auctions/cache.ts +5 -2
  400. package/src/plugins/builtin/treasury-auctions/client.ts +22 -8
  401. package/src/plugins/builtin/treasury-auctions/headless.ts +40 -4
  402. package/src/plugins/builtin/treasury-auctions/index.tsx +11 -9
  403. package/src/plugins/builtin/treasury-auctions/model.ts +197 -30
  404. package/src/plugins/builtin/treasury-auctions/pane.tsx +35 -11
  405. package/src/plugins/builtin/treasury-auctions/types.ts +11 -1
  406. package/src/plugins/builtin/vol-surface/charts.tsx +1 -3
  407. package/src/plugins/builtin/vol-surface/client.ts +6 -2
  408. package/src/plugins/builtin/vol-surface/evidence.ts +11 -6
  409. package/src/plugins/builtin/vol-surface/headless.ts +124 -20
  410. package/src/plugins/builtin/vol-surface/model.ts +23 -11
  411. package/src/plugins/builtin/vol-surface/pane.tsx +38 -27
  412. package/src/plugins/builtin/vol-surface/tables.ts +124 -0
  413. package/src/plugins/builtin/world-indices/client.ts +22 -4
  414. package/src/plugins/builtin/world-indices/headless.ts +54 -27
  415. package/src/plugins/builtin/world-indices/model.ts +4 -2
  416. package/src/plugins/builtin/world-indices/table.tsx +13 -2
  417. package/src/plugins/builtin/world-venue-map/basemap-layer.tsx +2 -0
  418. package/src/plugins/builtin/world-venue-map/basemap.ts +18 -0
  419. package/src/plugins/builtin/world-venue-map/country-labels.ts +78 -0
  420. package/src/plugins/builtin/world-venue-map/geo-series.ts +1 -1
  421. package/src/plugins/builtin/world-venue-map/geo-svg.tsx +2 -1
  422. package/src/plugins/builtin/world-venue-map/index.ts +6 -6
  423. package/src/plugins/builtin/world-venue-map/layered-pane.tsx +27 -14
  424. package/src/plugins/builtin/world-venue-map/layers.ts +68 -10
  425. package/src/plugins/builtin/world-venue-map/map.tsx +198 -62
  426. package/src/plugins/builtin/world-venue-map/model.ts +16 -0
  427. package/src/plugins/builtin/world-venue-map/natural-earth/index.chunk.ts +3 -0
  428. package/src/plugins/builtin/world-venue-map/natural-earth/labels.chunk.ts +244 -0
  429. package/src/plugins/builtin/world-venue-map/pane.tsx +16 -10
  430. package/src/plugins/builtin/yield-curve/index.tsx +1 -1
  431. package/src/plugins/builtin-editorial.ts +126 -0
  432. package/src/plugins/catalog-backend.ts +16 -9
  433. package/src/plugins/catalog-browser.ts +127 -63
  434. package/src/plugins/catalog-manifest.ts +73 -40
  435. package/src/plugins/catalog-ui.ts +26 -6
  436. package/src/plugins/extracted-plugins.ts +7 -1
  437. package/src/plugins/ownership.ts +150 -7
  438. package/src/plugins/plugin-functions.ts +54 -0
  439. package/src/plugins/plugin-off.ts +36 -0
  440. package/src/plugins/registry/host-actions.ts +16 -0
  441. package/src/plugins/registry/index.ts +84 -24
  442. package/src/plugins/registry/plugin-state.ts +25 -1
  443. package/src/plugins/runtime/context.tsx +29 -0
  444. package/src/react/input.ts +48 -1
  445. package/src/remote/app-host.tsx +5 -0
  446. package/src/remote/controller.ts +3 -53
  447. package/src/remote/matching.ts +69 -0
  448. package/src/remote/schema.ts +1 -1
  449. package/src/remote/semantic-tree.tsx +14 -0
  450. package/src/remote/types.ts +2 -0
  451. package/src/renderers/browser/app-services.ts +3 -1
  452. package/src/renderers/dom/data-table/dom.ts +31 -0
  453. package/src/renderers/dom/data-table/index.tsx +5 -0
  454. package/src/renderers/dom/desktop/icons.tsx +14 -0
  455. package/src/renderers/dom/desktop/query-bar.tsx +59 -32
  456. package/src/renderers/dom/host/box.tsx +113 -17
  457. package/src/renderers/dom/host/drag-shield.ts +35 -0
  458. package/src/renderers/dom/host/live-frame.ts +62 -0
  459. package/src/renderers/dom/host/mouse.ts +8 -1
  460. package/src/renderers/dom/host/scroll-box.tsx +6 -5
  461. package/src/renderers/dom/host/tabs.tsx +3 -2
  462. package/src/renderers/dom/input-host.tsx +6 -1
  463. package/src/renderers/dom/styles.css +26 -5
  464. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +7 -0
  465. package/src/renderers/electrobun/bun/desktop/backend.ts +7 -1
  466. package/src/renderers/electrobun/bun/desktop/detached-windows.ts +12 -1
  467. package/src/renderers/electrobun/bun/desktop/initialization.ts +15 -1
  468. package/src/renderers/electrobun/shared/protocol.ts +12 -1
  469. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +4 -1
  470. package/src/renderers/electrobun/view/cli-pane-shot-frame.tsx +101 -5
  471. package/src/renderers/electrobun/view/host/native.ts +0 -10
  472. package/src/renderers/electrobun/view/host/window-move.ts +28 -0
  473. package/src/renderers/electrobun/view/main.tsx +4 -1
  474. package/src/renderers/electrobun/view/remote/persistence.ts +15 -2
  475. package/src/renderers/electrobun/view/ui-host.tsx +2 -5
  476. package/src/renderers/opentui/input-host.tsx +6 -1
  477. package/src/renderers/opentui/start.tsx +9 -0
  478. package/src/renderers/opentui/ui-host.tsx +7 -1
  479. package/src/shares/portable-layout.ts +16 -6
  480. package/src/sources/gloomberb-cloud/index.ts +19 -10
  481. package/src/sources/gloomberb-cloud/normalizers.ts +20 -21
  482. package/src/sources/listing-symbols.ts +11 -5
  483. package/src/sources/provider-errors.ts +88 -3
  484. package/src/sources/provider-router/batches.ts +3 -3
  485. package/src/sources/provider-router/cached-routes.ts +8 -4
  486. package/src/sources/provider-router/financial-routes.ts +13 -7
  487. package/src/sources/provider-router/financials.ts +106 -16
  488. package/src/sources/provider-router/history.ts +27 -11
  489. package/src/sources/provider-router/index.ts +1 -1
  490. package/src/sources/provider-router/primary.ts +24 -6
  491. package/src/sources/sec-edgar.ts +68 -8
  492. package/src/state/pane-scroll-registry.tsx +67 -35
  493. package/src/sync/core-contributors.ts +4 -3
  494. package/src/test-support/cli-context.ts +2 -1
  495. package/src/test-support/data-provider.ts +34 -0
  496. package/src/test-support/margin-account.ts +108 -0
  497. package/src/test-support/shell-pane-keys.tsx +1 -0
  498. package/src/theme/series-colors.ts +46 -0
  499. package/src/theme/themes.ts +149 -0
  500. package/src/tickers/search/index.ts +22 -2
  501. package/src/tickers/search/ranking.ts +45 -1
  502. package/src/tickers/search/types.ts +3 -1
  503. package/src/time-series/resolution.ts +15 -0
  504. package/src/time-series/resolve.ts +1 -14
  505. package/src/types/config.ts +61 -1
  506. package/src/types/financials.ts +32 -3
  507. package/src/types/headless.ts +11 -0
  508. package/src/types/instrument.ts +8 -0
  509. package/src/types/plugin.ts +49 -3
  510. package/src/ui/host.tsx +16 -0
  511. package/src/ui/index.tsx +10 -3
  512. package/src/ui/scroll-box-axis.ts +26 -0
  513. package/src/utils/cli-output.ts +14 -1
  514. package/src/utils/currency-units.ts +8 -3
  515. package/src/utils/depositary-receipt.ts +54 -10
  516. package/src/utils/display-number.ts +63 -0
  517. package/src/utils/exchanges.ts +13 -0
  518. package/src/utils/format.ts +2 -1
  519. package/src/utils/fundamentals.ts +17 -1
  520. package/src/utils/fx-market-hours.ts +34 -0
  521. package/src/utils/option-expiry.ts +126 -0
  522. package/src/utils/price-history-integrity.ts +19 -6
  523. package/src/utils/price-history.ts +39 -6
  524. package/src/utils/rpc-timeout-error.ts +6 -2
  525. package/src/utils/sec-eps-basis.ts +159 -30
  526. package/src/utils/sec.ts +38 -34
  527. package/src/utils/throttled-fetch.ts +51 -22
@@ -1,13 +1,14 @@
1
1
  import { CLOUD_PLAN_KEY } from "../../shared/cloud-upgrade";
2
- import { ActionRow } from "../../../../components/ui/action-row";
3
2
  import { getCurrentPluginTarget } from "../../../current-target";
4
3
  import {
5
4
  useCallback,
6
5
  useEffect,
6
+ useLayoutEffect,
7
7
  useMemo,
8
8
  useRef,
9
9
  useState,
10
10
  useSyncExternalStore,
11
+ type ReactNode,
11
12
  } from "react";
12
13
  import { apiClient } from "../../../../api-client";
13
14
  import {
@@ -23,17 +24,15 @@ import {
23
24
  import {
24
25
  Button,
25
26
  ChoiceDialog,
26
- DataTableView,
27
+ Divider,
27
28
  EmptyState,
28
29
  getPaneSidebarWidth,
30
+ IconButton,
29
31
  MessageComposer,
30
32
  Prose,
31
- QueryBar,
32
33
  shouldShowPaneSidebar,
33
34
  Spinner,
34
35
  usePaneFooter,
35
- type DataTableCell,
36
- type DataTableColumn,
37
36
  type PaneHint,
38
37
  } from "../../../../components";
39
38
  import { MarkdownText } from "../../../../components/markdown-text";
@@ -46,16 +45,20 @@ import {
46
45
  } from "../../../../state/app/context";
47
46
  import { useInlineTickers } from "../../../../state/hooks/inline-tickers";
48
47
  import { useRemoteControlHandler } from "../../../../remote/app-host";
49
- import { colors } from "../../../../theme/colors";
48
+ import { useThemeColors } from "../../../../theme/theme-context";
50
49
  import type { PaneProps } from "../../../../types/plugin";
50
+ import type { ContextMenuItem } from "../../../../types/context-menu";
51
+ import type { AppConfig } from "../../../../types/config";
52
+ import type { TickerRecord } from "../../../../types/ticker";
51
53
  import { collectUniqueTickerSymbols } from "../../../../tickers/tokenizer";
52
54
  import { countEscapeTowardClose } from "../../../../utils/double-escape-close";
53
55
  import { isPlainKey } from "../../../../utils/keyboard";
54
- import { truncateWithEllipsis } from "../../../../utils/text-wrap";
56
+ import { truncateWithEllipsis, wrapTextLines } from "../../../../utils/text-wrap";
55
57
  import { usePluginAppActions, usePluginTickerActions } from "../../../runtime";
56
58
  import { usePlanAccess } from "../../../../api-client/plan-access";
57
59
  import { SignInWall } from "../auth-actions";
58
60
  import { afterLayout, revealInScrollBox } from "../../../../components/ui/reveal-in-scroll-box";
61
+ import { describePortfolioTab } from "../../analytics/portfolio-selection";
59
62
  import { ASKGSessionController, type ASKGControllerManifest } from "./controller";
60
63
  import {
61
64
  createASKGRendererToolExecutor,
@@ -74,26 +77,37 @@ import { ASKGConversationSidebar } from "./sidebar";
74
77
  import { ASKGUndoManager } from "./undo";
75
78
  import {
76
79
  activeTurn,
80
+ canRateTurn,
77
81
  canRetryASKGError,
78
82
  describeASKGError,
79
- describeToolStatus,
80
83
  formatCellValue,
81
84
  isTurnRunning,
82
85
  pendingConfirmation,
83
- rowSymbol,
84
86
  toolResultTables,
85
- toolRowHeadline,
86
87
  type ASKGConversationState,
87
- type ASKGResultTable,
88
88
  type ASKGToolRow,
89
89
  type ASKGTurn,
90
+ type ASKGTurnFeedback,
90
91
  } from "./model";
91
- import type { JsonValue } from "./protocol";
92
+ import type { ASKGFeedbackRating, ASKGFeedbackReason, JsonValue } from "./protocol";
93
+ import { describeToolRow, toolTitle, type ToolDisplayContext } from "./tool-display";
94
+ import {
95
+ canUndo,
96
+ ToolResultDetail,
97
+ toolGroupId,
98
+ TurnTools,
99
+ turnTimelineIds,
100
+ UNDO_KEY,
101
+ } from "./tool-timeline";
92
102
 
93
103
  export const ASKG_PANE_ID = "askg";
94
104
 
95
105
  const CLIENT_VERSION = "1";
96
106
  const MIN_DETAIL_HEIGHT = 8;
107
+ /** The composer grows with the question up to this many lines, then scrolls. */
108
+ const MAX_COMPOSER_LINES = 6;
109
+ /** The daily allowance is news only once it runs low. */
110
+ const LOW_QUOTA = 20;
97
111
 
98
112
  function clientKind(): "tui" | "desktop" | "web" {
99
113
  const target = getCurrentPluginTarget();
@@ -107,24 +121,6 @@ function tierLabel(row: ASKGToolRow): string | null {
107
121
  return "broker";
108
122
  }
109
123
 
110
- function statusColor(row: ASKGToolRow): string {
111
- switch (row.status) {
112
- case "ok":
113
- return colors.textDim;
114
- case "partial":
115
- case "awaiting-confirmation":
116
- return colors.warning;
117
- case "error":
118
- case "timeout":
119
- return colors.negative;
120
- case "denied":
121
- case "cancelled":
122
- return colors.textMuted;
123
- default:
124
- return colors.textDim;
125
- }
126
- }
127
-
128
124
  function previewLines(preview: JsonValue | undefined): string[] {
129
125
  if (preview == null) return [];
130
126
  if (typeof preview === "string") return preview.split("\n");
@@ -133,97 +129,162 @@ function previewLines(preview: JsonValue | undefined): string[] {
133
129
  return Object.entries(preview).map(([key, value]) => `${key}: ${formatCellValue(value)}`);
134
130
  }
135
131
 
136
- /** Undo a write; not `u`, which installs an app update whenever one is waiting. */
137
- const UNDO_KEY = "z";
138
-
139
132
  /** Upgrade from a Pro-only answer, on the key every Pro prompt uses. */
140
133
  const UPGRADE_KEY = CLOUD_PLAN_KEY;
141
134
 
142
- function canUndo(row: ASKGToolRow): boolean {
143
- return !!row.undoToken && (!row.undo || row.undo.status === "available");
135
+ /**
136
+ * Three questions a first look can send with one click, worded for what the
137
+ * profile holds: a portfolio, a ticker on screen, a watchlist.
138
+ */
139
+ function askgExampleQuestions({
140
+ config,
141
+ tickers,
142
+ activeSymbol,
143
+ }: {
144
+ config: Pick<AppConfig, "watchlists">;
145
+ tickers: Iterable<TickerRecord>;
146
+ activeSymbol: string | null;
147
+ }): string[] {
148
+ let holding: string | null = null;
149
+ let watched = false;
150
+ for (const ticker of tickers) {
151
+ if (!holding && ticker.metadata.positions.some((position) => position.shares !== 0)) holding = ticker.metadata.ticker;
152
+ if (ticker.metadata.watchlists.length > 0) watched = true;
153
+ }
154
+ const symbol = activeSymbol ?? holding ?? "NVDA";
155
+ // Short enough to read whole in a phone-wide pane.
156
+ return [
157
+ holding ? "How is my portfolio doing today?" : "What moved the market today?",
158
+ `What moved ${symbol} today?`,
159
+ holding
160
+ ? "Which holdings report earnings this week?"
161
+ : watched && config.watchlists.length > 0
162
+ ? "Which watchlist names report this week?"
163
+ : "What is on this week's economic calendar?",
164
+ ];
144
165
  }
145
166
 
146
- export function ToolTimelineRow({
147
- row,
167
+ /** Rate the answer the keyboard is on: the selected one, else the latest. */
168
+ const GOOD_ANSWER_KEY = "g";
169
+ const BAD_ANSWER_KEY = "b";
170
+ /** Send the answer a thumbs down is open on. */
171
+ const SHARE_ANSWER_KEY = "s";
172
+
173
+ /** `hint` is the footer's short form; the row and the pane menu say it in full. */
174
+ const FEEDBACK_REASONS: ReadonlyArray<{ reason: ASKGFeedbackReason; label: string; hint: string; title: string; key: string }> = [
175
+ { reason: "wrong", label: "Wrong", hint: "wrong", title: "Reason: Wrong", key: "1" },
176
+ { reason: "slow", label: "Too slow", hint: "slow", title: "Reason: Too Slow", key: "2" },
177
+ { reason: "missing_data", label: "Missing data", hint: "missing", title: "Reason: Missing Data", key: "3" },
178
+ { reason: "other", label: "Other", hint: "other", title: "Reason: Other", key: "4" },
179
+ ];
180
+
181
+ /**
182
+ * Exactly what the send carries. The answer goes as written, so whatever it
183
+ * says about the person's holdings goes with it; the rows the tools returned
184
+ * never do. A consent line must not promise less than what is sent.
185
+ */
186
+ const SHARE_DISCLOSURE = "Sends your question, this answer as written (it can mention your holdings) and which tools ran, not the data they returned.";
187
+
188
+ /** True when the only thing that changed between two transcripts is a rating. */
189
+ function onlyFeedbackChanged(previous: readonly ASKGTurn[], next: readonly ASKGTurn[]): boolean {
190
+ if (previous === next || previous.length !== next.length) return false;
191
+ return previous.every((turn, index) => {
192
+ const other = next[index];
193
+ if (!other) return false;
194
+ if (turn === other) return true;
195
+ const { feedback: _before, ...rest } = turn;
196
+ const { feedback: _after, ...otherRest } = other;
197
+ return (Object.keys(rest) as Array<keyof typeof rest>).every((key) => rest[key] === otherRest[key]);
198
+ });
199
+ }
200
+
201
+ /** The timeline stop an answer's rating sits on, beside the tool rows. */
202
+ function answerStopId(turnId: string): string {
203
+ return `answer:${turnId}`;
204
+ }
205
+
206
+ /**
207
+ * The rating under a finished answer: two quiet thumbs, and after a thumbs
208
+ * down the reasons and the one action that sends the answer, with a line that
209
+ * says what goes and what never does. Keys show beside the thumbs on the
210
+ * answer the keyboard would rate.
211
+ */
212
+ function AnswerFeedback({
213
+ feedback,
148
214
  width,
215
+ keysShown,
216
+ followUpOpen,
149
217
  selected,
150
- expanded,
151
218
  selectedRowRef,
152
- onSelect,
153
- onToggle,
154
- onUndo,
219
+ onRate,
220
+ onReason,
221
+ onShare,
155
222
  }: {
156
- row: ASKGToolRow;
223
+ feedback: ASKGTurnFeedback | undefined;
157
224
  width: number;
225
+ /** This is the answer `g` and `b` rate. */
226
+ keysShown: boolean;
227
+ followUpOpen: boolean;
158
228
  selected: boolean;
159
- expanded: boolean;
160
- /** Takes the row while it is selected, so the transcript can scroll to it. */
161
229
  selectedRowRef: (node: BoxRenderable | null) => void;
162
- onSelect: () => void;
163
- onToggle: () => void;
164
- onUndo: () => void;
230
+ onRate: (rating: ASKGFeedbackRating) => void;
231
+ onReason: (reason: ASKGFeedbackReason) => void;
232
+ onShare: () => void;
165
233
  }) {
166
- const hasRows = row.result !== undefined;
167
- const marker = hasRows ? (expanded ? "▾" : "▸") : "·";
168
- const tier = tierLabel(row);
169
- const status = describeToolStatus(row);
170
- const { label, summary } = toolRowHeadline(row);
171
- const undoLabel = row.undo?.status === "running"
172
- ? "undoing…"
173
- : row.undo?.status === "done"
174
- ? "undone"
175
- : row.undo?.status === "failed"
176
- ? "undo failed"
177
- : row.undoToken
178
- ? "undo"
179
- : null;
180
- // The row lays its parts out with a one-cell gap between each: marker, name,
181
- // " " + summary, spacer, tier, status, server mark. A summary that leaves no
182
- // room for them pushes the row onto two lines, over the note below it.
183
- const parts = [marker, label, " ", "", ...(tier ? [`${tier} `] : []), status, ...(row.origin === "server" ? [" · Gloom"] : [])];
184
- const fixedWidth = parts.reduce((total, part) => total + part.length, 0) + parts.length;
185
- const summaryWidth = Math.max(6, width - fixedWidth);
186
-
234
+ const colors = useThemeColors();
235
+ const rating = feedback?.rating ?? null;
236
+ const sharing = feedback?.pending === "share";
187
237
  return (
188
- <Box ref={selected ? selectedRowRef : undefined} flexDirection="column">
189
- <ActionRow
190
- label={label}
191
- expanded={hasRows ? expanded : undefined}
192
- active={selected}
193
- width={width}
194
- onPress={() => { onSelect(); onToggle(); }}
195
- >
196
- {summary ? (
197
- <Text fg={colors.textDim}>{` ${truncateWithEllipsis(summary, summaryWidth)}`}</Text>
198
- ) : null}
199
- <Box flexGrow={1} />
200
- {tier ? <Text fg={row.writeTier === "ui-write" ? colors.textMuted : colors.warning}>{`${tier} `}</Text> : null}
201
- {row.status === "running" || row.status === "pending" ? (
202
- <Spinner label={status} />
203
- ) : (
204
- <Text fg={statusColor(row)}>{status}</Text>
205
- )}
206
- {row.origin === "server" ? <Text fg={colors.textMuted}> · Gloom</Text> : null}
207
- </ActionRow>
208
- {undoLabel ? (
209
- <Box flexDirection="row" height={1} paddingLeft={2}>
210
- <Button
211
- label={undoLabel}
212
- variant={row.undo?.status === "failed" ? "danger" : "ghost"}
213
- compact
214
- disabled={!!row.undo && row.undo.status !== "available"}
215
- shortcut={selected && canUndo(row) ? UNDO_KEY : undefined}
216
- onPress={onUndo}
217
- />
218
- {row.undo?.note ? <Text fg={colors.textMuted}>{` ${row.undo.note}`}</Text> : null}
219
- </Box>
220
- ) : null}
221
- {row.note && row.status !== "ok" ? (
222
- <Box flexDirection="column" paddingLeft={2}>
238
+ <Box ref={selected ? selectedRowRef : undefined} flexDirection="column" paddingTop={1}>
239
+ <Box flexDirection="row" height={1} alignItems="center">
240
+ <IconButton
241
+ icon="thumbs-up"
242
+ label="Good answer"
243
+ shortcut={keysShown ? GOOD_ANSWER_KEY : undefined}
244
+ pressed={rating === "up"}
245
+ onPress={() => onRate("up")}
246
+ />
247
+ {keysShown ? <Text fg={colors.textMuted}>{`${GOOD_ANSWER_KEY} `}</Text> : null}
248
+ <IconButton
249
+ icon="thumbs-down"
250
+ label="Bad answer"
251
+ shortcut={keysShown ? BAD_ANSWER_KEY : undefined}
252
+ pressed={rating === "down"}
253
+ onPress={() => onRate("down")}
254
+ />
255
+ {keysShown ? <Text fg={colors.textMuted}>{BAD_ANSWER_KEY}</Text> : null}
256
+ {feedback?.shared ? <Text fg={colors.textMuted}>{" Sent to Gloom"}</Text> : null}
257
+ </Box>
258
+ {followUpOpen ? (
259
+ <Box flexDirection="column" paddingLeft={1}>
260
+ <Box flexDirection="row" flexWrap="wrap">
261
+ {FEEDBACK_REASONS.map((entry) => (
262
+ <Box key={entry.reason} flexDirection="row" height={1} marginRight={2}>
263
+ <Button
264
+ label={entry.label}
265
+ variant="ghost"
266
+ compact
267
+ active={feedback?.reason === entry.reason}
268
+ shortcut={entry.key}
269
+ onPress={() => onReason(entry.reason)}
270
+ />
271
+ </Box>
272
+ ))}
273
+ </Box>
274
+ <Box flexDirection="row" height={1}>
275
+ <Button
276
+ label={sharing ? "Sending\u2026" : "Send this answer to Gloom"}
277
+ variant="secondary"
278
+ compact
279
+ disabled={sharing}
280
+ shortcut={SHARE_ANSWER_KEY}
281
+ onPress={onShare}
282
+ />
283
+ </Box>
223
284
  <Prose
224
- text={row.note}
225
- width={Math.max(10, width - 3)}
226
- color={statusColor(row)}
285
+ text={feedback?.shareFailed ? "Could not send: Gloom no longer has this answer." : SHARE_DISCLOSURE}
286
+ width={Math.max(10, width - 1)}
287
+ color={colors.textMuted}
227
288
  figures={false}
228
289
  />
229
290
  </Box>
@@ -250,6 +311,7 @@ function ConfirmationPrompt({
250
311
  onApprove: () => void;
251
312
  onDecline: () => void;
252
313
  }) {
314
+ const colors = useThemeColors();
253
315
  const lines = previewLines(row.preview).slice(0, MAX_PREVIEW_LINES);
254
316
  return (
255
317
  <Box
@@ -260,9 +322,9 @@ function ConfirmationPrompt({
260
322
  backgroundColor={colors.panel}
261
323
  >
262
324
  <Box flexDirection="row" height={1}>
263
- <Text fg={colors.warning} attributes={TextAttributes.BOLD}>{`Approve ${row.name}?`}</Text>
325
+ <Text fg={colors.warning} attributes={TextAttributes.BOLD}>{`Approve ${toolTitle(row.name)}?`}</Text>
264
326
  <Text fg={colors.textDim}>
265
- {` writes ${tierLabel(row) ?? "data"}${row.argumentSummary ? ` · ${row.argumentSummary}` : ""}`}
327
+ {` changes ${tierLabel(row) ?? "data"}${row.argumentSummary ? ` · ${row.argumentSummary}` : ""}`}
266
328
  </Text>
267
329
  </Box>
268
330
  {lines.map((line, index) => (
@@ -279,124 +341,38 @@ function ConfirmationPrompt({
279
341
  );
280
342
  }
281
343
 
282
- function ToolResultDetail({
283
- row,
344
+ /** The first look: what to ask, and three questions that ask themselves. */
345
+ function ASKGWelcome({
346
+ examples,
284
347
  width,
285
- height,
286
- focused,
287
- openPaneShortcut,
288
- onOpenSymbol,
289
- onOpenPane,
348
+ selectedIndex,
349
+ onAsk,
290
350
  }: {
291
- row: ASKGToolRow;
351
+ examples: string[];
292
352
  width: number;
293
- height: number;
294
- /** The rows have the keyboard: j/k walk them and Enter opens one. */
295
- focused: boolean;
296
- openPaneShortcut?: string;
297
- onOpenSymbol: (symbol: string) => void;
298
- onOpenPane: () => void;
353
+ selectedIndex: number | null;
354
+ onAsk: (question: string) => void;
299
355
  }) {
300
- const tables = useMemo<ASKGResultTable[]>(() => toolResultTables(row.result), [row.result]);
301
- const [tableIndex, setTableIndex] = useState(0);
302
- const [rowIndex, setRowIndex] = useState(0);
303
- useEffect(() => {
304
- setTableIndex(0);
305
- }, [row.toolCallId]);
306
- useEffect(() => {
307
- setRowIndex(0);
308
- }, [row.toolCallId, tableIndex]);
309
- const table = tables[Math.min(tableIndex, tables.length - 1)] ?? null;
310
- const paneTarget = useMemo(() => resolveToolPaneTarget(row.name), [row.name]);
311
-
312
- const columns = useMemo<DataTableColumn[]>(() => (
313
- (table?.columns ?? []).map((column) => ({
314
- id: column.key,
315
- label: column.header,
316
- width: column.width ?? Math.max(column.header.length + 2, 10),
317
- align: column.align === "right" ? "right" : "left",
318
- flexGrow: 1,
319
- }))
320
- ), [table]);
321
-
322
- const renderCell = useCallback((
323
- item: Record<string, JsonValue>,
324
- column: DataTableColumn,
325
- ): DataTableCell => {
326
- const value = formatCellValue(item[column.id]);
327
- const symbol = rowSymbol(item);
328
- return {
329
- text: value,
330
- color: symbol && (column.id === "symbol" || column.id === "ticker")
331
- ? colors.textBright
332
- : colors.text,
333
- };
334
- }, []);
335
-
336
- const headerHeight = 1 + (tables.length > 1 ? 1 : 0);
337
- // The timeline row above already names the tool and its arguments, so the
338
- // detail header carries what the row cannot: shape, size, and cost.
339
- const metadata = [
340
- table?.title,
341
- row.rowCount !== undefined ? `${row.rowCount} ${row.rowCount === 1 ? "row" : "rows"}` : null,
342
- row.elapsedMs !== undefined ? `${row.elapsedMs}ms` : null,
343
- row.truncated ? "truncated" : null,
344
- ].filter(Boolean).join(" · ");
356
+ const { nativePaneChrome } = useUiCapabilities();
345
357
  return (
346
- <Box flexDirection="column" flexShrink={0} width={width} height={height}>
347
- <Box flexDirection="row" height={1} paddingX={1}>
348
- <Text fg={colors.textDim}>
349
- {truncateWithEllipsis(metadata, Math.max(10, width - 16))}
350
- </Text>
351
- <Box flexGrow={1} />
352
- {paneTarget ? (
353
- <Button label="Open pane" variant="ghost" compact shortcut={openPaneShortcut} onPress={onOpenPane} />
354
- ) : null}
358
+ <Box flexDirection="column" paddingTop={1}>
359
+ <EmptyState
360
+ title="Ask about your portfolio, a ticker or the market."
361
+ hint="Gloom reads your panes and data to answer, and lists each source it used."
362
+ />
363
+ {/* The empty state's actions: each sends its question. A terminal
364
+ button has no border, so a blank row keeps the three apart. */}
365
+ <Box flexDirection="column" alignItems="flex-start" paddingTop={1} gap={nativePaneChrome ? 0.5 : 1}>
366
+ {examples.map((question, index) => (
367
+ <Button
368
+ key={question}
369
+ label={truncateWithEllipsis(question, Math.max(10, width - 4))}
370
+ variant="ghost"
371
+ active={selectedIndex === index}
372
+ onPress={() => onAsk(question)}
373
+ />
374
+ ))}
355
375
  </Box>
356
- {tables.length > 1 ? (
357
- <QueryBar
358
- width={width}
359
- filters={[{
360
- id: "section",
361
- label: "Section",
362
- inline: true,
363
- value: String(tableIndex),
364
- options: tables.map((entry, index) => ({
365
- value: String(index),
366
- label: entry.title ?? `Section ${index + 1}`,
367
- })),
368
- onChange: (value: string) => setTableIndex(Number(value)),
369
- }]}
370
- />
371
- ) : null}
372
- {table && table.rows.length > 0 ? (
373
- <DataTableView<Record<string, JsonValue>>
374
- focused={focused}
375
- // A row cursor only while the rows have the keyboard; the pointer
376
- // opens a row as it always has.
377
- selection={focused
378
- ? { kind: "index", selectedIndex: rowIndex, onChange: (index) => setRowIndex(index) }
379
- : { kind: "none" }}
380
- rootWidth={width}
381
- rootHeight={Math.max(3, height - headerHeight)}
382
- columns={columns}
383
- items={table.rows}
384
- sortColumnId={null}
385
- sortDirection="asc"
386
- onHeaderClick={() => {}}
387
- getItemKey={(_item, index) => String(index)}
388
- renderCell={renderCell}
389
- onActivate={(item) => {
390
- const symbol = rowSymbol(item);
391
- if (symbol) onOpenSymbol(symbol);
392
- }}
393
- emptyStateTitle="No rows returned"
394
- />
395
- ) : (
396
- <Box paddingX={1}>
397
- <Text fg={colors.textMuted}>{row.note ?? "This tool returned no rows."}</Text>
398
- </Box>
399
- )}
400
376
  </Box>
401
377
  );
402
378
  }
@@ -404,33 +380,43 @@ function ToolResultDetail({
404
380
  function TurnView({
405
381
  turn,
406
382
  width,
407
- selectedToolCallId,
408
- expandedToolCallId,
409
- selectedRowRef,
410
383
  latest,
384
+ rateable,
385
+ feedbackSelected,
386
+ feedbackKeysShown,
387
+ feedbackFollowUpOpen,
388
+ selectedRowRef,
411
389
  catalog,
412
390
  openTicker,
413
- onSelectTool,
414
- onToggleTool,
415
- onUndo,
391
+ tools,
416
392
  onRetry,
417
393
  onUpgrade,
394
+ onRate,
395
+ onReason,
396
+ onShare,
418
397
  }: {
419
398
  turn: ASKGTurn;
420
399
  width: number;
421
- selectedToolCallId: string | null;
422
- expandedToolCallId: string | null;
423
- selectedRowRef: (node: BoxRenderable | null) => void;
424
400
  /** The last turn: its Retry and Upgrade answer the pane's keys. */
425
401
  latest: boolean;
402
+ /** The answer shows the rating control. */
403
+ rateable: boolean;
404
+ /** The keyboard is on this answer's rating. */
405
+ feedbackSelected: boolean;
406
+ feedbackKeysShown: boolean;
407
+ feedbackFollowUpOpen: boolean;
408
+ selectedRowRef: (node: BoxRenderable | null) => void;
426
409
  catalog: ReturnType<typeof useInlineTickers>["catalog"];
427
410
  openTicker: (symbol: string) => void;
428
- onSelectTool: (toolCallId: string) => void;
429
- onToggleTool: (toolCallId: string) => void;
430
- onUndo: (toolCallId: string) => void;
411
+ /** The turn's tool timeline. */
412
+ tools: ReactNode;
431
413
  onRetry: () => void;
432
414
  onUpgrade: () => void;
415
+ onRate: (rating: ASKGFeedbackRating) => void;
416
+ onReason: (reason: ASKGFeedbackReason) => void;
417
+ onShare: () => void;
433
418
  }) {
419
+ const colors = useThemeColors();
434
420
  return (
435
421
  <Box flexDirection="column" paddingTop={1}>
436
422
  <Prose
@@ -440,23 +426,7 @@ function TurnView({
440
426
  figures={false}
441
427
  attributes={TextAttributes.BOLD}
442
428
  />
443
- {turn.tools.length > 0 ? (
444
- <Box flexDirection="column" paddingTop={1}>
445
- {turn.tools.map((row) => (
446
- <ToolTimelineRow
447
- key={row.toolCallId}
448
- row={row}
449
- width={width}
450
- selected={row.toolCallId === selectedToolCallId}
451
- expanded={row.toolCallId === expandedToolCallId}
452
- selectedRowRef={selectedRowRef}
453
- onSelect={() => onSelectTool(row.toolCallId)}
454
- onToggle={() => onToggleTool(row.toolCallId)}
455
- onUndo={() => onUndo(row.toolCallId)}
456
- />
457
- ))}
458
- </Box>
459
- ) : null}
429
+ {tools}
460
430
  {turn.answer ? (
461
431
  <Box paddingTop={1}>
462
432
  <MarkdownText
@@ -470,6 +440,25 @@ function TurnView({
470
440
  ) : turn.status === "streaming" && turn.tools.length === 0 ? (
471
441
  <Box paddingTop={1}><Spinner label="Gloom is thinking…" /></Box>
472
442
  ) : null}
443
+ {rateable ? (
444
+ <AnswerFeedback
445
+ feedback={turn.feedback}
446
+ width={width}
447
+ keysShown={feedbackKeysShown}
448
+ followUpOpen={feedbackFollowUpOpen}
449
+ selected={feedbackSelected}
450
+ selectedRowRef={selectedRowRef}
451
+ onRate={onRate}
452
+ onReason={onReason}
453
+ onShare={onShare}
454
+ />
455
+ ) : null}
456
+ {turn.status === "cancelled" ? (
457
+ <Box flexDirection="row" paddingTop={1} gap={2}>
458
+ <Text fg={colors.textMuted}>Stopped.</Text>
459
+ {latest ? <Button label="Ask again" variant="ghost" compact shortcut="r" onPress={onRetry} /> : null}
460
+ </Box>
461
+ ) : null}
473
462
  {turn.error ? (
474
463
  <Box flexDirection="column" paddingTop={1}>
475
464
  {/* A failure that loses its tail tells the user nothing, so it wraps
@@ -477,15 +466,17 @@ function TurnView({
477
466
  <Prose
478
467
  text={describeASKGError(turn.error)}
479
468
  width={width}
480
- color={colors.negative}
469
+ // Running out of questions is a state, said again in the footer, not a failure.
470
+ color={turn.error.code === "daily_turn_cap" ? colors.textDim : colors.negative}
481
471
  figures={false}
482
472
  />
473
+ {/* A row of its own, so the desktop draws a button rather than a bar. */}
483
474
  {turn.error.code === "tier_required" ? (
484
- <Box paddingTop={1}>
475
+ <Box flexDirection="row" paddingTop={1}>
485
476
  <Button label="Upgrade to Pro" variant="primary" shortcut={latest ? UPGRADE_KEY : undefined} onPress={onUpgrade} />
486
477
  </Box>
487
478
  ) : canRetryASKGError(turn.error) ? (
488
- <Box paddingTop={1}>
479
+ <Box flexDirection="row" paddingTop={1}>
489
480
  <Button label="Retry" variant="primary" shortcut={latest ? "r" : undefined} onPress={onRetry} />
490
481
  </Box>
491
482
  ) : null}
@@ -568,11 +559,20 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
568
559
 
569
560
  const [inputValue, setInputValue] = useState("");
570
561
  const [inputFocused, setInputFocused] = useState(false);
571
- const [selectedToolCallId, setSelectedToolCallId] = useState<string | null>(null);
562
+ // A tool row or a turn's folded calls; j/k walk them.
563
+ const [selectedId, setSelectedId] = useState<string | null>(null);
572
564
  const [expandedToolCallId, setExpandedToolCallId] = useState<string | null>(null);
565
+ const [openGroups, setOpenGroups] = useState<ReadonlySet<string>>(() => new Set());
566
+ // The answer whose thumbs down shows its reasons and the send action.
567
+ const [feedbackFollowUpTurnId, setFeedbackFollowUpTurnId] = useState<string | null>(null);
568
+ // The example question the keyboard is on while the pane is empty.
569
+ const [exampleIndex, setExampleIndex] = useState<number | null>(null);
573
570
  const inputRef = useRef<TextareaRenderable | null>(null);
574
571
  const scrollRef = useRef<ScrollBoxRenderable | null>(null);
575
572
  const [queuedQuestion, setQueuedQuestion] = useState<string | null>(null);
573
+ const queuedQuestionRef = useRef<string | null>(null);
574
+ // The conversation list is a drawer: open only while it has the keyboard or
575
+ // the pointer, so it never takes the left of the pane unasked.
576
576
  const [sidebarFocused, setSidebarFocused] = useState(false);
577
577
  // The row the keyboard is on while the sidebar has focus. Arrows move it and
578
578
  // Enter opens it, so walking the list does not load a transcript per keypress.
@@ -593,32 +593,60 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
593
593
  askgConversationListStore.ensureLoaded();
594
594
  }, [planAccess.emailVerified]);
595
595
 
596
- // The same gate chat uses: at least two things to switch between, in a pane
597
- // wide enough to spare the width. One conversation is the one on screen.
598
- const showSidebar = shouldShowPaneSidebar(
599
- conversations.conversations.length,
600
- width,
601
- height,
602
- );
603
- const sidebarWidth = showSidebar
604
- ? getPaneSidebarWidth(width, !!nativePaneChrome, conversations.width)
605
- : 0;
596
+ // Any stored conversation is worth going back to, including the only one
597
+ // when it is not the one on screen. A pane too narrow for the list beside
598
+ // the answer shows it in the answer's place until one is picked.
599
+ const listRoom = conversations.conversations.length > 0 && height >= 8;
600
+ const listBeside = shouldShowPaneSidebar(conversations.conversations.length, width, height, 1);
601
+ const showSidebar = listRoom && sidebarFocused;
602
+ const listCoversBody = showSidebar && !listBeside;
603
+ const sidebarWidth = !showSidebar
604
+ ? 0
605
+ : listCoversBody
606
+ ? width
607
+ : getPaneSidebarWidth(width, !!nativePaneChrome, conversations.width);
606
608
 
607
609
  const running = isTurnRunning(state);
608
610
  const confirmation = pendingConfirmation(state);
609
- const timelineRows = useMemo(
611
+
612
+ // Names the profile gives the ids tools take, so a row reads "Interactive
613
+ // Brokers U1234567" rather than the id Gloom passed.
614
+ const displayContext = useMemo<ToolDisplayContext>(() => ({
615
+ collectionName: (id: string) => {
616
+ const portfolio = config.portfolios.find((entry) => entry.id === id);
617
+ if (portfolio) return describePortfolioTab(portfolio, config.brokerInstances);
618
+ return config.watchlists.find((entry) => entry.id === id)?.name ?? null;
619
+ },
620
+ toolTitle: (name: string) => resolveToolPaneTarget(name)?.label ?? null,
621
+ }), [config.brokerInstances, config.portfolios, config.watchlists]);
622
+
623
+ // What j/k walk: each turn's tool rows (or their fold), then its answer's rating.
624
+ const timelineIds = useMemo(
625
+ () => state.turns.flatMap((turn) => [
626
+ ...turnTimelineIds(turn, openGroups),
627
+ ...(canRateTurn(state, turn) ? [answerStopId(turn.id)] : []),
628
+ ]),
629
+ [openGroups, state],
630
+ );
631
+ const toolRows = useMemo(
610
632
  () => state.turns.flatMap((turn) => turn.tools),
611
633
  [state.turns],
612
634
  );
613
635
  const expandedRow = useMemo(
614
- () => timelineRows.find((row) => row.toolCallId === expandedToolCallId) ?? null,
615
- [expandedToolCallId, timelineRows],
636
+ () => toolRows.find((row) => row.toolCallId === expandedToolCallId) ?? null,
637
+ [expandedToolCallId, toolRows],
616
638
  );
639
+ const selectedRow = toolRows.find((row) => row.toolCallId === selectedId) ?? null;
640
+ const selectedGroupTurn = selectedId?.startsWith("group:")
641
+ ? state.turns.find((turn) => toolGroupId(turn.id) === selectedId) ?? null
642
+ : null;
617
643
  // The last question is the one a retry would repeat; an older failure is
618
644
  // already history the user moved past.
619
645
  const retryableTurn = useMemo(() => {
620
646
  const turn = activeTurn(state);
621
- if (!turn || turn.status !== "error" || !turn.error) return null;
647
+ if (!turn) return null;
648
+ if (turn.status === "cancelled") return turn;
649
+ if (turn.status !== "error" || !turn.error) return null;
622
650
  return canRetryASKGError(turn.error) ? turn : null;
623
651
  }, [state]);
624
652
  const needsUpgrade = useMemo(() => {
@@ -626,13 +654,64 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
626
654
  return turn?.status === "error" && turn.error?.code === "tier_required";
627
655
  }, [state]);
628
656
 
657
+ const examples = useMemo(() => {
658
+ const appState = getAppStateRef.current();
659
+ return askgExampleQuestions({
660
+ config: appState.config,
661
+ tickers: appState.tickers.values(),
662
+ activeSymbol,
663
+ });
664
+ }, [activeSymbol, config.portfolios, config.watchlists]);
665
+
629
666
  const ask = useCallback((question: string) => {
630
667
  const trimmed = question.trim();
631
668
  if (!trimmed) return;
632
669
  void controller.ask(trimmed);
633
670
  }, [controller]);
634
671
 
672
+ const focusInput = useCallback(() => {
673
+ setInputFocused(true);
674
+ setExampleIndex(null);
675
+ setResultFocused(false);
676
+ setSidebarFocused(false);
677
+ setSidebarCursorId(null);
678
+ dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
679
+ inputRef.current?.focus?.();
680
+ }, [dispatch]);
681
+
682
+ const blurInput = useCallback(() => {
683
+ setInputFocused(false);
684
+ dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
685
+ }, [dispatch]);
686
+
687
+ const focusedRef = useRef(focused);
688
+ focusedRef.current = focused;
689
+
690
+ // Asking from anywhere but the composer (an example, Retry) hands the
691
+ // keyboard back to it, so what is typed next is the follow-up and never a
692
+ // string of shortcuts. A click on the desktop would otherwise leave the
693
+ // keyboard on the button it pressed.
694
+ const askAndCompose = useCallback((question: string) => {
695
+ ask(question);
696
+ if (focusedRef.current) focusInput();
697
+ }, [ask, focusInput]);
698
+ const retryAndCompose = useCallback((turnId: string) => {
699
+ void controller.retryTurn(turnId);
700
+ if (focusedRef.current) focusInput();
701
+ }, [controller, focusInput]);
702
+
703
+ const resetTimeline = useCallback(() => {
704
+ setSelectedId(null);
705
+ setExpandedToolCallId(null);
706
+ setOpenGroups(new Set());
707
+ setExampleIndex(null);
708
+ setFeedbackFollowUpTurnId(null);
709
+ }, []);
710
+
635
711
  const openConversation = useCallback(async (conversationId: string) => {
712
+ setSidebarFocused(false);
713
+ setSidebarCursorId(null);
714
+ if (focusedRef.current) focusInput();
636
715
  if (conversationId === state.conversationId) return;
637
716
  const conversation = await apiClient.askg.loadConversation(conversationId);
638
717
  // A row the sidebar still shows may be gone; the refresh drops it.
@@ -640,16 +719,16 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
640
719
  void askgConversationListStore.refresh();
641
720
  return;
642
721
  }
643
- setSelectedToolCallId(null);
644
- setExpandedToolCallId(null);
722
+ resetTimeline();
645
723
  controller.openConversation(conversation);
646
- }, [controller, state.conversationId]);
724
+ }, [controller, focusInput, resetTimeline, state.conversationId]);
647
725
 
648
726
  const newConversation = useCallback(() => {
649
- setSelectedToolCallId(null);
650
- setExpandedToolCallId(null);
727
+ resetTimeline();
728
+ askgConversationListStore.rememberOpen(null);
651
729
  controller.startConversation();
652
- }, [controller]);
730
+ if (focusedRef.current) focusInput();
731
+ }, [controller, focusInput, resetTimeline]);
653
732
 
654
733
  // A transcript has no undo, so it is never one click from gone.
655
734
  const deleteConversation = useCallback(async (conversationId: string): Promise<boolean> => {
@@ -664,21 +743,24 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
664
743
  width: 48,
665
744
  });
666
745
  if (!confirmed) return false;
667
- if (conversationId === state.conversationId) controller.startConversation();
746
+ if (conversationId === state.conversationId) {
747
+ resetTimeline();
748
+ controller.startConversation();
749
+ }
668
750
  void askgConversationListStore.delete(conversationId);
669
751
  return true;
670
- }, [controller, conversations.conversations, dialog, state.conversationId]);
752
+ }, [controller, conversations.conversations, dialog, resetTimeline, state.conversationId]);
671
753
 
672
- const focusInput = useCallback(() => {
673
- setInputFocused(true);
674
- dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
675
- inputRef.current?.focus?.();
676
- }, [dispatch]);
677
-
678
- const blurInput = useCallback(() => {
679
- setInputFocused(false);
680
- dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
681
- }, [dispatch]);
754
+ // Typing lands in the composer as soon as the pane has the keyboard: when it
755
+ // opens, when it is focused again, once the account is ready. Only this
756
+ // pane's own focus moves it, so no other pane loses the keyboard.
757
+ const keyboardReady = focused && planAccess.emailVerified;
758
+ const wasReadyRef = useRef(false);
759
+ useEffect(() => {
760
+ const gained = keyboardReady && !wasReadyRef.current;
761
+ wasReadyRef.current = keyboardReady;
762
+ if (gained && !confirmation) focusInput();
763
+ }, [confirmation, focusInput, keyboardReady]);
682
764
 
683
765
  useEffect(() => {
684
766
  if (!focused && inputFocused) blurInput();
@@ -690,17 +772,47 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
690
772
 
691
773
  // A question typed at the command bar, either opening this pane or landing
692
774
  // in the conversation already open here.
693
- useEffect(() => subscribeASKGQuestions(setQueuedQuestion), []);
775
+ useEffect(() => subscribeASKGQuestions((question) => {
776
+ queuedQuestionRef.current = question;
777
+ setQueuedQuestion(question);
778
+ }), []);
694
779
 
695
780
  useEffect(() => {
696
781
  // The restored cloud session arrives after the first render, so a question
697
782
  // that beat it waits instead of being dropped.
698
783
  if (!queuedQuestion || !planAccess.emailVerified) return;
699
784
  setQueuedQuestion(null);
785
+ queuedQuestionRef.current = null;
700
786
  ask(queuedQuestion);
701
787
  }, [ask, planAccess.emailVerified, queuedQuestion]);
702
788
 
789
+ // Closing the pane is two Escs away, so the conversation it showed comes
790
+ // back with the next one this session opens. A question typed with the
791
+ // command that opened it starts a new one instead.
792
+ const restoredRef = useRef(false);
703
793
  useEffect(() => {
794
+ if (restoredRef.current || !planAccess.emailVerified) return;
795
+ restoredRef.current = true;
796
+ const remembered = askgConversationListStore.rememberedOpen();
797
+ if (!remembered || queuedQuestionRef.current || controller.getState().turns.length > 0) return;
798
+ void apiClient.askg.loadConversation(remembered).then((conversation) => {
799
+ const current = controller.getState();
800
+ if (!conversation || current.turns.length > 0 || current.conversationId) return;
801
+ controller.openConversation(conversation);
802
+ }).catch(() => {});
803
+ }, [controller, planAccess.emailVerified]);
804
+
805
+ useEffect(() => {
806
+ if (state.conversationId) askgConversationListStore.rememberOpen(state.conversationId);
807
+ }, [state.conversationId]);
808
+
809
+ // A rating changes a turn but not what was said, so it leaves the view on
810
+ // the answer being rated instead of pulling it to the newest one.
811
+ const scrolledTurnsRef = useRef(state.turns);
812
+ useEffect(() => {
813
+ const previous = scrolledTurnsRef.current;
814
+ scrolledTurnsRef.current = state.turns;
815
+ if (onlyFeedbackChanged(previous, state.turns)) return;
704
816
  const scroll = scrollRef.current;
705
817
  if (!scroll?.viewport) return;
706
818
  scroll.scrollTo({ x: 0, y: Math.max(0, scroll.scrollHeight - scroll.viewport.height) });
@@ -728,23 +840,46 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
728
840
 
729
841
  // A list that shrank below the point of switching leaves nothing to focus.
730
842
  useEffect(() => {
731
- if (!showSidebar && sidebarFocused) {
843
+ if (!listRoom && sidebarFocused) {
732
844
  setSidebarFocused(false);
733
845
  setSidebarCursorId(null);
734
846
  }
735
- }, [showSidebar, sidebarFocused]);
847
+ }, [listRoom, sidebarFocused]);
736
848
 
737
- // The answer that follows a question owns the keyboard (cancel, the tool
738
- // rows, retry), so sending leaves the composer; Enter comes back to it.
849
+ const readDraft = useCallback(
850
+ () => inputRef.current?.editBuffer.getText() ?? inputValue,
851
+ [inputValue],
852
+ );
853
+
854
+ // The question stays in the composer while Gloom answers, and goes once it
855
+ // is asked; the composer keeps the keyboard for the next one.
739
856
  const submitInput = useCallback(() => {
740
- const value = inputRef.current?.editBuffer.getText() ?? inputValue;
741
- const trimmed = value.trim();
742
- if (!trimmed) return;
857
+ const trimmed = readDraft().trim();
858
+ if (!trimmed || isTurnRunning(controller.getState())) return;
743
859
  setInputValue("");
744
860
  inputRef.current?.editBuffer.setText?.("");
745
- blurInput();
861
+ resetTimeline();
746
862
  ask(trimmed);
747
- }, [ask, blurInput, inputValue]);
863
+ }, [ask, controller, readDraft, resetTimeline]);
864
+
865
+ // The terminal textarea reports edits only through `onContentChange`, and
866
+ // the composer grows with what it holds, so the pane binds whichever
867
+ // textarea is mounted after each render.
868
+ const boundTextareaRef = useRef<TextareaRenderable | null>(null);
869
+ useLayoutEffect(() => {
870
+ const textarea = inputRef.current;
871
+ const bound = boundTextareaRef.current;
872
+ if (textarea === bound) return;
873
+ if (bound) bound.onContentChange = undefined;
874
+ boundTextareaRef.current = textarea;
875
+ if (!textarea) return;
876
+ textarea.onContentChange = () => setInputValue(textarea.editBuffer.getText());
877
+ });
878
+ useLayoutEffect(() => () => {
879
+ const bound = boundTextareaRef.current;
880
+ boundTextareaRef.current = null;
881
+ if (bound) bound.onContentChange = undefined;
882
+ }, []);
748
883
 
749
884
  const toggleExpanded = useCallback((toolCallId: string) => {
750
885
  setExpandedToolCallId((current) => (current === toolCallId ? null : toolCallId));
@@ -753,15 +888,24 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
753
888
  if (!expandedToolCallId) setResultFocused(false);
754
889
  }, [expandedToolCallId]);
755
890
 
891
+ const toggleGroup = useCallback((groupId: string) => {
892
+ setOpenGroups((current) => {
893
+ const next = new Set(current);
894
+ if (next.has(groupId)) next.delete(groupId);
895
+ else next.add(groupId);
896
+ return next;
897
+ });
898
+ }, []);
899
+
756
900
  const moveSelection = useCallback((direction: -1 | 1) => {
757
- if (timelineRows.length === 0) return;
758
- const index = timelineRows.findIndex((row) => row.toolCallId === selectedToolCallId);
901
+ if (timelineIds.length === 0) return;
902
+ const index = timelineIds.indexOf(selectedId ?? "");
759
903
  // Selection starts at the newest row: that is the answer being read.
760
904
  const nextIndex = index < 0
761
- ? timelineRows.length - 1
762
- : (index + direction + timelineRows.length) % timelineRows.length;
763
- setSelectedToolCallId(timelineRows[nextIndex]?.toolCallId ?? null);
764
- }, [selectedToolCallId, timelineRows]);
905
+ ? timelineIds.length - 1
906
+ : (index + direction + timelineIds.length) % timelineIds.length;
907
+ setSelectedId(timelineIds[nextIndex] ?? null);
908
+ }, [selectedId, timelineIds]);
765
909
 
766
910
  // The selected row follows the keyboard into view in a long conversation.
767
911
  const selectedRowNodeRef = useRef<BoxRenderable | null>(null);
@@ -769,9 +913,9 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
769
913
  selectedRowNodeRef.current = node;
770
914
  }, []);
771
915
  useEffect(() => {
772
- if (!selectedToolCallId) return;
916
+ if (!selectedId) return;
773
917
  return afterLayout(() => revealInScrollBox(scrollRef.current, selectedRowNodeRef.current));
774
- }, [selectedToolCallId]);
918
+ }, [expandedToolCallId, feedbackFollowUpTurnId, openGroups, selectedId]);
775
919
 
776
920
  const openPaneForTool = useCallback((row: ASKGToolRow) => {
777
921
  const target = resolveToolPaneTarget(row.name);
@@ -790,10 +934,14 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
790
934
  }, [pinTicker]);
791
935
 
792
936
  // A write waiting on the user owns the keyboard, so the answer never gets
793
- // typed into the composer instead.
937
+ // typed into the composer instead; once it is answered the composer has it back.
938
+ const hadConfirmationRef = useRef(false);
794
939
  useEffect(() => {
940
+ const had = hadConfirmationRef.current;
941
+ hadConfirmationRef.current = !!confirmation;
795
942
  if (confirmation && inputFocused) blurInput();
796
- }, [blurInput, confirmation, inputFocused]);
943
+ if (had && !confirmation && focusedRef.current) focusInput();
944
+ }, [blurInput, confirmation, focusInput, inputFocused]);
797
945
 
798
946
  const leaveSidebar = useCallback(() => {
799
947
  setSidebarFocused(false);
@@ -811,10 +959,13 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
811
959
  }, [conversations.conversations, sidebarCursorId]);
812
960
 
813
961
  const focusSidebar = useCallback(() => {
962
+ if (!listRoom) return;
963
+ if (inputFocused) blurInput();
814
964
  setResultFocused(false);
815
965
  setSidebarFocused(true);
816
- setSidebarCursorId(state.conversationId);
817
- }, [state.conversationId]);
966
+ // The list opens on the conversation on screen, else on the newest one.
967
+ setSidebarCursorId(state.conversationId ?? conversations.conversations[0]?.id ?? null);
968
+ }, [blurInput, conversations.conversations, inputFocused, listRoom, state.conversationId]);
818
969
 
819
970
  // The row the sidebar's keyboard is on, or the open conversation.
820
971
  const sidebarTargetId = sidebarCursorId ?? state.conversationId;
@@ -829,7 +980,6 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
829
980
  });
830
981
  }, [conversations.conversations, deleteConversation, sidebarTargetId]);
831
982
 
832
-
833
983
  const answerTexts = useMemo(
834
984
  () => state.turns.map((turn) => turn.answer).filter(Boolean),
835
985
  [state.turns],
@@ -863,15 +1013,72 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
863
1013
 
864
1014
  const upgrade = useCallback(() => openCommandBar("Upgrade to Pro"), [openCommandBar]);
865
1015
 
866
- const selectedRow = timelineRows.find((row) => row.toolCallId === selectedToolCallId) ?? null;
1016
+ // The answer `g` and `b` rate: the one the keyboard is on (its rating, its
1017
+ // tool rows or their fold), else the newest answer, which is the one being read.
1018
+ const feedbackTarget = useMemo<ASKGTurn | null>(() => {
1019
+ if (selectedId) {
1020
+ const turn = state.turns.find((entry) => (
1021
+ selectedId === answerStopId(entry.id)
1022
+ || selectedId === toolGroupId(entry.id)
1023
+ || entry.tools.some((row) => row.toolCallId === selectedId)
1024
+ ));
1025
+ if (turn) return canRateTurn(state, turn) ? turn : null;
1026
+ }
1027
+ return state.turns.findLast((turn) => canRateTurn(state, turn)) ?? null;
1028
+ }, [selectedId, state]);
1029
+ // Open until the thumb changes, the answer is sent, or Esc.
1030
+ const feedbackFollowUp = useMemo<ASKGTurn | null>(() => {
1031
+ const turn = state.turns.find((entry) => entry.id === feedbackFollowUpTurnId);
1032
+ return turn && canRateTurn(state, turn) && turn.feedback?.rating === "down" && !turn.feedback.shared
1033
+ ? turn
1034
+ : null;
1035
+ }, [feedbackFollowUpTurnId, state]);
1036
+
1037
+ // A second thumbs down on the same answer opens or closes its reasons, so
1038
+ // they are always one press away and never in the way. A thumbs down hands
1039
+ // the keyboard to the answer, so its reasons answer 1 to 4 straight away.
1040
+ const rateAnswer = useCallback((turn: ASKGTurn, rating: ASKGFeedbackRating) => {
1041
+ setSelectedId(answerStopId(turn.id));
1042
+ const current = turn.feedback?.rating ?? null;
1043
+ if (rating === "down") {
1044
+ if (inputFocused) blurInput();
1045
+ setFeedbackFollowUpTurnId((open) => (current === "down" && open === turn.id ? null : turn.id));
1046
+ } else {
1047
+ setFeedbackFollowUpTurnId((open) => (open === turn.id ? null : open));
1048
+ if (inputFocused) inputRef.current?.focus?.();
1049
+ }
1050
+ if (current === rating) return;
1051
+ void controller.rateAnswer(turn.id, rating);
1052
+ }, [blurInput, controller, inputFocused]);
1053
+ const chooseFeedbackReason = useCallback((turn: ASKGTurn, reason: ASKGFeedbackReason) => {
1054
+ if (inputFocused) inputRef.current?.focus?.();
1055
+ if (turn.feedback?.reason === reason) return;
1056
+ void controller.chooseFeedbackReason(turn.id, reason);
1057
+ }, [controller, inputFocused]);
1058
+ const shareAnswer = useCallback((turn: ASKGTurn) => {
1059
+ if (inputFocused) inputRef.current?.focus?.();
1060
+ void controller.shareAnswer(turn.id);
1061
+ }, [controller, inputFocused]);
1062
+
1063
+ const expandedTables = useMemo(() => toolResultTables(expandedRow?.result), [expandedRow?.result]);
1064
+ const expandedHasRows = expandedTables.some((table) => table.rows.length > 0);
1065
+ // The panel is as tall as its longest table needs (divider, heading, the
1066
+ // section bar, column header and rows), up to half the pane.
1067
+ const expandedNeed = 3 + (expandedTables.length > 1 ? 1 : 0)
1068
+ + Math.max(0, ...expandedTables.map((table) => table.rows.length));
867
1069
  const confirmationHeight = confirmation ? confirmationBlockHeight(confirmation) : 0;
868
- const composerHeight = nativePaneChrome ? 3 : 2;
1070
+ const draftLines = useMemo(() => {
1071
+ const textWidth = Math.max(8, width - sidebarWidth - (nativePaneChrome ? 6 : 5));
1072
+ return Math.min(MAX_COMPOSER_LINES, Math.max(1, wrapTextLines(inputValue, textWidth).length));
1073
+ }, [inputValue, nativePaneChrome, sidebarWidth, width]);
1074
+ const composerHeight = nativePaneChrome ? draftLines + 2 : draftLines;
1075
+ const composerBlockHeight = nativePaneChrome ? composerHeight : composerHeight + 2;
869
1076
  // The conversation keeps a readable slice no matter what else is open.
870
- const detailBudget = height - composerHeight - confirmationHeight - 6;
871
- const detailHeight = expandedRow && detailBudget >= MIN_DETAIL_HEIGHT
872
- ? Math.min(Math.floor(height / 2), detailBudget)
1077
+ const detailBudget = height - composerBlockHeight - confirmationHeight - 6;
1078
+ const detailHeight = expandedRow && expandedHasRows && detailBudget >= MIN_DETAIL_HEIGHT
1079
+ ? Math.min(Math.floor(height / 2), detailBudget, Math.max(MIN_DETAIL_HEIGHT, expandedNeed))
873
1080
  : 0;
874
- const resultActive = resultFocused && !!expandedRow && detailHeight > 0 && !inputFocused && !(sidebarFocused && showSidebar);
1081
+ const resultActive = resultFocused && !!expandedRow && detailHeight > 0 && !inputFocused && !showSidebar;
875
1082
  // `o` opens the pane of the result the rows belong to, else the selected row's.
876
1083
  const paneRow = resultActive ? expandedRow : selectedRow;
877
1084
  const canOpenPane = !!paneRow && !!resolveToolPaneTarget(paneRow.name);
@@ -883,6 +1090,29 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
883
1090
  setResultFocused(expanding && toolResultTables(row.result).some((table) => table.rows.length > 0));
884
1091
  }, [expandedToolCallId, toggleExpanded]);
885
1092
 
1093
+ // A click opens or closes a row without taking the keyboard from the
1094
+ // composer: the next thing typed is still the follow-up.
1095
+ const pressRow = useCallback((toolCallId: string) => {
1096
+ setSelectedId(toolCallId);
1097
+ toggleExpanded(toolCallId);
1098
+ if (inputFocused) inputRef.current?.focus?.();
1099
+ }, [inputFocused, toggleExpanded]);
1100
+ const pressGroup = useCallback((groupId: string) => {
1101
+ setSelectedId(groupId);
1102
+ toggleGroup(groupId);
1103
+ if (inputFocused) inputRef.current?.focus?.();
1104
+ }, [inputFocused, toggleGroup]);
1105
+
1106
+ // Leaves the composer for the timeline, on the newest row (or the last example).
1107
+ const enterTimeline = useCallback(() => {
1108
+ blurInput();
1109
+ if (state.turns.length === 0) {
1110
+ setExampleIndex(examples.length - 1);
1111
+ return;
1112
+ }
1113
+ if (!selectedId && timelineIds.length > 0) setSelectedId(timelineIds[timelineIds.length - 1] ?? null);
1114
+ }, [blurInput, examples.length, selectedId, state.turns.length, timelineIds]);
1115
+
886
1116
  useShortcut((event) => {
887
1117
  // Behind the sign-in wall the keys are the wall's (Enter logs in).
888
1118
  if (!focused || !planAccess.emailVerified) return;
@@ -904,15 +1134,17 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
904
1134
  }
905
1135
  // The sidebar owns the keyboard while it has focus, so the same arrows
906
1136
  // that walk the tool timeline walk the conversation list instead.
907
- if (sidebarFocused && showSidebar) {
1137
+ if (showSidebar) {
908
1138
  if (isPlainKey(event, "escape", "right")) {
909
1139
  consume();
910
1140
  leaveSidebar();
1141
+ focusInput();
911
1142
  } else if (isPlainKey(event, "enter", "return")) {
912
1143
  consume();
913
1144
  const target = sidebarTargetId;
914
1145
  leaveSidebar();
915
1146
  if (target) void openConversation(target);
1147
+ else focusInput();
916
1148
  } else if (isPlainKey(event, "j", "down")) {
917
1149
  consume();
918
1150
  moveSidebarCursor(1);
@@ -931,12 +1163,22 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
931
1163
  return;
932
1164
  }
933
1165
  if (inputFocused) {
1166
+ const empty = !readDraft().trim();
934
1167
  if (isPlainKey(event, "escape")) {
935
1168
  consume();
936
1169
  // Leaving an empty composer counts toward a double-Esc close; a draft does not.
937
- const value = inputRef.current?.editBuffer.getText() ?? inputValue;
938
- if (!value.trim()) countEscapeTowardClose(event);
1170
+ if (empty) countEscapeTowardClose(event);
939
1171
  blurInput();
1172
+ return;
1173
+ }
1174
+ if (empty && isPlainKey(event, "up")) {
1175
+ consume();
1176
+ enterTimeline();
1177
+ return;
1178
+ }
1179
+ if (empty && isPlainKey(event, "left") && listRoom) {
1180
+ consume();
1181
+ focusSidebar();
940
1182
  }
941
1183
  return;
942
1184
  }
@@ -949,7 +1191,56 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
949
1191
  // The result table walks and opens its own rows.
950
1192
  if (isPlainKey(event, "j", "k", "up", "down", "enter", "return", "home", "end", "pageup", "pagedown")) return;
951
1193
  }
952
- if (isPlainKey(event, "left") && showSidebar) {
1194
+ if (feedbackFollowUp) {
1195
+ if (isPlainKey(event, "escape")) {
1196
+ consume();
1197
+ setFeedbackFollowUpTurnId(null);
1198
+ return;
1199
+ }
1200
+ const reason = FEEDBACK_REASONS.find((entry) => isPlainKey(event, entry.key));
1201
+ if (reason) {
1202
+ consume();
1203
+ chooseFeedbackReason(feedbackFollowUp, reason.reason);
1204
+ return;
1205
+ }
1206
+ if (isPlainKey(event, SHARE_ANSWER_KEY)) {
1207
+ consume();
1208
+ shareAnswer(feedbackFollowUp);
1209
+ return;
1210
+ }
1211
+ }
1212
+ if (isPlainKey(event, GOOD_ANSWER_KEY, BAD_ANSWER_KEY) && feedbackTarget) {
1213
+ consume();
1214
+ rateAnswer(feedbackTarget, event.name === GOOD_ANSWER_KEY ? "up" : "down");
1215
+ return;
1216
+ }
1217
+ if (state.turns.length === 0) {
1218
+ if (isPlainKey(event, "j", "down")) {
1219
+ consume();
1220
+ setExampleIndex((index) => (index === null || index >= examples.length - 1 ? null : index + 1));
1221
+ if (exampleIndex === examples.length - 1) focusInput();
1222
+ return;
1223
+ }
1224
+ if (isPlainKey(event, "k", "up")) {
1225
+ consume();
1226
+ setExampleIndex((index) => (index === null ? examples.length - 1 : Math.max(0, index - 1)));
1227
+ return;
1228
+ }
1229
+ if (isPlainKey(event, "enter", "return") && exampleIndex !== null) {
1230
+ consume();
1231
+ const question = examples[exampleIndex];
1232
+ setExampleIndex(null);
1233
+ if (question) askAndCompose(question);
1234
+ return;
1235
+ }
1236
+ }
1237
+ if (isPlainKey(event, "escape") && (selectedId || exampleIndex !== null)) {
1238
+ consume();
1239
+ setSelectedId(null);
1240
+ setExampleIndex(null);
1241
+ return;
1242
+ }
1243
+ if (isPlainKey(event, "left") && listRoom) {
953
1244
  consume();
954
1245
  focusSidebar();
955
1246
  return;
@@ -960,16 +1251,21 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
960
1251
  return;
961
1252
  }
962
1253
  // With tool rows the arrows walk them; without, they scroll the answer.
963
- if (isPlainKey(event, "j", "down") && timelineRows.length > 0) {
1254
+ if (isPlainKey(event, "j", "down") && timelineIds.length > 0) {
964
1255
  consume();
965
1256
  moveSelection(1);
966
1257
  return;
967
1258
  }
968
- if (isPlainKey(event, "k", "up") && timelineRows.length > 0) {
1259
+ if (isPlainKey(event, "k", "up") && timelineIds.length > 0) {
969
1260
  consume();
970
1261
  moveSelection(-1);
971
1262
  return;
972
1263
  }
1264
+ if (isPlainKey(event, "x") && selectedGroupTurn && !resultActive) {
1265
+ consume();
1266
+ toggleGroup(toolGroupId(selectedGroupTurn.id));
1267
+ return;
1268
+ }
973
1269
  if (isPlainKey(event, "x") && expandTarget) {
974
1270
  consume();
975
1271
  toggleResult(expandTarget);
@@ -990,6 +1286,11 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
990
1286
  void chooseAnswerTicker();
991
1287
  return;
992
1288
  }
1289
+ if (isPlainKey(event, "n") && state.turns.length > 0 && !confirmation) {
1290
+ consume();
1291
+ newConversation();
1292
+ return;
1293
+ }
993
1294
  if (event.name === UPGRADE_KEY && !event.ctrl && !event.meta && !event.alt && needsUpgrade) {
994
1295
  consume();
995
1296
  upgrade();
@@ -997,7 +1298,7 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
997
1298
  }
998
1299
  if (isPlainKey(event, "r") && retryableTurn) {
999
1300
  consume();
1000
- void controller.retryTurn(retryableTurn.id);
1301
+ retryAndCompose(retryableTurn.id);
1001
1302
  return;
1002
1303
  }
1003
1304
  if (isPlainKey(event, "c") && running) {
@@ -1006,9 +1307,9 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
1006
1307
  }
1007
1308
  }, { allowEditable: true });
1008
1309
 
1009
-
1010
- // Hints name only what the current focus takes: the composer takes letters,
1011
- // the sidebar its own list keys, and the timeline everything else.
1310
+ // Hints follow the keyboard: the composer keeps only the conversation's own
1311
+ // actions (each one also a click), the sidebar its list keys, and the
1312
+ // timeline everything else.
1012
1313
  const hints = useMemo<PaneHint[]>(() => {
1013
1314
  const list: PaneHint[] = [];
1014
1315
  if (!planAccess.emailVerified) return list;
@@ -1018,96 +1319,152 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
1018
1319
  { id: "decline", key: "n", label: "o decline", title: "Decline", onPress: () => controller.resolveConfirmation(confirmation.toolCallId, false) },
1019
1320
  );
1020
1321
  }
1021
- if (inputFocused) return list;
1022
- if (showSidebar && sidebarFocused) {
1322
+ if (showSidebar) {
1023
1323
  // While a write waits, n answers it.
1024
1324
  if (!confirmation) list.push({ id: "new", key: "n", label: "ew conversation", onPress: () => { leaveSidebar(); newConversation(); } });
1025
1325
  if (sidebarTargetId) list.push({ id: "delete", key: "d", label: "elete", onPress: deleteSidebarConversation });
1026
1326
  return list;
1027
1327
  }
1028
- if (running) list.push({ id: "cancel", key: "c", label: "ancel", onPress: () => controller.cancel() });
1029
- if (retryableTurn) list.push({ id: "retry", key: "r", label: "etry", onPress: () => void controller.retryTurn(retryableTurn.id) });
1328
+ if (running) list.push({ id: "cancel", key: "c", label: "ancel", title: "Stop Answer", onPress: () => controller.cancel() });
1329
+ if (retryableTurn) list.push({ id: "retry", key: "r", label: retryableTurn.status === "cancelled" ? " ask again" : "etry", title: "Ask Again", onPress: () => retryAndCompose(retryableTurn.id) });
1030
1330
  if (needsUpgrade) list.push({ id: "upgrade", key: UPGRADE_KEY, label: "upgrade", title: "Upgrade to Pro", onPress: upgrade });
1031
- if (resultActive) list.push({ id: "result-back", key: "Esc", label: "timeline", title: "Back to Timeline", onPress: () => setResultFocused(false) });
1032
- if (expandTarget) {
1033
- const expanded = expandTarget.toolCallId === expandedToolCallId;
1034
- list.push({
1035
- id: "expand",
1036
- key: "x",
1037
- label: expanded ? " collapse" : "pand rows",
1038
- title: expanded ? "Collapse Rows" : "Expand Rows",
1039
- onPress: () => toggleResult(expandTarget),
1040
- });
1041
- }
1042
- if (paneRow && canOpenPane) list.push({ id: "open-pane", key: "o", label: "pen pane", onPress: () => openPaneForTool(paneRow) });
1043
- if (selectedRow && canUndo(selectedRow)) {
1044
- list.push({ id: "undo", key: UNDO_KEY, label: " undo", title: "Undo", onPress: () => void controller.undo(selectedRow.toolCallId) });
1331
+ if (!inputFocused) {
1332
+ if (resultActive) list.push({ id: "result-back", key: "Esc", label: "timeline", title: "Back to Timeline", onPress: () => setResultFocused(false) });
1333
+ if (selectedGroupTurn && !resultActive) {
1334
+ const open = openGroups.has(toolGroupId(selectedGroupTurn.id));
1335
+ list.push({
1336
+ id: "expand",
1337
+ key: "x",
1338
+ label: open ? " fold calls" : " show calls",
1339
+ title: open ? "Fold Calls" : "Show Calls",
1340
+ onPress: () => toggleGroup(toolGroupId(selectedGroupTurn.id)),
1341
+ });
1342
+ } else if (expandTarget) {
1343
+ const expanded = expandTarget.toolCallId === expandedToolCallId;
1344
+ list.push({
1345
+ id: "expand",
1346
+ key: "x",
1347
+ label: expanded ? " collapse" : "pand",
1348
+ title: expanded ? "Collapse Row" : "Expand Row",
1349
+ onPress: () => toggleResult(expandTarget),
1350
+ });
1351
+ }
1352
+ if (paneRow && canOpenPane) list.push({ id: "open-pane", key: "o", label: "pen pane", onPress: () => openPaneForTool(paneRow) });
1353
+ if (selectedRow && canUndo(selectedRow)) {
1354
+ list.push({ id: "undo", key: UNDO_KEY, label: " undo", title: "Undo", onPress: () => void controller.undo(selectedRow.toolCallId) });
1355
+ }
1356
+ // An open thumbs down takes the keys for its reasons and the send; the
1357
+ // thumbs keep working, they just leave the footer room for these.
1358
+ if (feedbackFollowUp) {
1359
+ for (const entry of FEEDBACK_REASONS) {
1360
+ list.push({
1361
+ id: `feedback-${entry.reason}`,
1362
+ key: entry.key,
1363
+ label: entry.hint,
1364
+ title: entry.title,
1365
+ onPress: () => chooseFeedbackReason(feedbackFollowUp, entry.reason),
1366
+ });
1367
+ }
1368
+ list.push({ id: "feedback-share", key: SHARE_ANSWER_KEY, label: "end", title: "Send This Answer to Gloom", onPress: () => shareAnswer(feedbackFollowUp) });
1369
+ } else if (feedbackTarget) {
1370
+ list.push(
1371
+ { id: "feedback-good", key: GOOD_ANSWER_KEY, label: "ood", title: "Good Answer", onPress: () => rateAnswer(feedbackTarget, "up") },
1372
+ { id: "feedback-bad", key: BAD_ANSWER_KEY, label: "ad", title: "Bad Answer", onPress: () => rateAnswer(feedbackTarget, "down") },
1373
+ );
1374
+ }
1375
+ if (answerTickers.length > 0) list.push({ id: "tickers", key: "t", label: "ickers", onPress: () => void chooseAnswerTicker() });
1045
1376
  }
1046
- if (answerTickers.length > 0) list.push({ id: "tickers", key: "t", label: "ickers", onPress: () => void chooseAnswerTicker() });
1047
- if (showSidebar) list.push({ id: "conversations", key: "←", label: " conversations", onPress: focusSidebar });
1377
+ if (state.turns.length > 0 && !confirmation) list.push({ id: "new", key: "n", label: "ew", title: "New Conversation", onPress: newConversation });
1378
+ if (listRoom) list.push({ id: "conversations", key: "←", label: " conversations", title: "Conversations", onPress: focusSidebar });
1048
1379
  return list;
1049
1380
  }, [
1050
1381
  planAccess.emailVerified,
1051
1382
  answerTickers.length,
1052
1383
  canOpenPane,
1053
1384
  chooseAnswerTicker,
1385
+ chooseFeedbackReason,
1054
1386
  confirmation,
1055
1387
  controller,
1056
1388
  deleteSidebarConversation,
1057
1389
  expandTarget,
1058
1390
  expandedToolCallId,
1391
+ feedbackFollowUp,
1392
+ feedbackTarget,
1059
1393
  focusSidebar,
1060
1394
  inputFocused,
1061
1395
  leaveSidebar,
1396
+ listRoom,
1062
1397
  needsUpgrade,
1063
1398
  newConversation,
1399
+ openGroups,
1064
1400
  openPaneForTool,
1065
1401
  paneRow,
1402
+ rateAnswer,
1066
1403
  resultActive,
1404
+ retryAndCompose,
1067
1405
  retryableTurn,
1068
1406
  running,
1407
+ selectedGroupTurn,
1069
1408
  selectedRow,
1409
+ shareAnswer,
1070
1410
  showSidebar,
1071
- sidebarFocused,
1072
1411
  sidebarTargetId,
1412
+ state.turns.length,
1413
+ toggleGroup,
1073
1414
  toggleResult,
1074
1415
  upgrade,
1075
1416
  ]);
1076
1417
 
1418
+ const quota = state.limits && state.limits.turnsRemainingToday >= 0
1419
+ ? state.limits.turnsRemainingToday
1420
+ : null;
1077
1421
  usePaneFooter(`askg:${paneId}`, () => ({
1078
1422
  info: [
1079
1423
  ...(running
1080
- ? [{ id: "streaming", parts: [{ text: "Streaming", tone: "positive" as const, bold: true }] }]
1424
+ ? [{ id: "streaming", parts: [{ text: "Answering", tone: "muted" as const }] }]
1081
1425
  : []),
1082
1426
  ...(confirmation
1083
1427
  ? [{ id: "confirm", parts: [{ text: "Waiting for approval", tone: "warning" as const }] }]
1084
1428
  : []),
1085
- ...(state.limits && state.limits.turnsRemainingToday >= 0 && !running
1429
+ // The answer that hit the cap already says so.
1430
+ ...(quota !== null && quota <= LOW_QUOTA && activeTurn(state)?.error?.code !== "daily_turn_cap"
1086
1431
  ? [{
1087
1432
  id: "quota",
1088
1433
  parts: [{
1089
- text: `${state.limits.turnsRemainingToday} questions left today`,
1090
- tone: "muted" as const,
1434
+ text: quota === 0 ? "No questions left today" : `${quota} ${quota === 1 ? "question" : "questions"} left today`,
1435
+ tone: quota === 0 ? "warning" as const : "muted" as const,
1091
1436
  }],
1092
1437
  }]
1093
1438
  : []),
1094
1439
  ],
1095
1440
  hints,
1096
- }), [confirmation, hints, running, state.limits]);
1441
+ menu: state.turns.length === 0 && planAccess.emailVerified
1442
+ ? examples.map((question, index): ContextMenuItem => ({
1443
+ id: `example-${index}`,
1444
+ label: `Ask: ${question}`,
1445
+ onSelect: () => askAndCompose(question),
1446
+ }))
1447
+ : [],
1448
+ }), [askAndCompose, confirmation, examples, hints, planAccess.emailVerified, quota, running, state]);
1097
1449
 
1098
1450
  if (!planAccess.emailVerified) {
1099
1451
  return (
1100
1452
  <SignInWall
1101
1453
  placement="gp-signin"
1102
- action="ask questions about any pane in the terminal"
1454
+ action="ask questions about anything on screen"
1103
1455
  needsVerification={planAccess.signedIn}
1104
1456
  />
1105
1457
  );
1106
1458
  }
1107
1459
 
1108
- const bodyWidth = Math.max(24, width - sidebarWidth);
1460
+ const bodyWidth = listCoversBody ? width : Math.max(24, width - sidebarWidth);
1109
1461
  const contentWidth = Math.max(24, bodyWidth - (nativePaneChrome ? 2 : 4));
1110
1462
  const lastTurnId = activeTurn(state)?.id ?? null;
1463
+ const placeholder = running
1464
+ ? "Gloom is answering…"
1465
+ : state.turns.length > 0
1466
+ ? "Ask a follow-up…"
1467
+ : "Ask Gloom a question…";
1111
1468
 
1112
1469
  return (
1113
1470
  <Box
@@ -1118,26 +1475,28 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
1118
1475
  >
1119
1476
  {showSidebar ? (
1120
1477
  <ASKGConversationSidebar
1121
- activeConversationId={
1122
- sidebarFocused ? sidebarCursorId ?? state.conversationId : state.conversationId
1123
- }
1478
+ activeConversationId={sidebarCursorId ?? state.conversationId}
1124
1479
  width={sidebarWidth}
1125
1480
  paneWidth={width}
1126
1481
  height={height}
1127
1482
  focused={focused}
1128
- keyboardFocused={focused && sidebarFocused}
1483
+ keyboardFocused={focused}
1129
1484
  onSelect={(conversationId) => void openConversation(conversationId)}
1130
1485
  onFocusRequest={() => {
1131
1486
  if (inputFocused) blurInput();
1132
1487
  setResultFocused(false);
1133
1488
  setSidebarFocused(true);
1134
1489
  }}
1135
- onNewConversation={newConversation}
1490
+ onNewConversation={() => {
1491
+ leaveSidebar();
1492
+ newConversation();
1493
+ }}
1136
1494
  onDelete={(conversationId) => void deleteConversation(conversationId)}
1137
1495
  />
1138
1496
  ) : null}
1139
1497
 
1140
1498
  <Box
1499
+ visible={!listCoversBody}
1141
1500
  flexDirection="column"
1142
1501
  width={nativePaneChrome ? undefined : bodyWidth}
1143
1502
  height={nativePaneChrome ? "100%" : height}
@@ -1151,26 +1510,55 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
1151
1510
  >
1152
1511
  <ScrollBox ref={scrollRef} flexGrow={1} minHeight={0} scrollY focusable={false} paddingX={1}>
1153
1512
  {state.turns.length === 0 ? (
1154
- <EmptyState
1155
- title="Ask about anything on screen."
1156
- hint="Gloom reads your panes to answer, and shows every tool it used."
1513
+ <ASKGWelcome
1514
+ examples={examples}
1515
+ width={contentWidth}
1516
+ selectedIndex={inputFocused ? null : exampleIndex}
1517
+ onAsk={askAndCompose}
1157
1518
  />
1158
1519
  ) : state.turns.map((turn) => (
1159
1520
  <TurnView
1160
1521
  key={turn.id}
1161
1522
  turn={turn}
1162
1523
  width={contentWidth}
1163
- selectedToolCallId={selectedToolCallId}
1164
- expandedToolCallId={expandedToolCallId}
1165
- selectedRowRef={selectedRowRef}
1166
1524
  latest={turn.id === lastTurnId}
1525
+ rateable={canRateTurn(state, turn)}
1526
+ feedbackSelected={selectedId === answerStopId(turn.id)}
1527
+ feedbackKeysShown={focused && !inputFocused && !showSidebar && feedbackTarget?.id === turn.id}
1528
+ feedbackFollowUpOpen={feedbackFollowUp?.id === turn.id}
1529
+ selectedRowRef={selectedRowRef}
1167
1530
  catalog={catalog}
1168
1531
  openTicker={openTicker}
1169
- onSelectTool={setSelectedToolCallId}
1170
- onToggleTool={toggleExpanded}
1171
- onUndo={(toolCallId) => void controller.undo(toolCallId)}
1172
- onRetry={() => void controller.retryTurn(turn.id)}
1532
+ tools={(
1533
+ <TurnTools
1534
+ turn={turn}
1535
+ width={contentWidth}
1536
+ context={displayContext}
1537
+ selectedId={selectedId}
1538
+ expandedToolCallId={expandedToolCallId}
1539
+ openGroups={openGroups}
1540
+ selectedRowRef={selectedRowRef}
1541
+ onPressRow={pressRow}
1542
+ onPressGroup={pressGroup}
1543
+ onUndo={(toolCallId) => void controller.undo(toolCallId)}
1544
+ />
1545
+ )}
1546
+ onRetry={() => retryAndCompose(turn.id)}
1173
1547
  onUpgrade={upgrade}
1548
+ // The controls keep their press from the column behind them, so
1549
+ // they close the list themselves.
1550
+ onRate={(rating) => {
1551
+ leaveSidebar();
1552
+ rateAnswer(turn, rating);
1553
+ }}
1554
+ onReason={(reason) => {
1555
+ leaveSidebar();
1556
+ chooseFeedbackReason(turn, reason);
1557
+ }}
1558
+ onShare={() => {
1559
+ leaveSidebar();
1560
+ shareAnswer(turn);
1561
+ }}
1174
1562
  />
1175
1563
  ))}
1176
1564
  </ScrollBox>
@@ -1185,22 +1573,27 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
1185
1573
  ) : null}
1186
1574
 
1187
1575
  {expandedRow && detailHeight > 0 ? (
1188
- <ToolResultDetail
1189
- row={expandedRow}
1190
- width={nativePaneChrome ? width : width - 2}
1191
- height={detailHeight}
1192
- focused={focused && resultActive}
1193
- openPaneShortcut={paneRow?.toolCallId === expandedRow.toolCallId && canOpenPane ? "o" : undefined}
1194
- onOpenSymbol={openSymbol}
1195
- onOpenPane={() => openPaneForTool(expandedRow)}
1196
- />
1576
+ <Box flexDirection="column" flexShrink={0}>
1577
+ <Divider />
1578
+ <ToolResultDetail
1579
+ row={expandedRow}
1580
+ view={describeToolRow(expandedRow, displayContext)}
1581
+ width={nativePaneChrome ? bodyWidth : bodyWidth - 2}
1582
+ height={detailHeight - 1}
1583
+ focused={focused && resultActive}
1584
+ canOpenPane={!!resolveToolPaneTarget(expandedRow.name)}
1585
+ openPaneShortcut={paneRow?.toolCallId === expandedRow.toolCallId && canOpenPane ? "o" : undefined}
1586
+ onOpenSymbol={openSymbol}
1587
+ onOpenPane={() => openPaneForTool(expandedRow)}
1588
+ />
1589
+ </Box>
1197
1590
  ) : null}
1198
1591
 
1199
1592
  <MessageComposer
1200
1593
  inputRef={inputRef}
1201
1594
  initialValue={inputValue}
1202
1595
  focused={inputFocused && focused}
1203
- placeholder={activeTurn(state) && running ? "Gloom is answering…" : "Ask Gloom a question…"}
1596
+ placeholder={placeholder}
1204
1597
  width="100%"
1205
1598
  height={composerHeight}
1206
1599
  terminalPrefix=" > "
@@ -1210,6 +1603,8 @@ export function ASKGPane({ paneId, focused, width, height }: PaneProps) {
1210
1603
  keyBindings={[
1211
1604
  { name: "return", action: "submit" },
1212
1605
  { name: "linefeed", action: "submit" },
1606
+ { name: "return", shift: true, action: "newline" },
1607
+ { name: "linefeed", shift: true, action: "newline" },
1213
1608
  ]}
1214
1609
  onSubmit={submitInput}
1215
1610
  wrapText