mouaif 0.3.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 (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +140 -0
  3. package/bin/mouaif.js +281 -0
  4. package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
  5. package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
  6. package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
  7. package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
  8. package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
  9. package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
  10. package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
  11. package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
  12. package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
  13. package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
  14. package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
  15. package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
  16. package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
  17. package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
  18. package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
  19. package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
  20. package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
  21. package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
  22. package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
  23. package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
  24. package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
  25. package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
  26. package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
  27. package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
  28. package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
  29. package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
  30. package/frontend/dist/assets/index-CANPYzQg.css +1 -0
  31. package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
  32. package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
  33. package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
  34. package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
  35. package/frontend/dist/icons/favicon-32.png +0 -0
  36. package/frontend/dist/icons/icon-180-apple.png +0 -0
  37. package/frontend/dist/icons/icon-192.png +0 -0
  38. package/frontend/dist/icons/icon-512.png +0 -0
  39. package/frontend/dist/icons/icon-maskable-512.png +0 -0
  40. package/frontend/dist/index.html +83 -0
  41. package/frontend/dist/manifest.webmanifest +33 -0
  42. package/frontend/dist/sw.js +482 -0
  43. package/package.json +98 -0
  44. package/scripts/patch-zimmerframe.js +58 -0
  45. package/src/access-auth.js +515 -0
  46. package/src/agentFeatures.js +294 -0
  47. package/src/agentFiles.js +164 -0
  48. package/src/agentSkills.js +147 -0
  49. package/src/agents.js +230 -0
  50. package/src/ai-chat.js +21 -0
  51. package/src/ai-endpoints.js +1880 -0
  52. package/src/ai-stream.js +2048 -0
  53. package/src/ai.js +68 -0
  54. package/src/auth.js +391 -0
  55. package/src/chatdb.js +816 -0
  56. package/src/chats.js +275 -0
  57. package/src/custom-actions.js +65 -0
  58. package/src/files.js +431 -0
  59. package/src/hideFileContent.js +327 -0
  60. package/src/http-server.js +535 -0
  61. package/src/index.js +15 -0
  62. package/src/inspector.js +731 -0
  63. package/src/inspectorProfiles.js +503 -0
  64. package/src/live-chat.js +107 -0
  65. package/src/mcp.js +1517 -0
  66. package/src/messages.js +238 -0
  67. package/src/modelList.js +137 -0
  68. package/src/notifications.js +52 -0
  69. package/src/oauth-anthropic.js +280 -0
  70. package/src/oauth-github-copilot.js +417 -0
  71. package/src/oauth-mcp.js +216 -0
  72. package/src/oauth-openrouter.js +285 -0
  73. package/src/package-version.js +20 -0
  74. package/src/projects.js +285 -0
  75. package/src/promptProfiles.js +256 -0
  76. package/src/prompts.js +384 -0
  77. package/src/providerShapes.js +44 -0
  78. package/src/providers/base.js +41 -0
  79. package/src/providers/index.js +25 -0
  80. package/src/push.js +315 -0
  81. package/src/qr.js +192 -0
  82. package/src/restart.js +47 -0
  83. package/src/server-handlers-access.js +306 -0
  84. package/src/server-handlers-actions.js +100 -0
  85. package/src/server-handlers-ai.js +248 -0
  86. package/src/server-handlers-auth.js +273 -0
  87. package/src/server-handlers-chats.js +1436 -0
  88. package/src/server-handlers-git.js +467 -0
  89. package/src/server-handlers-mcp-oauth.js +56 -0
  90. package/src/server-handlers-misc.js +783 -0
  91. package/src/server-handlers-projects.js +289 -0
  92. package/src/server-handlers-prompts.js +259 -0
  93. package/src/server-handlers-push.js +102 -0
  94. package/src/server-handlers-settings.js +406 -0
  95. package/src/server-handlers-tools.js +654 -0
  96. package/src/server-handlers-transcribe.js +399 -0
  97. package/src/server-shared.js +780 -0
  98. package/src/server-web-static.js +191 -0
  99. package/src/settings.js +898 -0
  100. package/src/statusBar.js +541 -0
  101. package/src/tags.js +414 -0
  102. package/src/toolFeedback.js +225 -0
  103. package/src/tools/ask.js +154 -0
  104. package/src/tools/authorization.js +932 -0
  105. package/src/tools/files.js +1150 -0
  106. package/src/tools/progress.js +71 -0
  107. package/src/tools/restart.js +32 -0
  108. package/src/tools/searchEngine.js +957 -0
  109. package/src/tools/shell.js +341 -0
  110. package/src/tools/subagent.js +47 -0
  111. package/src/tools/task.js +234 -0
  112. package/src/tools/webpreview.js +448 -0
  113. package/src/trace.js +103 -0
  114. package/src/transcribe.js +683 -0
  115. package/src/usage.js +389 -0
  116. package/src/util.js +151 -0
@@ -0,0 +1,541 @@
1
+ 'use strict';
2
+
3
+ // Status bar — the ASCII bar in every OS chat notification.
4
+ //
5
+ // Three things decide what that bar can look like, and all three vary per
6
+ // device:
7
+ //
8
+ // 1. Screen width. Every phone size gives a different number of characters
9
+ // per body line, so a fixed cell count is wrong somewhere. The device
10
+ // measures its own capacity and the cell count follows it continuously
11
+ // (no small/medium/large buckets).
12
+ // 2. Notification style. A collapsed Android notification shows exactly one
13
+ // body line; an iOS banner shows a two-line preview; a desktop toast
14
+ // about two. On a one-line style the bar and its message must share that
15
+ // single line, or the message is never seen.
16
+ // 3. OS version. It sets the fallback line counts when a device cannot
17
+ // measure itself (older WebKit, a headless browser), and it bounds a
18
+ // measurement that is out of line for the reported platform.
19
+ //
20
+ // The server owns this module; the browser only reports raw facts about
21
+ // itself (measured characters, viewport width, OS, OS version) at subscribe
22
+ // time. Keeping every decision here means one table to reason about and one
23
+ // place to test.
24
+
25
+ // ---- Shape constants ---------------------------------------------------
26
+
27
+ // How many cells a bar may use. The floor keeps a bar readable on the
28
+ // narrowest watch-class notification; the ceiling stops a wide desktop toast
29
+ // from turning the bar into a ruler.
30
+ const MIN_CELLS = 4;
31
+ const MAX_CELLS = 32;
32
+ // Characters a bar row adds around its cells: `[` + cells + `]` + ` 100%`.
33
+ const BAR_OVERHEAD_CHARS = 7;
34
+ // On a one-line style the bar shares its line with the message, so it takes
35
+ // at most this share of the line and leaves the rest for the text.
36
+ const INLINE_BAR_SHARE = 1 / 3;
37
+ const MAX_INLINE_CELLS = 16;
38
+ // On a stacked style the bar gets its own row, but keeping it a little
39
+ // shorter than the line means the message row stays the widest row in the
40
+ // notice — the thing a person actually reads — and the bar still reads as a
41
+ // bar rather than a ruler across the card.
42
+ const STACKED_BAR_SHARE = 0.8;
43
+ // Separator between facts sharing a line (the bar + message on an inline row,
44
+ // or the tool/model/time on an activity line).
45
+ const SEPARATOR = ' · ';
46
+ const INLINE_SEPARATOR = SEPARATOR;
47
+ // Below this many characters a message would be a meaningless stub, so a
48
+ // one-line style drops it and keeps the bar instead of a two-letter tease.
49
+ const MIN_INLINE_INFO_CHARS = 8;
50
+ // Detail rows below the bar. A one-line style shares its row with the bar, so
51
+ // it has room for the single most important fact. A multi-line banner or
52
+ // toast shows the bar plus the top few facts; an expanded notification is
53
+ // tall enough for every tier. This is the whole "show more on a bigger
54
+ // device" mechanism — no per-device special case.
55
+ const DETAIL_LINES_INLINE = 1;
56
+ const DETAIL_LINES_PREVIEW = 3;
57
+ const DETAIL_LINES_EXPANDED = 6;
58
+ // Truncation marker for a message clipped to fit a line. Kept ASCII for the
59
+ // same reason the bar is: notification fonts render it consistently.
60
+ const ELLIPSIS = '...';
61
+
62
+ // ---- Per-OS notification capacity --------------------------------------
63
+ //
64
+ // `collapsedChars` is the characters one body line held on a 360 CSS px
65
+ // reference phone for that platform. It is a *fallback and a sanity bound*,
66
+ // never the normal source: a device that can measure itself reports its own
67
+ // number, and this table only steers the cases where it cannot.
68
+ //
69
+ // `collapsedLines` is how many body lines the platform shows without the
70
+ // user expanding the notification — the number that decides whether the bar
71
+ // gets a line of its own. `expandedLines` is how many it shows once the user
72
+ // pulls the notification open.
73
+ const OS_CAPACITY = Object.freeze({
74
+ android: {
75
+ label: 'Android',
76
+ collapsedChars: 34,
77
+ // A collapsed Android notification shows one body line, whatever its
78
+ // size; the title above it is always visible.
79
+ collapsedLines: 1,
80
+ // Notification channels and the tall BigText card arrive in Android 8
81
+ // (Oreo); before that an expanded notification was only a little taller.
82
+ expandedLines: 4,
83
+ versionRules: [
84
+ { minVersion: 8, collapsedLines: 1, expandedLines: 8, note: 'notification channels (Oreo) introduced the tall expanded card' }
85
+ ]
86
+ },
87
+ ios: {
88
+ label: 'iOS',
89
+ collapsedChars: 40,
90
+ // Before iOS 15 a banner previewed a single body line; 15 and later
91
+ // preview two.
92
+ collapsedLines: 1,
93
+ expandedLines: 6,
94
+ versionRules: [
95
+ { minVersion: 15, collapsedLines: 2, expandedLines: 6, note: 'iOS 15 banners preview two body lines' }
96
+ ]
97
+ },
98
+ ipados: { alias: 'ios' },
99
+ macos: {
100
+ label: 'macOS',
101
+ collapsedChars: 52,
102
+ collapsedLines: 2,
103
+ expandedLines: 4,
104
+ versionRules: []
105
+ },
106
+ windows: {
107
+ label: 'Windows',
108
+ collapsedChars: 46,
109
+ collapsedLines: 2,
110
+ expandedLines: 4,
111
+ versionRules: []
112
+ },
113
+ linux: {
114
+ label: 'Linux',
115
+ collapsedChars: 46,
116
+ collapsedLines: 2,
117
+ expandedLines: 4,
118
+ versionRules: []
119
+ },
120
+ chromeos: {
121
+ label: 'ChromeOS',
122
+ collapsedChars: 48,
123
+ collapsedLines: 2,
124
+ expandedLines: 4,
125
+ versionRules: []
126
+ }
127
+ });
128
+
129
+ // A platform the client did not identify behaves like a phone on a
130
+ // conservative platform: one body line, moderate width.
131
+ const UNKNOWN_CAPACITY = Object.freeze({
132
+ label: 'Unknown',
133
+ collapsedChars: 34,
134
+ collapsedLines: 1,
135
+ expandedLines: 4,
136
+ versionRules: []
137
+ });
138
+
139
+ const REFERENCE_VIEWPORT_PX = 360;
140
+ // A measured capacity is trusted only within this band of what the platform's
141
+ // table expects for the reported viewport width. A measurement outside it is
142
+ // more likely a broken probe (a hidden 0-width element, a zoomed layout) than
143
+ // a genuinely unusual device, so it is pulled back to the bound.
144
+ const MEASURE_LOWER_BOUND = 0.6;
145
+ const MEASURE_UPPER_BOUND = 1.6;
146
+
147
+ // normalizeOs(raw) -> key in OS_CAPACITY, or '' when unrecognized.
148
+ function normalizeOs(raw) {
149
+ const value = String(raw == null ? '' : raw).trim().toLowerCase();
150
+ if (!value) return '';
151
+ if (value === 'ios' || value === 'iphone' || value === 'ipad' || value === 'ipados') {
152
+ // iPad reports as "iPadOS" from userAgentData and as "MacIntel" from a
153
+ // missing platform; both are the iOS notification family.
154
+ return value === 'ipados' ? 'ipados' : 'ios';
155
+ }
156
+ if (value.startsWith('android')) return 'android';
157
+ if (value === 'macos' || value === 'mac' || value === 'macintel') return 'macos';
158
+ if (value.startsWith('win')) return 'windows';
159
+ if (value.startsWith('linux')) return 'linux';
160
+ if (value.startsWith('chromeos') || value === 'cros') return 'chromeos';
161
+ return '';
162
+ }
163
+
164
+ // capacityFor(os, majorVersion) -> resolved table entry
165
+ //
166
+ // Applies the ordered version rules for the platform: the first rule whose
167
+ // `minVersion` the device meets wins, so the table reads oldest-first and a
168
+ // device that reports no version keeps the base row.
169
+ function capacityFor(os, majorVersion) {
170
+ const key = normalizeOs(os);
171
+ let entry = key ? OS_CAPACITY[key] : null;
172
+ if (entry && entry.alias) entry = OS_CAPACITY[entry.alias];
173
+ const base = entry || UNKNOWN_CAPACITY;
174
+ const major = Number(majorVersion);
175
+ let resolved = {
176
+ os: key || 'unknown',
177
+ label: base.label,
178
+ collapsedChars: base.collapsedChars,
179
+ collapsedLines: base.collapsedLines,
180
+ expandedLines: base.expandedLines,
181
+ versionMatched: false
182
+ };
183
+ if (!Number.isFinite(major) || major <= 0) return resolved;
184
+ for (const rule of base.versionRules || []) {
185
+ if (major >= rule.minVersion) {
186
+ resolved = Object.assign(resolved, {
187
+ collapsedLines: rule.collapsedLines,
188
+ expandedLines: rule.expandedLines,
189
+ versionMatched: true
190
+ });
191
+ break;
192
+ }
193
+ }
194
+ return resolved;
195
+ }
196
+
197
+ // ---- Plan --------------------------------------------------------------
198
+
199
+ function clamp(value, min, max) {
200
+ return Math.max(min, Math.min(max, value));
201
+ }
202
+
203
+ // clip(text, maxChars) -> text limited to maxChars, ellipsized when cut.
204
+ function clip(text, maxChars) {
205
+ const s = String(text == null ? '' : text).trim();
206
+ const max = Math.floor(maxChars);
207
+ if (max <= 0) return '';
208
+ if (s.length <= max) return s;
209
+ if (max <= ELLIPSIS.length) return s.slice(0, max);
210
+ return s.slice(0, max - ELLIPSIS.length).trimEnd() + ELLIPSIS;
211
+ }
212
+
213
+ // clipSmart(text, maxChars) -> clip() that keeps the part that identifies the
214
+ // thing. Paths, model ids, and tool names are read from their tail (`.../chat.js`,
215
+ // `.../gpt-5-mini`), messages from their head. Splitting on the last separator
216
+ // keeps the filename or the model name visible instead of truncating to a
217
+ // useless prefix.
218
+ function clipSmart(text, maxChars) {
219
+ const s = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
220
+ const max = Math.floor(maxChars);
221
+ if (!s || max <= 0) return '';
222
+ if (s.length <= max) return s;
223
+ if (max <= ELLIPSIS.length) return s.slice(0, max);
224
+ const budget = max - ELLIPSIS.length;
225
+ const cut = s.lastIndexOf('/');
226
+ if (cut > 0) {
227
+ const tail = s.slice(cut + 1);
228
+ if (tail.length <= budget) return ELLIPSIS + tail;
229
+ }
230
+ return s.slice(0, budget).trimEnd() + ELLIPSIS;
231
+ }
232
+
233
+ // usageSummary({ tokens, costKnown, cost }) -> '12.4K tok · $0.0312'
234
+ //
235
+ // The running turn usage as one compact token, or '' when nothing is known.
236
+ // It used to be appended to the notification *title*, where it competed with
237
+ // the chat name and was the first thing the OS clipped on a narrow lock
238
+ // screen; it lives in the body now, next to the rest of the status.
239
+ function usageSummary(opts) {
240
+ const o = opts || {};
241
+ const tokens = Number(o.tokens);
242
+ if (!Number.isFinite(tokens) || tokens <= 0) return '';
243
+ const parts = [formatTokenCount(tokens) + ' tok'];
244
+ if (o.costKnown === true && o.cost != null) parts.push(formatCost(o.cost));
245
+ return parts.join(' · ');
246
+ }
247
+
248
+ // formatTokenCount(n) -> '243' / '12.4K' / '1.2M'
249
+ //
250
+ // The same magnitude rules as the server's usage.formatTokens(), so a number
251
+ // shown in the notification matches the number shown in the chat. Kept local
252
+ // so src/statusBar.js stays free of the pricing/usage module.
253
+ function formatTokenCount(n) {
254
+ const v = Number(n);
255
+ if (!Number.isFinite(v) || v < 0) return '--';
256
+ if (v < 1000) return String(Math.round(v));
257
+ if (v < 1000000) return (v / 1000).toFixed(v < 10000 ? 1 : 0) + 'K';
258
+ return (v / 1000000).toFixed(v < 10000000 ? 2 : 1) + 'M';
259
+ }
260
+
261
+ // formatCost(amount) -> '$0.0312' / '$0.50'
262
+ //
263
+ // Deliberately the same fractional-digit rules as the server's
264
+ // usage.formatCost() (2–5 digits, minimum 2), so a price in the notification
265
+ // reads exactly like the price on the chat's own cost line. Trimming stops at
266
+ // two decimals, unlike a plain toFixed(5), so `$0.50` does not become
267
+ // `$0.50000`.
268
+ function formatCost(amount) {
269
+ const a = Number(amount);
270
+ if (!Number.isFinite(a) || a < 0) return '';
271
+ if (a === 0) return '$0.00';
272
+ const digits = a < 1 ? 5 : 2;
273
+ let body = a.toFixed(digits);
274
+ if (digits > 2) body = body.replace(/(\.\d\d[0-9]*?)0+$/, '$1');
275
+ return '$' + body;
276
+ }
277
+
278
+ // activityLine({ tool, model, elapsedMs }) -> 'shell · gpt-5-mini · 12s'
279
+ //
280
+ // What the model is doing and what is doing it, most specific first. Only
281
+ // non-empty fields appear, so a plain model round shows just the model. This
282
+ // is the place a new fact belongs: add a field here rather than growing the
283
+ // notification title, which the OS truncates first.
284
+ function activityLine(opts) {
285
+ const o = opts || {};
286
+ const parts = [];
287
+ if (o.tool) parts.push(clip(String(o.tool), 40));
288
+ if (o.model) parts.push(clip(String(o.model), 48));
289
+ if (Number.isFinite(Number(o.elapsedMs)) && Number(o.elapsedMs) > 0) {
290
+ const seconds = Number(o.elapsedMs) / 1000;
291
+ parts.push(seconds < 10 ? seconds.toFixed(1) + 's' : Math.round(seconds) + 's');
292
+ }
293
+ return parts.join(SEPARATOR);
294
+ }
295
+
296
+ // Detail tiers, most important first. A narrow notification (an Android
297
+ // shade, a phone lock screen) drops the lowest tiers rather than wrapping the
298
+ // block to a second screen; a desktop toast usually fits all of them, so a
299
+ // bigger screen simply shows more without any extra code.
300
+ const DETAIL_TIERS = Object.freeze([
301
+ 'counts', // '2 of 5' — where in the work we are
302
+ 'usage', // '12.4K tok · $0.0312'
303
+ 'time', // '12s'
304
+ 'tool', // 'shell' / 'read_file'
305
+ 'model' // 'gpt-5-mini' / 'openai-compatible/gpt-4o'
306
+ ]);
307
+
308
+ // detailLines(plan, percent, info) -> [] of detail rows, widest tier set that
309
+ // fits the device's line budget.
310
+ //
311
+ // `info` carries the status facts: { message, kind, title, current, total,
312
+ // time, tool, model, usage }. Every field is optional; a missing one simply
313
+ // does not offer its tier. A tier line is included only while it fits BOTH
314
+ // the body line and the remaining height — so a phone keeps '2 of 5' and the
315
+ // token count and drops the rest, while a desktop shows everything.
316
+ function detailLines(plan, percent, info) {
317
+ const p = plan || {};
318
+ const chars = Number(p.chars) > 0 ? Number(p.chars) : 0;
319
+ const maxLines = Math.max(1, Number(p.detailLines) || 1);
320
+ const share = p.layout === 'inline' ? INLINE_BAR_SHARE : 1;
321
+ const budget = chars ? Math.max(0, Math.floor(chars * share)) : 0;
322
+
323
+ const tiers = {};
324
+ const kind = info && info.kind;
325
+ const current = Number(info && info.current);
326
+ const total = Number(info && info.total);
327
+ if (Number.isFinite(current) && Number.isFinite(total) && total > 0) {
328
+ tiers.counts = current + ' of ' + total;
329
+ }
330
+ // The first update of a task is 0 of N, which says nothing the bar has not
331
+ // already said more clearly.
332
+ if (tiers.counts && current <= 0) delete tiers.counts;
333
+ if (info && info.time) tiers.time = String(info.time);
334
+ if (info && info.tool) tiers.tool = String(info.tool);
335
+ if (info && info.model) tiers.model = String(info.model);
336
+ if (info && info.usage) tiers.usage = String(info.usage);
337
+
338
+ const out = [];
339
+ if (info && info.message) {
340
+ const message = String(info.message).replace(/\s+/g, ' ').trim();
341
+ if (message) {
342
+ out.push(chars ? clipSmart(message, chars) : message);
343
+ }
344
+ }
345
+ for (const tier of DETAIL_TIERS) {
346
+ // A completion ('nothing left to do') makes the position in the work
347
+ // redundant, so the counts tier steps aside for a completer.
348
+ if (tier === 'counts' && info && info.kind === 'complete') continue;
349
+ const value = tiers[tier];
350
+ if (!value) continue;
351
+ if (out.length >= maxLines) break;
352
+ const line = chars ? clipSmart(value, budget) : value;
353
+ if (!line) continue;
354
+ out.push(line);
355
+ }
356
+ return out.slice(0, maxLines);
357
+ }
358
+
359
+ // composeStatusBody(plan, percent, info) -> notification body
360
+ //
361
+ // The whole status lives in the body, under the bar: the running message, the
362
+ // position in the work, the turn usage, the elapsed time, the tool, and the
363
+ // model. Nothing here belongs in the notification title — the OS shows the
364
+ // (fixed) chat name there and clips it first — and a task is one row, not a
365
+ // title row plus a separate task row, so the block reads as one unit.
366
+ //
367
+ // `info` accepts either a string (a lone message, the common case) or the
368
+ // fact object documented on detailLines().
369
+ //
370
+ // stacked: bar row first (so a body the OS collapses still shows progress),
371
+ // then the detail rows.
372
+ // inline: the bar shares its row with the most important fact, because a
373
+ // one-line style never shows a second row.
374
+ function composeStatusBody(plan, percent, info) {
375
+ const p = plan || {};
376
+ const cells = p.cells ? p.cells : MIN_CELLS;
377
+ const chars = Number(p.chars) > 0 ? Number(p.chars) : 0;
378
+ const layout = p.layout ? p.layout : 'stacked';
379
+ const facts = typeof info === 'string' || info == null ? { message: info } : info;
380
+ const bar = asciiStatusBar(percent, cells);
381
+ const lines = detailLines(p, percent, facts);
382
+ if (!lines.length) return bar;
383
+ if (layout !== 'inline') return bar + '\n' + lines.join('\n');
384
+
385
+ // One visible line: the bar, then the first detail row in whatever room the
386
+ // line has left. If nothing useful fits, the bar (with its percentage) is
387
+ // the whole line rather than a truncated stub.
388
+ const room = chars ? Math.max(0, chars - bar.length - INLINE_SEPARATOR.length) : 0;
389
+ if (room < MIN_INLINE_INFO_CHARS) return bar;
390
+ return bar + INLINE_SEPARATOR + clipSmart(lines[0], room);
391
+ }
392
+
393
+ // statusBarPlan(report) -> { os, osVersion, chars, lines, style, cells, layout, clamped }
394
+ //
395
+ // Turn a device's self-report into everything the body builder needs.
396
+ // `report` is what the browser measured and sniffed:
397
+ // chars measured characters per body line (optional)
398
+ // viewportWidth CSS px width of the device (optional, used to sanity-check)
399
+ // os platform id, see normalizeOs (optional)
400
+ // osVersion major version number (optional)
401
+ // style 'collapsed' (default) or 'expanded' (optional)
402
+ //
403
+ // Rules:
404
+ // chars — the measurement when it is plausible, otherwise the platform
405
+ // table scaled to the reported viewport. This is what makes the
406
+ // width continuous across phone sizes instead of bucketed.
407
+ // lines — collapsed line count from the version-aware table, expanded
408
+ // count when the device says the style is expanded. Never less
409
+ // than one.
410
+ // layout — 'stacked' when the style shows two or more lines (bar on its own
411
+ // row), 'inline' when it shows one (bar and message share the row,
412
+ // so the message is visible at all).
413
+ // cells — derived from `chars`: a stacked bar may use the whole line, an
414
+ // inline bar keeps to INLINE_BAR_SHARE of it so text remains.
415
+ function statusBarPlan(report) {
416
+ const input = report && typeof report === 'object' ? report : {};
417
+ const capacity = capacityFor(input.os, input.osVersion);
418
+
419
+ const viewport = Number(input.viewportWidth);
420
+ const hasViewport = Number.isFinite(viewport) && viewport > 0;
421
+ // What the platform expects at this device's width — the anchor for both
422
+ // the fallback and the plausibility band.
423
+ const expected = hasViewport
424
+ ? Math.round(capacity.collapsedChars * (viewport / REFERENCE_VIEWPORT_PX))
425
+ : capacity.collapsedChars;
426
+
427
+ const measured = Number(input.chars);
428
+ let chars = expected;
429
+ let clamped = false;
430
+ let measured_used = false;
431
+ if (Number.isFinite(measured) && measured > 0) {
432
+ chars = Math.round(measured);
433
+ measured_used = true;
434
+ const lower = Math.max(1, Math.floor(expected * MEASURE_LOWER_BOUND));
435
+ const upper = Math.max(lower, Math.ceil(expected * MEASURE_UPPER_BOUND));
436
+ if (chars < lower || chars > upper) {
437
+ chars = clamp(chars, lower, upper);
438
+ clamped = true;
439
+ }
440
+ }
441
+
442
+ const expanded = String(input.style || '').trim().toLowerCase() === 'expanded';
443
+ const lines = Math.max(1, expanded ? capacity.expandedLines : capacity.collapsedLines);
444
+ const layout = lines > 1 ? 'stacked' : 'inline';
445
+ // How many detail rows this surface has room for. It is the height budget
446
+ // that makes a bigger device show more facts, exactly like the width budget
447
+ // makes it show a longer bar.
448
+ const detailLines = layout === 'inline' ? DETAIL_LINES_INLINE
449
+ : (expanded ? DETAIL_LINES_EXPANDED : DETAIL_LINES_PREVIEW);
450
+
451
+ // Available cell width for the bar itself. A stacked bar may use most of
452
+ // the line; an inline bar keeps to INLINE_BAR_SHARE so text remains.
453
+ const barBudget = Math.max(1, chars - BAR_OVERHEAD_CHARS);
454
+ const cells = layout === 'inline'
455
+ ? clamp(Math.round(Math.min(barBudget, chars * INLINE_BAR_SHARE)), MIN_CELLS, MAX_INLINE_CELLS)
456
+ : clamp(Math.round(Math.min(barBudget, chars * STACKED_BAR_SHARE)), MIN_CELLS, MAX_CELLS);
457
+
458
+ return {
459
+ os: capacity.os,
460
+ osVersion: Number.isFinite(Number(input.osVersion)) && Number(input.osVersion) > 0 ? Math.round(Number(input.osVersion)) : 0,
461
+ versionMatched: capacity.versionMatched,
462
+ chars,
463
+ measuredChars: measured_used ? Math.round(measured) : 0,
464
+ clamped,
465
+ lines,
466
+ style: expanded ? 'expanded' : 'collapsed',
467
+ layout,
468
+ detailLines,
469
+ cells
470
+ };
471
+ }
472
+
473
+ // planForSubscription(sub) -> statusBarPlan
474
+ //
475
+ // A stored subscription carries the device report as JSON in
476
+ // `status_bar_profile` (the current shape) and/or the measured character
477
+ // count in `status_bar` (the original shape, and still what a client that
478
+ // only sends a number writes). Reading both lets a subscription created
479
+ // before the profile existed keep working.
480
+ function planForSubscription(sub) {
481
+ const report = {};
482
+ const raw = sub && sub.status_bar_profile;
483
+ if (typeof raw === 'string' && raw) {
484
+ try { Object.assign(report, JSON.parse(raw) || {}); } catch { /* fall through to the legacy column */ }
485
+ } else if (raw && typeof raw === 'object') {
486
+ Object.assign(report, raw);
487
+ }
488
+ const legacy = Number(sub && sub.status_bar);
489
+ if (report.chars == null && Number.isFinite(legacy) && legacy > 0) report.chars = legacy;
490
+ return statusBarPlan(report);
491
+ }
492
+
493
+ // ---- Rendering --------------------------------------------------------
494
+
495
+ // asciiStatusBar(percent, cells) -> '[##----] 40%'
496
+ //
497
+ // Pure ASCII (`#` filled, `-` empty) — no Unicode block glyphs, which render
498
+ // inconsistently across Android, iOS, and desktop notification fonts.
499
+ // `percent == null` means "no measurable progress" (an error): the bar is
500
+ // empty and carries no label. Any non-zero progress lights at least one cell,
501
+ // so a fine bar never reads as empty at the start of a task.
502
+ function asciiStatusBar(percent, cells) {
503
+ const width = clamp(Math.round(Number(cells) || MIN_CELLS), MIN_CELLS, MAX_CELLS);
504
+ const normalized = percent == null ? null : clamp(Math.round(Number(percent) || 0), 0, 100);
505
+ const filled = normalized == null ? 0
506
+ : (normalized > 0 ? Math.max(1, Math.round((normalized / 100) * width)) : 0);
507
+ return '[' + '#'.repeat(filled) + '-'.repeat(width - filled) + ']'
508
+ + (normalized == null ? '' : ' ' + normalized + '%');
509
+ }
510
+
511
+ module.exports = {
512
+ MIN_CELLS,
513
+ MAX_CELLS,
514
+ BAR_OVERHEAD_CHARS,
515
+ SEPARATOR,
516
+ INLINE_SEPARATOR,
517
+ INLINE_BAR_SHARE,
518
+ MAX_INLINE_CELLS,
519
+ STACKED_BAR_SHARE,
520
+ MIN_INLINE_INFO_CHARS,
521
+ DETAIL_TIERS,
522
+ REFERENCE_VIEWPORT_PX,
523
+ OS_CAPACITY,
524
+ UNKNOWN_CAPACITY,
525
+ normalizeOs,
526
+ capacityFor,
527
+ statusBarPlan,
528
+ planForSubscription,
529
+ asciiStatusBar,
530
+ DETAIL_LINES_INLINE,
531
+ DETAIL_LINES_PREVIEW,
532
+ DETAIL_LINES_EXPANDED,
533
+ usageSummary,
534
+ formatTokenCount,
535
+ formatCost,
536
+ activityLine,
537
+ detailLines,
538
+ composeStatusBody,
539
+ clipSmart,
540
+ clip
541
+ };