codex-autorunner 0.1.2__py3-none-any.whl → 1.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 (276) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/__main__.py +4 -0
  3. codex_autorunner/agents/codex/harness.py +1 -1
  4. codex_autorunner/agents/opencode/client.py +68 -35
  5. codex_autorunner/agents/opencode/constants.py +3 -0
  6. codex_autorunner/agents/opencode/harness.py +6 -1
  7. codex_autorunner/agents/opencode/logging.py +21 -5
  8. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  9. codex_autorunner/agents/opencode/runtime.py +176 -47
  10. codex_autorunner/agents/opencode/supervisor.py +36 -48
  11. codex_autorunner/agents/registry.py +155 -8
  12. codex_autorunner/api.py +25 -0
  13. codex_autorunner/bootstrap.py +22 -37
  14. codex_autorunner/cli.py +5 -1156
  15. codex_autorunner/codex_cli.py +20 -84
  16. codex_autorunner/core/__init__.py +4 -0
  17. codex_autorunner/core/about_car.py +49 -32
  18. codex_autorunner/core/adapter_utils.py +21 -0
  19. codex_autorunner/core/app_server_ids.py +59 -0
  20. codex_autorunner/core/app_server_logging.py +7 -3
  21. codex_autorunner/core/app_server_prompts.py +27 -260
  22. codex_autorunner/core/app_server_threads.py +26 -28
  23. codex_autorunner/core/app_server_utils.py +165 -0
  24. codex_autorunner/core/archive.py +349 -0
  25. codex_autorunner/core/codex_runner.py +12 -2
  26. codex_autorunner/core/config.py +587 -103
  27. codex_autorunner/core/docs.py +10 -2
  28. codex_autorunner/core/drafts.py +136 -0
  29. codex_autorunner/core/engine.py +1531 -866
  30. codex_autorunner/core/exceptions.py +4 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +202 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +88 -0
  35. codex_autorunner/core/flows/reasons.py +52 -0
  36. codex_autorunner/core/flows/reconciler.py +131 -0
  37. codex_autorunner/core/flows/runtime.py +382 -0
  38. codex_autorunner/core/flows/store.py +568 -0
  39. codex_autorunner/core/flows/transition.py +138 -0
  40. codex_autorunner/core/flows/ux_helpers.py +257 -0
  41. codex_autorunner/core/flows/worker_process.py +242 -0
  42. codex_autorunner/core/git_utils.py +62 -0
  43. codex_autorunner/core/hub.py +136 -16
  44. codex_autorunner/core/locks.py +4 -0
  45. codex_autorunner/core/notifications.py +14 -2
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/core/ports/agent_backend.py +150 -0
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/core/ports/run_event.py +91 -0
  50. codex_autorunner/core/prompt.py +15 -7
  51. codex_autorunner/core/redaction.py +29 -0
  52. codex_autorunner/core/review_context.py +5 -8
  53. codex_autorunner/core/run_index.py +6 -0
  54. codex_autorunner/core/runner_process.py +5 -2
  55. codex_autorunner/core/state.py +0 -88
  56. codex_autorunner/core/state_roots.py +57 -0
  57. codex_autorunner/core/supervisor_protocol.py +15 -0
  58. codex_autorunner/core/supervisor_utils.py +67 -0
  59. codex_autorunner/core/text_delta_coalescer.py +54 -0
  60. codex_autorunner/core/ticket_linter_cli.py +201 -0
  61. codex_autorunner/core/ticket_manager_cli.py +432 -0
  62. codex_autorunner/core/update.py +24 -16
  63. codex_autorunner/core/update_paths.py +28 -0
  64. codex_autorunner/core/update_runner.py +2 -0
  65. codex_autorunner/core/usage.py +164 -12
  66. codex_autorunner/core/utils.py +120 -11
  67. codex_autorunner/discovery.py +2 -4
  68. codex_autorunner/flows/review/__init__.py +17 -0
  69. codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
  70. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  71. codex_autorunner/flows/ticket_flow/definition.py +98 -0
  72. codex_autorunner/integrations/agents/__init__.py +17 -0
  73. codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
  74. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  75. codex_autorunner/integrations/agents/codex_backend.py +448 -0
  76. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  77. codex_autorunner/integrations/agents/opencode_backend.py +598 -0
  78. codex_autorunner/integrations/agents/runner.py +91 -0
  79. codex_autorunner/integrations/agents/wiring.py +271 -0
  80. codex_autorunner/integrations/app_server/client.py +583 -152
  81. codex_autorunner/integrations/app_server/env.py +2 -107
  82. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  83. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  84. codex_autorunner/integrations/telegram/adapter.py +204 -165
  85. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  86. codex_autorunner/integrations/telegram/config.py +221 -0
  87. codex_autorunner/integrations/telegram/constants.py +17 -2
  88. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  89. codex_autorunner/integrations/telegram/doctor.py +47 -0
  90. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
  91. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  92. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  93. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  94. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
  95. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  96. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  97. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  98. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
  99. codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
  100. codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
  101. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  102. codex_autorunner/integrations/telegram/helpers.py +111 -16
  103. codex_autorunner/integrations/telegram/outbox.py +208 -37
  104. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  105. codex_autorunner/integrations/telegram/service.py +221 -42
  106. codex_autorunner/integrations/telegram/state.py +100 -2
  107. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
  108. codex_autorunner/integrations/telegram/transport.py +39 -4
  109. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  110. codex_autorunner/manifest.py +2 -0
  111. codex_autorunner/plugin_api.py +22 -0
  112. codex_autorunner/routes/__init__.py +37 -67
  113. codex_autorunner/routes/agents.py +2 -137
  114. codex_autorunner/routes/analytics.py +3 -0
  115. codex_autorunner/routes/app_server.py +2 -131
  116. codex_autorunner/routes/base.py +2 -624
  117. codex_autorunner/routes/file_chat.py +7 -0
  118. codex_autorunner/routes/flows.py +7 -0
  119. codex_autorunner/routes/messages.py +7 -0
  120. codex_autorunner/routes/repos.py +2 -196
  121. codex_autorunner/routes/review.py +2 -147
  122. codex_autorunner/routes/sessions.py +2 -175
  123. codex_autorunner/routes/settings.py +2 -168
  124. codex_autorunner/routes/shared.py +2 -275
  125. codex_autorunner/routes/system.py +4 -188
  126. codex_autorunner/routes/usage.py +3 -0
  127. codex_autorunner/routes/voice.py +2 -119
  128. codex_autorunner/routes/workspace.py +3 -0
  129. codex_autorunner/server.py +3 -2
  130. codex_autorunner/static/agentControls.js +41 -11
  131. codex_autorunner/static/agentEvents.js +248 -0
  132. codex_autorunner/static/app.js +35 -24
  133. codex_autorunner/static/archive.js +826 -0
  134. codex_autorunner/static/archiveApi.js +37 -0
  135. codex_autorunner/static/autoRefresh.js +36 -8
  136. codex_autorunner/static/bootstrap.js +1 -0
  137. codex_autorunner/static/bus.js +1 -0
  138. codex_autorunner/static/cache.js +1 -0
  139. codex_autorunner/static/constants.js +20 -4
  140. codex_autorunner/static/dashboard.js +344 -325
  141. codex_autorunner/static/diffRenderer.js +37 -0
  142. codex_autorunner/static/docChatCore.js +324 -0
  143. codex_autorunner/static/docChatStorage.js +65 -0
  144. codex_autorunner/static/docChatVoice.js +65 -0
  145. codex_autorunner/static/docEditor.js +133 -0
  146. codex_autorunner/static/env.js +1 -0
  147. codex_autorunner/static/eventSummarizer.js +166 -0
  148. codex_autorunner/static/fileChat.js +182 -0
  149. codex_autorunner/static/health.js +155 -0
  150. codex_autorunner/static/hub.js +126 -185
  151. codex_autorunner/static/index.html +839 -863
  152. codex_autorunner/static/liveUpdates.js +1 -0
  153. codex_autorunner/static/loader.js +1 -0
  154. codex_autorunner/static/messages.js +873 -0
  155. codex_autorunner/static/mobileCompact.js +2 -1
  156. codex_autorunner/static/preserve.js +17 -0
  157. codex_autorunner/static/settings.js +149 -217
  158. codex_autorunner/static/smartRefresh.js +52 -0
  159. codex_autorunner/static/styles.css +8850 -3876
  160. codex_autorunner/static/tabs.js +175 -11
  161. codex_autorunner/static/terminal.js +32 -0
  162. codex_autorunner/static/terminalManager.js +34 -59
  163. codex_autorunner/static/ticketChatActions.js +333 -0
  164. codex_autorunner/static/ticketChatEvents.js +16 -0
  165. codex_autorunner/static/ticketChatStorage.js +16 -0
  166. codex_autorunner/static/ticketChatStream.js +264 -0
  167. codex_autorunner/static/ticketEditor.js +844 -0
  168. codex_autorunner/static/ticketVoice.js +9 -0
  169. codex_autorunner/static/tickets.js +1988 -0
  170. codex_autorunner/static/utils.js +43 -3
  171. codex_autorunner/static/voice.js +1 -0
  172. codex_autorunner/static/workspace.js +765 -0
  173. codex_autorunner/static/workspaceApi.js +53 -0
  174. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  175. codex_autorunner/surfaces/__init__.py +5 -0
  176. codex_autorunner/surfaces/cli/__init__.py +6 -0
  177. codex_autorunner/surfaces/cli/cli.py +1224 -0
  178. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  179. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  180. codex_autorunner/surfaces/web/__init__.py +1 -0
  181. codex_autorunner/surfaces/web/app.py +2019 -0
  182. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  183. codex_autorunner/surfaces/web/middleware.py +587 -0
  184. codex_autorunner/surfaces/web/pty_session.py +370 -0
  185. codex_autorunner/surfaces/web/review.py +6 -0
  186. codex_autorunner/surfaces/web/routes/__init__.py +78 -0
  187. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  188. codex_autorunner/surfaces/web/routes/analytics.py +277 -0
  189. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  190. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  191. codex_autorunner/surfaces/web/routes/base.py +615 -0
  192. codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
  193. codex_autorunner/surfaces/web/routes/flows.py +1164 -0
  194. codex_autorunner/surfaces/web/routes/messages.py +459 -0
  195. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  196. codex_autorunner/surfaces/web/routes/review.py +148 -0
  197. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  198. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  199. codex_autorunner/surfaces/web/routes/shared.py +280 -0
  200. codex_autorunner/surfaces/web/routes/system.py +196 -0
  201. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  202. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  203. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  204. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  205. codex_autorunner/surfaces/web/schemas.py +417 -0
  206. codex_autorunner/surfaces/web/static_assets.py +490 -0
  207. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  208. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  209. codex_autorunner/tickets/__init__.py +27 -0
  210. codex_autorunner/tickets/agent_pool.py +399 -0
  211. codex_autorunner/tickets/files.py +89 -0
  212. codex_autorunner/tickets/frontmatter.py +55 -0
  213. codex_autorunner/tickets/lint.py +102 -0
  214. codex_autorunner/tickets/models.py +97 -0
  215. codex_autorunner/tickets/outbox.py +244 -0
  216. codex_autorunner/tickets/replies.py +179 -0
  217. codex_autorunner/tickets/runner.py +881 -0
  218. codex_autorunner/tickets/spec_ingest.py +77 -0
  219. codex_autorunner/web/__init__.py +5 -1
  220. codex_autorunner/web/app.py +2 -1771
  221. codex_autorunner/web/hub_jobs.py +2 -191
  222. codex_autorunner/web/middleware.py +2 -587
  223. codex_autorunner/web/pty_session.py +2 -369
  224. codex_autorunner/web/runner_manager.py +2 -24
  225. codex_autorunner/web/schemas.py +2 -396
  226. codex_autorunner/web/static_assets.py +4 -484
  227. codex_autorunner/web/static_refresh.py +2 -85
  228. codex_autorunner/web/terminal_sessions.py +2 -77
  229. codex_autorunner/workspace/__init__.py +40 -0
  230. codex_autorunner/workspace/paths.py +335 -0
  231. codex_autorunner-1.1.0.dist-info/METADATA +154 -0
  232. codex_autorunner-1.1.0.dist-info/RECORD +308 -0
  233. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
  234. codex_autorunner/agents/execution/policy.py +0 -292
  235. codex_autorunner/agents/factory.py +0 -52
  236. codex_autorunner/agents/orchestrator.py +0 -358
  237. codex_autorunner/core/doc_chat.py +0 -1446
  238. codex_autorunner/core/snapshot.py +0 -580
  239. codex_autorunner/integrations/github/chatops.py +0 -268
  240. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  241. codex_autorunner/routes/docs.py +0 -381
  242. codex_autorunner/routes/github.py +0 -327
  243. codex_autorunner/routes/runs.py +0 -250
  244. codex_autorunner/spec_ingest.py +0 -812
  245. codex_autorunner/static/docChatActions.js +0 -287
  246. codex_autorunner/static/docChatEvents.js +0 -300
  247. codex_autorunner/static/docChatRender.js +0 -205
  248. codex_autorunner/static/docChatStream.js +0 -361
  249. codex_autorunner/static/docs.js +0 -20
  250. codex_autorunner/static/docsClipboard.js +0 -69
  251. codex_autorunner/static/docsCrud.js +0 -257
  252. codex_autorunner/static/docsDocUpdates.js +0 -62
  253. codex_autorunner/static/docsDrafts.js +0 -16
  254. codex_autorunner/static/docsElements.js +0 -69
  255. codex_autorunner/static/docsInit.js +0 -285
  256. codex_autorunner/static/docsParse.js +0 -160
  257. codex_autorunner/static/docsSnapshot.js +0 -87
  258. codex_autorunner/static/docsSpecIngest.js +0 -263
  259. codex_autorunner/static/docsState.js +0 -127
  260. codex_autorunner/static/docsThreadRegistry.js +0 -44
  261. codex_autorunner/static/docsUi.js +0 -153
  262. codex_autorunner/static/docsVoice.js +0 -56
  263. codex_autorunner/static/github.js +0 -504
  264. codex_autorunner/static/logs.js +0 -678
  265. codex_autorunner/static/review.js +0 -157
  266. codex_autorunner/static/runs.js +0 -418
  267. codex_autorunner/static/snapshot.js +0 -124
  268. codex_autorunner/static/state.js +0 -94
  269. codex_autorunner/static/todoPreview.js +0 -27
  270. codex_autorunner/workspace.py +0 -16
  271. codex_autorunner-0.1.2.dist-info/METADATA +0 -249
  272. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  273. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  274. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
  275. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
  276. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,844 @@
1
+ // GENERATED FILE - do not edit directly. Source: static_src/
2
+ /**
3
+ * Ticket Editor Modal - handles creating, editing, and deleting tickets
4
+ */
5
+ import { api, flash, updateUrlParams, splitMarkdownFrontmatter } from "./utils.js";
6
+ import { publish } from "./bus.js";
7
+ import { clearTicketChatHistory } from "./ticketChatStorage.js";
8
+ import { setTicketIndex, sendTicketChat, cancelTicketChat, applyTicketPatch, discardTicketPatch, loadTicketPending, renderTicketChat, resetTicketChatState, ticketChatState, } from "./ticketChatActions.js";
9
+ import { initAgentControls } from "./agentControls.js";
10
+ import { initTicketVoice } from "./ticketVoice.js";
11
+ import { initTicketChatEvents, renderTicketEvents, renderTicketMessages } from "./ticketChatEvents.js";
12
+ import { DocEditor } from "./docEditor.js";
13
+ const DEFAULT_FRONTMATTER = {
14
+ agent: "codex",
15
+ done: false,
16
+ title: "",
17
+ model: "",
18
+ reasoning: "",
19
+ };
20
+ const state = {
21
+ isOpen: false,
22
+ mode: "create",
23
+ ticketIndex: null,
24
+ originalBody: "",
25
+ originalFrontmatter: { ...DEFAULT_FRONTMATTER },
26
+ undoStack: [],
27
+ lastSavedBody: "",
28
+ lastSavedFrontmatter: { ...DEFAULT_FRONTMATTER },
29
+ };
30
+ // Autosave debounce timer
31
+ const AUTOSAVE_DELAY_MS = 1000;
32
+ let ticketDocEditor = null;
33
+ let ticketNavCache = [];
34
+ function isTypingTarget(target) {
35
+ if (!(target instanceof HTMLElement))
36
+ return false;
37
+ const tag = target.tagName;
38
+ return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || target.isContentEditable;
39
+ }
40
+ async function fetchTicketList() {
41
+ const data = (await api("/api/flows/ticket_flow/tickets"));
42
+ const list = (data?.tickets || []).filter((ticket) => typeof ticket.index === "number");
43
+ list.sort((a, b) => (a.index ?? 0) - (b.index ?? 0));
44
+ return list;
45
+ }
46
+ async function updateTicketNavButtons() {
47
+ const { prevBtn, nextBtn } = els();
48
+ if (!prevBtn || !nextBtn)
49
+ return;
50
+ if (state.mode !== "edit" || state.ticketIndex == null) {
51
+ prevBtn.disabled = true;
52
+ nextBtn.disabled = true;
53
+ return;
54
+ }
55
+ try {
56
+ const list = await fetchTicketList();
57
+ ticketNavCache = list;
58
+ }
59
+ catch {
60
+ // If fetch fails, fall back to the last known list.
61
+ }
62
+ const list = ticketNavCache;
63
+ if (!list.length) {
64
+ prevBtn.disabled = true;
65
+ nextBtn.disabled = true;
66
+ return;
67
+ }
68
+ const idx = list.findIndex((ticket) => ticket.index === state.ticketIndex);
69
+ const hasPrev = idx > 0;
70
+ const hasNext = idx >= 0 && idx < list.length - 1;
71
+ prevBtn.disabled = !hasPrev;
72
+ nextBtn.disabled = !hasNext;
73
+ }
74
+ async function navigateTicket(delta) {
75
+ if (state.mode !== "edit" || state.ticketIndex == null)
76
+ return;
77
+ await performAutosave();
78
+ let list = ticketNavCache;
79
+ if (!list.length) {
80
+ try {
81
+ list = await fetchTicketList();
82
+ ticketNavCache = list;
83
+ }
84
+ catch {
85
+ return;
86
+ }
87
+ }
88
+ const idx = list.findIndex((ticket) => ticket.index === state.ticketIndex);
89
+ const target = idx >= 0 ? list[idx + delta] : null;
90
+ if (target && target.index != null) {
91
+ openTicketEditor(target);
92
+ }
93
+ void updateTicketNavButtons();
94
+ }
95
+ function els() {
96
+ return {
97
+ modal: document.getElementById("ticket-editor-modal"),
98
+ content: document.getElementById("ticket-editor-content"),
99
+ error: document.getElementById("ticket-editor-error"),
100
+ deleteBtn: document.getElementById("ticket-editor-delete"),
101
+ closeBtn: document.getElementById("ticket-editor-close"),
102
+ newBtn: document.getElementById("ticket-new-btn"),
103
+ insertCheckboxBtn: document.getElementById("ticket-insert-checkbox"),
104
+ undoBtn: document.getElementById("ticket-undo-btn"),
105
+ prevBtn: document.getElementById("ticket-nav-prev"),
106
+ nextBtn: document.getElementById("ticket-nav-next"),
107
+ autosaveStatus: document.getElementById("ticket-autosave-status"),
108
+ // Frontmatter form elements
109
+ fmAgent: document.getElementById("ticket-fm-agent"),
110
+ fmModel: document.getElementById("ticket-fm-model"),
111
+ fmReasoning: document.getElementById("ticket-fm-reasoning"),
112
+ fmDone: document.getElementById("ticket-fm-done"),
113
+ fmTitle: document.getElementById("ticket-fm-title"),
114
+ // Chat elements
115
+ chatInput: document.getElementById("ticket-chat-input"),
116
+ chatSendBtn: document.getElementById("ticket-chat-send"),
117
+ chatVoiceBtn: document.getElementById("ticket-chat-voice"),
118
+ chatCancelBtn: document.getElementById("ticket-chat-cancel"),
119
+ chatStatus: document.getElementById("ticket-chat-status"),
120
+ patchApplyBtn: document.getElementById("ticket-patch-apply"),
121
+ patchDiscardBtn: document.getElementById("ticket-patch-discard"),
122
+ // Agent control selects (for chat)
123
+ agentSelect: document.getElementById("ticket-chat-agent-select"),
124
+ modelSelect: document.getElementById("ticket-chat-model-select"),
125
+ reasoningSelect: document.getElementById("ticket-chat-reasoning-select"),
126
+ };
127
+ }
128
+ /**
129
+ * Insert a checkbox at the current cursor position
130
+ */
131
+ function insertCheckbox() {
132
+ const { content } = els();
133
+ if (!content)
134
+ return;
135
+ const pos = content.selectionStart;
136
+ const text = content.value;
137
+ const insert = "- [ ] ";
138
+ // If at start of line or after newline, insert directly
139
+ // Otherwise, insert on a new line
140
+ const needsNewline = pos > 0 && text[pos - 1] !== "\n";
141
+ const toInsert = needsNewline ? "\n" + insert : insert;
142
+ content.value = text.slice(0, pos) + toInsert + text.slice(pos);
143
+ const newPos = pos + toInsert.length;
144
+ content.setSelectionRange(newPos, newPos);
145
+ content.focus();
146
+ }
147
+ function showError(message) {
148
+ const { error } = els();
149
+ if (!error)
150
+ return;
151
+ error.textContent = message;
152
+ error.classList.remove("hidden");
153
+ }
154
+ function hideError() {
155
+ const { error } = els();
156
+ if (!error)
157
+ return;
158
+ error.textContent = "";
159
+ error.classList.add("hidden");
160
+ }
161
+ function setButtonsLoading(loading) {
162
+ const { deleteBtn, closeBtn, undoBtn } = els();
163
+ [deleteBtn, closeBtn, undoBtn].forEach((btn) => {
164
+ if (btn)
165
+ btn.disabled = loading;
166
+ });
167
+ }
168
+ /**
169
+ * Update the autosave status indicator
170
+ */
171
+ function setAutosaveStatus(status) {
172
+ const { autosaveStatus } = els();
173
+ if (!autosaveStatus)
174
+ return;
175
+ switch (status) {
176
+ case "saving":
177
+ autosaveStatus.textContent = "Saving…";
178
+ autosaveStatus.classList.remove("error");
179
+ break;
180
+ case "saved":
181
+ autosaveStatus.textContent = "Saved";
182
+ autosaveStatus.classList.remove("error");
183
+ // Clear after a short delay
184
+ setTimeout(() => {
185
+ if (autosaveStatus.textContent === "Saved") {
186
+ autosaveStatus.textContent = "";
187
+ }
188
+ }, 2000);
189
+ break;
190
+ case "error":
191
+ autosaveStatus.textContent = "Save failed";
192
+ autosaveStatus.classList.add("error");
193
+ break;
194
+ default:
195
+ autosaveStatus.textContent = "";
196
+ autosaveStatus.classList.remove("error");
197
+ }
198
+ }
199
+ /**
200
+ * Push current state to undo stack
201
+ */
202
+ function pushUndoState() {
203
+ const { content, undoBtn } = els();
204
+ const fm = getFrontmatterFromForm();
205
+ const body = content?.value || "";
206
+ // Don't push if same as last undo state
207
+ const last = state.undoStack[state.undoStack.length - 1];
208
+ if (last && last.body === body &&
209
+ last.frontmatter.agent === fm.agent &&
210
+ last.frontmatter.done === fm.done &&
211
+ last.frontmatter.title === fm.title &&
212
+ last.frontmatter.model === fm.model &&
213
+ last.frontmatter.reasoning === fm.reasoning) {
214
+ return;
215
+ }
216
+ state.undoStack.push({ body, frontmatter: { ...fm } });
217
+ // Limit stack size
218
+ if (state.undoStack.length > 50) {
219
+ state.undoStack.shift();
220
+ }
221
+ // Enable undo button
222
+ if (undoBtn)
223
+ undoBtn.disabled = state.undoStack.length <= 1;
224
+ }
225
+ /**
226
+ * Undo to previous state
227
+ */
228
+ function undoChange() {
229
+ const { content, undoBtn } = els();
230
+ if (!content || state.undoStack.length <= 1)
231
+ return;
232
+ // Pop current state
233
+ state.undoStack.pop();
234
+ // Get previous state
235
+ const prev = state.undoStack[state.undoStack.length - 1];
236
+ if (!prev)
237
+ return;
238
+ // Restore state
239
+ content.value = prev.body;
240
+ setFrontmatterForm(prev.frontmatter);
241
+ // Trigger autosave for the restored state
242
+ scheduleAutosave();
243
+ // Update undo button
244
+ if (undoBtn)
245
+ undoBtn.disabled = state.undoStack.length <= 1;
246
+ }
247
+ /**
248
+ * Update undo button state
249
+ */
250
+ function updateUndoButton() {
251
+ const { undoBtn } = els();
252
+ if (undoBtn) {
253
+ undoBtn.disabled = state.undoStack.length <= 1;
254
+ }
255
+ }
256
+ /**
257
+ * Get current frontmatter values from form fields
258
+ */
259
+ function getFrontmatterFromForm() {
260
+ const { fmAgent, fmModel, fmReasoning, fmDone, fmTitle } = els();
261
+ return {
262
+ agent: fmAgent?.value || "codex",
263
+ done: fmDone?.checked || false,
264
+ title: fmTitle?.value || "",
265
+ model: fmModel?.value || "",
266
+ reasoning: fmReasoning?.value || "",
267
+ };
268
+ }
269
+ /**
270
+ * Set frontmatter form fields from values
271
+ */
272
+ function setFrontmatterForm(fm) {
273
+ const { fmAgent, fmModel, fmReasoning, fmDone, fmTitle } = els();
274
+ if (fmAgent)
275
+ fmAgent.value = fm.agent;
276
+ if (fmModel)
277
+ fmModel.value = fm.model;
278
+ if (fmReasoning)
279
+ fmReasoning.value = fm.reasoning;
280
+ if (fmDone)
281
+ fmDone.checked = fm.done;
282
+ if (fmTitle)
283
+ fmTitle.value = fm.title;
284
+ }
285
+ /**
286
+ * Extract frontmatter state from ticket data
287
+ */
288
+ function extractFrontmatter(ticket) {
289
+ const fm = ticket.frontmatter || {};
290
+ return {
291
+ agent: fm.agent || "codex",
292
+ done: Boolean(fm.done),
293
+ title: fm.title || "",
294
+ model: fm.model || "",
295
+ reasoning: fm.reasoning || "",
296
+ };
297
+ }
298
+ /**
299
+ * Build full markdown content from frontmatter form + body textarea
300
+ */
301
+ function buildTicketContent() {
302
+ const { content } = els();
303
+ const fm = getFrontmatterFromForm();
304
+ const body = content?.value || "";
305
+ // Reconstruct frontmatter YAML
306
+ const lines = ["---"];
307
+ lines.push(`agent: ${fm.agent}`);
308
+ lines.push(`done: ${fm.done}`);
309
+ if (fm.title)
310
+ lines.push(`title: ${fm.title}`);
311
+ if (fm.model)
312
+ lines.push(`model: ${fm.model}`);
313
+ if (fm.reasoning)
314
+ lines.push(`reasoning: ${fm.reasoning}`);
315
+ lines.push("---");
316
+ lines.push("");
317
+ lines.push(body);
318
+ return lines.join("\n");
319
+ }
320
+ // Model catalog cache for frontmatter selects
321
+ const fmModelCatalogs = new Map();
322
+ /**
323
+ * Load and populate the frontmatter model/reasoning selects based on the selected agent
324
+ */
325
+ async function refreshFmModelOptions(agent, preserveSelection = false) {
326
+ const { fmModel, fmReasoning } = els();
327
+ if (!fmModel || !fmReasoning)
328
+ return;
329
+ const currentModel = preserveSelection ? fmModel.value : "";
330
+ const currentReasoning = preserveSelection ? fmReasoning.value : "";
331
+ // Fetch catalog if not cached
332
+ if (!fmModelCatalogs.has(agent)) {
333
+ try {
334
+ const data = await api(`/api/agents/${encodeURIComponent(agent)}/models`, { method: "GET" });
335
+ const models = Array.isArray(data?.models) ? data.models : [];
336
+ const catalog = {
337
+ default_model: data?.default_model || "",
338
+ models,
339
+ };
340
+ fmModelCatalogs.set(agent, catalog);
341
+ }
342
+ catch {
343
+ fmModelCatalogs.set(agent, null);
344
+ }
345
+ }
346
+ const catalog = fmModelCatalogs.get(agent);
347
+ // Populate model select
348
+ fmModel.innerHTML = "";
349
+ const defaultOption = document.createElement("option");
350
+ defaultOption.value = "";
351
+ defaultOption.textContent = "(default)";
352
+ fmModel.appendChild(defaultOption);
353
+ if (catalog?.models?.length) {
354
+ fmModel.disabled = false;
355
+ for (const m of catalog.models) {
356
+ const opt = document.createElement("option");
357
+ opt.value = m.id;
358
+ opt.textContent = m.display_name && m.display_name !== m.id ? `${m.display_name} (${m.id})` : m.id;
359
+ fmModel.appendChild(opt);
360
+ }
361
+ // Restore selection if valid
362
+ if (currentModel && catalog.models.some((m) => m.id === currentModel)) {
363
+ fmModel.value = currentModel;
364
+ }
365
+ }
366
+ else {
367
+ fmModel.disabled = true;
368
+ }
369
+ // Populate reasoning select based on selected model
370
+ refreshFmReasoningOptions(catalog, fmModel.value, currentReasoning);
371
+ }
372
+ /**
373
+ * Populate reasoning options based on selected model
374
+ */
375
+ function refreshFmReasoningOptions(catalog, modelId, currentReasoning = "") {
376
+ const { fmReasoning } = els();
377
+ if (!fmReasoning)
378
+ return;
379
+ fmReasoning.innerHTML = "";
380
+ const defaultOption = document.createElement("option");
381
+ defaultOption.value = "";
382
+ defaultOption.textContent = "(default)";
383
+ fmReasoning.appendChild(defaultOption);
384
+ const model = catalog?.models?.find((m) => m.id === modelId);
385
+ if (model?.supports_reasoning && model.reasoning_options?.length) {
386
+ fmReasoning.disabled = false;
387
+ for (const r of model.reasoning_options) {
388
+ const opt = document.createElement("option");
389
+ opt.value = r;
390
+ opt.textContent = r;
391
+ fmReasoning.appendChild(opt);
392
+ }
393
+ // Restore selection if valid
394
+ if (currentReasoning && model.reasoning_options.includes(currentReasoning)) {
395
+ fmReasoning.value = currentReasoning;
396
+ }
397
+ }
398
+ else {
399
+ fmReasoning.disabled = true;
400
+ }
401
+ }
402
+ /**
403
+ * Check if there are unsaved changes (compared to last saved state)
404
+ */
405
+ function hasUnsavedChanges() {
406
+ const { content } = els();
407
+ const currentFm = getFrontmatterFromForm();
408
+ const currentBody = content?.value || "";
409
+ return (currentBody !== state.lastSavedBody ||
410
+ currentFm.agent !== state.lastSavedFrontmatter.agent ||
411
+ currentFm.done !== state.lastSavedFrontmatter.done ||
412
+ currentFm.title !== state.lastSavedFrontmatter.title ||
413
+ currentFm.model !== state.lastSavedFrontmatter.model ||
414
+ currentFm.reasoning !== state.lastSavedFrontmatter.reasoning);
415
+ }
416
+ /**
417
+ * Schedule autosave with debounce
418
+ */
419
+ function scheduleAutosave() {
420
+ // DocEditor handles debounced autosave; leave for compatibility
421
+ void ticketDocEditor?.save();
422
+ }
423
+ /**
424
+ * Perform autosave (silent save without closing modal)
425
+ */
426
+ async function performAutosave() {
427
+ const { content } = els();
428
+ if (!content || !state.isOpen)
429
+ return;
430
+ // Don't autosave if no changes
431
+ if (!hasUnsavedChanges())
432
+ return;
433
+ const fm = getFrontmatterFromForm();
434
+ const fullContent = buildTicketContent();
435
+ // Validate required fields
436
+ if (!fm.agent)
437
+ return;
438
+ setAutosaveStatus("saving");
439
+ try {
440
+ if (state.mode === "create") {
441
+ // Create with form data
442
+ const createRes = await api("/api/flows/ticket_flow/tickets", {
443
+ method: "POST",
444
+ body: {
445
+ agent: fm.agent,
446
+ title: fm.title || undefined,
447
+ body: content.value,
448
+ },
449
+ });
450
+ if (createRes?.index != null) {
451
+ // Switch to edit mode now that ticket exists
452
+ state.mode = "edit";
453
+ state.ticketIndex = createRes.index;
454
+ // If done is true, update to set done flag
455
+ if (fm.done) {
456
+ await api(`/api/flows/ticket_flow/tickets/${createRes.index}`, {
457
+ method: "PUT",
458
+ body: { content: fullContent },
459
+ });
460
+ }
461
+ // Set up chat for this ticket
462
+ setTicketIndex(createRes.index);
463
+ }
464
+ }
465
+ else {
466
+ // Update existing
467
+ if (state.ticketIndex == null)
468
+ return;
469
+ await api(`/api/flows/ticket_flow/tickets/${state.ticketIndex}`, {
470
+ method: "PUT",
471
+ body: { content: fullContent },
472
+ });
473
+ }
474
+ // Update saved state
475
+ state.lastSavedBody = content.value;
476
+ state.lastSavedFrontmatter = { ...fm };
477
+ setAutosaveStatus("saved");
478
+ // Notify that tickets changed
479
+ publish("tickets:updated", {});
480
+ }
481
+ catch {
482
+ setAutosaveStatus("error");
483
+ }
484
+ }
485
+ /**
486
+ * Trigger change tracking and schedule autosave
487
+ */
488
+ function onContentChange() {
489
+ pushUndoState();
490
+ scheduleAutosave();
491
+ }
492
+ function onFrontmatterChange() {
493
+ pushUndoState();
494
+ void ticketDocEditor?.save(true);
495
+ }
496
+ /**
497
+ * Open the ticket editor modal
498
+ * @param ticket - If provided, opens in edit mode; otherwise creates new ticket
499
+ */
500
+ export function openTicketEditor(ticket) {
501
+ const { modal, content, deleteBtn, chatInput, fmTitle } = els();
502
+ if (!modal || !content)
503
+ return;
504
+ hideError();
505
+ setAutosaveStatus("");
506
+ if (ticket && ticket.index != null) {
507
+ // Edit mode
508
+ state.mode = "edit";
509
+ state.ticketIndex = ticket.index;
510
+ // Extract and set frontmatter
511
+ const fm = extractFrontmatter(ticket);
512
+ state.originalFrontmatter = { ...fm };
513
+ state.lastSavedFrontmatter = { ...fm };
514
+ setFrontmatterForm(fm);
515
+ // Load model/reasoning options for the agent, then restore selections
516
+ void refreshFmModelOptions(fm.agent, false).then(() => {
517
+ const { fmModel, fmReasoning } = els();
518
+ if (fmModel && fm.model)
519
+ fmModel.value = fm.model;
520
+ if (fmReasoning && fm.reasoning) {
521
+ // Refresh reasoning options based on selected model first
522
+ const catalog = fmModelCatalogs.get(fm.agent);
523
+ refreshFmReasoningOptions(catalog, fm.model, fm.reasoning);
524
+ }
525
+ });
526
+ // Set body (without frontmatter)
527
+ let body = ticket.body || "";
528
+ // If the body itself contains frontmatter, strip it if it's well-formed
529
+ const [fmYaml, strippedBody] = splitMarkdownFrontmatter(body);
530
+ if (fmYaml !== null) {
531
+ body = strippedBody.trimStart();
532
+ }
533
+ else if (body.startsWith("---")) {
534
+ // If it starts with --- but splitMarkdownFrontmatter returned null, it's malformed.
535
+ // We keep it in the body so the user can see/fix it.
536
+ flash("Malformed frontmatter detected in body", "error");
537
+ }
538
+ else {
539
+ // Ensure we don't accumulate whitespace from the backend's normalization
540
+ body = body.trimStart();
541
+ }
542
+ state.originalBody = body;
543
+ state.lastSavedBody = body;
544
+ content.value = body;
545
+ if (deleteBtn)
546
+ deleteBtn.classList.remove("hidden");
547
+ // Set up chat for this ticket
548
+ setTicketIndex(ticket.index);
549
+ // Load any pending draft
550
+ void loadTicketPending(ticket.index, true);
551
+ }
552
+ else {
553
+ // Create mode
554
+ state.mode = "create";
555
+ state.ticketIndex = null;
556
+ // Reset frontmatter to defaults
557
+ state.originalFrontmatter = { ...DEFAULT_FRONTMATTER };
558
+ state.lastSavedFrontmatter = { ...DEFAULT_FRONTMATTER };
559
+ setFrontmatterForm(DEFAULT_FRONTMATTER);
560
+ // Load model/reasoning options for the default agent
561
+ void refreshFmModelOptions(DEFAULT_FRONTMATTER.agent, false);
562
+ // Clear body
563
+ state.originalBody = "";
564
+ state.lastSavedBody = "";
565
+ content.value = "";
566
+ if (deleteBtn)
567
+ deleteBtn.classList.add("hidden");
568
+ // Clear chat state for new ticket
569
+ setTicketIndex(null);
570
+ }
571
+ // Initialize undo stack with current state
572
+ state.undoStack = [{ body: content.value, frontmatter: getFrontmatterFromForm() }];
573
+ updateUndoButton();
574
+ if (ticketDocEditor) {
575
+ ticketDocEditor.destroy();
576
+ }
577
+ ticketDocEditor = new DocEditor({
578
+ target: state.ticketIndex != null ? `ticket:${state.ticketIndex}` : "ticket:new",
579
+ textarea: content,
580
+ statusEl: els().autosaveStatus,
581
+ autoSaveDelay: AUTOSAVE_DELAY_MS,
582
+ onLoad: async () => content.value,
583
+ onSave: async () => {
584
+ await performAutosave();
585
+ },
586
+ });
587
+ // Clear chat input
588
+ if (chatInput)
589
+ chatInput.value = "";
590
+ renderTicketChat();
591
+ renderTicketEvents();
592
+ renderTicketMessages();
593
+ state.isOpen = true;
594
+ modal.classList.remove("hidden");
595
+ // Update URL with ticket index
596
+ if (ticket?.index != null) {
597
+ updateUrlParams({ ticket: ticket.index });
598
+ }
599
+ if (ticket?.path) {
600
+ publish("ticket-editor:opened", { path: ticket.path, index: ticket.index ?? null });
601
+ }
602
+ void updateTicketNavButtons();
603
+ // Focus on title field for new tickets
604
+ if (state.mode === "create" && fmTitle) {
605
+ fmTitle.focus();
606
+ }
607
+ }
608
+ /**
609
+ * Close the ticket editor modal (autosaves on close)
610
+ */
611
+ export function closeTicketEditor() {
612
+ const { modal } = els();
613
+ if (!modal)
614
+ return;
615
+ // Autosave on close if there are changes
616
+ if (hasUnsavedChanges()) {
617
+ void performAutosave();
618
+ }
619
+ // Cancel any running chat
620
+ if (ticketChatState.status === "running") {
621
+ void cancelTicketChat();
622
+ }
623
+ state.isOpen = false;
624
+ state.ticketIndex = null;
625
+ state.originalBody = "";
626
+ state.originalFrontmatter = { ...DEFAULT_FRONTMATTER };
627
+ state.lastSavedBody = "";
628
+ state.lastSavedFrontmatter = { ...DEFAULT_FRONTMATTER };
629
+ state.undoStack = [];
630
+ modal.classList.add("hidden");
631
+ hideError();
632
+ ticketDocEditor?.destroy();
633
+ ticketDocEditor = null;
634
+ // Clear ticket from URL
635
+ updateUrlParams({ ticket: null });
636
+ void updateTicketNavButtons();
637
+ // Reset chat state
638
+ resetTicketChatState();
639
+ setTicketIndex(null);
640
+ // Notify that editor was closed (for selection state cleanup)
641
+ publish("ticket-editor:closed", {});
642
+ }
643
+ /**
644
+ * Save the current ticket (triggers immediate autosave)
645
+ */
646
+ export async function saveTicket() {
647
+ await performAutosave();
648
+ }
649
+ /**
650
+ * Delete the current ticket (only available in edit mode)
651
+ */
652
+ export async function deleteTicket() {
653
+ if (state.mode !== "edit" || state.ticketIndex == null) {
654
+ flash("Cannot delete: no ticket selected", "error");
655
+ return;
656
+ }
657
+ const confirmed = window.confirm(`Delete TICKET-${String(state.ticketIndex).padStart(3, "0")}.md? This cannot be undone.`);
658
+ if (!confirmed)
659
+ return;
660
+ setButtonsLoading(true);
661
+ hideError();
662
+ try {
663
+ await api(`/api/flows/ticket_flow/tickets/${state.ticketIndex}`, {
664
+ method: "DELETE",
665
+ });
666
+ clearTicketChatHistory(state.ticketIndex);
667
+ flash("Ticket deleted");
668
+ // Close modal
669
+ state.isOpen = false;
670
+ state.originalBody = "";
671
+ state.originalFrontmatter = { ...DEFAULT_FRONTMATTER };
672
+ const { modal } = els();
673
+ if (modal)
674
+ modal.classList.add("hidden");
675
+ // Notify that tickets changed
676
+ publish("tickets:updated", {});
677
+ }
678
+ catch (err) {
679
+ showError(err.message || "Failed to delete ticket");
680
+ }
681
+ finally {
682
+ setButtonsLoading(false);
683
+ }
684
+ }
685
+ /**
686
+ * Initialize the ticket editor - wire up event listeners
687
+ */
688
+ export function initTicketEditor() {
689
+ const { modal, content, deleteBtn, closeBtn, newBtn, insertCheckboxBtn, undoBtn, prevBtn, nextBtn, fmAgent, fmModel, fmReasoning, fmDone, fmTitle, chatInput, chatSendBtn, chatCancelBtn, patchApplyBtn, patchDiscardBtn, agentSelect, modelSelect, reasoningSelect, } = els();
690
+ if (!modal)
691
+ return;
692
+ // Prevent double initialization
693
+ if (modal.dataset.editorInitialized === "1")
694
+ return;
695
+ modal.dataset.editorInitialized = "1";
696
+ // Initialize agent controls for ticket chat (populates agent/model/reasoning selects)
697
+ initAgentControls({
698
+ agentSelect,
699
+ modelSelect,
700
+ reasoningSelect,
701
+ });
702
+ // Initialize voice input for ticket chat
703
+ void initTicketVoice();
704
+ // Initialize rich chat experience (events toggle, etc.)
705
+ initTicketChatEvents();
706
+ // Button handlers
707
+ if (deleteBtn)
708
+ deleteBtn.addEventListener("click", () => void deleteTicket());
709
+ if (closeBtn)
710
+ closeBtn.addEventListener("click", closeTicketEditor);
711
+ if (newBtn)
712
+ newBtn.addEventListener("click", () => openTicketEditor());
713
+ if (insertCheckboxBtn)
714
+ insertCheckboxBtn.addEventListener("click", insertCheckbox);
715
+ if (undoBtn)
716
+ undoBtn.addEventListener("click", undoChange);
717
+ if (prevBtn)
718
+ prevBtn.addEventListener("click", (e) => {
719
+ e.preventDefault();
720
+ void navigateTicket(-1);
721
+ });
722
+ if (nextBtn)
723
+ nextBtn.addEventListener("click", (e) => {
724
+ e.preventDefault();
725
+ void navigateTicket(1);
726
+ });
727
+ // Autosave on content changes
728
+ if (content) {
729
+ content.addEventListener("input", onContentChange);
730
+ }
731
+ // Autosave on frontmatter changes
732
+ if (fmAgent) {
733
+ fmAgent.addEventListener("change", () => {
734
+ // Refresh model/reasoning options when agent changes
735
+ void refreshFmModelOptions(fmAgent.value, false);
736
+ onFrontmatterChange();
737
+ });
738
+ }
739
+ if (fmModel) {
740
+ fmModel.addEventListener("change", () => {
741
+ // Refresh reasoning options when model changes
742
+ const catalog = fmModelCatalogs.get(fmAgent?.value || "codex");
743
+ refreshFmReasoningOptions(catalog, fmModel.value, fmReasoning?.value || "");
744
+ onFrontmatterChange();
745
+ });
746
+ }
747
+ if (fmReasoning)
748
+ fmReasoning.addEventListener("change", onFrontmatterChange);
749
+ if (fmDone)
750
+ fmDone.addEventListener("change", onFrontmatterChange);
751
+ if (fmTitle)
752
+ fmTitle.addEventListener("input", onFrontmatterChange);
753
+ // Chat button handlers
754
+ if (chatSendBtn)
755
+ chatSendBtn.addEventListener("click", () => void sendTicketChat());
756
+ if (chatCancelBtn)
757
+ chatCancelBtn.addEventListener("click", () => void cancelTicketChat());
758
+ if (patchApplyBtn)
759
+ patchApplyBtn.addEventListener("click", () => void applyTicketPatch());
760
+ if (patchDiscardBtn)
761
+ patchDiscardBtn.addEventListener("click", () => void discardTicketPatch());
762
+ // Cmd/Ctrl+Enter in chat input sends message
763
+ if (chatInput) {
764
+ chatInput.addEventListener("keydown", (e) => {
765
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
766
+ e.preventDefault();
767
+ void sendTicketChat();
768
+ }
769
+ });
770
+ // Auto-resize textarea on input
771
+ chatInput.addEventListener("input", () => {
772
+ chatInput.style.height = "auto";
773
+ chatInput.style.height = Math.min(chatInput.scrollHeight, 100) + "px";
774
+ });
775
+ }
776
+ // Close on backdrop click
777
+ modal.addEventListener("click", (e) => {
778
+ if (e.target === modal) {
779
+ closeTicketEditor();
780
+ }
781
+ });
782
+ // Close on Escape key
783
+ document.addEventListener("keydown", (e) => {
784
+ if (e.key === "Escape" && state.isOpen) {
785
+ closeTicketEditor();
786
+ }
787
+ });
788
+ // Cmd/Ctrl+Z triggers undo
789
+ document.addEventListener("keydown", (e) => {
790
+ if (state.isOpen && (e.metaKey || e.ctrlKey) && e.key === "z" && !e.shiftKey) {
791
+ // Only handle if not in chat input
792
+ const active = document.activeElement;
793
+ if (active === chatInput)
794
+ return;
795
+ e.preventDefault();
796
+ undoChange();
797
+ }
798
+ });
799
+ // Left/Right arrows navigate between tickets when editor is open and not typing
800
+ document.addEventListener("keydown", (e) => {
801
+ if (!state.isOpen)
802
+ return;
803
+ // Check for navigation keys
804
+ if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
805
+ return;
806
+ // Don't interfere with typing
807
+ if (isTypingTarget(e.target))
808
+ return;
809
+ // Only allow Alt or no modifier (no Ctrl/Meta/Shift)
810
+ if (e.ctrlKey || e.metaKey || e.shiftKey)
811
+ return;
812
+ e.preventDefault();
813
+ void navigateTicket(e.key === "ArrowLeft" ? -1 : 1);
814
+ });
815
+ // Enter key creates new TODO checkbox when on a checkbox line
816
+ if (content) {
817
+ content.addEventListener("keydown", (e) => {
818
+ // Prevent manual frontmatter entry in the body
819
+ if (e.key === "-" && content.selectionStart === 2 && content.value.startsWith("--") && !content.value.includes("\n")) {
820
+ flash("Please use the frontmatter editor above", "error");
821
+ e.preventDefault();
822
+ return;
823
+ }
824
+ if (e.key === "Enter" && !e.isComposing && !e.shiftKey) {
825
+ const text = content.value;
826
+ const pos = content.selectionStart;
827
+ const lineStart = text.lastIndexOf("\n", pos - 1) + 1;
828
+ const lineEnd = text.indexOf("\n", pos);
829
+ const currentLine = text.slice(lineStart, lineEnd === -1 ? text.length : lineEnd);
830
+ const match = currentLine.match(/^(\s*)- \[(x|X| )?\]/);
831
+ if (match) {
832
+ e.preventDefault();
833
+ const indent = match[1];
834
+ const newLine = "\n" + indent + "- [ ] ";
835
+ const endOfCurrentLine = lineEnd === -1 ? text.length : lineEnd;
836
+ const newValue = text.slice(0, endOfCurrentLine) + newLine + text.slice(endOfCurrentLine);
837
+ content.value = newValue;
838
+ const newPos = endOfCurrentLine + newLine.length;
839
+ content.setSelectionRange(newPos, newPos);
840
+ }
841
+ }
842
+ });
843
+ }
844
+ }