@oar-sh/oar 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (293) hide show
  1. package/.github/extensions/web-relay/README.md +190 -0
  2. package/.github/extensions/web-relay/extension.mjs +972 -0
  3. package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
  4. package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
  5. package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
  6. package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
  7. package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
  8. package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
  9. package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
  10. package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
  11. package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
  12. package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
  13. package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
  14. package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
  15. package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
  16. package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
  17. package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
  18. package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
  19. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
  20. package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
  21. package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
  22. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
  23. package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
  24. package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
  25. package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
  26. package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
  27. package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
  28. package/CHANGELOG.md +25 -0
  29. package/LICENSE +21 -0
  30. package/README.md +717 -0
  31. package/bin/bat/oar.bat +46 -0
  32. package/bin/oar.js +665 -0
  33. package/package.json +68 -0
  34. package/server/README.md +1379 -0
  35. package/server/claude-worker/claude-attachments.mjs +134 -0
  36. package/server/claude-worker/claude-preview-tool.mjs +57 -0
  37. package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
  38. package/server/claude-worker/claude-session-process.mjs +2391 -0
  39. package/server/claude-worker/claude-session-test-harness.mjs +236 -0
  40. package/server/claude-worker/claude-session-worker.mjs +186 -0
  41. package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
  42. package/server/claude-worker/claude-turn-publisher.mjs +291 -0
  43. package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
  44. package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
  45. package/server/claude-worker/workflow-progress-digest.mjs +269 -0
  46. package/server/copilot-sdk-runtime.mjs +126 -0
  47. package/server/copilot-worker/copilot-attachments.mjs +97 -0
  48. package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
  49. package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
  50. package/server/copilot-worker/copilot-plan-board.mjs +110 -0
  51. package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
  52. package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
  53. package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
  54. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
  55. package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
  56. package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
  57. package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
  58. package/server/copilot-worker/fixtures/abort-turn.json +588 -0
  59. package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
  60. package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
  61. package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
  62. package/server/copilot-worker/fixtures/happy-turn.json +566 -0
  63. package/server/copilot-worker/fixtures/quota-turn.json +647 -0
  64. package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
  65. package/server/copilot-worker/fixtures/resume-turn.json +700 -0
  66. package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
  67. package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
  68. package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
  69. package/server/cursor-worker/cursor-attachments.mjs +88 -0
  70. package/server/cursor-worker/cursor-context-usage.mjs +85 -0
  71. package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
  72. package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
  73. package/server/cursor-worker/cursor-session-worker.mjs +145 -0
  74. package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
  75. package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
  76. package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
  77. package/server/db-schema.mjs +932 -0
  78. package/server/features.mjs +106 -0
  79. package/server/grok-worker/acp-client.mjs +434 -0
  80. package/server/grok-worker/acp-host-services.mjs +277 -0
  81. package/server/grok-worker/grok-context-usage.mjs +77 -0
  82. package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
  83. package/server/grok-worker/grok-session-worker.mjs +145 -0
  84. package/server/grok-worker/grok-turn-runner.mjs +523 -0
  85. package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
  86. package/server/migrations/0001-add-structured-answer.mjs +80 -0
  87. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
  88. package/server/migrations/0003-push-subscriptions.mjs +87 -0
  89. package/server/public/app/action-confirmations.js +267 -0
  90. package/server/public/app/activity-replay-state.mjs +149 -0
  91. package/server/public/app/api-client.js +1082 -0
  92. package/server/public/app/ask-user-view.js +545 -0
  93. package/server/public/app/attachments-view.js +2112 -0
  94. package/server/public/app/auto-compact-window-options.mjs +59 -0
  95. package/server/public/app/background-tasks-view.mjs +494 -0
  96. package/server/public/app/bootstrap.js +4347 -0
  97. package/server/public/app/claude-auth-ui.js +394 -0
  98. package/server/public/app/claude-thinking-options.mjs +38 -0
  99. package/server/public/app/cli-install-ui.js +455 -0
  100. package/server/public/app/code-copy.mjs +85 -0
  101. package/server/public/app/composer-attachment-cache.mjs +151 -0
  102. package/server/public/app/composer-control-state.mjs +107 -0
  103. package/server/public/app/composer-paste.mjs +132 -0
  104. package/server/public/app/context-tier-options.mjs +68 -0
  105. package/server/public/app/context-usage-view.mjs +338 -0
  106. package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
  107. package/server/public/app/conversation-preferences.mjs +91 -0
  108. package/server/public/app/conversation-provider-indicator.mjs +88 -0
  109. package/server/public/app/conversation-view.js +2898 -0
  110. package/server/public/app/cwd-menu-placement.mjs +85 -0
  111. package/server/public/app/cwd-picker.js +605 -0
  112. package/server/public/app/emoji-view.js +101 -0
  113. package/server/public/app/external-link-policy.mjs +80 -0
  114. package/server/public/app/file-preview-navigation.mjs +51 -0
  115. package/server/public/app/font-scaling.js +262 -0
  116. package/server/public/app/git-changes-view.js +339 -0
  117. package/server/public/app/git-diff-model.mjs +109 -0
  118. package/server/public/app/grok-auth-ui.js +387 -0
  119. package/server/public/app/image-downscale.mjs +174 -0
  120. package/server/public/app/in-flight-snapshot.mjs +40 -0
  121. package/server/public/app/infinite-loader.js +164 -0
  122. package/server/public/app/journal-view.js +1141 -0
  123. package/server/public/app/known-cwd-options.mjs +41 -0
  124. package/server/public/app/launch-cwd-path.mjs +23 -0
  125. package/server/public/app/live-message-dedupe.mjs +57 -0
  126. package/server/public/app/message-search-view.js +242 -0
  127. package/server/public/app/model-selector-options.mjs +31 -0
  128. package/server/public/app/model-vision-support.mjs +56 -0
  129. package/server/public/app/new-conversation-model-choice.mjs +67 -0
  130. package/server/public/app/plan-usage-view.mjs +307 -0
  131. package/server/public/app/preview-cards.mjs +273 -0
  132. package/server/public/app/preview-command.mjs +103 -0
  133. package/server/public/app/push-settings.js +370 -0
  134. package/server/public/app/pwa-install.js +456 -0
  135. package/server/public/app/question-schema-view.mjs +71 -0
  136. package/server/public/app/reasoning-effort-labels.mjs +42 -0
  137. package/server/public/app/relay-board-view.js +117 -0
  138. package/server/public/app/relay-dot-state.mjs +69 -0
  139. package/server/public/app/relay-error-ctas.mjs +52 -0
  140. package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
  141. package/server/public/app/relay-thoughts.mjs +98 -0
  142. package/server/public/app/relay-watchdog-policy.mjs +29 -0
  143. package/server/public/app/repo-browser-preferences.mjs +79 -0
  144. package/server/public/app/repo-browser-tree-state.mjs +97 -0
  145. package/server/public/app/router.js +657 -0
  146. package/server/public/app/selection-guard.mjs +93 -0
  147. package/server/public/app/settings-modal.js +1484 -0
  148. package/server/public/app/settings-tabs.js +129 -0
  149. package/server/public/app/slash-autocomplete.mjs +188 -0
  150. package/server/public/app/slash-commands.mjs +183 -0
  151. package/server/public/app/socket-handlers.js +703 -0
  152. package/server/public/app/status-store.mjs +242 -0
  153. package/server/public/app/status-view.mjs +315 -0
  154. package/server/public/app/store.js +1454 -0
  155. package/server/public/app/stream-state.mjs +73 -0
  156. package/server/public/app/streaming-dom-patch.mjs +23 -0
  157. package/server/public/app/sync-outbox.mjs +208 -0
  158. package/server/public/app/tap-actions.js +62 -0
  159. package/server/public/app/thread-order.mjs +54 -0
  160. package/server/public/app/tmux-inspector-view.js +422 -0
  161. package/server/public/app/transcript-separators.mjs +282 -0
  162. package/server/public/app-icon-192.png +0 -0
  163. package/server/public/app-icon-512.png +0 -0
  164. package/server/public/app-icon.svg +4 -0
  165. package/server/public/favicon.ico +0 -0
  166. package/server/public/index.html +5743 -0
  167. package/server/public/manifest.webmanifest +32 -0
  168. package/server/public/sw.js +307 -0
  169. package/server/relay-exit-codes.mjs +4 -0
  170. package/server/relay-self-restart.mjs +170 -0
  171. package/server/relay-tools.md +27 -0
  172. package/server/relay.mjs +1026 -0
  173. package/server/repositories/image-conversation-repository.mjs +317 -0
  174. package/server/repositories/message-repository.mjs +434 -0
  175. package/server/repositories/question-repository.mjs +161 -0
  176. package/server/repositories/session-repository.mjs +320 -0
  177. package/server/repositories/share-visibility-statements.mjs +24 -0
  178. package/server/routes/ask-user-routes.mjs +292 -0
  179. package/server/routes/cache-routes.mjs +44 -0
  180. package/server/routes/git-routes.mjs +92 -0
  181. package/server/routes/messages-routes-test-harness.mjs +111 -0
  182. package/server/routes/messages-routes.mjs +7107 -0
  183. package/server/routes/preview-routes.mjs +108 -0
  184. package/server/routes/push-routes.mjs +59 -0
  185. package/server/routes/relay-board-routes.mjs +268 -0
  186. package/server/routes/sessions-routes.mjs +6150 -0
  187. package/server/server-runtime.mjs +7463 -0
  188. package/server/server.js +24 -0
  189. package/server/services/ask-user-routing-service.mjs +117 -0
  190. package/server/services/cache-rebuild-service.mjs +236 -0
  191. package/server/services/claude-auth-service.mjs +578 -0
  192. package/server/services/claude-context-usage.mjs +240 -0
  193. package/server/services/claude-session-root-service.mjs +228 -0
  194. package/server/services/cli-install-service.mjs +1152 -0
  195. package/server/services/cli-process-runner.mjs +181 -0
  196. package/server/services/cloudflared-tunnel-service.mjs +358 -0
  197. package/server/services/context-snapshot-service.mjs +499 -0
  198. package/server/services/context-usage-view.mjs +160 -0
  199. package/server/services/conversation-preferences-service.mjs +208 -0
  200. package/server/services/cursor-dashboard-usage.mjs +217 -0
  201. package/server/services/cursor-session-root-service.mjs +62 -0
  202. package/server/services/delete-archive-service.mjs +154 -0
  203. package/server/services/drives-path-helpers.mjs +66 -0
  204. package/server/services/fixtures/claude-auth-stub.sh +62 -0
  205. package/server/services/fixtures/cli-install-stub.sh +75 -0
  206. package/server/services/fixtures/grok-stub.sh +86 -0
  207. package/server/services/generated-image-cleanup-service.mjs +114 -0
  208. package/server/services/git-changes-service.mjs +195 -0
  209. package/server/services/github-billing-usage.mjs +133 -0
  210. package/server/services/grok-auth-service.mjs +593 -0
  211. package/server/services/grok-billing-usage.mjs +120 -0
  212. package/server/services/image-context-service.mjs +106 -0
  213. package/server/services/image-operation-service.mjs +135 -0
  214. package/server/services/keyed-mutex-service.mjs +82 -0
  215. package/server/services/mime-sniffer.mjs +118 -0
  216. package/server/services/oar-cli-helpers.mjs +109 -0
  217. package/server/services/oar-state-migration-service.mjs +243 -0
  218. package/server/services/plan-usage-claude.mjs +387 -0
  219. package/server/services/plan-usage-contract.mjs +271 -0
  220. package/server/services/plan-usage-copilot.mjs +421 -0
  221. package/server/services/plan-usage-cursor.mjs +566 -0
  222. package/server/services/plan-usage-grok.mjs +436 -0
  223. package/server/services/plan-usage-service.mjs +487 -0
  224. package/server/services/preview-health-probe.mjs +82 -0
  225. package/server/services/preview-proxy-server.mjs +498 -0
  226. package/server/services/preview-registry-service.mjs +457 -0
  227. package/server/services/preview-static-handler.mjs +237 -0
  228. package/server/services/provider-model-selection.mjs +91 -0
  229. package/server/services/provider-reasoning-effort.mjs +94 -0
  230. package/server/services/push-active-device-service.mjs +57 -0
  231. package/server/services/push-dispatch-service.mjs +459 -0
  232. package/server/services/rate-limit-service.mjs +63 -0
  233. package/server/services/relay-bridge-owner-service.mjs +84 -0
  234. package/server/services/relay-cli-launcher-service.mjs +209 -0
  235. package/server/services/relay-prompt-sanitizer.mjs +137 -0
  236. package/server/services/relay-queue-gate-service.mjs +38 -0
  237. package/server/services/relay-restart-orchestrator-service.mjs +935 -0
  238. package/server/services/relay-singleton-guard.mjs +124 -0
  239. package/server/services/safe-served-content.mjs +57 -0
  240. package/server/services/sdk-session-import-service.mjs +271 -0
  241. package/server/services/sdk-session-sync-service.mjs +207 -0
  242. package/server/services/session-history-refresh-service.mjs +497 -0
  243. package/server/services/session-transcript-service.mjs +419 -0
  244. package/server/services/session-worker-launch-service.mjs +786 -0
  245. package/server/services/session-worker-process-service.mjs +319 -0
  246. package/server/services/session-worker-registry-service.mjs +194 -0
  247. package/server/services/session-worker-stop-service.mjs +141 -0
  248. package/server/services/session-worker-supervisor-service.mjs +898 -0
  249. package/server/services/session-worker-websocket-service.mjs +483 -0
  250. package/server/services/ssh-tunnel-manager-service.mjs +423 -0
  251. package/server/services/status-event-service.mjs +158 -0
  252. package/server/services/tmux-inspector-access-policy.mjs +77 -0
  253. package/server/services/tmux-inspector-socket-service.mjs +191 -0
  254. package/server/services/tmux-inspector-stream-service.mjs +552 -0
  255. package/server/services/tunnel-worker-path-guard.mjs +107 -0
  256. package/server/services/upload-sweep.mjs +60 -0
  257. package/server/services/usage-snapshot-helpers.mjs +209 -0
  258. package/server/services/windows-autostart-service.mjs +115 -0
  259. package/server/services/workspace-root-defaults-service.mjs +34 -0
  260. package/server/services/workspace-root-path-policy.mjs +196 -0
  261. package/server/services/workspace-root-relaunch-service.mjs +132 -0
  262. package/server/services/workspace-symlink-guard.mjs +54 -0
  263. package/server/tools/capture-proxy.mjs +173 -0
  264. package/server/tty-console-bootstrap.mjs +75 -0
  265. package/server/uploads/README.md +36 -0
  266. package/server/uploads/image_input.md +540 -0
  267. package/server/windows-terminal-launcher.mjs +29 -0
  268. package/server/workspace-root.mjs +87 -0
  269. package/shared/ask-user-bridge.mjs +131 -0
  270. package/shared/auto-compact-window.mjs +116 -0
  271. package/shared/background-task-timeout.mjs +52 -0
  272. package/shared/claude-thinking.mjs +101 -0
  273. package/shared/context-window-fallbacks.mjs +148 -0
  274. package/shared/control-poller.mjs +115 -0
  275. package/shared/empty-turn-completion.mjs +20 -0
  276. package/shared/model-catalog-freshness.mjs +11 -0
  277. package/shared/model-descriptors.mjs +118 -0
  278. package/shared/model-id.mjs +147 -0
  279. package/shared/model-refresh.mjs +24 -0
  280. package/shared/openai-reasoning.mjs +13 -0
  281. package/shared/plan-lines.mjs +13 -0
  282. package/shared/preview-instructions.mjs +77 -0
  283. package/shared/preview-tool-core.mjs +191 -0
  284. package/shared/provider-routing.mjs +29 -0
  285. package/shared/question-schema.mjs +315 -0
  286. package/shared/question-timeout.mjs +18 -0
  287. package/shared/stream-emit-gating.mjs +28 -0
  288. package/shared/subagent-run-id.mjs +17 -0
  289. package/shared/thought-cap.mjs +13 -0
  290. package/shared/tool-activity.mjs +98 -0
  291. package/shared/turn-ceiling.mjs +53 -0
  292. package/shared/worker-bootstrap.mjs +44 -0
  293. package/shared/worker-crash-guard.mjs +60 -0
@@ -0,0 +1,107 @@
1
+ export function hasComposerDraft({ text = '', attachmentCount = 0 } = {}) {
2
+ return String(text || '').trim().length > 0 || Math.max(0, Number(attachmentCount) || 0) > 0;
3
+ }
4
+
5
+ /**
6
+ * Counts attachments that are actively being uploaded. Failed uploads are
7
+ * deliberately excluded: treating them as in-flight would leave Send disabled
8
+ * forever with no way out except reloading the page.
9
+ */
10
+ export function countUploadingAttachments(attachments = []) {
11
+ const list = Array.isArray(attachments) ? attachments : [];
12
+ return list.filter((attachment) => {
13
+ const state = String(attachment?.uploadState || '').trim();
14
+ return state === 'pending' || state === 'uploading';
15
+ }).length;
16
+ }
17
+
18
+ export function hasUploadingAttachments(attachments = []) {
19
+ return countUploadingAttachments(attachments) > 0;
20
+ }
21
+
22
+ export function deriveComposerControlState({
23
+ hasActiveTurn = false,
24
+ cancelRequested = false,
25
+ hasDraft = false,
26
+ sendInFlight = false,
27
+ modelMetadataBlocked = false,
28
+ attachmentsUploading = false,
29
+ } = {}) {
30
+ const active = !!hasActiveTurn;
31
+ const stopping = !!cancelRequested;
32
+ const draft = !!hasDraft;
33
+ const metadataBlocked = !!modelMetadataBlocked;
34
+ const uploading = !!attachmentsUploading;
35
+
36
+ if (metadataBlocked && !active) {
37
+ return {
38
+ action: 'send',
39
+ label: 'Send',
40
+ title: 'Refresh model metadata to send',
41
+ disabled: true,
42
+ };
43
+ }
44
+
45
+ // Uploads are eager, so the blocking window is short. Keeping the button
46
+ // labelled Send/Queue (rather than switching to Stop) avoids the control
47
+ // flipping meaning mid-upload while a turn is running.
48
+ if (uploading) {
49
+ const queueing = active && draft;
50
+ return {
51
+ action: queueing ? 'queue' : 'send',
52
+ label: queueing ? 'Queue' : 'Send',
53
+ title: 'Waiting for attachments to finish uploading',
54
+ disabled: true,
55
+ };
56
+ }
57
+
58
+ if (sendInFlight) {
59
+ if (active && draft) {
60
+ return {
61
+ action: 'queue',
62
+ label: 'Queue',
63
+ title: 'Queue message behind current turn',
64
+ disabled: true,
65
+ };
66
+ }
67
+ if (active) {
68
+ return {
69
+ action: 'stop',
70
+ label: stopping ? 'Stopping…' : 'Stop',
71
+ title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
72
+ disabled: true,
73
+ };
74
+ }
75
+ return {
76
+ action: 'send',
77
+ label: 'Send',
78
+ title: 'Send message',
79
+ disabled: true,
80
+ };
81
+ }
82
+
83
+ if (active && draft) {
84
+ return {
85
+ action: 'queue',
86
+ label: 'Queue',
87
+ title: 'Queue message behind current turn',
88
+ disabled: false,
89
+ };
90
+ }
91
+
92
+ if (active) {
93
+ return {
94
+ action: 'stop',
95
+ label: stopping ? 'Stopping…' : 'Stop',
96
+ title: stopping ? 'Stopping the current turn' : 'Stop the current turn',
97
+ disabled: stopping,
98
+ };
99
+ }
100
+
101
+ return {
102
+ action: 'send',
103
+ label: 'Send',
104
+ title: 'Send message',
105
+ disabled: false,
106
+ };
107
+ }
@@ -0,0 +1,132 @@
1
+ // Pure helpers for turning clipboard/drag payloads into composer attachments.
2
+ // Kept free of DOM globals so the logic is unit-testable in Node.
3
+
4
+ const MIME_EXTENSIONS = new Map([
5
+ ['image/png', 'png'],
6
+ ['image/jpeg', 'jpg'],
7
+ ['image/jpg', 'jpg'],
8
+ ['image/gif', 'gif'],
9
+ ['image/webp', 'webp'],
10
+ ['image/avif', 'avif'],
11
+ ['image/heic', 'heic'],
12
+ ['image/bmp', 'bmp'],
13
+ ['image/svg+xml', 'svg'],
14
+ ['application/pdf', 'pdf'],
15
+ ['text/plain', 'txt'],
16
+ ['text/markdown', 'md'],
17
+ ['application/json', 'json'],
18
+ ]);
19
+
20
+ // Chrome hands every clipboard bitmap over as "image.png" regardless of origin, so
21
+ // these names carry no information worth keeping and get replaced with a timestamp.
22
+ const GENERIC_CLIPBOARD_NAMES = new Set(['image.png', 'image.jpeg', 'image.jpg', 'image', 'blob', 'unknown']);
23
+
24
+ export function extensionForMimeType(mimeType) {
25
+ const normalized = String(mimeType || '').trim().toLowerCase().split(';')[0];
26
+ return MIME_EXTENSIONS.get(normalized) || 'bin';
27
+ }
28
+
29
+ export function isGenericClipboardName(name) {
30
+ const normalized = String(name || '').trim().toLowerCase();
31
+ if (!normalized) return true;
32
+ return GENERIC_CLIPBOARD_NAMES.has(normalized);
33
+ }
34
+
35
+ export function pastedFileName(mimeType, now = new Date(), index = 0) {
36
+ const date = now instanceof Date ? now : new Date(now);
37
+ const stamp = (Number.isNaN(date.getTime()) ? new Date(0) : date)
38
+ .toISOString()
39
+ .replace(/\.\d+Z$/, '')
40
+ .replace(/[:.]/g, '-');
41
+ const suffix = Number(index) > 0 ? `-${Number(index) + 1}` : '';
42
+ return `pasted-${stamp}${suffix}.${extensionForMimeType(mimeType)}`;
43
+ }
44
+
45
+ function collectFilesFromItems(items) {
46
+ const files = [];
47
+ let hadText = false;
48
+ for (const item of Array.from(items || [])) {
49
+ if (!item) continue;
50
+ const kind = String(item.kind || '').toLowerCase();
51
+ if (kind === 'string') {
52
+ hadText = true;
53
+ continue;
54
+ }
55
+ if (kind !== 'file') continue;
56
+ const file = typeof item.getAsFile === 'function' ? item.getAsFile() : null;
57
+ if (file) files.push(file);
58
+ }
59
+ return { files, hadText };
60
+ }
61
+
62
+ /**
63
+ * Reads a paste payload. Files win over text: when the clipboard carries both
64
+ * (copying from Word or a browser produces exactly this), the caller attaches the
65
+ * file and suppresses the text insert. When no file is present we report nothing
66
+ * so the browser's native text paste is left completely untouched.
67
+ */
68
+ export function extractPastedFiles(clipboardData) {
69
+ if (!clipboardData) return { files: [], hadText: false };
70
+
71
+ const types = Array.from(clipboardData.types || []).map((type) => String(type || '').toLowerCase());
72
+ const hadTextType = types.some((type) => type === 'text/plain' || type === 'text/html');
73
+
74
+ const direct = Array.from(clipboardData.files || []).filter(Boolean);
75
+ if (direct.length) return { files: direct, hadText: hadTextType };
76
+
77
+ // Safari and older Firefox expose pasted images only through `items`.
78
+ const fromItems = collectFilesFromItems(clipboardData.items);
79
+ return { files: fromItems.files, hadText: hadTextType || fromItems.hadText };
80
+ }
81
+
82
+ /**
83
+ * Reads a drop payload. Dragging selected text or a link also fires `drop`, so we
84
+ * only claim the event when the transfer actually advertises files.
85
+ */
86
+ export function extractDroppedFiles(dataTransfer) {
87
+ if (!dataTransfer) return { files: [], hadText: false };
88
+
89
+ const types = Array.from(dataTransfer.types || []).map((type) => String(type || '').toLowerCase());
90
+ const hadText = types.some((type) => type === 'text/plain' || type === 'text/html' || type === 'text/uri-list');
91
+ if (types.length && !types.includes('files')) return { files: [], hadText };
92
+
93
+ const direct = Array.from(dataTransfer.files || []).filter(Boolean);
94
+ if (direct.length) return { files: direct, hadText };
95
+
96
+ const fromItems = collectFilesFromItems(dataTransfer.items);
97
+ return { files: fromItems.files, hadText };
98
+ }
99
+
100
+ export function dataTransferHasFiles(dataTransfer) {
101
+ if (!dataTransfer) return false;
102
+ const types = Array.from(dataTransfer.types || []).map((type) => String(type || '').toLowerCase());
103
+ if (types.includes('files')) return true;
104
+ return Array.from(dataTransfer.items || []).some((item) => String(item?.kind || '').toLowerCase() === 'file');
105
+ }
106
+
107
+ /**
108
+ * Applies the attachment cap without silently discarding the overflow, so callers
109
+ * can tell the user what was dropped.
110
+ */
111
+ export function planAttachmentMerge(existing = [], incoming = [], max = 6) {
112
+ const current = Array.isArray(existing) ? existing.filter(Boolean) : [];
113
+ const additions = Array.isArray(incoming) ? incoming.filter(Boolean) : [];
114
+ const limit = Math.max(0, Number(max) || 0);
115
+
116
+ const remaining = Math.max(0, limit - current.length);
117
+ const acceptedAdditions = additions.slice(0, remaining);
118
+ const droppedCount = additions.length - acceptedAdditions.length;
119
+
120
+ return {
121
+ accepted: current.concat(acceptedAdditions),
122
+ acceptedAdditions,
123
+ droppedCount,
124
+ };
125
+ }
126
+
127
+ export function overCapNoticeText(droppedCount, max = 6) {
128
+ const dropped = Math.max(0, Number(droppedCount) || 0);
129
+ if (!dropped) return '';
130
+ const noun = dropped === 1 ? 'file was' : 'files were';
131
+ return `Only ${max} attachments allowed — ${dropped} ${noun} dropped.`;
132
+ }
@@ -0,0 +1,68 @@
1
+ // Mirrors shared/model-id.mjs, which the browser cannot import (only
2
+ // server/public is served).
3
+ const CLAUDE_LONG_CONTEXT_LIMIT_TOKENS = 1000000;
4
+ const UNKNOWN_WINDOW_LABEL = '—';
5
+
6
+ function tokenLabel(tokens) {
7
+ const limit = Number(tokens);
8
+ return Number.isFinite(limit) && limit > 0 ? `${Math.round(limit / 1000)}K` : UNKNOWN_WINDOW_LABEL;
9
+ }
10
+
11
+ function claudeTiersForModel(modelId, claudeTiers) {
12
+ const key = String(modelId || '').trim().toLowerCase();
13
+ if (!key || !claudeTiers || typeof claudeTiers !== 'object') return null;
14
+ const tiers = claudeTiers[key];
15
+ if (!Array.isArray(tiers) || tiers.length === 0) return null;
16
+ return tiers;
17
+ }
18
+
19
+ /**
20
+ * The context-window options for one model *under one provider*.
21
+ *
22
+ * modelMetadataByModel is keyed by model id alone, so a model that both Copilot
23
+ * and the Claude SDK serve (claude-opus-5) would otherwise advertise Copilot's
24
+ * windows on a Claude conversation. For the Claude provider the offered tiers
25
+ * are exactly the enabled catalog ids the server folded into
26
+ * claudeContextTiersByModel: base id → default, "[1m]" id → long_context, and a
27
+ * model may legitimately have only one of the two.
28
+ */
29
+ export function buildContextTierOptions({
30
+ modelId = '',
31
+ providerType = '',
32
+ metadata = null,
33
+ claudeTiers = null,
34
+ } = {}) {
35
+ const meta = metadata && typeof metadata === 'object' ? metadata : {};
36
+ const defaultLabel = tokenLabel(meta.defaultContextLimitTokens);
37
+ const provider = String(providerType || '').trim().toLowerCase();
38
+ const claudeTierList = provider === 'claude' ? claudeTiersForModel(modelId, claudeTiers) : null;
39
+ if (claudeTierList) {
40
+ return claudeTierList
41
+ .map((tier) => String(tier?.value || '').trim().toLowerCase())
42
+ .filter((value) => value === 'default' || value === 'long_context')
43
+ // The Claude catalog carries no per-model window, and the number in the
44
+ // merged metadata is Copilot's (claude-opus-5 reads 264000 there). The
45
+ // SDK's own defaults vary per model anyway — claude-sonnet-5 reports
46
+ // 967000, claude-haiku-4-5 200000, claude-opus-5 1000000 — so any
47
+ // borrowed figure would be wrong for most models. Blank beats wrong.
48
+ .map((value) => (value === 'long_context'
49
+ ? { value, label: tokenLabel(CLAUDE_LONG_CONTEXT_LIMIT_TOKENS) }
50
+ : { value, label: UNKNOWN_WINDOW_LABEL }));
51
+ }
52
+ const longLimit = Number(meta.longContextLimitTokens);
53
+ return [
54
+ { value: 'default', label: defaultLabel },
55
+ ...(Number.isFinite(longLimit) && longLimit > 0
56
+ ? [{ value: 'long_context', label: tokenLabel(longLimit) }]
57
+ : []),
58
+ ];
59
+ }
60
+
61
+ // A Claude model can offer long_context only (claude-opus-5 ships as "[1m]"
62
+ // alone), so "fall back to default" is no longer a safe clamp.
63
+ export function resolveContextTierValue(options = [], currentValue = '') {
64
+ const values = (Array.isArray(options) ? options : []).map((option) => option?.value).filter(Boolean);
65
+ const current = String(currentValue || '').trim().toLowerCase();
66
+ if (values.includes(current)) return current;
67
+ return values[0] || '';
68
+ }
@@ -0,0 +1,338 @@
1
+ /**
2
+ * Renders the context-usage modal body from the provider-neutral payload that
3
+ * `/api/context` returns (see `server/services/context-usage-view.mjs`).
4
+ *
5
+ * Kept DOM-free and dependency-light so it can be unit tested directly.
6
+ */
7
+
8
+ import {
9
+ AUTO_COMPACT_WINDOW_STOPS,
10
+ autoCompactWindowToIndex,
11
+ formatAutoCompactWindowLabel,
12
+ parseAutoCompactWindow,
13
+ } from './auto-compact-window-options.mjs';
14
+
15
+ // The SDK labels categories with names, not CSS. Anything unmapped falls back
16
+ // to a neutral swatch rather than rendering an invalid color.
17
+ const CATEGORY_COLORS = Object.freeze({
18
+ gray: '#6e7681',
19
+ grey: '#6e7681',
20
+ blue: '#3b82f6',
21
+ green: '#3fb950',
22
+ orange: '#e3b341',
23
+ yellow: '#e3b341',
24
+ red: '#f85149',
25
+ purple: '#a371f7',
26
+ magenta: '#db61a2',
27
+ cyan: '#39c5cf',
28
+ white: '#c9d1d9',
29
+ });
30
+ const FALLBACK_CATEGORY_COLOR = '#6e7681';
31
+
32
+ export function escapeHtml(value) {
33
+ return String(value ?? '')
34
+ .replace(/&/g, '&')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/"/g, '&quot;')
38
+ .replace(/'/g, '&#39;');
39
+ }
40
+
41
+ // `Number(null)` and `Number('')` are 0, not NaN, so a plain isFinite check
42
+ // would render "absent" as a real zero.
43
+ function toNullableNumber(value) {
44
+ if (value === null || value === undefined || value === '') return null;
45
+ const n = Number(value);
46
+ return Number.isFinite(n) ? n : null;
47
+ }
48
+
49
+ export function categoryColor(name) {
50
+ const key = String(name || '').trim().toLowerCase();
51
+ return CATEGORY_COLORS[key] || FALLBACK_CATEGORY_COLOR;
52
+ }
53
+
54
+ /**
55
+ * 4823 → "4.8k", 221340 → "221.3k", 1000000 → "1.0M". Matches the compact
56
+ * token formatting used by Claude Code's own context display.
57
+ */
58
+ export function formatCompactTokens(value) {
59
+ const n = toNullableNumber(value);
60
+ if (n === null) return '—';
61
+ const abs = Math.abs(n);
62
+ if (abs < 1000) return String(Math.round(n));
63
+ if (abs < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
64
+ return `${(n / 1_000_000).toFixed(1)}M`;
65
+ }
66
+
67
+ export function formatUsagePercent(value, { digits = 1 } = {}) {
68
+ const n = toNullableNumber(value);
69
+ if (n === null) return '—';
70
+ return `${n.toFixed(digits)}%`;
71
+ }
72
+
73
+ function renderBarSegments(categories, maxTokens) {
74
+ const max = toNullableNumber(maxTokens);
75
+ if (max === null || max <= 0) return '';
76
+ return categories
77
+ .map((category) => {
78
+ const percent = toNullableNumber(category.percent);
79
+ if (percent === null || percent <= 0) return '';
80
+ return `<span class="ctx-usage-bar-seg" style="width:${percent}%;background:${categoryColor(category.color)}" title="${escapeHtml(category.name)}"></span>`;
81
+ })
82
+ .join('');
83
+ }
84
+
85
+ // Estimate wording keyed by the snapshot's estimate_kind; the fallback covers
86
+ // kinds added server-side before this file learns about them.
87
+ const ESTIMATE_NOTES = Object.freeze({
88
+ 'assistant-output-lower-bound':
89
+ 'Estimated lower bound — this runtime does not report a full breakdown.',
90
+ 'cursor-per-call-average':
91
+ 'Estimated from the turn’s aggregate token usage — the Cursor runtime does not report context occupancy directly.',
92
+ });
93
+ const DEFAULT_ESTIMATE_NOTE = 'Estimated — this runtime does not report context occupancy directly.';
94
+
95
+ function estimateNoteText(estimateKind) {
96
+ return ESTIMATE_NOTES[String(estimateKind || '').trim()] || DEFAULT_ESTIMATE_NOTE;
97
+ }
98
+
99
+ function formatCapturedAt(value) {
100
+ const raw = String(value || '').trim();
101
+ if (!raw) return '';
102
+ const stamp = new Date(raw);
103
+ if (Number.isNaN(stamp.getTime())) return '';
104
+ return stamp.toLocaleString();
105
+ }
106
+
107
+ function renderRow({ name, tokens, percent, color, muted = false }) {
108
+ const swatch = color === null
109
+ ? '<span class="ctx-usage-swatch ctx-usage-swatch-empty"></span>'
110
+ : `<span class="ctx-usage-swatch" style="background:${categoryColor(color)}"></span>`;
111
+ return `
112
+ <tr class="${muted ? 'ctx-usage-row-muted' : ''}">
113
+ <td class="ctx-usage-cat">${swatch}${escapeHtml(name)}</td>
114
+ <td class="ctx-usage-num">${escapeHtml(formatCompactTokens(tokens))}</td>
115
+ <td class="ctx-usage-num ctx-usage-pct">${escapeHtml(formatUsagePercent(percent))}</td>
116
+ </tr>
117
+ `;
118
+ }
119
+
120
+ // How the CLI describes where the effective threshold came from. Unknown
121
+ // sources are rendered verbatim rather than dropped, so a new CLI vocabulary
122
+ // still tells the user something.
123
+ const AUTOCOMPACT_SOURCE_LABELS = Object.freeze({
124
+ auto: 'auto (model-tuned)',
125
+ 'model-default': 'model default',
126
+ settings: 'from settings',
127
+ env: 'from CLAUDE_CODE_AUTO_COMPACT_WINDOW',
128
+ clientdata: 'from client data',
129
+ });
130
+
131
+ function autocompactSourceLabel(source) {
132
+ const key = String(source || '').trim();
133
+ if (!key) return '';
134
+ return AUTOCOMPACT_SOURCE_LABELS[key.toLowerCase()] || key;
135
+ }
136
+
137
+ /**
138
+ * The Claude-only auto-compact window control: a snap-stop slider over
139
+ * AUTO_COMPACT_WINDOW_STOPS plus the measured effective threshold.
140
+ *
141
+ * No stop is annotated as out of the model's reach: the payload carries no
142
+ * trustworthy model-limit field. `rawMaxTokens` tracks the ACTIVE setting, not
143
+ * the model's own window (probed on claude-opus-5[1m]: no setting → 1000000,
144
+ * setting 100000 → 100000), so annotating from it told a user who had just
145
+ * pinned 100k that every larger stop was pointless. Applied on the next
146
+ * message delivery, which is why there is no live preview of the effect.
147
+ *
148
+ * `isAutoCompactEnabled` is tri-state on purpose: `null`/`undefined` means the
149
+ * runtime has not reported yet (no context-usage snapshot exists before the
150
+ * first turn) and must not be read as "disabled" — auto-compact is on by
151
+ * default, so claiming otherwise next to an unknown threshold is a lie.
152
+ *
153
+ * @returns {string} HTML, or '' when there is nothing to control
154
+ */
155
+ export function renderAutoCompactControlHtml({
156
+ autoCompactWindow = null,
157
+ autoCompactThreshold = null,
158
+ autocompactSource = null,
159
+ isAutoCompactEnabled = null,
160
+ maxTokens = null,
161
+ } = {}) {
162
+ const window = parseAutoCompactWindow(autoCompactWindow);
163
+ const index = autoCompactWindowToIndex(window);
164
+
165
+ const threshold = toNullableNumber(autoCompactThreshold);
166
+ const sourceLabel = autocompactSourceLabel(autocompactSource);
167
+ const effective = threshold === null
168
+ ? '<span class="ctx-autocompact-muted">— (known after the first turn)</span>'
169
+ : `compacts at ${escapeHtml(formatCompactTokens(threshold))}${toNullableNumber(maxTokens) === null
170
+ ? ''
171
+ : ` of ${escapeHtml(formatCompactTokens(maxTokens))}`} tokens${sourceLabel ? ` · ${escapeHtml(sourceLabel)}` : ''}`;
172
+
173
+ // No toggle by design: the window is the only thing this control owns. Only
174
+ // an explicit `false` from a real snapshot renders the note — unknown stays
175
+ // silent rather than contradicting the "known after the first turn" line.
176
+ const disabledNote = isAutoCompactEnabled === false
177
+ ? '<div class="ctx-autocompact-note">Auto-compact is disabled for this session.</div>'
178
+ : '';
179
+
180
+ return `
181
+ <div class="ctx-autocompact">
182
+ <div class="ctx-autocompact-row">
183
+ <label for="ctx-autocompact-slider">Auto-compact window:</label>
184
+ <b id="ctx-autocompact-value">${escapeHtml(formatAutoCompactWindowLabel(window))}</b>
185
+ </div>
186
+ <input
187
+ id="ctx-autocompact-slider"
188
+ class="ctx-autocompact-slider"
189
+ type="range"
190
+ min="0"
191
+ max="${AUTO_COMPACT_WINDOW_STOPS.length - 1}"
192
+ step="1"
193
+ value="${index}"
194
+ aria-label="Auto-compact window"
195
+ >
196
+ <div class="ctx-autocompact-effective">Effective: ${effective}</div>
197
+ ${disabledNote}
198
+ <div class="ctx-autocompact-note">Applied on the next message in this conversation.</div>
199
+ </div>
200
+ `;
201
+ }
202
+
203
+ // Wire value ↔ segmented-button value. Two states per axis: the relay's
204
+ // defaults are On + Summarized, and an unset conversation reads back as those,
205
+ // so a third "host default" button could not be stored distinctly from unset.
206
+ const THINKING_ENABLED_CHOICES = Object.freeze([
207
+ { key: 'on', label: 'On' },
208
+ { key: 'off', label: 'Off' },
209
+ ]);
210
+ const THINKING_DISPLAY_CHOICES = Object.freeze([
211
+ { key: 'summarized', label: 'Summarized' },
212
+ { key: 'omitted', label: 'Hidden' },
213
+ ]);
214
+
215
+ /** Anything but an explicit off is on — the relay default. */
216
+ export function thinkingEnabledToKey(enabled) {
217
+ return enabled === false ? 'off' : 'on';
218
+ }
219
+
220
+ export function thinkingEnabledFromKey(key) {
221
+ return key !== 'off';
222
+ }
223
+
224
+ function renderThinkingButtons(axis, choices, activeKey) {
225
+ return choices.map(({ key, label }) => `
226
+ <button
227
+ type="button"
228
+ class="ctx-thinking-btn${key === activeKey ? ' is-active' : ''}"
229
+ data-thinking-axis="${axis}"
230
+ data-thinking-value="${key}"
231
+ aria-pressed="${key === activeKey ? 'true' : 'false'}"
232
+ >${escapeHtml(label)}</button>`).join('');
233
+ }
234
+
235
+ /**
236
+ * Claude-only thinking control, rendered under the auto-compact slider.
237
+ *
238
+ * Two axes with different application semantics (probed 2026-08-26, see
239
+ * docs/plans/claude-thinking-control.md): display changes and turning
240
+ * thinking ON apply on the next message; turning it OFF only applies when the
241
+ * CLI process next starts — the CLI silently ignores mid-session disabling —
242
+ * so the note states both rather than pretending one uniform rule.
243
+ *
244
+ * @returns {string} HTML
245
+ */
246
+ export function renderThinkingControlHtml({ thinkingEnabled = true, thinkingDisplay = null } = {}) {
247
+ const enabledKey = thinkingEnabledToKey(thinkingEnabled);
248
+ const displayKey = String(thinkingDisplay || '').trim().toLowerCase() === 'omitted'
249
+ ? 'omitted'
250
+ : 'summarized';
251
+ return `
252
+ <div class="ctx-thinking">
253
+ <div class="ctx-thinking-row">
254
+ <span class="ctx-thinking-label">Thinking:</span>
255
+ <div class="ctx-thinking-group" role="group" aria-label="Thinking">
256
+ ${renderThinkingButtons('enabled', THINKING_ENABLED_CHOICES, enabledKey)}
257
+ </div>
258
+ </div>
259
+ <div class="ctx-thinking-row">
260
+ <span class="ctx-thinking-label">Thinking text:</span>
261
+ <div class="ctx-thinking-group" role="group" aria-label="Thinking text">
262
+ ${renderThinkingButtons('display', THINKING_DISPLAY_CHOICES, displayKey)}
263
+ </div>
264
+ </div>
265
+ <div class="ctx-thinking-note">Defaults are On and Summarized. Text changes and turning thinking on apply on the next message; turning it off applies to the next CLI session. Hiding the text does not reduce thinking or cost — it only keeps it out of the transcript.</div>
266
+ </div>
267
+ `;
268
+ }
269
+
270
+ /**
271
+ * @param {object|null} usage the `contextUsage` field of an /api/context response
272
+ * @returns {string} modal body HTML, or '' when there is nothing to render
273
+ */
274
+ export function renderContextUsageHtml(usage) {
275
+ if (!usage || typeof usage !== 'object') return '';
276
+ const categories = Array.isArray(usage.categories) ? usage.categories : [];
277
+ const hasTotals = toNullableNumber(usage.totalTokens) !== null
278
+ && toNullableNumber(usage.maxTokens) !== null;
279
+ if (!categories.length && !hasTotals) return '';
280
+
281
+ const headline = hasTotals
282
+ ? `${formatCompactTokens(usage.totalTokens)} / ${formatCompactTokens(usage.maxTokens)} tokens (${formatUsagePercent(usage.percentage, { digits: 0 })})`
283
+ : '';
284
+
285
+ const rows = categories.map((category) => renderRow(category)).join('');
286
+ const freeRow = toNullableNumber(usage.freeTokens) !== null
287
+ ? renderRow({
288
+ name: 'Free space',
289
+ tokens: usage.freeTokens,
290
+ percent: usage.freePercent,
291
+ color: null,
292
+ muted: true,
293
+ })
294
+ : '';
295
+ // Unused window the conversation may not occupy: without its own row the
296
+ // reserve would just be missing from the table, since free space has it
297
+ // subtracted out.
298
+ const bufferRow = toNullableNumber(usage.bufferTokens) !== null && Number(usage.bufferTokens) > 0
299
+ ? renderRow({
300
+ name: 'Auto-compact reserve',
301
+ tokens: usage.bufferTokens,
302
+ percent: usage.bufferPercent,
303
+ color: null,
304
+ muted: true,
305
+ })
306
+ : '';
307
+
308
+ // Deferred tool definitions are listed by the SDK but left out of
309
+ // totalTokens, so the category rows legitimately sum higher than the
310
+ // headline. Without this the table reads as an arithmetic error.
311
+ const deferredNote = categories.some((category) => category.isDeferred === true)
312
+ ? '<div class="ctx-usage-note">Deferred tools are listed but not loaded, so they are not counted in the total.</div>'
313
+ : '';
314
+
315
+ const estimateNote = usage.isEstimate
316
+ ? `<div class="ctx-usage-note">${escapeHtml(estimateNoteText(usage.estimateKind))}</div>`
317
+ : '';
318
+ const capturedNote = formatCapturedAt(usage.capturedAt)
319
+ ? `<div class="ctx-usage-note">As of the last completed turn (${escapeHtml(formatCapturedAt(usage.capturedAt))}${usage.model ? ` on ${escapeHtml(usage.model)}` : ''}).</div>`
320
+ : '';
321
+
322
+ return `
323
+ <div class="ctx-usage">
324
+ ${usage.model ? `<div class="ctx-usage-model">${escapeHtml(usage.model)}</div>` : ''}
325
+ ${headline ? `<div class="ctx-usage-headline">${escapeHtml(headline)}</div>` : ''}
326
+ <div class="ctx-usage-bar">${renderBarSegments(categories, usage.maxTokens)}</div>
327
+ ${estimateNote}
328
+ ${deferredNote}
329
+ ${capturedNote}
330
+ <table class="ctx-usage-table">
331
+ <thead>
332
+ <tr><th>Category</th><th class="ctx-usage-num">Tokens</th><th class="ctx-usage-num">Usage</th></tr>
333
+ </thead>
334
+ <tbody>${rows}${freeRow}${bufferRow}</tbody>
335
+ </table>
336
+ </div>
337
+ `;
338
+ }
@@ -0,0 +1,11 @@
1
+ export function normalizeDraftTimestampMs(value) {
2
+ const parsed = Date.parse(String(value || '').trim());
3
+ return Number.isFinite(parsed) ? parsed : 0;
4
+ }
5
+
6
+ export function isIncomingDraftTimestampStale({
7
+ existingMs = 0,
8
+ incomingMs = 0,
9
+ } = {}) {
10
+ return !!(incomingMs && existingMs && incomingMs < existingMs);
11
+ }