@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.
- package/.github/extensions/web-relay/README.md +190 -0
- package/.github/extensions/web-relay/extension.mjs +972 -0
- package/.github/extensions/web-relay/model-api/model-switching.mjs +338 -0
- package/.github/extensions/web-relay/polling/heartbeat.mjs +55 -0
- package/.github/extensions/web-relay/polling/polling-loop.mjs +1105 -0
- package/.github/extensions/web-relay/runtime/api-client.mjs +34 -0
- package/.github/extensions/web-relay/runtime/banner-state.mjs +126 -0
- package/.github/extensions/web-relay/runtime/config-loader.mjs +104 -0
- package/.github/extensions/web-relay/runtime/debug-log.mjs +27 -0
- package/.github/extensions/web-relay/runtime/send-and-wait-errors.mjs +170 -0
- package/.github/extensions/web-relay/runtime/session-binding.mjs +78 -0
- package/.github/extensions/web-relay/runtime/session-io.mjs +349 -0
- package/.github/extensions/web-relay/runtime/session-join-retry.mjs +44 -0
- package/.github/extensions/web-relay/runtime/session-registry.mjs +29 -0
- package/.github/extensions/web-relay/runtime/session-runtime-manager.mjs +156 -0
- package/.github/extensions/web-relay/runtime/session-sync-bridge.mjs +74 -0
- package/.github/extensions/web-relay/runtime/worker-websocket-link.mjs +325 -0
- package/.github/extensions/web-relay/runtime/workspace-root.mjs +55 -0
- package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +386 -0
- package/.github/extensions/web-relay/skills/prompt-context.mjs +160 -0
- package/.github/extensions/web-relay/skills/question-bridge.mjs +141 -0
- package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +463 -0
- package/.github/extensions/web-relay/skills/reasoning-stream.mjs +285 -0
- package/.github/extensions/web-relay/skills/subagent-lifecycle.mjs +345 -0
- package/.github/extensions/web-relay/skills/tool-activity.mjs +243 -0
- package/.github/extensions/web-relay/utils/process-utils.mjs +25 -0
- package/.github/extensions/web-relay/utils/token-generation.mjs +58 -0
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +717 -0
- package/bin/bat/oar.bat +46 -0
- package/bin/oar.js +665 -0
- package/package.json +68 -0
- package/server/README.md +1379 -0
- package/server/claude-worker/claude-attachments.mjs +134 -0
- package/server/claude-worker/claude-preview-tool.mjs +57 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +420 -0
- package/server/claude-worker/claude-session-process.mjs +2391 -0
- package/server/claude-worker/claude-session-test-harness.mjs +236 -0
- package/server/claude-worker/claude-session-worker.mjs +186 -0
- package/server/claude-worker/claude-transcript-relocator.mjs +189 -0
- package/server/claude-worker/claude-turn-publisher.mjs +291 -0
- package/server/claude-worker/fixtures/workflow-run-record.json +1 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +629 -0
- package/server/claude-worker/workflow-progress-digest.mjs +269 -0
- package/server/copilot-sdk-runtime.mjs +126 -0
- package/server/copilot-worker/copilot-attachments.mjs +97 -0
- package/server/copilot-worker/copilot-byok-provider.mjs +121 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +349 -0
- package/server/copilot-worker/copilot-plan-board.mjs +110 -0
- package/server/copilot-worker/copilot-prompt-context.mjs +86 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +216 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +494 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +800 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +1729 -0
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +167 -0
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +329 -0
- package/server/copilot-worker/fixtures/abort-turn.json +588 -0
- package/server/copilot-worker/fixtures/ask-user-turn.json +1154 -0
- package/server/copilot-worker/fixtures/background-timer-continuation.json +445 -0
- package/server/copilot-worker/fixtures/background-timer-turn.json +497 -0
- package/server/copilot-worker/fixtures/happy-turn.json +566 -0
- package/server/copilot-worker/fixtures/quota-turn.json +647 -0
- package/server/copilot-worker/fixtures/reasoning-turn.json +625 -0
- package/server/copilot-worker/fixtures/resume-turn.json +700 -0
- package/server/copilot-worker/fixtures/subagent-turn.json +1378 -0
- package/server/copilot-worker/fixtures/tool-permission-turn.json +1293 -0
- package/server/cursor-worker/cursor-ask-user-tool.mjs +83 -0
- package/server/cursor-worker/cursor-attachments.mjs +88 -0
- package/server/cursor-worker/cursor-context-usage.mjs +85 -0
- package/server/cursor-worker/cursor-preview-tool.mjs +44 -0
- package/server/cursor-worker/cursor-sdk-adapter.mjs +554 -0
- package/server/cursor-worker/cursor-session-worker.mjs +145 -0
- package/server/cursor-worker/cursor-subagent-roster.mjs +81 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +717 -0
- package/server/cursor-worker/sdk-message-normalizer.mjs +485 -0
- package/server/db-schema.mjs +932 -0
- package/server/features.mjs +106 -0
- package/server/grok-worker/acp-client.mjs +434 -0
- package/server/grok-worker/acp-host-services.mjs +277 -0
- package/server/grok-worker/grok-context-usage.mjs +77 -0
- package/server/grok-worker/grok-sdk-adapter.mjs +334 -0
- package/server/grok-worker/grok-session-worker.mjs +145 -0
- package/server/grok-worker/grok-turn-runner.mjs +523 -0
- package/server/grok-worker/sdk-message-normalizer.mjs +311 -0
- package/server/migrations/0001-add-structured-answer.mjs +80 -0
- package/server/migrations/0002-recent-workspace-roots-path-key.mjs +121 -0
- package/server/migrations/0003-push-subscriptions.mjs +87 -0
- package/server/public/app/action-confirmations.js +267 -0
- package/server/public/app/activity-replay-state.mjs +149 -0
- package/server/public/app/api-client.js +1082 -0
- package/server/public/app/ask-user-view.js +545 -0
- package/server/public/app/attachments-view.js +2112 -0
- package/server/public/app/auto-compact-window-options.mjs +59 -0
- package/server/public/app/background-tasks-view.mjs +494 -0
- package/server/public/app/bootstrap.js +4347 -0
- package/server/public/app/claude-auth-ui.js +394 -0
- package/server/public/app/claude-thinking-options.mjs +38 -0
- package/server/public/app/cli-install-ui.js +455 -0
- package/server/public/app/code-copy.mjs +85 -0
- package/server/public/app/composer-attachment-cache.mjs +151 -0
- package/server/public/app/composer-control-state.mjs +107 -0
- package/server/public/app/composer-paste.mjs +132 -0
- package/server/public/app/context-tier-options.mjs +68 -0
- package/server/public/app/context-usage-view.mjs +338 -0
- package/server/public/app/conversation-draft-timestamp-utils.mjs +11 -0
- package/server/public/app/conversation-preferences.mjs +91 -0
- package/server/public/app/conversation-provider-indicator.mjs +88 -0
- package/server/public/app/conversation-view.js +2898 -0
- package/server/public/app/cwd-menu-placement.mjs +85 -0
- package/server/public/app/cwd-picker.js +605 -0
- package/server/public/app/emoji-view.js +101 -0
- package/server/public/app/external-link-policy.mjs +80 -0
- package/server/public/app/file-preview-navigation.mjs +51 -0
- package/server/public/app/font-scaling.js +262 -0
- package/server/public/app/git-changes-view.js +339 -0
- package/server/public/app/git-diff-model.mjs +109 -0
- package/server/public/app/grok-auth-ui.js +387 -0
- package/server/public/app/image-downscale.mjs +174 -0
- package/server/public/app/in-flight-snapshot.mjs +40 -0
- package/server/public/app/infinite-loader.js +164 -0
- package/server/public/app/journal-view.js +1141 -0
- package/server/public/app/known-cwd-options.mjs +41 -0
- package/server/public/app/launch-cwd-path.mjs +23 -0
- package/server/public/app/live-message-dedupe.mjs +57 -0
- package/server/public/app/message-search-view.js +242 -0
- package/server/public/app/model-selector-options.mjs +31 -0
- package/server/public/app/model-vision-support.mjs +56 -0
- package/server/public/app/new-conversation-model-choice.mjs +67 -0
- package/server/public/app/plan-usage-view.mjs +307 -0
- package/server/public/app/preview-cards.mjs +273 -0
- package/server/public/app/preview-command.mjs +103 -0
- package/server/public/app/push-settings.js +370 -0
- package/server/public/app/pwa-install.js +456 -0
- package/server/public/app/question-schema-view.mjs +71 -0
- package/server/public/app/reasoning-effort-labels.mjs +42 -0
- package/server/public/app/relay-board-view.js +117 -0
- package/server/public/app/relay-dot-state.mjs +69 -0
- package/server/public/app/relay-error-ctas.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +114 -0
- package/server/public/app/relay-thoughts.mjs +98 -0
- package/server/public/app/relay-watchdog-policy.mjs +29 -0
- package/server/public/app/repo-browser-preferences.mjs +79 -0
- package/server/public/app/repo-browser-tree-state.mjs +97 -0
- package/server/public/app/router.js +657 -0
- package/server/public/app/selection-guard.mjs +93 -0
- package/server/public/app/settings-modal.js +1484 -0
- package/server/public/app/settings-tabs.js +129 -0
- package/server/public/app/slash-autocomplete.mjs +188 -0
- package/server/public/app/slash-commands.mjs +183 -0
- package/server/public/app/socket-handlers.js +703 -0
- package/server/public/app/status-store.mjs +242 -0
- package/server/public/app/status-view.mjs +315 -0
- package/server/public/app/store.js +1454 -0
- package/server/public/app/stream-state.mjs +73 -0
- package/server/public/app/streaming-dom-patch.mjs +23 -0
- package/server/public/app/sync-outbox.mjs +208 -0
- package/server/public/app/tap-actions.js +62 -0
- package/server/public/app/thread-order.mjs +54 -0
- package/server/public/app/tmux-inspector-view.js +422 -0
- package/server/public/app/transcript-separators.mjs +282 -0
- package/server/public/app-icon-192.png +0 -0
- package/server/public/app-icon-512.png +0 -0
- package/server/public/app-icon.svg +4 -0
- package/server/public/favicon.ico +0 -0
- package/server/public/index.html +5743 -0
- package/server/public/manifest.webmanifest +32 -0
- package/server/public/sw.js +307 -0
- package/server/relay-exit-codes.mjs +4 -0
- package/server/relay-self-restart.mjs +170 -0
- package/server/relay-tools.md +27 -0
- package/server/relay.mjs +1026 -0
- package/server/repositories/image-conversation-repository.mjs +317 -0
- package/server/repositories/message-repository.mjs +434 -0
- package/server/repositories/question-repository.mjs +161 -0
- package/server/repositories/session-repository.mjs +320 -0
- package/server/repositories/share-visibility-statements.mjs +24 -0
- package/server/routes/ask-user-routes.mjs +292 -0
- package/server/routes/cache-routes.mjs +44 -0
- package/server/routes/git-routes.mjs +92 -0
- package/server/routes/messages-routes-test-harness.mjs +111 -0
- package/server/routes/messages-routes.mjs +7107 -0
- package/server/routes/preview-routes.mjs +108 -0
- package/server/routes/push-routes.mjs +59 -0
- package/server/routes/relay-board-routes.mjs +268 -0
- package/server/routes/sessions-routes.mjs +6150 -0
- package/server/server-runtime.mjs +7463 -0
- package/server/server.js +24 -0
- package/server/services/ask-user-routing-service.mjs +117 -0
- package/server/services/cache-rebuild-service.mjs +236 -0
- package/server/services/claude-auth-service.mjs +578 -0
- package/server/services/claude-context-usage.mjs +240 -0
- package/server/services/claude-session-root-service.mjs +228 -0
- package/server/services/cli-install-service.mjs +1152 -0
- package/server/services/cli-process-runner.mjs +181 -0
- package/server/services/cloudflared-tunnel-service.mjs +358 -0
- package/server/services/context-snapshot-service.mjs +499 -0
- package/server/services/context-usage-view.mjs +160 -0
- package/server/services/conversation-preferences-service.mjs +208 -0
- package/server/services/cursor-dashboard-usage.mjs +217 -0
- package/server/services/cursor-session-root-service.mjs +62 -0
- package/server/services/delete-archive-service.mjs +154 -0
- package/server/services/drives-path-helpers.mjs +66 -0
- package/server/services/fixtures/claude-auth-stub.sh +62 -0
- package/server/services/fixtures/cli-install-stub.sh +75 -0
- package/server/services/fixtures/grok-stub.sh +86 -0
- package/server/services/generated-image-cleanup-service.mjs +114 -0
- package/server/services/git-changes-service.mjs +195 -0
- package/server/services/github-billing-usage.mjs +133 -0
- package/server/services/grok-auth-service.mjs +593 -0
- package/server/services/grok-billing-usage.mjs +120 -0
- package/server/services/image-context-service.mjs +106 -0
- package/server/services/image-operation-service.mjs +135 -0
- package/server/services/keyed-mutex-service.mjs +82 -0
- package/server/services/mime-sniffer.mjs +118 -0
- package/server/services/oar-cli-helpers.mjs +109 -0
- package/server/services/oar-state-migration-service.mjs +243 -0
- package/server/services/plan-usage-claude.mjs +387 -0
- package/server/services/plan-usage-contract.mjs +271 -0
- package/server/services/plan-usage-copilot.mjs +421 -0
- package/server/services/plan-usage-cursor.mjs +566 -0
- package/server/services/plan-usage-grok.mjs +436 -0
- package/server/services/plan-usage-service.mjs +487 -0
- package/server/services/preview-health-probe.mjs +82 -0
- package/server/services/preview-proxy-server.mjs +498 -0
- package/server/services/preview-registry-service.mjs +457 -0
- package/server/services/preview-static-handler.mjs +237 -0
- package/server/services/provider-model-selection.mjs +91 -0
- package/server/services/provider-reasoning-effort.mjs +94 -0
- package/server/services/push-active-device-service.mjs +57 -0
- package/server/services/push-dispatch-service.mjs +459 -0
- package/server/services/rate-limit-service.mjs +63 -0
- package/server/services/relay-bridge-owner-service.mjs +84 -0
- package/server/services/relay-cli-launcher-service.mjs +209 -0
- package/server/services/relay-prompt-sanitizer.mjs +137 -0
- package/server/services/relay-queue-gate-service.mjs +38 -0
- package/server/services/relay-restart-orchestrator-service.mjs +935 -0
- package/server/services/relay-singleton-guard.mjs +124 -0
- package/server/services/safe-served-content.mjs +57 -0
- package/server/services/sdk-session-import-service.mjs +271 -0
- package/server/services/sdk-session-sync-service.mjs +207 -0
- package/server/services/session-history-refresh-service.mjs +497 -0
- package/server/services/session-transcript-service.mjs +419 -0
- package/server/services/session-worker-launch-service.mjs +786 -0
- package/server/services/session-worker-process-service.mjs +319 -0
- package/server/services/session-worker-registry-service.mjs +194 -0
- package/server/services/session-worker-stop-service.mjs +141 -0
- package/server/services/session-worker-supervisor-service.mjs +898 -0
- package/server/services/session-worker-websocket-service.mjs +483 -0
- package/server/services/ssh-tunnel-manager-service.mjs +423 -0
- package/server/services/status-event-service.mjs +158 -0
- package/server/services/tmux-inspector-access-policy.mjs +77 -0
- package/server/services/tmux-inspector-socket-service.mjs +191 -0
- package/server/services/tmux-inspector-stream-service.mjs +552 -0
- package/server/services/tunnel-worker-path-guard.mjs +107 -0
- package/server/services/upload-sweep.mjs +60 -0
- package/server/services/usage-snapshot-helpers.mjs +209 -0
- package/server/services/windows-autostart-service.mjs +115 -0
- package/server/services/workspace-root-defaults-service.mjs +34 -0
- package/server/services/workspace-root-path-policy.mjs +196 -0
- package/server/services/workspace-root-relaunch-service.mjs +132 -0
- package/server/services/workspace-symlink-guard.mjs +54 -0
- package/server/tools/capture-proxy.mjs +173 -0
- package/server/tty-console-bootstrap.mjs +75 -0
- package/server/uploads/README.md +36 -0
- package/server/uploads/image_input.md +540 -0
- package/server/windows-terminal-launcher.mjs +29 -0
- package/server/workspace-root.mjs +87 -0
- package/shared/ask-user-bridge.mjs +131 -0
- package/shared/auto-compact-window.mjs +116 -0
- package/shared/background-task-timeout.mjs +52 -0
- package/shared/claude-thinking.mjs +101 -0
- package/shared/context-window-fallbacks.mjs +148 -0
- package/shared/control-poller.mjs +115 -0
- package/shared/empty-turn-completion.mjs +20 -0
- package/shared/model-catalog-freshness.mjs +11 -0
- package/shared/model-descriptors.mjs +118 -0
- package/shared/model-id.mjs +147 -0
- package/shared/model-refresh.mjs +24 -0
- package/shared/openai-reasoning.mjs +13 -0
- package/shared/plan-lines.mjs +13 -0
- package/shared/preview-instructions.mjs +77 -0
- package/shared/preview-tool-core.mjs +191 -0
- package/shared/provider-routing.mjs +29 -0
- package/shared/question-schema.mjs +315 -0
- package/shared/question-timeout.mjs +18 -0
- package/shared/stream-emit-gating.mjs +28 -0
- package/shared/subagent-run-id.mjs +17 -0
- package/shared/thought-cap.mjs +13 -0
- package/shared/tool-activity.mjs +98 -0
- package/shared/turn-ceiling.mjs +53 -0
- package/shared/worker-bootstrap.mjs +44 -0
- 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, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/"/g, '"')
|
|
38
|
+
.replace(/'/g, ''');
|
|
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
|
+
}
|