@oar-sh/oar 0.9.20 → 0.9.21

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 (95) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-session-process.mjs +7 -0
  9. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  13. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  15. package/server/grok-worker/grok-session-worker.mjs +12 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +3 -0
  17. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  18. package/server/public/app/about-ui.js +1 -1
  19. package/server/public/app/about-view.mjs +19 -0
  20. package/server/public/app/action-confirmations.js +1 -1
  21. package/server/public/app/api-client.js +4 -2
  22. package/server/public/app/ask-user-view.js +20 -6
  23. package/server/public/app/attachments-view.js +1 -1
  24. package/server/public/app/back-guard.mjs +31 -5
  25. package/server/public/app/bootstrap.js +556 -68
  26. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  27. package/server/public/app/chat-header-meta.mjs +27 -0
  28. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  29. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  30. package/server/public/app/composer-settings-label.mjs +90 -0
  31. package/server/public/app/composer-settings-ui.js +442 -0
  32. package/server/public/app/context-menu.mjs +36 -5
  33. package/server/public/app/conversation-deep-link.mjs +15 -11
  34. package/server/public/app/conversation-group-drag.mjs +12 -2
  35. package/server/public/app/conversation-group-order.mjs +9 -2
  36. package/server/public/app/conversation-group-state.mjs +47 -10
  37. package/server/public/app/conversation-origin-ui.js +17 -1
  38. package/server/public/app/conversation-view.js +3 -0
  39. package/server/public/app/cwd-menu-placement.mjs +18 -6
  40. package/server/public/app/cwd-picker.js +36 -6
  41. package/server/public/app/emoji-view.js +1 -1
  42. package/server/public/app/font-scaling.js +26 -0
  43. package/server/public/app/header-priority-actions.mjs +234 -0
  44. package/server/public/app/hidden-messages-view.mjs +4 -0
  45. package/server/public/app/inbox-ui.js +18 -2
  46. package/server/public/app/journal-view.js +311 -58
  47. package/server/public/app/phone-landscape-ui.js +225 -0
  48. package/server/public/app/phone-landscape.mjs +99 -0
  49. package/server/public/app/phone-typing-state.mjs +104 -0
  50. package/server/public/app/pinned-messages-view.mjs +5 -0
  51. package/server/public/app/push-settings.js +2 -2
  52. package/server/public/app/pwa-install.js +24 -3
  53. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  54. package/server/public/app/relay-event-view.mjs +38 -0
  55. package/server/public/app/relay-restart-ui.js +2 -2
  56. package/server/public/app/relay-schedule-view.mjs +32 -6
  57. package/server/public/app/relay-schedules-ui.js +69 -20
  58. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  59. package/server/public/app/session-history-ui.js +250 -0
  60. package/server/public/app/session-history.mjs +599 -0
  61. package/server/public/app/settings-modal.js +61 -6
  62. package/server/public/app/settings-tabs.js +12 -0
  63. package/server/public/app/share-state.js +1 -1
  64. package/server/public/app/shared-conversations-settings.js +2 -2
  65. package/server/public/app/socket-handlers.js +5 -2
  66. package/server/public/app/status-view.mjs +18 -2
  67. package/server/public/app/store.js +87 -16
  68. package/server/public/app/subagent-viewer.mjs +27 -2
  69. package/server/public/app/tab-strip-fade.mjs +101 -0
  70. package/server/public/app/tap-actions.js +66 -0
  71. package/server/public/app/usage-limit-ui.js +63 -2
  72. package/server/public/app/viewer-history.mjs +13 -2
  73. package/server/public/index.html +2249 -1590
  74. package/server/relay-tools.md +1 -0
  75. package/server/repositories/message-repository.mjs +23 -0
  76. package/server/repositories/session-repository.mjs +19 -0
  77. package/server/routes/claude-cloud-routes.mjs +19 -0
  78. package/server/routes/messages-routes.mjs +57 -1
  79. package/server/routes/sessions-routes.mjs +41 -7
  80. package/server/server-runtime.mjs +190 -40
  81. package/server/services/about-service.mjs +22 -53
  82. package/server/services/claude-billing-swap-service.mjs +7 -20
  83. package/server/services/claude-cloud-session-service.mjs +47 -5
  84. package/server/services/remote-relay-dispatcher.mjs +61 -14
  85. package/server/services/session-activity-tracker.mjs +1 -1
  86. package/server/services/session-launch-mutex-service.mjs +23 -0
  87. package/server/services/session-worker-registry-service.mjs +36 -0
  88. package/server/services/session-worker-websocket-service.mjs +40 -16
  89. package/server/services/worker-recycle-service.mjs +772 -0
  90. package/server/services/worker-replacement.mjs +197 -0
  91. package/shared/build-id.mjs +128 -0
  92. package/shared/worker-runtime/heartbeat.mjs +5 -0
  93. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  94. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  95. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -1,4 +1,4 @@
1
- // The header's ⋯ menu: one decision for its trigger and for every entry, with
1
+ // The header's ︙ menu: one decision for its trigger and for every entry, with
2
2
  // or without a conversation open. No DOM here; bootstrap.js applies the result.
3
3
  //
4
4
  // The menu holds two kinds of entries. Relay-wide ones (Settings, About OAR,
@@ -12,10 +12,27 @@
12
12
  //
13
13
  // The shared read-only view is its own branch: Day/Night only, whatever the
14
14
  // conversation state, and the trigger stays usable even when the shared
15
- // conversation failed to load.
15
+ // conversation failed to load. Once Day/Night fits in the header the shared
16
+ // menu is empty and the trigger hides.
17
+ //
18
+ // The twins of the header's priority+ actions (pins, hidden, search, context,
19
+ // Day/Night, Check Usage, fullscreen) show only while their header button
20
+ // does not fit; the header's fit pass hands in which ones that is.
21
+ //
22
+ // A phone on its side hides the header's meta line, and with it the chips
23
+ // that open something (the watching chip's list, the schedule chip's list,
24
+ // the origin's conversation). There, and only for a chip the conversation
25
+ // has, the menu carries an entry that does the same.
16
26
 
17
27
  export const CHAT_ACTIONS_MENU_TRIGGER_ID = 'chat-actions-menu-btn';
18
28
 
29
+ /** The stand-ins for the meta line's chips in phone landscape, by chip. */
30
+ export const CHAT_ACTIONS_MENU_META_CHIP_IDS = Object.freeze({
31
+ watching: 'chat-menu-watching',
32
+ schedules: 'chat-menu-schedules',
33
+ origin: 'chat-menu-origin',
34
+ });
35
+
19
36
  /** Entries that are text, not actions: `hidden` is applied, nothing else. */
20
37
  export const CHAT_ACTIONS_MENU_META_IDS = Object.freeze([
21
38
  'chat-menu-pwa-version',
@@ -26,9 +43,17 @@ export const CHAT_ACTIONS_MENU_META_IDS = Object.freeze([
26
43
  export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
27
44
  'chat-menu-pwa-version',
28
45
  'chat-menu-queue-status',
46
+ 'chat-menu-watching',
47
+ 'chat-menu-schedules',
48
+ 'chat-menu-origin',
29
49
  'chat-menu-edit-title',
50
+ 'chat-menu-pins',
51
+ 'chat-menu-hidden',
52
+ 'chat-menu-search',
53
+ 'chat-menu-context',
30
54
  'chat-menu-theme-toggle',
31
55
  'chat-menu-usage',
56
+ 'chat-menu-fullscreen',
32
57
  'chat-menu-compact',
33
58
  'chat-menu-share-conversation',
34
59
  'chat-menu-stop-sharing',
@@ -51,6 +76,35 @@ export const CHAT_ACTIONS_MENU_ENTRY_IDS = Object.freeze([
51
76
  /** Entries that are never shown: retired actions kept in the markup. */
52
77
  const ALWAYS_HIDDEN_IDS = new Set(['chat-menu-compact', 'chat-menu-refresh-history']);
53
78
 
79
+ /**
80
+ * The twins of the header's priority+ actions (header-priority-actions.mjs):
81
+ * each shows here only while its header button does not fit. Kept in sync
82
+ * with HEADER_PRIORITY_MENU_IDS by the tests.
83
+ */
84
+ export const CHAT_ACTIONS_MENU_TWIN_IDS = Object.freeze([
85
+ 'chat-menu-pins',
86
+ 'chat-menu-hidden',
87
+ 'chat-menu-search',
88
+ 'chat-menu-context',
89
+ 'chat-menu-usage',
90
+ 'chat-menu-theme-toggle',
91
+ 'chat-menu-fullscreen',
92
+ ]);
93
+
94
+ // Before the header was ever measured, the menu keeps the reach it always
95
+ // had: Day/Night and Check Usage. The other twins stay hidden, their header
96
+ // buttons show as they always did.
97
+ const UNMEASURED_TWIN_IDS = new Set(['chat-menu-theme-toggle', 'chat-menu-usage']);
98
+
99
+ // The shared read-only view offers Day/Night and nothing else.
100
+ const SHARED_TWIN_IDS = new Set(['chat-menu-theme-toggle']);
101
+
102
+ function twinShown(id, { sharedMode, headerMenuIds }) {
103
+ if (sharedMode && !SHARED_TWIN_IDS.has(id)) return false;
104
+ if (!Array.isArray(headerMenuIds)) return UNMEASURED_TWIN_IDS.has(id);
105
+ return headerMenuIds.includes(id);
106
+ }
107
+
54
108
  const shown = (disabled = false) => ({ hidden: false, disabled: !!disabled });
55
109
  const hidden = () => ({ hidden: true, disabled: true });
56
110
 
@@ -65,6 +119,17 @@ const hidden = () => ({ hidden: true, disabled: true });
65
119
  * @param {boolean} [input.editing] its title is being edited
66
120
  * @param {boolean} [input.suspendVisible] the "show Suspend host" setting
67
121
  * @param {boolean} [input.hasActiveShare] it has a public share link
122
+ * @param {string[]|null} [input.headerMenuIds] the twin entries the header's
123
+ * fit pass put into the menu (available and not fitting); null before the
124
+ * header was measured
125
+ * @param {boolean} [input.fullscreenSupported] the browser can go fullscreen
126
+ * (requestFullscreen exists); the twin is disabled otherwise, as the header
127
+ * button is
128
+ * @param {boolean} [input.phoneLandscape] :root[data-phone-landscape], where
129
+ * the header's meta line is hidden
130
+ * @param {{ watching?: boolean, schedules?: boolean, origin?: boolean }} [input.metaChips]
131
+ * the meta line's chips the conversation has (a watching chip, a schedule
132
+ * chip, an origin that opens its conversation)
68
133
  * @returns {{ trigger: { hidden: boolean, disabled: boolean }, entries: Record<string, { hidden: boolean, disabled: boolean }> }}
69
134
  */
70
135
  export function resolveChatActionsMenuState({
@@ -77,12 +142,19 @@ export function resolveChatActionsMenuState({
77
142
  editing = false,
78
143
  suspendVisible = false,
79
144
  hasActiveShare = false,
145
+ headerMenuIds = null,
146
+ fullscreenSupported = true,
147
+ phoneLandscape = false,
148
+ metaChips = null,
80
149
  } = {}) {
81
150
  const entries = {};
151
+ const twins = { sharedMode: !!sharedMode, headerMenuIds };
82
152
  if (sharedMode) {
83
153
  for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = hidden();
84
- entries['chat-menu-theme-toggle'] = shown();
85
- return { trigger: { hidden: false, disabled: false }, entries };
154
+ for (const id of CHAT_ACTIONS_MENU_TWIN_IDS) entries[id] = twinShown(id, twins) ? shown() : hidden();
155
+ // With Day/Night in the header there is nothing left to open.
156
+ const empty = !CHAT_ACTIONS_MENU_ENTRY_IDS.some((id) => !entries[id].hidden);
157
+ return { trigger: { hidden: empty, disabled: false }, entries };
86
158
  }
87
159
 
88
160
  const convId = String(conversationId || '').trim();
@@ -94,6 +166,14 @@ export function resolveChatActionsMenuState({
94
166
 
95
167
  for (const id of CHAT_ACTIONS_MENU_ENTRY_IDS) entries[id] = shown();
96
168
  for (const id of ALWAYS_HIDDEN_IDS) entries[id] = hidden();
169
+ for (const id of CHAT_ACTIONS_MENU_TWIN_IDS) entries[id] = twinShown(id, twins) ? shown() : hidden();
170
+ // Offered where the browser has no fullscreen (iPhone): disabled, as its
171
+ // header button is, rather than a live entry that does nothing.
172
+ if (!entries['chat-menu-fullscreen'].hidden && !fullscreenSupported) entries['chat-menu-fullscreen'] = shown(true);
173
+ // The meta line's chips, standing in where that line is hidden.
174
+ for (const [chip, id] of Object.entries(CHAT_ACTIONS_MENU_META_CHIP_IDS)) {
175
+ entries[id] = hasConversation && phoneLandscape && !!metaChips?.[chip] ? shown() : hidden();
176
+ }
97
177
  entries['chat-menu-edit-title'] = perConversation();
98
178
  entries['chat-menu-share-conversation'] = perConversation();
99
179
  // Only a conversation with a live link has something to stop.
@@ -119,6 +199,61 @@ export function resolveChatActionsMenuState({
119
199
  };
120
200
  }
121
201
 
202
+ /**
203
+ * The labels of the meta line's stand-ins: "👁 Watching 2 sessions…" (from the
204
+ * watching chip's text), "⏰ Schedules…", and the origin's line ("Started by
205
+ * an agent in “title”"). An entry with no text gets ''.
206
+ *
207
+ * @param {{ watching?: string, origin?: string }} input
208
+ * @returns {{ watching: string, schedules: string, origin: string }}
209
+ */
210
+ export function chatActionsMenuMetaChipLabels({ watching = '', origin = '' } = {}) {
211
+ const watchingText = String(watching || '').trim();
212
+ const originText = String(origin || '').trim();
213
+ return {
214
+ watching: watchingText ? `👁 ${watchingText.charAt(0).toUpperCase()}${watchingText.slice(1)}…` : '',
215
+ schedules: '⏰ Schedules…',
216
+ origin: originText ? `↗ ${originText}` : '',
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Where the open menu goes. It hangs from ︙ with its right edge on the
222
+ * button's; this keeps it inside `bounds` (the viewport and #main, so never
223
+ * off-screen and never under the sidebar) with `margin` to spare, narrows it
224
+ * when even that is too wide, and caps its height at what is left below its
225
+ * top (the menu then scrolls).
226
+ *
227
+ * @param {object} input
228
+ * @param {{ left: number, right: number, top: number }} input.rect the menu's box as CSS placed it
229
+ * @param {{ left: number, right: number, bottom: number }} input.bounds the area it must stay in
230
+ * @param {number} [input.margin] the gap it keeps from the bounds
231
+ * @param {number} [input.minHeight] the height it keeps at least
232
+ * @returns {{ right: number, maxWidth: number|null, maxHeight: number }}
233
+ * `right`: how far to move its right edge to the left of where CSS put it
234
+ * (negative: to the right); `maxWidth`: null when it fits as it is
235
+ */
236
+ export function resolveChatActionsMenuPlacement({ rect, bounds, margin = 8, minHeight = 120 } = {}) {
237
+ const left = Number(rect?.left) || 0;
238
+ const right = Number(rect?.right) || 0;
239
+ const top = Number(rect?.top) || 0;
240
+ const width = Math.max(0, right - left);
241
+ const minLeft = (Number(bounds?.left) || 0) + margin;
242
+ const maxRight = (Number(bounds?.right) || 0) - margin;
243
+ const room = Math.max(0, maxRight - minLeft);
244
+ const fitWidth = Math.min(width, room);
245
+ let nextLeft = right - fitWidth;
246
+ if (nextLeft + fitWidth > maxRight) nextLeft = maxRight - fitWidth;
247
+ if (nextLeft < minLeft) nextLeft = minLeft;
248
+ const nextRight = nextLeft + fitWidth;
249
+ const bottom = Number(bounds?.bottom) || 0;
250
+ return {
251
+ right: Math.round((right - nextRight) * 100) / 100,
252
+ maxWidth: width > room ? Math.floor(room) : null,
253
+ maxHeight: Math.floor(Math.max(minHeight, bottom - margin - top)),
254
+ };
255
+ }
256
+
122
257
  /** The ids of the entries a state shows, in menu order. */
123
258
  export function visibleChatActionsMenuEntryIds(state) {
124
259
  const entries = state?.entries || {};
@@ -0,0 +1,27 @@
1
+ // The lines under the chat title (origin, relay chips, folder) come with a
2
+ // long and a short label. On a desktop the long one shows; on a phone the
3
+ // meta lines share one row that scrolls sideways and show the short one. CSS
4
+ // picks (index.html, .chat-meta-long / .chat-meta-short). DOM-free.
5
+
6
+ export const CHAT_META_LONG_CLASS = 'chat-meta-long';
7
+ export const CHAT_META_SHORT_CLASS = 'chat-meta-short';
8
+
9
+ /** Both labels; the arguments are HTML the caller already escaped. */
10
+ export function chatMetaLabelHtml(longHtml, shortHtml) {
11
+ return `<span class="${CHAT_META_LONG_CLASS}">${longHtml}</span><span class="${CHAT_META_SHORT_CLASS}">${shortHtml}</span>`;
12
+ }
13
+
14
+ /**
15
+ * The last folder name of a path, for the phone's folder chip:
16
+ * "/home/dev/sample-repo/" → "sample-repo", "C:\\work\\api" → "api". A root
17
+ * ("/", "C:\\") is kept whole; '' for nothing.
18
+ */
19
+ export function cwdFolderName(path) {
20
+ const raw = String(path ?? '').trim();
21
+ if (!raw) return '';
22
+ const trimmed = raw.replace(/[\\/]+$/, '');
23
+ if (!trimmed) return raw.slice(0, 1);
24
+ if (/^[A-Za-z]:$/.test(trimmed)) return raw;
25
+ const parts = trimmed.split(/[\\/]+/).filter(Boolean);
26
+ return parts.length ? parts[parts.length - 1] : raw;
27
+ }
@@ -1,5 +1,6 @@
1
1
  // The composer's billing toggle (claude-billing-toggle.mjs) on the page: a
2
- // row above the composer, under the lock note, in Claude SDK chats. A press
2
+ // field of the ⚙ panel, under Model, in Claude SDK chats; the label row's
3
+ // billing pill (composer-settings-ui.js) shows its selected side. A press
3
4
  // moves the open chat between the subscription and the saved API key through
4
5
  // the relay's billing route; the relay's answer and its `conversation_billing`
5
6
  // events (usage-limit-ui.js keeps them per chat) redraw it.
@@ -7,7 +7,7 @@
7
7
  // and the server's `cloud` payload field), so the one sync function below is
8
8
  // idempotent and is simply called whenever the header is synced. A
9
9
  // conversation of any other provider gets every element back exactly as it was.
10
- // The ⋯ menu is not touched here: its entries are decided in one place
10
+ // The ︙ menu is not touched here: its entries are decided in one place
11
11
  // (chat-actions-menu-state.mjs), which reads the cloud state through
12
12
  // isCurrentConversationClaudeCloud() and currentCloudSessionUrl().
13
13
 
@@ -57,7 +57,7 @@ export function syncClaudeCloudConversationUi() {
57
57
  line.hidden = !lineHtml;
58
58
  }
59
59
 
60
- // The ⋯ menu entries (no Change CWD for a cloud chat, Open on claude.ai once
60
+ // The ︙ menu entries (no Change CWD for a cloud chat, Open on claude.ai once
61
61
  // there is a link) are decided with the rest of the menu in
62
62
  // chat-actions-menu-state.mjs, from `isCloud` and currentCloudSessionUrl().
63
63
 
@@ -0,0 +1,90 @@
1
+ // The composer's settings label ("🔒 Opus 5.5 · high · Auto · 1M") and
2
+ // its billing pill. Pure and DOM-free: composer-settings-ui.js reads the
3
+ // selects' shown option texts and hands them in.
4
+
5
+ export const COMPOSER_LABEL_SEPARATOR = ' · ';
6
+ export const COMPOSER_LOCK_GLYPH = '🔒';
7
+
8
+ // The effort select's placeholder when a model has no effort choice.
9
+ const UNAVAILABLE_EFFORT = 'unavailable';
10
+
11
+ // Values of the context-window select that are its default: the model's base
12
+ // window, or "auto" when the select is the image-size picker.
13
+ const DEFAULT_TIER_VALUES = new Set(['', 'default', 'auto']);
14
+
15
+ // Mode names the label shortens; the panel's select keeps the full name.
16
+ const SHORT_MODE_NAMES = new Map([['autopilot', 'Auto']]);
17
+
18
+ function clean(value) {
19
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
20
+ }
21
+
22
+ /**
23
+ * The label's parts, in order: model, effort, mode, context window.
24
+ * Each is `{ field, text }`; `field` names the panel field a tap on it opens
25
+ * ('model' | 'effort' | 'mode' | 'tier'). The model part carries `locked`.
26
+ *
27
+ * - `cloud`: a cloud chat has no mode, effort or context window.
28
+ * - an effort with no value or "Unavailable" is left out.
29
+ * - the context window shows only when it is not the default.
30
+ * - a hidden field (`hidden.mode` etc.) is left out.
31
+ */
32
+ export function composerSettingsLabelParts({
33
+ model = '',
34
+ locked = false,
35
+ effort = '',
36
+ effortValue = undefined,
37
+ mode = '',
38
+ tier = '',
39
+ tierValue = '',
40
+ cloud = false,
41
+ hidden = {},
42
+ } = {}) {
43
+ const parts = [];
44
+ const modelText = clean(model);
45
+ if (modelText && !hidden.model) {
46
+ parts.push({ field: 'model', text: modelText, locked: locked === true });
47
+ }
48
+ if (!cloud && !hidden.effort) {
49
+ const effortText = clean(effort);
50
+ const effortEmpty = effortValue !== undefined && clean(effortValue) === '';
51
+ if (effortText && !effortEmpty && effortText.toLowerCase() !== UNAVAILABLE_EFFORT) {
52
+ parts.push({ field: 'effort', text: effortText });
53
+ }
54
+ }
55
+ if (!cloud && !hidden.mode) {
56
+ const modeText = clean(mode);
57
+ if (modeText) parts.push({ field: 'mode', text: SHORT_MODE_NAMES.get(modeText.toLowerCase()) || modeText });
58
+ }
59
+ if (!cloud && !hidden.tier) {
60
+ const tierText = clean(tier);
61
+ const value = clean(tierValue).toLowerCase();
62
+ if (tierText && !DEFAULT_TIER_VALUES.has(value)) parts.push({ field: 'tier', text: tierText });
63
+ }
64
+ return parts;
65
+ }
66
+
67
+ /** A part as it reads in the label: the model gets the lock in front. */
68
+ export function composerSettingsPartText(part) {
69
+ if (!part) return '';
70
+ return part.field === 'model' && part.locked ? `${COMPOSER_LOCK_GLYPH} ${part.text}` : part.text;
71
+ }
72
+
73
+ /** The whole label as one string, e.g. "🔒 Opus 5.5 · high · Autopilot · 1M". */
74
+ export function composerSettingsLabelText(input = {}) {
75
+ const parts = Array.isArray(input) ? input : composerSettingsLabelParts(input);
76
+ return parts.map(composerSettingsPartText).filter(Boolean).join(COMPOSER_LABEL_SEPARATOR);
77
+ }
78
+
79
+ /**
80
+ * The billing pill beside the label, from the billing toggle's state:
81
+ * `{ visible, billing, text }`. Hidden when the toggle is hidden or says
82
+ * no billing.
83
+ */
84
+ export function composerBillingPill({ toggleVisible = false, billing = '' } = {}) {
85
+ const value = clean(billing).toLowerCase();
86
+ if (!toggleVisible || (value !== 'claude' && value !== 'claude-api')) {
87
+ return { visible: false, billing: '', text: '' };
88
+ }
89
+ return { visible: true, billing: value, text: value === 'claude-api' ? 'API key' : 'Subscription' };
90
+ }