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.
Files changed (57) hide show
  1. package/README.md +94 -6
  2. package/dist/ask.js +76 -15
  3. package/dist/mcp.js +198 -26
  4. package/dist/protocol.js +130 -2
  5. package/dist/reference.js +22 -0
  6. package/dist/request-status.js +221 -0
  7. package/dist/session.js +256 -7
  8. package/package.json +1 -1
  9. package/page/ask-view.js +64 -57
  10. package/page/ask.js +363 -188
  11. package/page/bar-model.js +43 -2
  12. package/page/change-timing.js +60 -4
  13. package/page/harmony.js +13 -0
  14. package/page/index.html +7 -4
  15. package/page/jam-analysis.js +68 -23
  16. package/page/jam-model.js +196 -31
  17. package/page/jam-resolve.js +271 -0
  18. package/page/jam-view.js +182 -10
  19. package/page/lights.js +300 -0
  20. package/page/main.js +2171 -188
  21. package/page/midi-clock.js +23 -4
  22. package/page/midi-out.js +17 -2
  23. package/page/mixer-groups.js +114 -0
  24. package/page/mixer-model.js +172 -29
  25. package/page/mixer-rewrite.js +30 -1
  26. package/page/mixer-view.js +289 -14
  27. package/page/note-edit.js +267 -0
  28. package/page/note-locate.js +346 -0
  29. package/page/note-panel.js +402 -0
  30. package/page/note-tag.js +141 -0
  31. package/page/page-rewrite.js +184 -0
  32. package/page/query-check.js +21 -2
  33. package/page/request-status-view.js +419 -0
  34. package/page/request-status.js +686 -0
  35. package/page/song-edit.js +230 -0
  36. package/page/song-fit.js +250 -0
  37. package/page/song-grid-model.js +506 -0
  38. package/page/song-grid-view.js +710 -0
  39. package/page/song-prefs.js +100 -0
  40. package/page/structure-check.js +216 -0
  41. package/page/structure-view.js +38 -0
  42. package/page/structure.js +761 -0
  43. package/page/style.css +1218 -65
  44. package/page/takeover.js +20 -5
  45. package/page/tempo-glide.js +330 -0
  46. package/page/tempo-guard.js +62 -10
  47. package/page/timeline-edit-view.js +1956 -0
  48. package/page/timeline-gesture.js +279 -0
  49. package/page/timeline.js +197 -3
  50. package/page/top-bar-view.js +18 -13
  51. package/page/tracks.js +37 -8
  52. package/page/ui-state.js +10 -5
  53. package/page/views.js +39 -9
  54. package/reference/functions.md +1 -0
  55. package/reference/midi.md +2 -0
  56. package/reference/structure.md +131 -0
  57. 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, the box placement and the request status sequencing. No DOM access at import time,
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 one muted message line of the box, in the spec's priority order, or null when none applies. */
149
- export function askMessage({ connected, length, notSent, tokenRefused, lineTooLong = false }) {
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 top-bar
162
- * status text of that request, or null.
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
- * A copy of the moment the Timeline described, checked and with every string cut to the context limit
250
- * (numbers are untouched). The server refuses what is out of bounds and never trims, so a moment the page got
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
- function checkedMoment(kind, moment) {
254
- if (moment === null || typeof moment !== 'object') throw missing(kind, 'a moment that is an object');
255
- if (!isNonNegativeInteger(moment.cycle)) throw missing(kind, 'a whole number moment cycle of at least 0');
256
- if (!Number.isInteger(moment.beat) || moment.beat < 1 || moment.beat > BEATS_PER_CYCLE) {
257
- throw missing(kind, `a whole number moment beat from 1 to ${BEATS_PER_CYCLE}`);
258
- }
259
- if (!isNonNegativeNumber(moment.at)) throw missing(kind, 'a finite moment instant of at least 0');
260
- if (moment.version !== null && !(Number.isInteger(moment.version) && moment.version > 0)) {
261
- throw missing(kind, 'a moment version that is null or a positive whole number');
262
- }
263
- if (!Array.isArray(moment.notes) || moment.notes.length > MAX_MOMENT_NOTES) {
264
- throw missing(kind, `at most ${MAX_MOMENT_NOTES} moment notes`);
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
- if (!isNonNegativeInteger(moment.more)) throw missing(kind, 'a whole number moment count of more notes of at least 0');
267
- const notes = moment.notes.map((note, index) => {
268
- const where = `moment note ${index + 1}`;
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
- const fields = moment === undefined ? { id, text, context, chip, current } : { id, text, context, chip, current, moment };
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
- * Whether the request fits one line once its moment is cut as far as `fitMomentToLine` cuts it. False means the
333
- * text, the context and the chip alone are over MAX_REQUEST_LINE_BYTES: a text within the character limit can
334
- * still get here (JSON writes a control character as six bytes, and some scripts take three), so the box does
335
- * not send it and says so, instead of letting the server refuse it with the wrong reason.
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
- return requestLineBytes({ ...fields, moment: fitMomentToLine(fields) }) <= MAX_REQUEST_LINE_BYTES;
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
- const count = moment.notes.length + moment.more;
344
- const notes = count === 0 ? 'no notes' : count === 1 ? '1 note' : `${count} notes`;
345
- return `cycle ${moment.cycle}, beat ${moment.beat}${SEPARATOR}${notes}`;
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
- return moment === undefined ? { context, chip: cutText(chip) } : { context, chip: cutText(chip), moment };
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
- }