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.
- package/LICENSE +21 -0
- package/README.md +140 -0
- package/bin/mouaif.js +281 -0
- package/frontend/dist/assets/AgentFilePicker-CcKLJorU.js +1 -0
- package/frontend/dist/assets/CliModal-Hs5phmNZ.js +7 -0
- package/frontend/dist/assets/DictationPage-BI23lp42.js +2 -0
- package/frontend/dist/assets/FileEditor-DDl31c6d.js +2 -0
- package/frontend/dist/assets/GitModal-3EC_gpJ5.js +2 -0
- package/frontend/dist/assets/Inspector-Ba3R1w04.js +73 -0
- package/frontend/dist/assets/SettingsAbout-bvZGDEDw.js +1 -0
- package/frontend/dist/assets/SettingsActions-Dk6WX9jv.js +1 -0
- package/frontend/dist/assets/SettingsAgents-BNV0MgDB.js +1 -0
- package/frontend/dist/assets/SettingsDefaults-DbMmQbzc.js +1 -0
- package/frontend/dist/assets/SettingsHiddenContent-BZ2sloH1.js +1 -0
- package/frontend/dist/assets/SettingsMcp-DOrfbQd1.js +1 -0
- package/frontend/dist/assets/SettingsMcpEdit-BGMQ2CWC.js +3 -0
- package/frontend/dist/assets/SettingsMcpRegistry-BywXee_A.js +1 -0
- package/frontend/dist/assets/SettingsNotifications-B0LEs11a.js +1 -0
- package/frontend/dist/assets/SettingsPricing-BAg33iVF.js +1 -0
- package/frontend/dist/assets/SettingsProject-DNrKhCcZ.js +14 -0
- package/frontend/dist/assets/SettingsProjects-IqkBfDcm.js +1 -0
- package/frontend/dist/assets/SettingsPrompts-BgeiASuk.js +1 -0
- package/frontend/dist/assets/SettingsProviders-k0xJN0IK.js +1 -0
- package/frontend/dist/assets/SettingsTags-B5kjFdQi.js +1 -0
- package/frontend/dist/assets/agentNavigation-BiiCpFz5.js +1 -0
- package/frontend/dist/assets/codemirror-Bp6CUUFk.js +30 -0
- package/frontend/dist/assets/index-BGvI4n0T.js +61 -0
- package/frontend/dist/assets/index-Bgg1gnDf.css +1 -0
- package/frontend/dist/assets/index-C1sQFIC-.css +1 -0
- package/frontend/dist/assets/index-CANPYzQg.css +1 -0
- package/frontend/dist/assets/index-Crn1LdzK.css +1 -0
- package/frontend/dist/assets/index-FbCWDPiB.css +1 -0
- package/frontend/dist/assets/projectQS-D1cSZ7Gr.js +1 -0
- package/frontend/dist/assets/virtual-list-6H9b4K51.js +1 -0
- package/frontend/dist/icons/favicon-32.png +0 -0
- package/frontend/dist/icons/icon-180-apple.png +0 -0
- package/frontend/dist/icons/icon-192.png +0 -0
- package/frontend/dist/icons/icon-512.png +0 -0
- package/frontend/dist/icons/icon-maskable-512.png +0 -0
- package/frontend/dist/index.html +83 -0
- package/frontend/dist/manifest.webmanifest +33 -0
- package/frontend/dist/sw.js +482 -0
- package/package.json +98 -0
- package/scripts/patch-zimmerframe.js +58 -0
- package/src/access-auth.js +515 -0
- package/src/agentFeatures.js +294 -0
- package/src/agentFiles.js +164 -0
- package/src/agentSkills.js +147 -0
- package/src/agents.js +230 -0
- package/src/ai-chat.js +21 -0
- package/src/ai-endpoints.js +1880 -0
- package/src/ai-stream.js +2048 -0
- package/src/ai.js +68 -0
- package/src/auth.js +391 -0
- package/src/chatdb.js +816 -0
- package/src/chats.js +275 -0
- package/src/custom-actions.js +65 -0
- package/src/files.js +431 -0
- package/src/hideFileContent.js +327 -0
- package/src/http-server.js +535 -0
- package/src/index.js +15 -0
- package/src/inspector.js +731 -0
- package/src/inspectorProfiles.js +503 -0
- package/src/live-chat.js +107 -0
- package/src/mcp.js +1517 -0
- package/src/messages.js +238 -0
- package/src/modelList.js +137 -0
- package/src/notifications.js +52 -0
- package/src/oauth-anthropic.js +280 -0
- package/src/oauth-github-copilot.js +417 -0
- package/src/oauth-mcp.js +216 -0
- package/src/oauth-openrouter.js +285 -0
- package/src/package-version.js +20 -0
- package/src/projects.js +285 -0
- package/src/promptProfiles.js +256 -0
- package/src/prompts.js +384 -0
- package/src/providerShapes.js +44 -0
- package/src/providers/base.js +41 -0
- package/src/providers/index.js +25 -0
- package/src/push.js +315 -0
- package/src/qr.js +192 -0
- package/src/restart.js +47 -0
- package/src/server-handlers-access.js +306 -0
- package/src/server-handlers-actions.js +100 -0
- package/src/server-handlers-ai.js +248 -0
- package/src/server-handlers-auth.js +273 -0
- package/src/server-handlers-chats.js +1436 -0
- package/src/server-handlers-git.js +467 -0
- package/src/server-handlers-mcp-oauth.js +56 -0
- package/src/server-handlers-misc.js +783 -0
- package/src/server-handlers-projects.js +289 -0
- package/src/server-handlers-prompts.js +259 -0
- package/src/server-handlers-push.js +102 -0
- package/src/server-handlers-settings.js +406 -0
- package/src/server-handlers-tools.js +654 -0
- package/src/server-handlers-transcribe.js +399 -0
- package/src/server-shared.js +780 -0
- package/src/server-web-static.js +191 -0
- package/src/settings.js +898 -0
- package/src/statusBar.js +541 -0
- package/src/tags.js +414 -0
- package/src/toolFeedback.js +225 -0
- package/src/tools/ask.js +154 -0
- package/src/tools/authorization.js +932 -0
- package/src/tools/files.js +1150 -0
- package/src/tools/progress.js +71 -0
- package/src/tools/restart.js +32 -0
- package/src/tools/searchEngine.js +957 -0
- package/src/tools/shell.js +341 -0
- package/src/tools/subagent.js +47 -0
- package/src/tools/task.js +234 -0
- package/src/tools/webpreview.js +448 -0
- package/src/trace.js +103 -0
- package/src/transcribe.js +683 -0
- package/src/usage.js +389 -0
- package/src/util.js +151 -0
package/src/statusBar.js
ADDED
|
@@ -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
|
+
};
|