moontide-mcp 2.0.2 → 2.2.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/README.md +94 -6
- package/dist/ask.js +76 -15
- package/dist/mcp.js +198 -26
- package/dist/protocol.js +130 -2
- package/dist/reference.js +22 -0
- package/dist/request-status.js +221 -0
- package/dist/session.js +256 -7
- package/package.json +1 -1
- package/page/ask-view.js +64 -57
- package/page/ask.js +363 -188
- package/page/bar-model.js +43 -2
- package/page/change-timing.js +60 -4
- package/page/harmony.js +13 -0
- package/page/index.html +7 -4
- package/page/jam-analysis.js +68 -23
- package/page/jam-model.js +196 -31
- package/page/jam-resolve.js +271 -0
- package/page/jam-view.js +182 -10
- package/page/lights.js +300 -0
- package/page/main.js +2171 -188
- package/page/midi-clock.js +23 -4
- package/page/midi-out.js +17 -2
- package/page/mixer-groups.js +114 -0
- package/page/mixer-model.js +172 -29
- package/page/mixer-rewrite.js +30 -1
- package/page/mixer-view.js +289 -14
- package/page/note-edit.js +267 -0
- package/page/note-locate.js +346 -0
- package/page/note-panel.js +402 -0
- package/page/note-tag.js +141 -0
- package/page/page-rewrite.js +184 -0
- package/page/query-check.js +21 -2
- package/page/request-status-view.js +419 -0
- package/page/request-status.js +686 -0
- package/page/song-edit.js +230 -0
- package/page/song-fit.js +250 -0
- package/page/song-grid-model.js +506 -0
- package/page/song-grid-view.js +710 -0
- package/page/song-prefs.js +100 -0
- package/page/structure-check.js +216 -0
- package/page/structure-view.js +38 -0
- package/page/structure.js +761 -0
- package/page/style.css +1218 -65
- package/page/takeover.js +20 -5
- package/page/tempo-glide.js +330 -0
- package/page/tempo-guard.js +62 -10
- package/page/timeline-edit-view.js +1956 -0
- package/page/timeline-gesture.js +279 -0
- package/page/timeline.js +197 -3
- package/page/top-bar-view.js +18 -13
- package/page/tracks.js +37 -8
- package/page/ui-state.js +10 -5
- package/page/views.js +39 -9
- package/reference/functions.md +1 -0
- package/reference/midi.md +2 -0
- package/reference/structure.md +131 -0
- package/reference/transitions.md +13 -1
package/page/ask.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Pure module for the Ask Claude box: the copy, the length rules, the context
|
|
2
|
-
// descriptors
|
|
2
|
+
// descriptors and the box placement. No DOM access at import time,
|
|
3
3
|
// so node:test and page/main.js import it directly with no build step. The box's own DOM lives in
|
|
4
4
|
// page/ask-view.js.
|
|
5
5
|
|
|
6
6
|
import { BEATS_PER_CYCLE } from './change-timing.js';
|
|
7
|
-
import { MAX_MOMENT_NOTES } from './timeline.js';
|
|
7
|
+
import { MAX_MOMENT_NOTES, MAX_RANGE_CYCLES } from './timeline.js';
|
|
8
|
+
import { cleanText } from './structure.js';
|
|
8
9
|
|
|
9
10
|
/** Mirrors src/protocol.ts's MAX_ASK_TEXT_LENGTH by hand (test/ask.test.js checks both are 2000). */
|
|
10
11
|
export const MAX_ASK_TEXT_LENGTH = 2000;
|
|
@@ -24,12 +25,6 @@ export const PAGE_TOKEN_PLACEHOLDER = '__MOONTIDE_PAGE_TOKEN__';
|
|
|
24
25
|
/** No answer from the server within this long: the request is reported as not confirmed. */
|
|
25
26
|
export const ASK_CONFIRM_TIMEOUT_MS = 5000;
|
|
26
27
|
|
|
27
|
-
/** A non-error status stays readable at least this long before the next one replaces it. */
|
|
28
|
-
export const STATUS_MIN_SHOWN_MS = 600;
|
|
29
|
-
|
|
30
|
-
/** "Claude received it" is shown this long, then the status hides. */
|
|
31
|
-
export const RECEIVED_SHOWN_MS = 4000;
|
|
32
|
-
|
|
33
28
|
/** From this many characters the box shows a counter, so the limit is seen before it is hit. */
|
|
34
29
|
export const COUNTER_FROM = 1800;
|
|
35
30
|
|
|
@@ -51,9 +46,6 @@ export const ASK_COPY = {
|
|
|
51
46
|
disconnected: 'Disconnected — asking Claude is off until Moontide reconnects.',
|
|
52
47
|
notSent: 'This request was not sent. Press Enter to try again.',
|
|
53
48
|
notSentToken: 'This request was not sent.',
|
|
54
|
-
sent: 'Sent to Claude',
|
|
55
|
-
received: 'Claude received it',
|
|
56
|
-
waiting: 'Claude will see it on its next action',
|
|
57
49
|
tooLong: 'request too long — 2000 characters at most.',
|
|
58
50
|
lineTooLong: 'request too long to send — some characters take extra room, so shorten the text.',
|
|
59
51
|
rateLimit: 'too many requests — wait a moment, then send again.',
|
|
@@ -67,6 +59,24 @@ export const ASK_COPY = {
|
|
|
67
59
|
// A failed change sent to Claude from the banner (the chip's lead and the first words of the prefill).
|
|
68
60
|
errorChipLead: 'Failed change',
|
|
69
61
|
errorPrefillLead: 'The last change failed with this error:',
|
|
62
|
+
// The ready-made request on a cell whose fixed variation has notes outside its section's harmony. The words in braces
|
|
63
|
+
// are filled by cellFitPrefill; the user reads the text, may change it and presses Enter.
|
|
64
|
+
cellFitRequest: "Fit variation '{variation}' of track '{track}' to the harmony of section '{section}' ({harmony}). It has notes outside it: {notes}.",
|
|
65
|
+
// The ready-made requests of a gesture on a Timeline note the page cannot edit. timelineEditPrefill fills the words in
|
|
66
|
+
// braces; the user reads the text, may change it and presses Enter.
|
|
67
|
+
raiseRequest: "Raise note {note} at cycle {cycle}, beat {beat} of '{track}' by {amount}.",
|
|
68
|
+
lowerRequest: "Lower note {note} at cycle {cycle}, beat {beat} of '{track}' by {amount}.",
|
|
69
|
+
restRequest: "Turn note {note} at cycle {cycle}, beat {beat} of '{track}' into a rest.",
|
|
70
|
+
softerRequest: "Play note {note} at cycle {cycle}, beat {beat} of '{track}' softer.",
|
|
71
|
+
louderRequest: "Play note {note} at cycle {cycle}, beat {beat} of '{track}' louder.",
|
|
72
|
+
semitoneAmount: '1 semitone',
|
|
73
|
+
semitonesAmount: '{n} semitones',
|
|
74
|
+
octaveAmount: '1 octave',
|
|
75
|
+
octavesAmount: '{n} octaves',
|
|
76
|
+
// The line the box shows when a range was cut to the page shown (the pointer left the lanes while the range was drawn). The
|
|
77
|
+
// words in braces are filled by rangeCutText.
|
|
78
|
+
rangeCut: 'Cut to the page shown — cycles {a} to {b}.',
|
|
79
|
+
rangeCutOne: 'Cut to the page shown — cycle {a}.',
|
|
70
80
|
};
|
|
71
81
|
|
|
72
82
|
/**
|
|
@@ -102,33 +112,6 @@ export function pageRequestsText(live) {
|
|
|
102
112
|
throw new Error(`Moontide: the page-requests state must be true or false, got ${JSON.stringify(live)}.`);
|
|
103
113
|
}
|
|
104
114
|
|
|
105
|
-
/** The status text for requests handed to Claude: one, or a count. */
|
|
106
|
-
export function receivedText(count) {
|
|
107
|
-
return count === 1 ? ASK_COPY.received : `Claude received ${count} requests`;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** The status text for requests that wait on the server: one, or a count. */
|
|
111
|
-
export function waitingText(count) {
|
|
112
|
-
return count === 1 ? ASK_COPY.waiting : `Claude will see ${count} requests on its next action`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* The short forms of the status texts, shown at fit step 3 of the top bar when the long
|
|
117
|
-
* sentence no longer fits. They are made here, next to the long texts, and travel with them: nothing shortens a long
|
|
118
|
-
* text by matching strings afterwards. The long text stays in the accessibility tree.
|
|
119
|
-
*/
|
|
120
|
-
export const ASK_SHORT_COPY = Object.freeze({ sent: 'Sent', received: 'Received', waiting: 'Waiting' });
|
|
121
|
-
|
|
122
|
-
/** The short form of receivedText(count). */
|
|
123
|
-
export function receivedShortText(count) {
|
|
124
|
-
return count === 1 ? ASK_SHORT_COPY.received : `${count} received`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/** The short form of waitingText(count). */
|
|
128
|
-
export function waitingShortText(count) {
|
|
129
|
-
return count === 1 ? ASK_SHORT_COPY.waiting : `${count} waiting`;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
115
|
/** 'empty' | 'too-long' | null for the text the user typed (counted on the trimmed text, in UTF-16 units,
|
|
133
116
|
* the same count the server uses). */
|
|
134
117
|
export function askTextProblem(text) {
|
|
@@ -145,21 +128,34 @@ export function counterText(length) {
|
|
|
145
128
|
return `${length} / ${MAX_ASK_TEXT_LENGTH} — too long. Shorten it to send.`;
|
|
146
129
|
}
|
|
147
130
|
|
|
148
|
-
/** The
|
|
149
|
-
export function
|
|
131
|
+
/** The line that says a range was cut to the page shown, for `cut` = `{ from, to }` (the cycles the range ended up with). */
|
|
132
|
+
export function rangeCutText(cut) {
|
|
133
|
+
if (cut === null || typeof cut !== 'object' || !isNonNegativeInteger(cut.from) || !isNonNegativeInteger(cut.to) || cut.to < cut.from) {
|
|
134
|
+
throw new Error('Moontide: a cut range needs whole cycles from and to, to not before from');
|
|
135
|
+
}
|
|
136
|
+
const template = cut.from === cut.to ? ASK_COPY.rangeCutOne : ASK_COPY.rangeCut;
|
|
137
|
+
return template.replace('{a}', String(cut.from)).replace('{b}', String(cut.to));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The one muted message line of the box, in the spec's priority order, or null when none applies. `rangeCut` (`{ from, to }`
|
|
142
|
+
* or null) is last: it shows only when nothing above it applies.
|
|
143
|
+
*/
|
|
144
|
+
export function askMessage({ connected, length, notSent, tokenRefused, lineTooLong = false, rangeCut = null }) {
|
|
150
145
|
if (!connected) return ASK_COPY.disconnected;
|
|
151
146
|
const counter = counterText(length);
|
|
152
147
|
if (counter !== null && !(lineTooLong && length <= MAX_ASK_TEXT_LENGTH)) return counter;
|
|
153
148
|
if (lineTooLong) return ASK_COPY.lineTooLong;
|
|
154
149
|
if (notSent && tokenRefused) return ASK_COPY.notSentToken;
|
|
155
150
|
if (notSent) return ASK_COPY.notSent;
|
|
151
|
+
if (rangeCut !== null) return rangeCutText(rangeCut);
|
|
156
152
|
return null;
|
|
157
153
|
}
|
|
158
154
|
|
|
159
155
|
/**
|
|
160
156
|
* The Parts view's "Ask Claude to add sections" button: disabled while asking is off or its request is
|
|
161
|
-
* pending, with the line under it saying why. `statusText` is the
|
|
162
|
-
*
|
|
157
|
+
* pending, with the line under it saying why. `statusText` is the state phrase of that request's row in the
|
|
158
|
+
* Requests list, or null.
|
|
163
159
|
*/
|
|
164
160
|
export function partsAskState({ connected, pending, statusText }) {
|
|
165
161
|
if (!connected) return { disabled: true, line: ASK_COPY.disconnected };
|
|
@@ -211,6 +207,28 @@ function optionalText(descriptor, field) {
|
|
|
211
207
|
return cutText(value);
|
|
212
208
|
}
|
|
213
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The section names of a range: undefined when the descriptor has none, else a list of 1 to MAX_RANGE_CYCLES non-empty strings
|
|
212
|
+
* (a range crosses at most one section per cycle). A name is not cut here.
|
|
213
|
+
*/
|
|
214
|
+
function rangeSectionNames(descriptor) {
|
|
215
|
+
const names = descriptor.sections;
|
|
216
|
+
if (names === undefined || names === null) return undefined;
|
|
217
|
+
if (!Array.isArray(names) || names.length < 1 || names.length > MAX_RANGE_CYCLES || names.some((name) => typeof name !== 'string' || name === '')) {
|
|
218
|
+
throw missing(descriptor.kind, `sections as 1 to ${MAX_RANGE_CYCLES} non-empty names`);
|
|
219
|
+
}
|
|
220
|
+
return names;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The count of sections a range leaves out: undefined when absent, else a whole number from 1 to MAX_RANGE_CYCLES - 1. */
|
|
224
|
+
function rangeMoreSections(descriptor, names) {
|
|
225
|
+
const more = descriptor.moreSections;
|
|
226
|
+
if (more === undefined || more === null) return undefined;
|
|
227
|
+
if (names === undefined) throw missing(descriptor.kind, 'moreSections only with sections');
|
|
228
|
+
if (!Number.isInteger(more) || more < 1 || more > MAX_RANGE_CYCLES - 1) throw missing(descriptor.kind, `moreSections as a whole number from 1 to ${MAX_RANGE_CYCLES - 1}`);
|
|
229
|
+
return more;
|
|
230
|
+
}
|
|
231
|
+
|
|
214
232
|
function requiredInteger(descriptor, field, min) {
|
|
215
233
|
const value = descriptor[field];
|
|
216
234
|
if (!Number.isInteger(value) || value < min) throw missing(descriptor.kind, `a whole number ${field} of at least ${min}`);
|
|
@@ -224,6 +242,12 @@ function requiredBus(descriptor) {
|
|
|
224
242
|
|
|
225
243
|
const SEPARATOR = ' \u00B7 ';
|
|
226
244
|
|
|
245
|
+
/** The words after a cell's name in its chip, by the grid's fit state. */
|
|
246
|
+
const CELL_FIT_WORDS = Object.freeze({ fits: 'fits', 'does-not-fit': 'does not fit', 'cannot-check': 'cannot check' });
|
|
247
|
+
|
|
248
|
+
/** The most characters the fit part of a cell's chip may take, so the base always keeps room. */
|
|
249
|
+
const MAX_CELL_SUFFIX = 100;
|
|
250
|
+
|
|
227
251
|
/** The bus name a bus chip shows: 'Reverb bus', with the orbit while several orbits are in use. */
|
|
228
252
|
function busTitle(bus, orbit, multipleOrbits) {
|
|
229
253
|
const name = `${bus === 'reverb' ? 'Reverb' : 'Delay'} bus`;
|
|
@@ -246,29 +270,63 @@ function isNonNegativeNumber(value) {
|
|
|
246
270
|
export const MOMENT_NOTE_FIELDS = Object.freeze(['track', 'instrument', 'sound', 'note', 'drum', 'velocity', 'start', 'end', 'state']);
|
|
247
271
|
|
|
248
272
|
/**
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
* wrong is a loud error here instead of a request that is refused later. The input is never mutated.
|
|
273
|
+
* The kinds of gesture a request can carry, equal to GESTURE_KINDS in src/protocol.ts (test/ask.test.js pins the two
|
|
274
|
+
* lists equal). The server rejects a kind it does not know.
|
|
252
275
|
*/
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
276
|
+
export const GESTURE_KINDS = Object.freeze(['pitch', 'rest', 'velocity', 'range']);
|
|
277
|
+
|
|
278
|
+
/** The gestures that change one note of the Timeline (the ones a ready-made request is written for). */
|
|
279
|
+
const NOTE_GESTURE_KINDS = Object.freeze(GESTURE_KINDS.filter((kind) => kind !== 'range'));
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The keys checkedGesture may copy into a note of a range: the moment note's keys without the state. The server's range note
|
|
283
|
+
* shape (askRangeNoteShape in src/protocol.ts) names the same keys; test/ask.test.js pins the two lists equal.
|
|
284
|
+
*/
|
|
285
|
+
export const RANGE_NOTE_FIELDS = Object.freeze(MOMENT_NOTE_FIELDS.filter((field) => field !== 'state'));
|
|
286
|
+
|
|
287
|
+
/** The most semitones one pitch gesture may carry, up or down; equals MAX_GESTURE_SEMITONES in src/protocol.ts. */
|
|
288
|
+
export const MAX_GESTURE_SEMITONES = 127;
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* A frozen copy of the gesture a descriptor carries, with only the keys its kind names. The server rejects a gesture out
|
|
292
|
+
* of bounds and never trims it, so a gesture the page got wrong is a loud error here.
|
|
293
|
+
*/
|
|
294
|
+
function checkedGesture(gesture) {
|
|
295
|
+
if (gesture === null || typeof gesture !== 'object') throw new Error('Moontide: an ask gesture is an object');
|
|
296
|
+
switch (gesture.kind) {
|
|
297
|
+
case 'pitch':
|
|
298
|
+
if (!Number.isInteger(gesture.semitones) || gesture.semitones === 0 || Math.abs(gesture.semitones) > MAX_GESTURE_SEMITONES) {
|
|
299
|
+
throw new Error(`Moontide: a pitch gesture needs whole semitones from -${MAX_GESTURE_SEMITONES} to ${MAX_GESTURE_SEMITONES} other than 0`);
|
|
300
|
+
}
|
|
301
|
+
return Object.freeze({ kind: 'pitch', semitones: gesture.semitones });
|
|
302
|
+
case 'rest':
|
|
303
|
+
return Object.freeze({ kind: 'rest' });
|
|
304
|
+
case 'velocity':
|
|
305
|
+
if (gesture.direction !== 'softer' && gesture.direction !== 'louder') throw new Error('Moontide: a velocity gesture needs a direction of softer or louder');
|
|
306
|
+
return Object.freeze({ kind: 'velocity', direction: gesture.direction });
|
|
307
|
+
case 'range': {
|
|
308
|
+
if (gesture.version !== null && !(Number.isInteger(gesture.version) && gesture.version > 0)) {
|
|
309
|
+
throw missing('range', 'a gesture version that is null or a positive whole number');
|
|
310
|
+
}
|
|
311
|
+
if (!Array.isArray(gesture.notes) || gesture.notes.length > MAX_MOMENT_NOTES) throw missing('range', `at most ${MAX_MOMENT_NOTES} range notes`);
|
|
312
|
+
if (!isNonNegativeInteger(gesture.more)) throw missing('range', 'a whole number count of more notes of at least 0');
|
|
313
|
+
return Object.freeze({ kind: 'range', version: gesture.version, notes: checkedNotes('range', gesture.notes, false), more: gesture.more });
|
|
314
|
+
}
|
|
315
|
+
default:
|
|
316
|
+
throw new Error(`Moontide: unknown ask gesture kind ${String(gesture.kind)}`);
|
|
265
317
|
}
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* A checked copy of the notes of a moment (`withState` true) or of a range (no state), every string cut to the context limit.
|
|
322
|
+
* The input is never mutated; a note that is not as the server takes it is a loud error.
|
|
323
|
+
*/
|
|
324
|
+
function checkedNotes(kind, notes, withState) {
|
|
325
|
+
return notes.map((note, index) => {
|
|
326
|
+
const where = `${withState ? 'moment' : 'range'} note ${index + 1}`;
|
|
269
327
|
if (note === null || typeof note !== 'object') throw missing(kind, `${where} as an object`);
|
|
270
328
|
if (typeof note.track !== 'string' || note.track === '') throw missing(kind, `a non-empty track in ${where}`);
|
|
271
|
-
if (note.state !== 'sounding' && note.state !== 'starts') throw missing(kind, `a state of sounding or starts in ${where}`);
|
|
329
|
+
if (withState && note.state !== 'sounding' && note.state !== 'starts') throw missing(kind, `a state of sounding or starts in ${where}`);
|
|
272
330
|
if (!isNonNegativeNumber(note.start) || !isNonNegativeNumber(note.end)) {
|
|
273
331
|
throw missing(kind, `a finite start and end of at least 0 in ${where}`);
|
|
274
332
|
}
|
|
@@ -293,9 +351,31 @@ function checkedMoment(kind, moment) {
|
|
|
293
351
|
}
|
|
294
352
|
copy.start = note.start;
|
|
295
353
|
copy.end = note.end;
|
|
296
|
-
copy.state = note.state;
|
|
354
|
+
if (withState) copy.state = note.state;
|
|
297
355
|
return copy;
|
|
298
356
|
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* A copy of the moment the Timeline described, checked and with every string cut to the context limit
|
|
361
|
+
* (numbers are untouched). The server refuses what is out of bounds and never trims, so a moment the page got
|
|
362
|
+
* wrong is a loud error here instead of a request that is refused later. The input is never mutated.
|
|
363
|
+
*/
|
|
364
|
+
function checkedMoment(kind, moment) {
|
|
365
|
+
if (moment === null || typeof moment !== 'object') throw missing(kind, 'a moment that is an object');
|
|
366
|
+
if (!isNonNegativeInteger(moment.cycle)) throw missing(kind, 'a whole number moment cycle of at least 0');
|
|
367
|
+
if (!Number.isInteger(moment.beat) || moment.beat < 1 || moment.beat > BEATS_PER_CYCLE) {
|
|
368
|
+
throw missing(kind, `a whole number moment beat from 1 to ${BEATS_PER_CYCLE}`);
|
|
369
|
+
}
|
|
370
|
+
if (!isNonNegativeNumber(moment.at)) throw missing(kind, 'a finite moment instant of at least 0');
|
|
371
|
+
if (moment.version !== null && !(Number.isInteger(moment.version) && moment.version > 0)) {
|
|
372
|
+
throw missing(kind, 'a moment version that is null or a positive whole number');
|
|
373
|
+
}
|
|
374
|
+
if (!Array.isArray(moment.notes) || moment.notes.length > MAX_MOMENT_NOTES) {
|
|
375
|
+
throw missing(kind, `at most ${MAX_MOMENT_NOTES} moment notes`);
|
|
376
|
+
}
|
|
377
|
+
if (!isNonNegativeInteger(moment.more)) throw missing(kind, 'a whole number moment count of more notes of at least 0');
|
|
378
|
+
const notes = checkedNotes(kind, moment.notes, true);
|
|
299
379
|
return { cycle: moment.cycle, beat: moment.beat, at: moment.at, version: moment.version, notes, more: moment.more };
|
|
300
380
|
}
|
|
301
381
|
|
|
@@ -304,8 +384,9 @@ function checkedMoment(kind, moment) {
|
|
|
304
384
|
* prefix, a space and the JSON of the fields in the same order (U+2028 and U+2029 escaped), so the page can
|
|
305
385
|
* tell before it sends whether the server will take the line.
|
|
306
386
|
*/
|
|
307
|
-
export function requestLineBytes({ id, text, context, chip, current, moment }) {
|
|
308
|
-
|
|
387
|
+
export function requestLineBytes({ id, text, context, chip, current, moment, gesture }) {
|
|
388
|
+
// The moment, then the gesture, each only when there is one (the order of src/ask.ts's requestLine).
|
|
389
|
+
const fields = { id, text, context, chip, current, ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
|
|
309
390
|
const json = JSON.stringify(fields).replaceAll('\u2028', '\\u2028').replaceAll('\u2029', '\\u2029');
|
|
310
391
|
return new TextEncoder().encode(`${REQUEST_LINE_PREFIX} ${json}`).length;
|
|
311
392
|
}
|
|
@@ -329,20 +410,86 @@ export function fitMomentToLine(fields) {
|
|
|
329
410
|
}
|
|
330
411
|
|
|
331
412
|
/**
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
413
|
+
* The gesture of a request cut to what the line holds. A range gesture loses notes from its end, one at a time, counted in
|
|
414
|
+
* `more`, until the whole line is at most MAX_REQUEST_LINE_BYTES (or no note is left); any other gesture, and undefined, come
|
|
415
|
+
* back as they are. The input is never mutated, and a gesture that fits is returned as it is. The chip counts notes and
|
|
416
|
+
* `more` together, so it needs no change.
|
|
417
|
+
*/
|
|
418
|
+
export function fitGestureToLine(fields) {
|
|
419
|
+
const { gesture } = fields;
|
|
420
|
+
if (gesture === undefined || gesture.kind !== 'range') return gesture;
|
|
421
|
+
let notes = gesture.notes;
|
|
422
|
+
let more = gesture.more;
|
|
423
|
+
while (notes.length > 0 && requestLineBytes({ ...fields, gesture: { ...gesture, notes, more } }) > MAX_REQUEST_LINE_BYTES) {
|
|
424
|
+
notes = notes.slice(0, -1);
|
|
425
|
+
more += 1;
|
|
426
|
+
}
|
|
427
|
+
return notes === gesture.notes ? gesture : { ...gesture, notes, more };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Whether the request fits one line once its moment and its range notes are cut as far as `fitMomentToLine` and
|
|
432
|
+
* `fitGestureToLine` cut them. False means the text, the context and the chip alone are over MAX_REQUEST_LINE_BYTES: a text
|
|
433
|
+
* within the character limit can still get here (JSON writes a control character as six bytes, and some scripts take three),
|
|
434
|
+
* so the box does not send it and says so, instead of letting the server refuse it with the wrong reason.
|
|
336
435
|
*/
|
|
337
436
|
export function requestFitsLine(fields) {
|
|
338
|
-
|
|
437
|
+
const moment = fitMomentToLine(fields);
|
|
438
|
+
return requestLineBytes({ ...fields, moment, gesture: fitGestureToLine({ ...fields, moment }) }) <= MAX_REQUEST_LINE_BYTES;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** '3 notes', '1 note' or 'no notes' for a count. */
|
|
442
|
+
function notesWords(count) {
|
|
443
|
+
return count === 0 ? 'no notes' : count === 1 ? '1 note' : `${count} notes`;
|
|
339
444
|
}
|
|
340
445
|
|
|
341
446
|
/** The part of a chip that says where and what: 'cycle 34, beat 3 · 3 notes'. */
|
|
342
447
|
function momentChipPart(moment) {
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
448
|
+
return `cycle ${moment.cycle}, beat ${moment.beat}${SEPARATOR}${notesWords(moment.notes.length + moment.more)}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** The longest section name the chip of a range shows; the context carries the whole name (cut at the context limit). */
|
|
452
|
+
const MAX_RANGE_CHIP_SECTION = 40;
|
|
453
|
+
|
|
454
|
+
/** The words of the sections part of a range chip: one section, a list of them, and the sections the chip does not show. */
|
|
455
|
+
const RANGE_SECTION_WORDS = Object.freeze({ one: 'section', list: 'sections', more: (n) => `and ${n} more` });
|
|
456
|
+
|
|
457
|
+
/** The shortest lane part a range chip keeps, so the tail never takes its room: 'Range', the separator and one character. */
|
|
458
|
+
const SHORTEST_RANGE_LANE_PART = `Range${SEPARATOR}x`;
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* The section names of a range cut to what the context holds: names from the start while they and their ', ' separators stay
|
|
462
|
+
* within MAX_ASK_CONTEXT_LENGTH characters. The first name is always kept (cut with cutText when it is longer). Gives
|
|
463
|
+
* `{ names, more }` with `more` the count of names left out. The server rejects a longer list and never trims.
|
|
464
|
+
*/
|
|
465
|
+
export function fitSectionNames(names) {
|
|
466
|
+
if (names.length === 0) throw new Error('Moontide: a section list to fit needs at least one name');
|
|
467
|
+
const kept = [cutText(names[0])];
|
|
468
|
+
let length = kept[0].length;
|
|
469
|
+
for (let at = 1; at < names.length; at++) {
|
|
470
|
+
length += 2 + names[at].length;
|
|
471
|
+
if (length > MAX_ASK_CONTEXT_LENGTH) break;
|
|
472
|
+
kept.push(names[at]);
|
|
473
|
+
}
|
|
474
|
+
return { names: kept, more: names.length - kept.length };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* The sections part of a range chip, built around `others` (the chip's other parts, as they will be joined): the most names
|
|
479
|
+
* that keep the tail within the room left after the shortest lane part, each cut to MAX_RANGE_CHIP_SECTION, and 'and N more' for
|
|
480
|
+
* every name not shown. `more` counts the names the context already leaves out. At least one name stays.
|
|
481
|
+
*/
|
|
482
|
+
function rangeSectionsPart(names, more, assemble) {
|
|
483
|
+
const total = names.length + more;
|
|
484
|
+
const room = MAX_ASK_CONTEXT_LENGTH - SEPARATOR.length - SHORTEST_RANGE_LANE_PART.length;
|
|
485
|
+
let part = '';
|
|
486
|
+
for (let shown = names.length; shown >= 1; shown--) {
|
|
487
|
+
const quoted = names.slice(0, shown).map((name) => `"${cutText(name, MAX_RANGE_CHIP_SECTION)}"`).join(', ');
|
|
488
|
+
const hidden = total - shown;
|
|
489
|
+
part = total === 1 ? `${RANGE_SECTION_WORDS.one} ${quoted}` : `${RANGE_SECTION_WORDS.list} ${quoted}${hidden > 0 ? ` ${RANGE_SECTION_WORDS.more(hidden)}` : ''}`;
|
|
490
|
+
if (assemble(part).length <= room) break;
|
|
491
|
+
}
|
|
492
|
+
return part;
|
|
346
493
|
}
|
|
347
494
|
|
|
348
495
|
/**
|
|
@@ -356,6 +503,7 @@ function momentChipPart(moment) {
|
|
|
356
503
|
export function describeContext(descriptor) {
|
|
357
504
|
const kind = descriptor && descriptor.kind;
|
|
358
505
|
let moment = descriptor ? descriptor.moment : undefined;
|
|
506
|
+
let gesture = descriptor ? descriptor.gesture : undefined;
|
|
359
507
|
// A moment is the Timeline's facts at the pointer: a lane, a part header over the lanes and the rest of the
|
|
360
508
|
// Timeline (a session descriptor) carry one; nothing else does.
|
|
361
509
|
if (moment !== undefined && kind !== 'lane' && kind !== 'part' && kind !== 'session') throw new Error(`Moontide: ask context ${kind} cannot carry a moment`);
|
|
@@ -490,6 +638,38 @@ export function describeContext(descriptor) {
|
|
|
490
638
|
}
|
|
491
639
|
break;
|
|
492
640
|
}
|
|
641
|
+
case 'range': {
|
|
642
|
+
const track = requiredText(descriptor, 'track');
|
|
643
|
+
const from = requiredInteger(descriptor, 'from', 0);
|
|
644
|
+
const to = requiredInteger(descriptor, 'to', from);
|
|
645
|
+
if (to - from >= MAX_RANGE_CYCLES) throw missing(kind, `at most ${MAX_RANGE_CYCLES} cycles`);
|
|
646
|
+
const sections = rangeSectionNames(descriptor);
|
|
647
|
+
const moreSections = rangeMoreSections(descriptor, sections);
|
|
648
|
+
if (gesture === undefined || gesture.kind !== 'range') throw missing(kind, 'a range gesture with its notes');
|
|
649
|
+
gesture = checkedGesture(gesture);
|
|
650
|
+
const cut = descriptor.cut === undefined || descriptor.cut === null ? null : { from: descriptor.cut.from, to: descriptor.cut.to };
|
|
651
|
+
if (cut !== null) rangeCutText(cut);
|
|
652
|
+
context = { kind, track, from, to };
|
|
653
|
+
let fitted;
|
|
654
|
+
if (sections !== undefined) {
|
|
655
|
+
// The page cuts the list to what the context holds; the cut and a cut the descriptor already made are one count.
|
|
656
|
+
fitted = fitSectionNames(sections);
|
|
657
|
+
const left = fitted.more + (moreSections ?? 0);
|
|
658
|
+
if (left > MAX_RANGE_CYCLES - 1) throw missing(kind, `at most ${MAX_RANGE_CYCLES - 1} moreSections`);
|
|
659
|
+
context.sections = fitted.names;
|
|
660
|
+
if (left > 0) context.moreSections = left;
|
|
661
|
+
}
|
|
662
|
+
// The cycles, the section and the notes are built first and the lane name is cut to the room left, so a long name never
|
|
663
|
+
// costs the cycles.
|
|
664
|
+
const cycles = from === to ? `cycle ${from}` : `cycles ${from} to ${to}`;
|
|
665
|
+
const notes = notesWords(gesture.notes.length + gesture.more);
|
|
666
|
+
const parts = [cycles];
|
|
667
|
+
if (fitted !== undefined) parts.push(rangeSectionsPart(fitted.names, context.moreSections ?? 0, (part) => [cycles, part, notes].join(SEPARATOR)));
|
|
668
|
+
parts.push(notes);
|
|
669
|
+
const tail = parts.join(SEPARATOR);
|
|
670
|
+
chip = `${cutText(`Range${SEPARATOR}${track}`, MAX_ASK_CONTEXT_LENGTH - SEPARATOR.length - tail.length)}${SEPARATOR}${tail}`;
|
|
671
|
+
return { context, chip: cutText(chip), gesture, ...(cut === null ? {} : { rangeCut: cut }) };
|
|
672
|
+
}
|
|
493
673
|
case 'note': {
|
|
494
674
|
const note = requiredText(descriptor, 'note');
|
|
495
675
|
const role = descriptor.role === null ? undefined : descriptor.role;
|
|
@@ -523,10 +703,57 @@ export function describeContext(descriptor) {
|
|
|
523
703
|
chip = `Warning${SEPARATOR}${chord} brings ${outside}${avoid === undefined ? '' : `, avoid ${avoid}`}, ${seconds === 0 ? 'now' : `in ${seconds} s`}`;
|
|
524
704
|
break;
|
|
525
705
|
}
|
|
706
|
+
case 'cell': {
|
|
707
|
+
const section = requiredText(descriptor, 'section');
|
|
708
|
+
const track = requiredText(descriptor, 'track');
|
|
709
|
+
const variation = optionalText(descriptor, 'variation');
|
|
710
|
+
const rest = descriptor.rest;
|
|
711
|
+
if (rest !== undefined && rest !== true) throw missing(kind, 'rest as true, or no rest');
|
|
712
|
+
// A cell holds one variation or is a rest, never both and never neither.
|
|
713
|
+
if ((variation === undefined) === (rest === undefined)) throw missing(kind, 'a variation or rest true, not both');
|
|
714
|
+
const fit = descriptor.fit === null ? undefined : descriptor.fit;
|
|
715
|
+
let outside;
|
|
716
|
+
let suffix = rest === undefined ? '' : `${SEPARATOR}rest`;
|
|
717
|
+
if (fit !== undefined) {
|
|
718
|
+
// The fit is the grid's word for a fixed variation; a rest has none.
|
|
719
|
+
if (rest !== undefined) throw missing(kind, 'no fit on a rest');
|
|
720
|
+
if (!Object.hasOwn(CELL_FIT_WORDS, fit)) throw missing(kind, 'a fit of fits, does-not-fit or cannot-check');
|
|
721
|
+
outside = optionalText(descriptor, 'outside');
|
|
722
|
+
if (outside !== undefined && fit !== 'does-not-fit') throw missing(kind, 'outside only with a fit of does-not-fit');
|
|
723
|
+
suffix = `${SEPARATOR}${CELL_FIT_WORDS[fit]}${outside === undefined ? '' : ` (${outside})`}`;
|
|
724
|
+
} else if (descriptor.outside !== undefined && descriptor.outside !== null) {
|
|
725
|
+
throw missing(kind, 'outside only with a fit of does-not-fit');
|
|
726
|
+
}
|
|
727
|
+
context = { kind, section, track };
|
|
728
|
+
if (variation !== undefined) context.variation = variation;
|
|
729
|
+
if (rest !== undefined) context.rest = true;
|
|
730
|
+
if (fit !== undefined) context.fit = fit;
|
|
731
|
+
if (outside !== undefined) context.outside = outside;
|
|
732
|
+
// The fit part is built first and the base (section, track, variation) is cut to the room left, so a long name
|
|
733
|
+
// never costs the words that say whether the notes fit.
|
|
734
|
+
const fitPart = cutText(suffix, MAX_CELL_SUFFIX);
|
|
735
|
+
const base = `Cell "${section}"${SEPARATOR}${track}${variation === undefined ? '' : `${SEPARATOR}${variation}`}`;
|
|
736
|
+
chip = `${cutText(base, MAX_ASK_CONTEXT_LENGTH - fitPart.length)}${fitPart}`;
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
case 'group': {
|
|
740
|
+
const name = requiredText(descriptor, 'name');
|
|
741
|
+
const parent = optionalText(descriptor, 'parent');
|
|
742
|
+
context = parent === undefined ? { kind, name } : { kind, name, parent };
|
|
743
|
+
chip = `Group "${name}"${parent === undefined ? '' : `${SEPARATOR}in "${parent}"`}`;
|
|
744
|
+
break;
|
|
745
|
+
}
|
|
526
746
|
default:
|
|
527
747
|
throw new Error(`Moontide: unknown ask context kind ${kind}`);
|
|
528
748
|
}
|
|
529
|
-
|
|
749
|
+
// A gesture is about one note of a lane: it rides only on a lane that carries a moment (the range gesture is the range
|
|
750
|
+
// context's own and is checked in its case above).
|
|
751
|
+
if (gesture !== undefined) {
|
|
752
|
+
if (kind !== 'lane' || moment === undefined) throw new Error(`Moontide: ask context ${kind} cannot carry a gesture without a lane and its moment`);
|
|
753
|
+
gesture = checkedGesture(gesture);
|
|
754
|
+
if (gesture.kind === 'range') throw new Error('Moontide: a range gesture goes only with a range context');
|
|
755
|
+
}
|
|
756
|
+
return { context, chip: cutText(chip), ...(moment === undefined ? {} : { moment }), ...(gesture === undefined ? {} : { gesture }) };
|
|
530
757
|
}
|
|
531
758
|
|
|
532
759
|
/**
|
|
@@ -539,6 +766,69 @@ export function errorAskPrefill(fullText) {
|
|
|
539
766
|
return `${ASK_COPY.errorPrefillLead}\n\n${cutText(fullText, MAX_ERROR_PREFILL)}\n\n`;
|
|
540
767
|
}
|
|
541
768
|
|
|
769
|
+
/**
|
|
770
|
+
* The text the Ask field starts with on a cell whose fixed variation does not fit its section: ASK_COPY.cellFitRequest with
|
|
771
|
+
* the variation, the track, the section, the section's harmony text and the outside notes. Every value is a non-empty string,
|
|
772
|
+
* cleaned of hidden characters and cut at the context limit, so the text stays within the field. A missing value throws.
|
|
773
|
+
*/
|
|
774
|
+
export function cellFitPrefill(values) {
|
|
775
|
+
const filled = {};
|
|
776
|
+
for (const field of ['variation', 'track', 'section', 'harmony', 'notes']) {
|
|
777
|
+
const value = values === null || typeof values !== 'object' ? undefined : values[field];
|
|
778
|
+
if (typeof value !== 'string' || value === '') throw new Error(`Moontide: the fit request needs a non-empty ${field}`);
|
|
779
|
+
filled[field] = cutText(cleanText(value));
|
|
780
|
+
}
|
|
781
|
+
const text = ASK_COPY.cellFitRequest.replace(/\{(\w+)\}/g, (_, name) => filled[name]);
|
|
782
|
+
if (text.length > MAX_ASK_TEXT_LENGTH) throw new Error('Moontide: the fit request is longer than the Ask box takes.');
|
|
783
|
+
return text;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/** A pitch change in words: `1 semitone`, `5 semitones`, `1 octave`, `2 octaves` (a multiple of 12 reads in octaves). */
|
|
787
|
+
function amountWords(size) {
|
|
788
|
+
if (size % 12 === 0) return size === 12 ? ASK_COPY.octaveAmount : ASK_COPY.octavesAmount.replace('{n}', String(size / 12));
|
|
789
|
+
return size === 1 ? ASK_COPY.semitoneAmount : ASK_COPY.semitonesAmount.replace('{n}', String(size));
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* The text the Ask field starts with when the user does something to a Timeline note the page cannot edit: raise, lower,
|
|
794
|
+
* rest or softer / louder, with the note as the Timeline shows it, its cycle and beat and its track. `values` is `{ kind,
|
|
795
|
+
* note, cycle, beat, track }` and, for a pitch gesture, `semitones` (a whole number from -127 to 127 other than 0), for a
|
|
796
|
+
* velocity gesture `direction` ('softer' or 'louder'). The note and the track are cleaned of hidden characters and cut at the
|
|
797
|
+
* context limit (this is the user's own request, so it keeps the name Claude needs to find the track); anything else wrong
|
|
798
|
+
* throws.
|
|
799
|
+
*/
|
|
800
|
+
export function timelineEditPrefill(values) {
|
|
801
|
+
const from = (field) => (values === null || typeof values !== 'object' ? undefined : values[field]);
|
|
802
|
+
const kind = from('kind');
|
|
803
|
+
if (!NOTE_GESTURE_KINDS.includes(kind)) throw new Error(`Moontide: the Timeline request needs a kind of ${NOTE_GESTURE_KINDS.join(', ')}`);
|
|
804
|
+
const note = from('note');
|
|
805
|
+
if (!((typeof note === 'string' && note !== '') || (typeof note === 'number' && Number.isFinite(note)))) {
|
|
806
|
+
throw new Error('Moontide: the Timeline request needs the note as a non-empty string or a finite number');
|
|
807
|
+
}
|
|
808
|
+
const cycle = from('cycle');
|
|
809
|
+
if (!isNonNegativeInteger(cycle)) throw new Error('Moontide: the Timeline request needs a whole number cycle of at least 0');
|
|
810
|
+
const beat = from('beat');
|
|
811
|
+
if (!Number.isInteger(beat) || beat < 1 || beat > BEATS_PER_CYCLE) throw new Error(`Moontide: the Timeline request needs a whole number beat from 1 to ${BEATS_PER_CYCLE}`);
|
|
812
|
+
const track = from('track');
|
|
813
|
+
if (typeof track !== 'string' || track === '') throw new Error('Moontide: the Timeline request needs a non-empty track');
|
|
814
|
+
const filled = { note: cutText(cleanText(String(note))), cycle: String(cycle), beat: String(beat), track: cutText(cleanText(track)) };
|
|
815
|
+
let template;
|
|
816
|
+
if (kind === 'pitch') {
|
|
817
|
+
const semitones = from('semitones');
|
|
818
|
+
checkedGesture({ kind, semitones });
|
|
819
|
+
template = semitones > 0 ? ASK_COPY.raiseRequest : ASK_COPY.lowerRequest;
|
|
820
|
+
filled.amount = amountWords(Math.abs(semitones));
|
|
821
|
+
} else if (kind === 'velocity') {
|
|
822
|
+
const direction = checkedGesture({ kind, direction: from('direction') }).direction;
|
|
823
|
+
template = direction === 'softer' ? ASK_COPY.softerRequest : ASK_COPY.louderRequest;
|
|
824
|
+
} else {
|
|
825
|
+
template = ASK_COPY.restRequest;
|
|
826
|
+
}
|
|
827
|
+
const text = template.replace(/\{(\w+)\}/g, (_, name) => filled[name]);
|
|
828
|
+
if (text.length > MAX_ASK_TEXT_LENGTH) throw new Error('Moontide: the Timeline request is longer than the Ask box takes.');
|
|
829
|
+
return text;
|
|
830
|
+
}
|
|
831
|
+
|
|
542
832
|
// The cycle under the pointer on a Timeline lane lives in page/timeline.js next to momentAt, so a lane's
|
|
543
833
|
// context cycle and a moment's cycle share one rule; it stays importable from here.
|
|
544
834
|
export { cycleAt } from './timeline.js';
|
|
@@ -580,118 +870,3 @@ export function placeBox({ pointerX, pointerY, boxWidth, boxHeight, maxBoxHeight
|
|
|
580
870
|
export function newAskId(counter, nowMs) {
|
|
581
871
|
return `ask-${nowMs.toString(36)}-${counter}`;
|
|
582
872
|
}
|
|
583
|
-
|
|
584
|
-
/**
|
|
585
|
-
* The top-bar status of the latest request, as a small state machine with injected time (the
|
|
586
|
-
* page/mixer-gesture.js style). `render(text, short)` receives a string or null and, with a string, its short form (the
|
|
587
|
-
* second argument is left out when hiding). `onTimeout(id)` is called
|
|
588
|
-
* for a request the server did not answer in time, or whose connection dropped.
|
|
589
|
-
*
|
|
590
|
-
* Every non-error text becomes visible no sooner than `minShownMs` after the previous non-error text
|
|
591
|
-
* appeared (a later text is scheduled, never lost); hiding for an error or a refusal is immediate and
|
|
592
|
-
* cancels anything scheduled.
|
|
593
|
-
*/
|
|
594
|
-
export function createAskStatus({
|
|
595
|
-
render,
|
|
596
|
-
onTimeout,
|
|
597
|
-
setTimer,
|
|
598
|
-
clearTimer,
|
|
599
|
-
now,
|
|
600
|
-
minShownMs = STATUS_MIN_SHOWN_MS,
|
|
601
|
-
receivedShownMs = RECEIVED_SHOWN_MS,
|
|
602
|
-
confirmTimeoutMs = ASK_CONFIRM_TIMEOUT_MS,
|
|
603
|
-
}) {
|
|
604
|
-
// The request the status describes.
|
|
605
|
-
let latestId = null;
|
|
606
|
-
// Requests sent and not yet answered: id -> confirm timer.
|
|
607
|
-
const unconfirmed = new Map();
|
|
608
|
-
// When the text on screen appeared; null while nothing shows.
|
|
609
|
-
let shownAt = null;
|
|
610
|
-
let scheduled = null;
|
|
611
|
-
let hideTimer = null;
|
|
612
|
-
|
|
613
|
-
function cancelScheduled() {
|
|
614
|
-
if (scheduled !== null) clearTimer(scheduled);
|
|
615
|
-
scheduled = null;
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function cancelHide() {
|
|
619
|
-
if (hideTimer !== null) clearTimer(hideTimer);
|
|
620
|
-
hideTimer = null;
|
|
621
|
-
}
|
|
622
|
-
|
|
623
|
-
function hideNow() {
|
|
624
|
-
cancelScheduled();
|
|
625
|
-
cancelHide();
|
|
626
|
-
shownAt = null;
|
|
627
|
-
render(null);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
function reveal(text, short, hideAfterMs) {
|
|
631
|
-
scheduled = null;
|
|
632
|
-
render(text, short);
|
|
633
|
-
shownAt = now();
|
|
634
|
-
if (hideAfterMs !== null) {
|
|
635
|
-
hideTimer = setTimer(() => {
|
|
636
|
-
hideTimer = null;
|
|
637
|
-
hideNow();
|
|
638
|
-
}, hideAfterMs);
|
|
639
|
-
}
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
/** Shows a non-error text now, or as soon as the text on screen has been readable long enough. */
|
|
643
|
-
function present(text, short, hideAfterMs) {
|
|
644
|
-
cancelScheduled();
|
|
645
|
-
cancelHide();
|
|
646
|
-
const wait = shownAt === null ? 0 : Math.max(0, minShownMs - (now() - shownAt));
|
|
647
|
-
if (wait === 0) {
|
|
648
|
-
reveal(text, short, hideAfterMs);
|
|
649
|
-
} else {
|
|
650
|
-
scheduled = setTimer(() => reveal(text, short, hideAfterMs), wait);
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
function forget(id) {
|
|
655
|
-
const timer = unconfirmed.get(id);
|
|
656
|
-
if (timer === undefined) return;
|
|
657
|
-
clearTimer(timer);
|
|
658
|
-
unconfirmed.delete(id);
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
function expire(id) {
|
|
662
|
-
unconfirmed.delete(id);
|
|
663
|
-
onTimeout(id);
|
|
664
|
-
if (id === latestId) hideNow();
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
return {
|
|
668
|
-
/** A request was sent: it becomes the latest and waits for the server's answer. */
|
|
669
|
-
sent(id) {
|
|
670
|
-
latestId = id;
|
|
671
|
-
unconfirmed.set(id, setTimer(() => expire(id), confirmTimeoutMs));
|
|
672
|
-
present(ASK_COPY.sent, ASK_SHORT_COPY.sent, null);
|
|
673
|
-
},
|
|
674
|
-
/** The server's ask-result for a request. */
|
|
675
|
-
answered(msg) {
|
|
676
|
-
forget(msg.id);
|
|
677
|
-
if (msg.id !== latestId) return;
|
|
678
|
-
if (msg.status === 'received') present(receivedText(1), receivedShortText(1), receivedShownMs);
|
|
679
|
-
else if (msg.status === 'waiting') present(waitingText(msg.waiting), waitingShortText(msg.waiting), null);
|
|
680
|
-
else hideNow();
|
|
681
|
-
},
|
|
682
|
-
/** The server's ask-received: waiting requests were just handed to Claude. */
|
|
683
|
-
handedOver(ids) {
|
|
684
|
-
for (const id of ids) forget(id);
|
|
685
|
-
if (latestId !== null && ids.includes(latestId)) present(receivedText(ids.length), receivedShortText(ids.length), receivedShownMs);
|
|
686
|
-
},
|
|
687
|
-
/** The connection is gone: nothing sent and unanswered will be answered any more. */
|
|
688
|
-
dropAll() {
|
|
689
|
-
for (const [id, timer] of unconfirmed) {
|
|
690
|
-
clearTimer(timer);
|
|
691
|
-
onTimeout(id);
|
|
692
|
-
}
|
|
693
|
-
unconfirmed.clear();
|
|
694
|
-
hideNow();
|
|
695
|
-
},
|
|
696
|
-
};
|
|
697
|
-
}
|