alloy-runtime-cli 0.1.0__py3-none-any.whl

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 (451) hide show
  1. alloy_runtime_cli-0.1.0.dist-info/METADATA +61 -0
  2. alloy_runtime_cli-0.1.0.dist-info/RECORD +451 -0
  3. alloy_runtime_cli-0.1.0.dist-info/WHEEL +5 -0
  4. alloy_runtime_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. alloy_runtime_cli-0.1.0.dist-info/top_level.txt +1 -0
  6. cli/__init__.py +0 -0
  7. cli/commands/__init__.py +0 -0
  8. cli/commands/admin/__init__.py +0 -0
  9. cli/commands/admin/bootstrap_command.py +118 -0
  10. cli/commands/admin/credentials/__init__.py +0 -0
  11. cli/commands/admin/credentials/create/__init__.py +0 -0
  12. cli/commands/admin/credentials/create/command.py +148 -0
  13. cli/commands/admin/credentials/create/presenter.py +16 -0
  14. cli/commands/admin/credentials/grant/__init__.py +0 -0
  15. cli/commands/admin/credentials/grant/command.py +119 -0
  16. cli/commands/admin/credentials/grant/fields.py +33 -0
  17. cli/commands/admin/credentials/grant/presenter.py +23 -0
  18. cli/commands/agents/__init__.py +0 -0
  19. cli/commands/agents/create/__init__.py +0 -0
  20. cli/commands/agents/create/command.py +475 -0
  21. cli/commands/agents/create/fields.py +64 -0
  22. cli/commands/agents/create/presenter.py +68 -0
  23. cli/commands/agents/delete/__init__.py +0 -0
  24. cli/commands/agents/delete/command.py +47 -0
  25. cli/commands/agents/delete/presenter.py +16 -0
  26. cli/commands/agents/get/command.py +37 -0
  27. cli/commands/agents/get/presenter.py +32 -0
  28. cli/commands/agents/list/__init__.py +1 -0
  29. cli/commands/agents/list/command.py +54 -0
  30. cli/commands/agents/list/presenter.py +82 -0
  31. cli/commands/agents/update/__init__.py +0 -0
  32. cli/commands/agents/update/command.py +435 -0
  33. cli/commands/agents/update/fields.py +40 -0
  34. cli/commands/agents/update/presenter.py +68 -0
  35. cli/commands/audio/__init__.py +0 -0
  36. cli/commands/audio/transcribe/__init__.py +0 -0
  37. cli/commands/audio/transcribe/command.py +144 -0
  38. cli/commands/audio/transcribe/presenter.py +15 -0
  39. cli/commands/auth/__init__.py +0 -0
  40. cli/commands/auth/login/__init__.py +0 -0
  41. cli/commands/auth/login/command.py +80 -0
  42. cli/commands/auth/signup/__init__.py +0 -0
  43. cli/commands/auth/signup/command.py +115 -0
  44. cli/commands/billing/__init__.py +1 -0
  45. cli/commands/billing/costs/__init__.py +1 -0
  46. cli/commands/billing/costs/by_agent/__init__.py +1 -0
  47. cli/commands/billing/costs/by_agent/command.py +57 -0
  48. cli/commands/billing/costs/by_agent/presenter.py +81 -0
  49. cli/commands/billing/costs/by_model/__init__.py +1 -0
  50. cli/commands/billing/costs/by_model/command.py +57 -0
  51. cli/commands/billing/costs/by_model/presenter.py +80 -0
  52. cli/commands/billing/costs/daily/__init__.py +1 -0
  53. cli/commands/billing/costs/daily/command.py +55 -0
  54. cli/commands/billing/costs/daily/presenter.py +75 -0
  55. cli/commands/billing/costs/summary/__init__.py +1 -0
  56. cli/commands/billing/costs/summary/command.py +57 -0
  57. cli/commands/billing/costs/summary/presenter.py +42 -0
  58. cli/commands/billing/projects/__init__.py +1 -0
  59. cli/commands/billing/projects/create/__init__.py +1 -0
  60. cli/commands/billing/projects/create/command.py +60 -0
  61. cli/commands/billing/projects/create/presenter.py +26 -0
  62. cli/commands/billing/projects/get/__init__.py +1 -0
  63. cli/commands/billing/projects/get/command.py +33 -0
  64. cli/commands/billing/projects/get/presenter.py +32 -0
  65. cli/commands/billing/projects/list/__init__.py +1 -0
  66. cli/commands/billing/projects/list/command.py +40 -0
  67. cli/commands/billing/projects/list/presenter.py +57 -0
  68. cli/commands/content/__init__.py +1 -0
  69. cli/commands/content/delete/__init__.py +0 -0
  70. cli/commands/content/delete/command.py +49 -0
  71. cli/commands/content/delete/presenter.py +18 -0
  72. cli/commands/content/edit/__init__.py +1 -0
  73. cli/commands/content/edit/command.py +155 -0
  74. cli/commands/content/edit/editor.py +150 -0
  75. cli/commands/content/edit/presenter.py +146 -0
  76. cli/commands/content/get/__init__.py +1 -0
  77. cli/commands/content/get/command.py +39 -0
  78. cli/commands/content/get/presenter.py +176 -0
  79. cli/commands/content/list/__init__.py +1 -0
  80. cli/commands/content/list/command.py +347 -0
  81. cli/commands/content/list/export_formatters.py +409 -0
  82. cli/commands/content/list/export_handler.py +165 -0
  83. cli/commands/content/list/presenter.py +190 -0
  84. cli/commands/credentials/__init__.py +0 -0
  85. cli/commands/credentials/create/__init__.py +0 -0
  86. cli/commands/credentials/create/command.py +165 -0
  87. cli/commands/credentials/create/fields.py +38 -0
  88. cli/commands/credentials/create/presenter.py +20 -0
  89. cli/commands/credentials/update/__init__.py +0 -0
  90. cli/commands/credentials/update/command.py +53 -0
  91. cli/commands/credentials/update/fields.py +71 -0
  92. cli/commands/credentials/update/presenter.py +16 -0
  93. cli/commands/flag_utils.py +366 -0
  94. cli/commands/generate/__init__.py +0 -0
  95. cli/commands/generate/cancel/__init__.py +1 -0
  96. cli/commands/generate/cancel/command.py +44 -0
  97. cli/commands/generate/cancel/presenter.py +26 -0
  98. cli/commands/generate/status/__init__.py +1 -0
  99. cli/commands/generate/status/command.py +58 -0
  100. cli/commands/generate/status/presenter.py +78 -0
  101. cli/commands/generate/text/__init__.py +0 -0
  102. cli/commands/generate/text/command.py +1325 -0
  103. cli/commands/generate/text/concurrent_renderer.py +355 -0
  104. cli/commands/generate/text/presenter.py +287 -0
  105. cli/commands/generate/text/stream_renderer.py +129 -0
  106. cli/commands/knowledge/__init__.py +0 -0
  107. cli/commands/knowledge/collections/__init__.py +0 -0
  108. cli/commands/knowledge/collections/cluster/__init__.py +0 -0
  109. cli/commands/knowledge/collections/cluster/command.py +64 -0
  110. cli/commands/knowledge/collections/cluster/presenter.py +74 -0
  111. cli/commands/knowledge/collections/cluster_status/__init__.py +0 -0
  112. cli/commands/knowledge/collections/cluster_status/command.py +46 -0
  113. cli/commands/knowledge/collections/cluster_status/presenter.py +10 -0
  114. cli/commands/knowledge/collections/create/__init__.py +0 -0
  115. cli/commands/knowledge/collections/create/command.py +137 -0
  116. cli/commands/knowledge/collections/create/presenter.py +38 -0
  117. cli/commands/knowledge/collections/delete/__init__.py +1 -0
  118. cli/commands/knowledge/collections/delete/command.py +47 -0
  119. cli/commands/knowledge/collections/delete/presenter.py +20 -0
  120. cli/commands/knowledge/collections/get/__init__.py +1 -0
  121. cli/commands/knowledge/collections/get/command.py +30 -0
  122. cli/commands/knowledge/collections/get/presenter.py +44 -0
  123. cli/commands/knowledge/collections/list/__init__.py +1 -0
  124. cli/commands/knowledge/collections/list/command.py +41 -0
  125. cli/commands/knowledge/collections/list/presenter.py +68 -0
  126. cli/commands/knowledge/collections/update/__init__.py +0 -0
  127. cli/commands/knowledge/collections/update/command.py +97 -0
  128. cli/commands/knowledge/collections/update/presenter.py +42 -0
  129. cli/commands/knowledge/documents/__init__.py +0 -0
  130. cli/commands/knowledge/documents/bulk_metadata/__init__.py +0 -0
  131. cli/commands/knowledge/documents/bulk_metadata/command.py +119 -0
  132. cli/commands/knowledge/documents/bulk_metadata/presenter.py +36 -0
  133. cli/commands/knowledge/documents/delete/__init__.py +0 -0
  134. cli/commands/knowledge/documents/delete/command.py +47 -0
  135. cli/commands/knowledge/documents/delete/presenter.py +20 -0
  136. cli/commands/knowledge/documents/get/__init__.py +0 -0
  137. cli/commands/knowledge/documents/get/command.py +39 -0
  138. cli/commands/knowledge/documents/get/presenter.py +78 -0
  139. cli/commands/knowledge/documents/ingest/__init__.py +0 -0
  140. cli/commands/knowledge/documents/ingest/command.py +222 -0
  141. cli/commands/knowledge/documents/ingest/presenter.py +41 -0
  142. cli/commands/knowledge/documents/list/__init__.py +0 -0
  143. cli/commands/knowledge/documents/list/command.py +69 -0
  144. cli/commands/knowledge/documents/list/presenter.py +86 -0
  145. cli/commands/knowledge/documents/reingest/__init__.py +0 -0
  146. cli/commands/knowledge/documents/reingest/command.py +102 -0
  147. cli/commands/knowledge/documents/reingest/presenter.py +70 -0
  148. cli/commands/knowledge/documents/update/__init__.py +0 -0
  149. cli/commands/knowledge/documents/update/command.py +85 -0
  150. cli/commands/knowledge/documents/update/presenter.py +37 -0
  151. cli/commands/knowledge/recover/__init__.py +0 -0
  152. cli/commands/knowledge/recover/command.py +46 -0
  153. cli/commands/knowledge/recover/presenter.py +79 -0
  154. cli/commands/knowledge/search/__init__.py +0 -0
  155. cli/commands/knowledge/search/command.py +218 -0
  156. cli/commands/knowledge/search/presenter.py +111 -0
  157. cli/commands/knowledge/synthesis/__init__.py +0 -0
  158. cli/commands/knowledge/synthesis/create/__init__.py +0 -0
  159. cli/commands/knowledge/synthesis/create/command.py +127 -0
  160. cli/commands/knowledge/synthesis/create/presenter.py +33 -0
  161. cli/commands/knowledge/synthesis/delete/__init__.py +0 -0
  162. cli/commands/knowledge/synthesis/delete/command.py +53 -0
  163. cli/commands/knowledge/synthesis/delete/presenter.py +31 -0
  164. cli/commands/knowledge/synthesis/get/__init__.py +0 -0
  165. cli/commands/knowledge/synthesis/get/command.py +55 -0
  166. cli/commands/knowledge/synthesis/get/presenter.py +114 -0
  167. cli/commands/knowledge/synthesis/list/__init__.py +0 -0
  168. cli/commands/knowledge/synthesis/list/command.py +132 -0
  169. cli/commands/knowledge/synthesis/list/presenter.py +84 -0
  170. cli/commands/knowledge/synthesis/refresh/__init__.py +0 -0
  171. cli/commands/knowledge/synthesis/refresh/command.py +42 -0
  172. cli/commands/knowledge/synthesis/refresh/presenter.py +33 -0
  173. cli/commands/knowledge/synthesis/update/__init__.py +0 -0
  174. cli/commands/knowledge/synthesis/update/command.py +76 -0
  175. cli/commands/knowledge/synthesis/update/presenter.py +41 -0
  176. cli/commands/models/__init__.py +0 -0
  177. cli/commands/models/list/__init__.py +0 -0
  178. cli/commands/models/list/command.py +84 -0
  179. cli/commands/models/list/presenter.py +114 -0
  180. cli/commands/organizations/__init__.py +0 -0
  181. cli/commands/organizations/create/command.py +32 -0
  182. cli/commands/organizations/create/presenter.py +9 -0
  183. cli/commands/pipelines/__init__.py +1 -0
  184. cli/commands/pipelines/approvals/__init__.py +1 -0
  185. cli/commands/pipelines/approvals/decide_command.py +77 -0
  186. cli/commands/pipelines/approvals/get_command.py +44 -0
  187. cli/commands/pipelines/approvals/presenter.py +56 -0
  188. cli/commands/pipelines/costs/__init__.py +1 -0
  189. cli/commands/pipelines/costs/command.py +57 -0
  190. cli/commands/pipelines/costs/daily_command.py +54 -0
  191. cli/commands/pipelines/costs/daily_presenter.py +59 -0
  192. cli/commands/pipelines/costs/presenter.py +37 -0
  193. cli/commands/pipelines/create/__init__.py +1 -0
  194. cli/commands/pipelines/create/command.py +103 -0
  195. cli/commands/pipelines/create/presenter.py +22 -0
  196. cli/commands/pipelines/env_vars/__init__.py +1 -0
  197. cli/commands/pipelines/env_vars/command.py +51 -0
  198. cli/commands/pipelines/env_vars/presenter.py +16 -0
  199. cli/commands/pipelines/execute/__init__.py +1 -0
  200. cli/commands/pipelines/execute/command.py +142 -0
  201. cli/commands/pipelines/execute/presenter.py +47 -0
  202. cli/commands/pipelines/executions/__init__.py +1 -0
  203. cli/commands/pipelines/executions/costs/__init__.py +1 -0
  204. cli/commands/pipelines/executions/costs/command.py +48 -0
  205. cli/commands/pipelines/executions/costs/presenter.py +29 -0
  206. cli/commands/pipelines/executions/costs_by_model/__init__.py +1 -0
  207. cli/commands/pipelines/executions/costs_by_model/command.py +50 -0
  208. cli/commands/pipelines/executions/costs_by_model/presenter.py +78 -0
  209. cli/commands/pipelines/executions/costs_by_step/__init__.py +1 -0
  210. cli/commands/pipelines/executions/costs_by_step/command.py +50 -0
  211. cli/commands/pipelines/executions/costs_by_step/presenter.py +72 -0
  212. cli/commands/pipelines/executions/get_command.py +38 -0
  213. cli/commands/pipelines/executions/list_command.py +123 -0
  214. cli/commands/pipelines/executions/presenter.py +131 -0
  215. cli/commands/pipelines/executions/rerun_command.py +41 -0
  216. cli/commands/pipelines/executions/update/__init__.py +1 -0
  217. cli/commands/pipelines/executions/update/command.py +110 -0
  218. cli/commands/pipelines/executions/update/presenter.py +28 -0
  219. cli/commands/pipelines/get/__init__.py +1 -0
  220. cli/commands/pipelines/get/command.py +33 -0
  221. cli/commands/pipelines/get/presenter.py +48 -0
  222. cli/commands/pipelines/list/__init__.py +1 -0
  223. cli/commands/pipelines/list/command.py +53 -0
  224. cli/commands/pipelines/list/presenter.py +66 -0
  225. cli/commands/pipelines/schedules/__init__.py +1 -0
  226. cli/commands/pipelines/schedules/create_command.py +119 -0
  227. cli/commands/pipelines/schedules/create_presenter.py +35 -0
  228. cli/commands/pipelines/schedules/delete_command.py +52 -0
  229. cli/commands/pipelines/schedules/env_vars_command.py +59 -0
  230. cli/commands/pipelines/schedules/env_vars_presenter.py +16 -0
  231. cli/commands/pipelines/schedules/get_command.py +38 -0
  232. cli/commands/pipelines/schedules/list_command.py +33 -0
  233. cli/commands/pipelines/schedules/once_command.py +90 -0
  234. cli/commands/pipelines/schedules/once_presenter.py +30 -0
  235. cli/commands/pipelines/schedules/presenter.py +104 -0
  236. cli/commands/pipelines/schedules/update_command.py +139 -0
  237. cli/commands/pipelines/schedules/update_presenter.py +29 -0
  238. cli/commands/render/__init__.py +0 -0
  239. cli/commands/render/html_to_image/__init__.py +0 -0
  240. cli/commands/render/html_to_image/command.py +170 -0
  241. cli/commands/schemas/__init__.py +0 -0
  242. cli/commands/schemas/create/__init__.py +0 -0
  243. cli/commands/schemas/create/command.py +122 -0
  244. cli/commands/schemas/create/presenter.py +53 -0
  245. cli/commands/schemas/delete/command.py +45 -0
  246. cli/commands/schemas/delete/presenter.py +9 -0
  247. cli/commands/schemas/get/__init__.py +0 -0
  248. cli/commands/schemas/get/command.py +56 -0
  249. cli/commands/schemas/get/presenter.py +129 -0
  250. cli/commands/schemas/list/__init__.py +0 -0
  251. cli/commands/schemas/list/command.py +64 -0
  252. cli/commands/schemas/list/presenter.py +133 -0
  253. cli/commands/schemas/update/__init__.py +0 -0
  254. cli/commands/schemas/update/command.py +369 -0
  255. cli/commands/schemas/update/presenter.py +53 -0
  256. cli/commands/sessions/__init__.py +1 -0
  257. cli/commands/sessions/delete/__init__.py +1 -0
  258. cli/commands/sessions/delete/command.py +47 -0
  259. cli/commands/sessions/delete/presenter.py +10 -0
  260. cli/commands/sessions/get/__init__.py +1 -0
  261. cli/commands/sessions/get/command.py +42 -0
  262. cli/commands/sessions/get/presenter.py +59 -0
  263. cli/commands/sessions/list/__init__.py +1 -0
  264. cli/commands/sessions/list/command.py +61 -0
  265. cli/commands/sessions/list/presenter.py +68 -0
  266. cli/commands/sessions/messages/__init__.py +1 -0
  267. cli/commands/sessions/messages/command.py +78 -0
  268. cli/commands/sessions/messages/presenter.py +79 -0
  269. cli/commands/shared_flags.py +500 -0
  270. cli/commands/sync/__init__.py +0 -0
  271. cli/commands/sync/command.py +45 -0
  272. cli/commands/sync/presenter.py +49 -0
  273. cli/commands/tags/__init__.py +1 -0
  274. cli/commands/tags/create/__init__.py +1 -0
  275. cli/commands/tags/create/command.py +60 -0
  276. cli/commands/tags/delete/__init__.py +1 -0
  277. cli/commands/tags/delete/command.py +47 -0
  278. cli/commands/tags/delete/presenter.py +10 -0
  279. cli/commands/tags/get/command.py +31 -0
  280. cli/commands/tags/get/presenter.py +23 -0
  281. cli/commands/tags/list/__init__.py +1 -0
  282. cli/commands/tags/list/command.py +52 -0
  283. cli/commands/tags/list/presenter.py +49 -0
  284. cli/commands/tags/update/command.py +64 -0
  285. cli/commands/tags/update/presenter.py +9 -0
  286. cli/commands/templates/__init__.py +0 -0
  287. cli/commands/templates/create/__init__.py +0 -0
  288. cli/commands/templates/create/command.py +152 -0
  289. cli/commands/templates/create/presenter.py +86 -0
  290. cli/commands/templates/delete/__init__.py +0 -0
  291. cli/commands/templates/delete/command.py +47 -0
  292. cli/commands/templates/delete/presenter.py +16 -0
  293. cli/commands/templates/get/__init__.py +0 -0
  294. cli/commands/templates/get/command.py +52 -0
  295. cli/commands/templates/get/presenter.py +233 -0
  296. cli/commands/templates/get_by_version/command.py +32 -0
  297. cli/commands/templates/get_by_version/presenter.py +30 -0
  298. cli/commands/templates/list/__init__.py +1 -0
  299. cli/commands/templates/list/command.py +102 -0
  300. cli/commands/templates/list/presenter.py +93 -0
  301. cli/commands/templates/render/__init__.py +0 -0
  302. cli/commands/templates/render/command.py +115 -0
  303. cli/commands/templates/render/presenter.py +276 -0
  304. cli/commands/templates/update/__init__.py +0 -0
  305. cli/commands/templates/update/command.py +199 -0
  306. cli/commands/templates/update/presenter.py +94 -0
  307. cli/commands/templates/version/__init__.py +1 -0
  308. cli/commands/templates/version/command.py +116 -0
  309. cli/commands/templates/version/presenter.py +100 -0
  310. cli/commands/tool_configs/__init__.py +0 -0
  311. cli/commands/tool_configs/create/__init__.py +0 -0
  312. cli/commands/tool_configs/create/command.py +118 -0
  313. cli/commands/tool_configs/create/presenter.py +53 -0
  314. cli/commands/tool_configs/delete/__init__.py +0 -0
  315. cli/commands/tool_configs/delete/command.py +47 -0
  316. cli/commands/tool_configs/delete/presenter.py +18 -0
  317. cli/commands/tool_configs/get/__init__.py +0 -0
  318. cli/commands/tool_configs/get/command.py +31 -0
  319. cli/commands/tool_configs/get/presenter.py +62 -0
  320. cli/commands/tool_configs/list/__init__.py +0 -0
  321. cli/commands/tool_configs/list/command.py +59 -0
  322. cli/commands/tool_configs/list/presenter.py +60 -0
  323. cli/commands/tool_configs/update/__init__.py +0 -0
  324. cli/commands/tool_configs/update/command.py +128 -0
  325. cli/commands/tool_configs/update/presenter.py +53 -0
  326. cli/commands/tools/__init__.py +1 -0
  327. cli/commands/tools/get/__init__.py +1 -0
  328. cli/commands/tools/get/command.py +42 -0
  329. cli/commands/tools/get/presenter.py +45 -0
  330. cli/commands/tools/list/__init__.py +1 -0
  331. cli/commands/tools/list/command.py +56 -0
  332. cli/commands/tools/list/presenter.py +44 -0
  333. cli/commands/users/__init__.py +0 -0
  334. cli/commands/users/create/command.py +53 -0
  335. cli/commands/users/create/presenter.py +9 -0
  336. cli/commands/whoami/__init__.py +0 -0
  337. cli/commands/whoami/command.py +42 -0
  338. cli/infrastructure/__init__.py +0 -0
  339. cli/infrastructure/auth_storage.py +71 -0
  340. cli/infrastructure/client_factory.py +36 -0
  341. cli/infrastructure/command.py +75 -0
  342. cli/infrastructure/config.py +188 -0
  343. cli/infrastructure/console.py +27 -0
  344. cli/infrastructure/editor.py +138 -0
  345. cli/infrastructure/error_display.py +178 -0
  346. cli/infrastructure/field_extractor.py +360 -0
  347. cli/infrastructure/file_content.py +210 -0
  348. cli/infrastructure/filter_parser.py +256 -0
  349. cli/infrastructure/formatters/__init__.py +0 -0
  350. cli/infrastructure/formatters/base.py +99 -0
  351. cli/infrastructure/formatters/compact_formatter.py +245 -0
  352. cli/infrastructure/formatters/json_formatter.py +84 -0
  353. cli/infrastructure/formatters/lines_formatter.py +102 -0
  354. cli/infrastructure/formatting/__init__.py +0 -0
  355. cli/infrastructure/formatting/fields.py +193 -0
  356. cli/infrastructure/forms/__init__.py +0 -0
  357. cli/infrastructure/forms/agent_picker.py +123 -0
  358. cli/infrastructure/forms/agent_tool_editor.py +384 -0
  359. cli/infrastructure/forms/agent_tools_manager.py +212 -0
  360. cli/infrastructure/forms/base_picker.py +469 -0
  361. cli/infrastructure/forms/components.py +126 -0
  362. cli/infrastructure/forms/json_schema_builder.py +149 -0
  363. cli/infrastructure/forms/model_picker.py +134 -0
  364. cli/infrastructure/forms/parsers.py +173 -0
  365. cli/infrastructure/forms/resolution_modal.py +302 -0
  366. cli/infrastructure/forms/schema_picker.py +137 -0
  367. cli/infrastructure/forms/tag_management_modal.py +103 -0
  368. cli/infrastructure/forms/tag_picker.py +207 -0
  369. cli/infrastructure/forms/template_picker.py +131 -0
  370. cli/infrastructure/forms/tool_config_picker.py +130 -0
  371. cli/infrastructure/forms/tool_picker.py +103 -0
  372. cli/infrastructure/injection/__init__.py +0 -0
  373. cli/infrastructure/injection/parser.py +302 -0
  374. cli/infrastructure/injection/resolver.py +399 -0
  375. cli/infrastructure/kv_parser.py +130 -0
  376. cli/infrastructure/local_storage.py +227 -0
  377. cli/infrastructure/macro_parser.py +215 -0
  378. cli/infrastructure/output.py +192 -0
  379. cli/infrastructure/provider_setup.py +81 -0
  380. cli/infrastructure/renderers/__init__.py +0 -0
  381. cli/infrastructure/renderers/entity_renderer.py +77 -0
  382. cli/infrastructure/renderers/list_renderer.py +114 -0
  383. cli/infrastructure/scope_utils.py +47 -0
  384. cli/infrastructure/spinner.py +101 -0
  385. cli/infrastructure/tui/__init__.py +0 -0
  386. cli/infrastructure/tui/clipboard.py +41 -0
  387. cli/infrastructure/tui/formatters.py +105 -0
  388. cli/infrastructure/tui/preview.py +14 -0
  389. cli/infrastructure/tui/selectable.py +198 -0
  390. cli/infrastructure/validation/__init__.py +0 -0
  391. cli/infrastructure/validation/tag_validation.py +74 -0
  392. cli/main.py +759 -0
  393. cli/tui/__init__.py +0 -0
  394. cli/tui/app.py +199 -0
  395. cli/tui/app_store.py +73 -0
  396. cli/tui/chat/__init__.py +0 -0
  397. cli/tui/chat/commands/__init__.py +0 -0
  398. cli/tui/chat/commands/base.py +65 -0
  399. cli/tui/chat/commands/create_session.py +135 -0
  400. cli/tui/chat/commands/load_session.py +119 -0
  401. cli/tui/chat/commands/regenerate.py +120 -0
  402. cli/tui/chat/commands/reload_session.py +63 -0
  403. cli/tui/chat/commands/send_message.py +190 -0
  404. cli/tui/chat/commands/undo.py +66 -0
  405. cli/tui/chat/editor.py +71 -0
  406. cli/tui/chat/messages.py +223 -0
  407. cli/tui/chat/pane.py +141 -0
  408. cli/tui/chat/renderers/__init__.py +0 -0
  409. cli/tui/chat/renderers/base.py +72 -0
  410. cli/tui/chat/renderers/markdown.py +250 -0
  411. cli/tui/chat/renderers/plain.py +83 -0
  412. cli/tui/chat/screen.py +1155 -0
  413. cli/tui/chat/services/__init__.py +0 -0
  414. cli/tui/chat/services/injection.py +386 -0
  415. cli/tui/chat/services/name_generator.py +256 -0
  416. cli/tui/chat/slash_commands.py +424 -0
  417. cli/tui/chat/store.py +280 -0
  418. cli/tui/chat/types.py +220 -0
  419. cli/tui/chat/widgets/__init__.py +0 -0
  420. cli/tui/chat/widgets/chat_header.py +75 -0
  421. cli/tui/chat/widgets/chat_input.py +362 -0
  422. cli/tui/chat/widgets/injection_popup.py +161 -0
  423. cli/tui/chat/widgets/message_display.py +287 -0
  424. cli/tui/chat/widgets/session_sidebar.py +214 -0
  425. cli/tui/chat/widgets/welcome_screen.py +290 -0
  426. cli/tui/screens/__init__.py +0 -0
  427. cli/tui/screens/agents.py +344 -0
  428. cli/tui/screens/base.py +301 -0
  429. cli/tui/screens/content.py +508 -0
  430. cli/tui/screens/dashboard.py +89 -0
  431. cli/tui/screens/models.py +96 -0
  432. cli/tui/screens/nav_screen.py +186 -0
  433. cli/tui/screens/schemas.py +522 -0
  434. cli/tui/screens/templates.py +734 -0
  435. cli/tui/screens/tool_configs.py +335 -0
  436. cli/tui/styles/__init__.py +0 -0
  437. cli/tui/widgets/__init__.py +0 -0
  438. cli/tui/widgets/agent_create_modal.py +139 -0
  439. cli/tui/widgets/agent_form_modal.py +659 -0
  440. cli/tui/widgets/agent_update_modal.py +299 -0
  441. cli/tui/widgets/base_form_modal.py +77 -0
  442. cli/tui/widgets/confirm_modal.py +75 -0
  443. cli/tui/widgets/help_modal.py +145 -0
  444. cli/tui/widgets/new_session_modal.py +328 -0
  445. cli/tui/widgets/schema_create_modal.py +271 -0
  446. cli/tui/widgets/schema_update_modal.py +188 -0
  447. cli/tui/widgets/status_footer.py +147 -0
  448. cli/tui/widgets/template_create_modal.py +502 -0
  449. cli/tui/widgets/template_update_modal.py +308 -0
  450. cli/tui/widgets/tool_config_create_modal.py +216 -0
  451. cli/tui/widgets/tool_config_update_modal.py +208 -0
cli/tui/chat/screen.py ADDED
@@ -0,0 +1,1155 @@
1
+ """ChatScreen - the main chat screen with split-pane support.
2
+
3
+ This is the main chat interface that:
4
+ 1. Composes child widgets (sidebar + pane(s))
5
+ 2. Handles Messages from children
6
+ 3. Dispatches commands for async operations
7
+ 4. Coordinates with AppStore for API access
8
+ 5. Supports split-pane view for comparing models
9
+
10
+ Now implemented as a proper Textual Screen for proper keybinding isolation.
11
+ """
12
+
13
+ from typing import TYPE_CHECKING
14
+ from uuid import UUID
15
+
16
+ from textual import on, work
17
+ from textual.app import ComposeResult
18
+ from textual.binding import Binding
19
+ from textual.containers import Horizontal
20
+ from textual.message import Message
21
+
22
+ from alloy_runtime_sdk.api_client.client import ApiClient
23
+ from alloy_runtime_types.dtos.sessions import UpdateSessionRequest
24
+
25
+ from cli.infrastructure.injection.resolver import ResolvedMessage
26
+ from cli.infrastructure.local_storage import get_storage
27
+ from cli.infrastructure.tui.clipboard import copy_to_clipboard
28
+ from cli.tui.chat.commands.create_session import CreateSessionCommand
29
+ from cli.tui.chat.commands.load_session import LoadSessionCommand
30
+ from cli.tui.chat.commands.regenerate import RegenerateCommand
31
+ from cli.tui.chat.commands.reload_session import ReloadSessionCommand
32
+ from cli.tui.chat.commands.send_message import SendMessageCommand
33
+ from cli.tui.chat.commands.undo import UndoCommand
34
+ from cli.tui.chat.messages import (
35
+ AgentQuickSelected,
36
+ CopyToClipboardRequested,
37
+ FocusInputRequested,
38
+ FocusSessionSearchRequested,
39
+ NewSessionRequested,
40
+ OpenEditorRequested,
41
+ RecentSessionSelected,
42
+ RegenerateRequested,
43
+ SendMessageRequested,
44
+ SessionSelected,
45
+ SessionsLoadError,
46
+ SlashCommandExecuted,
47
+ UndoRequested,
48
+ UnknownSlashCommand,
49
+ )
50
+ from cli.tui.chat.editor import EditorError, open_in_editor
51
+ from cli.tui.chat.pane import ChatPane
52
+ from cli.tui.chat.services.injection import InjectionService
53
+ from cli.tui.chat.services.name_generator import SessionNameGenerator
54
+ from cli.tui.chat.store import ChatStore
55
+ from cli.tui.chat.types import ChatPhase, extract_message_text
56
+ from cli.tui.chat.widgets.chat_input import ChatInput
57
+ from cli.tui.chat.widgets.message_display import MessageDisplay
58
+ from cli.tui.chat.widgets.session_sidebar import SessionSidebar
59
+ from cli.tui.screens.nav_screen import NavScreen
60
+ from cli.tui.widgets.new_session_modal import NewSessionConfig, NewSessionModal
61
+ from alloy_runtime_sdk.logging.config import get_logger
62
+
63
+ logger = get_logger(__name__)
64
+
65
+ # Storage keys for preferences
66
+ PREF_AUTO_NAME_SESSIONS = "auto_name_sessions"
67
+
68
+ if TYPE_CHECKING:
69
+ from cli.tui.app import AlloyRuntimeApp
70
+
71
+
72
+ class PaneFocusChanged(Message):
73
+ """Message posted when the active pane changes in split view."""
74
+
75
+ def __init__(self, pane_id: str) -> None:
76
+ self.pane_id = pane_id
77
+ super().__init__()
78
+
79
+
80
+ class ChatScreen(NavScreen):
81
+ """Main chat screen - orchestrates child widgets and handles commands.
82
+
83
+ Supports both single-pane and split-pane modes for comparing models.
84
+
85
+ Architecture:
86
+ - ChatStore: Centralized reactive state (one per pane)
87
+ - SessionSidebar: Session list with search (shared, controls active pane)
88
+ - ChatPane: Individual chat area (header, messages, input)
89
+
90
+ Split View:
91
+ - Toggle with Ctrl+Shift+S to enable/disable split view
92
+ - Switch between panes with Ctrl+Tab
93
+ - Sync mode sends messages to both panes simultaneously
94
+
95
+ Responsibilities:
96
+ - Compose child widgets with shared/separate stores
97
+ - Handle Messages from children
98
+ - Execute commands for async API operations
99
+ - Coordinate state transitions
100
+ - Manage split-pane layout and focus
101
+ """
102
+
103
+ app: "AlloyRuntimeApp"
104
+
105
+ SCREEN_ID = "chat"
106
+
107
+ DEFAULT_CSS = """
108
+ ChatScreen #screen-root {
109
+ height: 1fr;
110
+ }
111
+
112
+ ChatScreen #chat-root {
113
+ height: 100%;
114
+ width: 100%;
115
+ }
116
+
117
+ ChatScreen #split-container {
118
+ width: 1fr;
119
+ height: 100%;
120
+ }
121
+
122
+ ChatScreen #panes-container {
123
+ width: 1fr;
124
+ height: 100%;
125
+ }
126
+
127
+ ChatScreen .sync-indicator {
128
+ dock: bottom;
129
+ height: 1;
130
+ background: $warning;
131
+ color: $text;
132
+ text-align: center;
133
+ display: none;
134
+ }
135
+
136
+ ChatScreen .sync-indicator.visible {
137
+ display: block;
138
+ }
139
+ """
140
+
141
+ # Screen-specific bindings (Ctrl+ prefixed to not conflict with typing)
142
+ # NavScreen.BINDINGS provides global navigation (c, a, t, s, m, d, ?)
143
+ BINDINGS = NavScreen.BINDINGS + [
144
+ # Session management
145
+ Binding("ctrl+n", "new_session", "New Chat", show=True),
146
+ Binding("ctrl+l", "clear_display", "Clear", show=False),
147
+ # Chat operations
148
+ Binding("ctrl+r", "regenerate", "Regenerate", show=True),
149
+ Binding("ctrl+u", "undo", "Undo", show=True),
150
+ Binding("ctrl+y", "copy_last", "Copy", show=True),
151
+ # Navigation - use escape to focus input
152
+ Binding("escape", "cancel_or_focus", "Cancel/Focus", show=False),
153
+ # UI toggles
154
+ Binding("ctrl+b", "toggle_sidebar", "Sidebar", show=True),
155
+ # Split view (Ctrl+Shift to avoid conflicts)
156
+ Binding("ctrl+shift+s", "toggle_split", "Split View", show=False),
157
+ Binding("ctrl+shift+tab", "switch_pane", "Switch Pane", show=False),
158
+ Binding("ctrl+shift+y", "toggle_sync", "Sync Input", show=False),
159
+ ]
160
+
161
+ def __init__(self) -> None:
162
+ """Initialize the chat screen with stores for both panes."""
163
+ super().__init__()
164
+ # Primary (left) pane store - always exists
165
+ self._left_store = ChatStore()
166
+ # Secondary (right) pane store - only used in split mode
167
+ self._right_store = ChatStore()
168
+
169
+ # Split view state
170
+ self._split_mode = False
171
+ self._active_pane: str = "left" # "left" or "right"
172
+ self._sync_mode = False # Send to both panes when True
173
+
174
+ # Injection service (initialized on first use)
175
+ self._injection_service: InjectionService | None = None
176
+
177
+ @property
178
+ def _store(self) -> ChatStore:
179
+ """Get the active pane's store."""
180
+ return self._left_store if self._active_pane == "left" else self._right_store
181
+
182
+ @property
183
+ def _active_pane_widget(self) -> ChatPane | None:
184
+ """Get the currently active ChatPane widget."""
185
+ pane_id = "left-pane" if self._active_pane == "left" else "right-pane"
186
+ try:
187
+ return self.query_one(f"#{pane_id}", ChatPane)
188
+ except Exception:
189
+ return None
190
+
191
+ def compose_content(self) -> ComposeResult:
192
+ """Compose the chat layout with child widgets."""
193
+ with Horizontal(id="chat-root"):
194
+ yield SessionSidebar(self._left_store)
195
+ with Horizontal(id="panes-container"):
196
+ # Left pane (always visible)
197
+ yield ChatPane(
198
+ self._left_store,
199
+ pane_id="left",
200
+ show_border=False,
201
+ id="left-pane",
202
+ )
203
+ # Right pane (hidden by default, shown in split mode)
204
+ right_pane = ChatPane(
205
+ self._right_store,
206
+ pane_id="right",
207
+ show_border=True,
208
+ id="right-pane",
209
+ )
210
+ right_pane.display = False
211
+ yield right_pane
212
+
213
+ def on_mount(self) -> None:
214
+ """Initialize pane focus state and injection service."""
215
+ self._update_pane_focus_indicators()
216
+ # Initialize injection service in background
217
+ self._initialize_injection_service()
218
+
219
+ def on_screen_resume(self) -> None:
220
+ """Called when screen becomes active - focus the chat input."""
221
+ self.call_after_refresh(self.focus_input)
222
+
223
+ # =========================================================================
224
+ # Split View Management
225
+ # =========================================================================
226
+
227
+ def action_toggle_split(self) -> None:
228
+ """Toggle split-pane view."""
229
+ self._split_mode = not self._split_mode
230
+
231
+ # Show/hide right pane
232
+ try:
233
+ right_pane = self.query_one("#right-pane", ChatPane)
234
+ left_pane = self.query_one("#left-pane", ChatPane)
235
+
236
+ right_pane.display = self._split_mode
237
+
238
+ # Update border visibility
239
+ left_pane.set_show_border(self._split_mode)
240
+ if self._split_mode:
241
+ left_pane.add_class(
242
+ "focused-pane" if self._active_pane == "left" else "unfocused-pane"
243
+ )
244
+ else:
245
+ left_pane.remove_class("focused-pane")
246
+ left_pane.remove_class("unfocused-pane")
247
+ # Reset to left pane when disabling split
248
+ self._active_pane = "left"
249
+ self._sync_mode = False
250
+
251
+ self._update_pane_focus_indicators()
252
+
253
+ status = "enabled" if self._split_mode else "disabled"
254
+ self.app.notify(f"Split view {status}", severity="information")
255
+
256
+ except Exception as e:
257
+ logger.debug(f"Failed to toggle split view: {e}")
258
+
259
+ def action_switch_pane(self) -> None:
260
+ """Switch focus between left and right panes."""
261
+ if not self._split_mode:
262
+ self.app.notify(
263
+ "Split view not enabled. Press Ctrl+Shift+S to enable.",
264
+ severity="warning",
265
+ )
266
+ return
267
+
268
+ # Toggle active pane
269
+ self._active_pane = "right" if self._active_pane == "left" else "left"
270
+ self._update_pane_focus_indicators()
271
+
272
+ # Update sidebar to use new active pane's store
273
+ self._update_sidebar_store()
274
+
275
+ # Focus the input in the new active pane
276
+ pane = self._active_pane_widget
277
+ if pane:
278
+ pane.focus_input()
279
+
280
+ self.app.notify(f"Switched to {self._active_pane} pane", severity="information")
281
+
282
+ def action_toggle_sync(self) -> None:
283
+ """Toggle synchronized input mode (send to both panes)."""
284
+ if not self._split_mode:
285
+ self.app.notify(
286
+ "Split view not enabled. Press Ctrl+Shift+S to enable.",
287
+ severity="warning",
288
+ )
289
+ return
290
+
291
+ self._sync_mode = not self._sync_mode
292
+ status = "enabled" if self._sync_mode else "disabled"
293
+ self.app.notify(
294
+ f"Sync mode {status} - messages sent to {'both panes' if self._sync_mode else 'active pane only'}",
295
+ severity="information",
296
+ )
297
+
298
+ def _update_pane_focus_indicators(self) -> None:
299
+ """Update visual focus indicators on panes."""
300
+ if not self._split_mode:
301
+ return
302
+
303
+ try:
304
+ left_pane = self.query_one("#left-pane", ChatPane)
305
+ right_pane = self.query_one("#right-pane", ChatPane)
306
+
307
+ left_pane.set_focused(self._active_pane == "left")
308
+ right_pane.set_focused(self._active_pane == "right")
309
+ except Exception:
310
+ pass
311
+
312
+ def _update_sidebar_store(self) -> None:
313
+ """Update sidebar to reflect the active pane's store.
314
+
315
+ Note: This is a limitation - the sidebar is created with left_store.
316
+ For full implementation, we'd need to make the sidebar reactive to store changes.
317
+ For now, session selection affects the active pane.
318
+ """
319
+ # The sidebar always shows sessions, but selection loads into active pane
320
+ pass
321
+
322
+ # =========================================================================
323
+ # Message Handlers (from child widgets)
324
+ # =========================================================================
325
+
326
+ @on(SessionSelected)
327
+ def handle_session_selected(self, message: SessionSelected) -> None:
328
+ """Load messages when a session is selected."""
329
+ logger.info("session_selected", session_id=str(message.session_id))
330
+ self._execute_load_session(message.session_id)
331
+
332
+ @on(AgentQuickSelected)
333
+ def handle_agent_quick_selected(self, message: AgentQuickSelected) -> None:
334
+ """Create a new session with the selected agent immediately."""
335
+ logger.info(
336
+ "agent_quick_selected",
337
+ agent_id=str(message.agent_id),
338
+ agent_name=message.agent_name,
339
+ )
340
+ self._execute_quick_agent_session(message.agent_id, message.agent_name)
341
+
342
+ @on(RecentSessionSelected)
343
+ def handle_recent_session_selected(self, message: RecentSessionSelected) -> None:
344
+ """Load a recent session from the welcome screen."""
345
+ logger.info("recent_session_selected", session_id=str(message.session_id))
346
+ self._execute_load_session(message.session_id)
347
+
348
+ @on(SendMessageRequested)
349
+ def handle_send_message(self, message: SendMessageRequested) -> None:
350
+ """Send a message and stream the response."""
351
+ if message.content:
352
+ logger.info(
353
+ "send_message_requested",
354
+ content_length=len(message.content),
355
+ sync_mode=self._sync_mode,
356
+ split_mode=self._split_mode,
357
+ )
358
+ if self._sync_mode and self._split_mode:
359
+ # Send to both panes
360
+ self._execute_send_message_sync(message.content)
361
+ else:
362
+ # Send to active pane only
363
+ self._execute_send_message(message.content)
364
+
365
+ @on(RegenerateRequested)
366
+ def handle_regenerate(self, message: RegenerateRequested) -> None:
367
+ """Regenerate the last response."""
368
+ self.action_regenerate()
369
+
370
+ @on(UndoRequested)
371
+ def handle_undo(self, message: UndoRequested) -> None:
372
+ """Undo the last conversation turn."""
373
+ self.action_undo()
374
+
375
+ @on(CopyToClipboardRequested)
376
+ def handle_copy(self, message: CopyToClipboardRequested) -> None:
377
+ """Copy content to clipboard.
378
+
379
+ If the label is '_copy_last_request_', this is a request to fetch
380
+ and copy the last assistant message from the server.
381
+ """
382
+ if message.label == "_copy_last_request_":
383
+ # This is a copy_last request from ChatTextArea
384
+ self.action_copy_last()
385
+ else:
386
+ # Regular copy request with content
387
+ copy_to_clipboard(self.app, message.content, message.label)
388
+
389
+ @on(FocusInputRequested)
390
+ def handle_focus_input(self, message: FocusInputRequested) -> None:
391
+ """Focus the chat input."""
392
+ self.action_focus_input()
393
+
394
+ @on(FocusSessionSearchRequested)
395
+ def handle_focus_search(self, message: FocusSessionSearchRequested) -> None:
396
+ """Focus the session search."""
397
+ self.action_focus_session_search()
398
+
399
+ @on(NewSessionRequested)
400
+ def handle_new_session(self, message: NewSessionRequested) -> None:
401
+ """Open new session modal."""
402
+ self.action_new_session()
403
+
404
+ @on(SessionsLoadError)
405
+ def handle_sessions_error(self, message: SessionsLoadError) -> None:
406
+ """Handle session loading errors."""
407
+ self.app.notify(f"Failed to load sessions: {message.error}", severity="error")
408
+
409
+ @on(SlashCommandExecuted)
410
+ def handle_slash_command(self, message: SlashCommandExecuted) -> None:
411
+ """Handle slash commands that require screen-level actions."""
412
+ logger.debug(
413
+ "slash_command_executed", command=message.command_name, args=message.args
414
+ )
415
+ command_name = message.command_name
416
+
417
+ # Map command names to actions
418
+ if command_name in ("new", "n"):
419
+ self.action_new_session()
420
+ elif command_name in ("sessions", "s", "list"):
421
+ self.action_focus_session_search()
422
+ elif command_name in ("undo", "u", "z"):
423
+ self.action_undo()
424
+ elif command_name in ("regenerate", "r", "regen"):
425
+ self.action_regenerate()
426
+ elif command_name in ("split",):
427
+ self.action_toggle_split()
428
+ elif command_name in ("sync",):
429
+ self.action_toggle_sync()
430
+ elif command_name in ("switch", "sw"):
431
+ self.action_switch_pane()
432
+ # Note: clear, help, copy are handled directly in ChatInput
433
+
434
+ @on(UnknownSlashCommand)
435
+ def handle_unknown_command(self, message: UnknownSlashCommand) -> None:
436
+ """Handle unknown slash commands."""
437
+ self.app.notify(
438
+ f"Unknown command: {message.command_text}. Type /help for available commands.",
439
+ severity="warning",
440
+ )
441
+
442
+ @on(OpenEditorRequested)
443
+ def handle_open_editor(self, message: OpenEditorRequested) -> None:
444
+ """Open external editor for message composition."""
445
+ self._open_external_editor(message.current_text)
446
+
447
+ # =========================================================================
448
+ # Actions (triggered by bindings)
449
+ # =========================================================================
450
+
451
+ def action_new_session(self) -> None:
452
+ """Create a new chat session via modal."""
453
+ self._show_new_session_modal()
454
+
455
+ def action_regenerate(self) -> None:
456
+ """Regenerate the last AI response."""
457
+ state = self._store.state
458
+ session = state.session
459
+
460
+ if not session:
461
+ self.app.notify("No active session", severity="warning")
462
+ return
463
+
464
+ if state.phase != ChatPhase.IDLE:
465
+ self.app.notify("Please wait for the current operation", severity="warning")
466
+ return
467
+
468
+ if not session.is_agent_mode and not session.is_model_mode:
469
+ self.app.notify(
470
+ "No agent or model configured for this session", severity="warning"
471
+ )
472
+ return
473
+
474
+ self._execute_regenerate()
475
+
476
+ def action_undo(self) -> None:
477
+ """Undo the last conversation turn."""
478
+ state = self._store.state
479
+
480
+ if not state.session:
481
+ self.app.notify("No active session", severity="warning")
482
+ return
483
+
484
+ if state.phase != ChatPhase.IDLE:
485
+ self.app.notify("Please wait for the current operation", severity="warning")
486
+ return
487
+
488
+ messages = state.messages
489
+ if len(messages) < 2:
490
+ self.app.notify("Nothing to undo", severity="warning")
491
+ return
492
+
493
+ # Check last two messages form a user->assistant turn
494
+ last = messages[-1]
495
+ second_last = messages[-2]
496
+ if not (second_last.role == "user" and last.role == "assistant"):
497
+ self.app.notify("No complete turn to undo", severity="warning")
498
+ return
499
+
500
+ self._execute_undo()
501
+
502
+ def action_copy_last(self) -> None:
503
+ """Copy the last assistant response to clipboard (fetches from server)."""
504
+ state = self._store.state
505
+
506
+ if not state.session:
507
+ self.app.notify("No active session", severity="warning")
508
+ return
509
+
510
+ # Fetch from server to get post-processed content
511
+ self._execute_copy_last()
512
+
513
+ def action_clear_display(self) -> None:
514
+ """Clear the current session and show welcome."""
515
+ self._store.clear_session()
516
+ pane = self._active_pane_widget
517
+ if pane:
518
+ message_display = pane.query_one(MessageDisplay)
519
+ message_display.show_welcome()
520
+
521
+ def action_focus_session_search(self) -> None:
522
+ """Focus the session search input."""
523
+ # Make sure sidebar is visible first
524
+ if not self._left_store.state.sidebar_visible:
525
+ self._left_store.set_sidebar_visible(True)
526
+ sidebar = self.query_one(SessionSidebar)
527
+ sidebar.action_focus_search()
528
+
529
+ def action_toggle_sidebar(self) -> None:
530
+ """Toggle the session sidebar visibility."""
531
+ self._left_store.toggle_sidebar()
532
+ visible = self._left_store.state.sidebar_visible
533
+ self.app.notify(
534
+ f"Sidebar {'shown' if visible else 'hidden'}",
535
+ severity="information",
536
+ )
537
+
538
+ def action_focus_input(self) -> None:
539
+ """Focus the chat input in the active pane."""
540
+ self.focus_input()
541
+
542
+ def action_cancel_or_focus(self) -> None:
543
+ """Cancel streaming if active, otherwise focus the chat input.
544
+
545
+ This provides a clean escape key experience:
546
+ - During streaming: Cancel the generation
547
+ - Otherwise: Focus the input for typing
548
+ """
549
+ state = self._store.state
550
+ if state.phase == ChatPhase.STREAMING:
551
+ self._store.request_cancel()
552
+ self.app.notify("Generation cancelled", severity="warning")
553
+ else:
554
+ self.focus_input()
555
+
556
+ def focus_input(self) -> None:
557
+ """Focus the chat input in the active pane.
558
+
559
+ Public method for external callers (e.g., when switching to chat tab).
560
+ """
561
+ pane = self._active_pane_widget
562
+ if pane:
563
+ pane.focus_input()
564
+
565
+ def _open_external_editor(self, current_text: str) -> None:
566
+ """Open text in external editor and update input with result.
567
+
568
+ Uses Textual's suspend() to temporarily yield control to the editor.
569
+ """
570
+ from cli.tui.chat.widgets.chat_input import ChatTextArea
571
+
572
+ try:
573
+ with self.app.suspend():
574
+ edited_text = open_in_editor(current_text)
575
+
576
+ if edited_text is not None:
577
+ # Update the chat input with edited content in active pane
578
+ pane = self._active_pane_widget
579
+ if pane:
580
+ chat_input = pane.query_one(ChatInput)
581
+ text_area = chat_input.query_one("#chat-input", ChatTextArea)
582
+ text_area.text = edited_text
583
+ # Move cursor to end
584
+ text_area.move_cursor(text_area.document.end)
585
+ chat_input.focus_input()
586
+ self.app.notify("Editor content loaded", severity="information")
587
+ except EditorError as e:
588
+ self.app.notify(str(e), severity="error")
589
+
590
+ # =========================================================================
591
+ # Command Execution (async operations)
592
+ # =========================================================================
593
+
594
+ async def _get_client(self) -> ApiClient:
595
+ """Get the shared server client."""
596
+ return await self.app.store.get_client()
597
+
598
+ async def _get_injection_service(self) -> InjectionService:
599
+ """Get or create the injection service."""
600
+ if self._injection_service is None:
601
+ client = await self._get_client()
602
+ self._injection_service = InjectionService(client)
603
+ return self._injection_service
604
+
605
+ @work(exclusive=False, group="injection_init")
606
+ async def _initialize_injection_service(self) -> None:
607
+ """Initialize the injection service and refresh its cache.
608
+
609
+ Called on mount to pre-populate autocomplete data.
610
+ """
611
+ try:
612
+ service = await self._get_injection_service()
613
+ await service.refresh_cache()
614
+
615
+ # Pass service to chat input widgets in both panes
616
+ self._update_chat_inputs_with_injection_service(service)
617
+
618
+ logger.debug(
619
+ "injection_service_initialized",
620
+ extra={
621
+ "fragments": len(service.fragments),
622
+ "content": len(service.content_parts),
623
+ "schemas": len(service.schemas),
624
+ },
625
+ )
626
+ except Exception as e:
627
+ logger.debug(
628
+ "injection_service_init_failed",
629
+ extra={"error": str(e)},
630
+ )
631
+
632
+ def _update_chat_inputs_with_injection_service(
633
+ self, service: InjectionService
634
+ ) -> None:
635
+ """Update ChatInput widgets with the injection service."""
636
+ try:
637
+ # Update left pane
638
+ left_pane = self.query_one("#left-pane", ChatPane)
639
+ left_input = left_pane.query_one(ChatInput)
640
+ left_input.set_injection_service(service)
641
+
642
+ # Update right pane (if exists)
643
+ try:
644
+ right_pane = self.query_one("#right-pane", ChatPane)
645
+ right_input = right_pane.query_one(ChatInput)
646
+ right_input.set_injection_service(service)
647
+ except Exception:
648
+ pass # Right pane may not exist yet
649
+ except Exception as e:
650
+ logger.debug(f"Failed to update chat inputs with injection service: {e}")
651
+
652
+ @work(exclusive=True)
653
+ async def _show_new_session_modal(self) -> None:
654
+ """Show the new session modal."""
655
+ client = await self._get_client()
656
+ modal = NewSessionModal(client=client)
657
+ self.app.push_screen(modal, self._on_new_session_created)
658
+
659
+ def _on_new_session_created(self, config: NewSessionConfig | None) -> None:
660
+ """Handle result from new session modal."""
661
+ if config is None:
662
+ return # Cancelled
663
+
664
+ # Validate: must have either agent or model selected
665
+ if not config.is_agent_mode and not config.is_model_mode:
666
+ self.app.notify(
667
+ "Please select an agent or model to start a chat session",
668
+ severity="warning",
669
+ )
670
+ return
671
+
672
+ self._execute_create_session(config)
673
+
674
+ @work(exclusive=True)
675
+ async def _execute_create_session(self, config: NewSessionConfig) -> None:
676
+ """Execute the create session command."""
677
+ client = await self._get_client()
678
+
679
+ command = CreateSessionCommand(
680
+ client=client,
681
+ store=self._store,
682
+ name=config.name,
683
+ # Agent mode
684
+ agent_id=config.agent_id,
685
+ agent_name=config.agent_name,
686
+ # Model mode
687
+ provider_key=config.provider_key,
688
+ provider_model_name=config.provider_model_name,
689
+ system_instruction=config.system_instruction,
690
+ )
691
+
692
+ result = await command.execute()
693
+
694
+ if result.success:
695
+ # Focus input in active pane and refresh sidebar
696
+ pane = self._active_pane_widget
697
+ if pane:
698
+ pane.focus_input()
699
+
700
+ sidebar = self.query_one(SessionSidebar)
701
+ sidebar.refresh_sessions()
702
+
703
+ mode = "agent" if config.is_agent_mode else "model"
704
+ self.app.notify(f"Session created ({mode} mode)!", severity="information")
705
+ else:
706
+ self.app.notify(
707
+ f"Failed to create session: {result.error}", severity="error"
708
+ )
709
+
710
+ @work(exclusive=True)
711
+ async def _execute_quick_agent_session(
712
+ self, agent_id: UUID, agent_name: str
713
+ ) -> None:
714
+ """Create a new session with the selected agent (no modal, immediate creation).
715
+
716
+ This is triggered from the welcome screen quick start list.
717
+ """
718
+ client = await self._get_client()
719
+
720
+ command = CreateSessionCommand(
721
+ client=client,
722
+ store=self._store,
723
+ name=None, # Will be auto-named after first message
724
+ agent_id=agent_id,
725
+ agent_name=agent_name,
726
+ # Model mode params not used
727
+ provider_key=None,
728
+ provider_model_name=None,
729
+ system_instruction=None,
730
+ )
731
+
732
+ result = await command.execute()
733
+
734
+ if result.success:
735
+ # Focus input in active pane and refresh sidebar
736
+ pane = self._active_pane_widget
737
+ if pane:
738
+ pane.focus_input()
739
+
740
+ sidebar = self.query_one(SessionSidebar)
741
+ sidebar.refresh_sessions()
742
+
743
+ self.app.notify(f"Chat started with {agent_name}", severity="information")
744
+ else:
745
+ self.app.notify(f"Failed to start chat: {result.error}", severity="error")
746
+
747
+ @work(exclusive=True)
748
+ async def _execute_load_session(self, session_id: UUID) -> None:
749
+ """Execute the load session command."""
750
+ client = await self._get_client()
751
+
752
+ command = LoadSessionCommand(
753
+ client=client,
754
+ store=self._store,
755
+ session_id=session_id,
756
+ )
757
+
758
+ result = await command.execute()
759
+
760
+ if result.success:
761
+ # Focus input in active pane
762
+ pane = self._active_pane_widget
763
+ if pane:
764
+ pane.focus_input()
765
+ else:
766
+ self.app.notify(f"Failed to load session: {result.error}", severity="error")
767
+
768
+ @work(exclusive=True)
769
+ async def _execute_send_message(self, user_message: str) -> None:
770
+ """Execute the send message command for the active pane."""
771
+ state = self._store.state
772
+ session = state.session
773
+
774
+ if not session:
775
+ self.app.notify("No active session", severity="warning")
776
+ return
777
+
778
+ # Check that we have either agent or model configured
779
+ if not session.is_agent_mode and not session.is_model_mode:
780
+ self.app.notify(
781
+ "No agent or model configured for this session", severity="warning"
782
+ )
783
+ return
784
+
785
+ client = await self._get_client()
786
+ injection_service = await self._get_injection_service()
787
+
788
+ def on_injection_resolved(resolved: ResolvedMessage) -> None:
789
+ """Callback when injections are resolved."""
790
+ injected_items: list[str] = []
791
+ if resolved.fragments_used:
792
+ injected_items.append(f"{len(resolved.fragments_used)} fragment(s)")
793
+ if resolved.content_used:
794
+ injected_items.append(f"{len(resolved.content_used)} content item(s)")
795
+ if resolved.schemas_used:
796
+ injected_items.append(f"{len(resolved.schemas_used)} schema(s)")
797
+ if injected_items:
798
+ self.app.notify(
799
+ f"Injected: {', '.join(injected_items)}", severity="information"
800
+ )
801
+
802
+ command = SendMessageCommand(
803
+ client=client,
804
+ store=self._store,
805
+ session_id=session.session_id,
806
+ user_message=user_message,
807
+ on_chunk=lambda c: self._store.append_streaming_content(c),
808
+ on_thinking_chunk=lambda c: self._store.append_streaming_thinking_content(
809
+ c
810
+ ),
811
+ # Agent mode
812
+ agent_id=session.agent_id,
813
+ # Model mode
814
+ provider_key=session.provider_key,
815
+ provider_model_name=session.model_name,
816
+ system_instruction=session.system_instruction,
817
+ # Injection support
818
+ injection_service=injection_service,
819
+ on_injection_resolved=on_injection_resolved,
820
+ )
821
+
822
+ result = await command.execute()
823
+
824
+ if result.success:
825
+ # Reload to get proper message objects
826
+ await self._reload_current_session()
827
+
828
+ # Trigger auto-naming for sessions without a name (non-blocking)
829
+ self._maybe_generate_session_name(user_message)
830
+ else:
831
+ self.app.notify(f"Error: {result.error}", severity="error")
832
+
833
+ @work(exclusive=False, group="sync_send")
834
+ async def _execute_send_message_sync(self, user_message: str) -> None:
835
+ """Execute the send message command for both panes simultaneously."""
836
+ client = await self._get_client()
837
+
838
+ # Send to left pane
839
+ left_state = self._left_store.state
840
+ if left_state.session and (
841
+ left_state.session.is_agent_mode or left_state.session.is_model_mode
842
+ ):
843
+ self._send_to_pane(client, self._left_store, user_message)
844
+
845
+ # Send to right pane
846
+ right_state = self._right_store.state
847
+ if right_state.session and (
848
+ right_state.session.is_agent_mode or right_state.session.is_model_mode
849
+ ):
850
+ self._send_to_pane(client, self._right_store, user_message)
851
+
852
+ @work(exclusive=False, group="pane_send")
853
+ async def _send_to_pane(
854
+ self, client: ApiClient, store: ChatStore, user_message: str
855
+ ) -> None:
856
+ """Send a message to a specific pane's session."""
857
+ state = store.state
858
+ session = state.session
859
+
860
+ if not session:
861
+ return
862
+
863
+ command = SendMessageCommand(
864
+ client=client,
865
+ store=store,
866
+ session_id=session.session_id,
867
+ user_message=user_message,
868
+ on_chunk=lambda c: store.append_streaming_content(c),
869
+ on_thinking_chunk=lambda c: store.append_streaming_thinking_content(c),
870
+ agent_id=session.agent_id,
871
+ provider_key=session.provider_key,
872
+ provider_model_name=session.model_name,
873
+ system_instruction=session.system_instruction,
874
+ )
875
+
876
+ result = await command.execute()
877
+
878
+ if result.success:
879
+ # Reload session
880
+ reload_cmd = ReloadSessionCommand(
881
+ client=client,
882
+ store=store,
883
+ session_id=session.session_id,
884
+ )
885
+ await reload_cmd.execute()
886
+
887
+ @work(exclusive=False, group="name_generation")
888
+ async def _maybe_generate_session_name(self, first_message: str) -> None:
889
+ """Generate a session name if this is the first message and auto-naming is enabled.
890
+
891
+ This runs in a separate worker group so it doesn't block the main chat flow.
892
+
893
+ Args:
894
+ first_message: The user's first message in the session.
895
+ """
896
+ logger.info(
897
+ "session_auto_naming_worker_started",
898
+ first_message_preview=first_message[:50] if first_message else None,
899
+ )
900
+
901
+ # Re-read state fresh (worker runs async, state may have changed)
902
+ state = self._store.state
903
+ session = state.session
904
+
905
+ if not session:
906
+ logger.info("session_auto_naming_skipped_no_session")
907
+ return
908
+
909
+ logger.info(
910
+ "session_auto_naming_session_found",
911
+ session_id=str(session.session_id),
912
+ session_name=session.session_name,
913
+ provider_key=session.provider_key,
914
+ model_name=session.model_name,
915
+ agent_id=str(session.agent_id) if session.agent_id else None,
916
+ )
917
+
918
+ # Skip if session already has a name
919
+ if session.session_name:
920
+ logger.info(
921
+ "session_auto_naming_skipped_has_name",
922
+ session_name=session.session_name,
923
+ )
924
+ return
925
+
926
+ # Check if this is the first exchange (should have exactly 1 user and 1 assistant message)
927
+ # Count by role to handle cases where there might be system messages or other roles
928
+ user_messages = sum(1 for m in state.messages if m.role == "user")
929
+ assistant_messages = sum(1 for m in state.messages if m.role == "assistant")
930
+ logger.info(
931
+ "session_auto_naming_message_count",
932
+ total_messages=len(state.messages),
933
+ user_messages=user_messages,
934
+ assistant_messages=assistant_messages,
935
+ session_id=str(session.session_id),
936
+ )
937
+ if user_messages != 1 or assistant_messages != 1:
938
+ logger.info(
939
+ "session_auto_naming_skipped_not_first_exchange",
940
+ user_messages=user_messages,
941
+ assistant_messages=assistant_messages,
942
+ )
943
+ return
944
+
945
+ # Check if auto-naming is enabled (default: True)
946
+ storage = get_storage()
947
+ auto_naming_enabled = storage.get("preferences", PREF_AUTO_NAME_SESSIONS, True)
948
+ logger.info(
949
+ "session_auto_naming_preference_check",
950
+ auto_naming_enabled=auto_naming_enabled,
951
+ )
952
+ if not auto_naming_enabled:
953
+ logger.info("session_auto_naming_skipped_disabled_in_preferences")
954
+ return
955
+
956
+ logger.info(
957
+ "session_auto_naming_calling_generator",
958
+ session_id=str(session.session_id),
959
+ first_message_length=len(first_message),
960
+ )
961
+
962
+ try:
963
+ client = await self._get_client()
964
+ generator = SessionNameGenerator()
965
+ generated_name = await generator.generate_name(
966
+ client,
967
+ first_message,
968
+ session_provider_key=session.provider_key,
969
+ session_model_name=session.model_name,
970
+ )
971
+
972
+ logger.info(
973
+ "session_auto_naming_generator_returned",
974
+ generated_name=generated_name,
975
+ session_id=str(session.session_id),
976
+ )
977
+
978
+ if generated_name:
979
+ # Update session on server
980
+ logger.info(
981
+ "session_auto_naming_updating_server",
982
+ session_id=str(session.session_id),
983
+ generated_name=generated_name,
984
+ )
985
+ await client.update_session(
986
+ session.session_id,
987
+ UpdateSessionRequest(name=generated_name),
988
+ )
989
+
990
+ # Re-read current state to get latest message count
991
+ current_state = self._store.state
992
+
993
+ # Update local store with new name
994
+ self._store.set_session(
995
+ session_id=session.session_id,
996
+ session_name=generated_name,
997
+ agent_id=session.agent_id,
998
+ agent_name=session.agent_name,
999
+ message_count=len(current_state.messages),
1000
+ provider_key=session.provider_key,
1001
+ model_name=session.model_name,
1002
+ system_instruction=session.system_instruction,
1003
+ )
1004
+
1005
+ # Refresh sidebar to show new name
1006
+ sidebar = self.query_one(SessionSidebar)
1007
+ sidebar.refresh_sessions()
1008
+
1009
+ # Notify user that session was named
1010
+ self.app.notify(
1011
+ f"Session named: {generated_name}", severity="information"
1012
+ )
1013
+
1014
+ logger.info(
1015
+ "session_auto_naming_completed",
1016
+ session_id=str(session.session_id),
1017
+ generated_name=generated_name,
1018
+ )
1019
+ else:
1020
+ logger.warning(
1021
+ "session_auto_naming_no_name_generated",
1022
+ session_id=str(session.session_id),
1023
+ )
1024
+
1025
+ except Exception as e:
1026
+ # Log error but don't show to user - auto-naming is a nice-to-have feature
1027
+ logger.warning(
1028
+ "session_auto_naming_failed",
1029
+ session_id=str(session.session_id),
1030
+ error=str(e),
1031
+ error_type=type(e).__name__,
1032
+ )
1033
+
1034
+ @work(exclusive=True)
1035
+ async def _execute_regenerate(self) -> None:
1036
+ """Execute the regenerate command."""
1037
+ state = self._store.state
1038
+ session = state.session
1039
+
1040
+ if not session:
1041
+ return
1042
+
1043
+ # Check that we have either agent or model configured
1044
+ if not session.is_agent_mode and not session.is_model_mode:
1045
+ return
1046
+
1047
+ client = await self._get_client()
1048
+
1049
+ command = RegenerateCommand(
1050
+ client=client,
1051
+ store=self._store,
1052
+ session_id=session.session_id,
1053
+ on_chunk=lambda c: self._store.append_streaming_content(c),
1054
+ on_thinking_chunk=lambda c: self._store.append_streaming_thinking_content(
1055
+ c
1056
+ ),
1057
+ # Agent mode
1058
+ agent_id=session.agent_id,
1059
+ # Model mode
1060
+ provider_key=session.provider_key,
1061
+ provider_model_name=session.model_name,
1062
+ system_instruction=session.system_instruction,
1063
+ )
1064
+
1065
+ result = await command.execute()
1066
+
1067
+ if result.success:
1068
+ await self._reload_current_session()
1069
+ self.app.notify("Response regenerated", severity="information")
1070
+ else:
1071
+ self.app.notify(f"Regenerate failed: {result.error}", severity="error")
1072
+
1073
+ @work(exclusive=True)
1074
+ async def _execute_undo(self) -> None:
1075
+ """Execute the undo command."""
1076
+ state = self._store.state
1077
+ messages = state.messages
1078
+
1079
+ if len(messages) < 2:
1080
+ return
1081
+
1082
+ client = await self._get_client()
1083
+
1084
+ command = UndoCommand(
1085
+ client=client,
1086
+ user_message=messages[-2],
1087
+ assistant_message=messages[-1],
1088
+ )
1089
+
1090
+ result = await command.execute()
1091
+
1092
+ if result.success and result.data:
1093
+ await self._reload_current_session()
1094
+ self.app.notify(
1095
+ f"Undone: {result.data.user_preview}", severity="information"
1096
+ )
1097
+ else:
1098
+ self.app.notify(f"Undo failed: {result.error}", severity="error")
1099
+
1100
+ async def _reload_current_session(self) -> None:
1101
+ """Reload the current session messages."""
1102
+ state = self._store.state
1103
+ if not state.session:
1104
+ return
1105
+
1106
+ client = await self._get_client()
1107
+
1108
+ command = ReloadSessionCommand(
1109
+ client=client,
1110
+ store=self._store,
1111
+ session_id=state.session.session_id,
1112
+ )
1113
+
1114
+ # Silently execute - we already showed the response
1115
+ await command.execute()
1116
+
1117
+ @work(exclusive=True)
1118
+ async def _execute_copy_last(self) -> None:
1119
+ """Fetch the last assistant message from server and copy to clipboard.
1120
+
1121
+ Fetches from server to ensure we get post-processed content
1122
+ (e.g., after output schema regex processing).
1123
+ """
1124
+ state = self._store.state
1125
+ if not state.session:
1126
+ return
1127
+
1128
+ client = await self._get_client()
1129
+
1130
+ try:
1131
+ # Fetch last assistant message from server with role filter
1132
+ response = await client.list_session_messages(
1133
+ session_id=state.session.session_id,
1134
+ role="assistant",
1135
+ limit=1,
1136
+ order="desc",
1137
+ )
1138
+
1139
+ if not response.messages:
1140
+ self.app.notify("No assistant message to copy", severity="warning")
1141
+ return
1142
+
1143
+ # Extract text from content parts (excludes thinking content)
1144
+ message = response.messages[0]
1145
+ text = extract_message_text(message)
1146
+
1147
+ if not text:
1148
+ self.app.notify("Assistant message is empty", severity="warning")
1149
+ return
1150
+
1151
+ # Copy to clipboard
1152
+ copy_to_clipboard(self.app, text, "Last response")
1153
+
1154
+ except Exception as e:
1155
+ self.app.notify(f"Failed to copy: {e}", severity="error")