moontide-mcp 2.1.0 → 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/dist/session.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { AskChannel, listenerScriptPath } from './ask.js';
3
+ import { NOT_OPEN_REQUEST_MESSAGE, RequestStatusBoard, cleanAnswerText } from './request-status.js';
3
4
  import { MIDI_PERMISSION_WAIT_MESSAGE, MidiChannel } from './midi-state.js';
4
5
  import { applyEdits } from './edit-code.js';
5
6
  import { CatalogSaveError, MAX_CATALOG_DRAFTS, MAX_CATALOG_KITS, MAX_CATALOG_SOUNDS, MAX_PART_SOUNDS, catalogFileExists, countText, flattenCatalogPart, parseCatalog, problemsText, saveCatalog, summarizeCatalog, } from './catalog.js';
@@ -71,6 +72,14 @@ export function cleanErrorDetail(detail) {
71
72
  }
72
73
  return codePoints.slice(0, MAX_DETAIL_CODE_POINTS).join('') + '…';
73
74
  }
75
+ /** The page's refusal text of a tempo ramp it could not make, trimmed and cut like any error detail but kept as a sentence. */
76
+ function cleanRefusal(text) {
77
+ const trimmed = text.trim();
78
+ if (trimmed === '')
79
+ return UNKNOWN_ERROR_DETAIL;
80
+ const codePoints = Array.from(trimmed);
81
+ return codePoints.length <= MAX_DETAIL_CODE_POINTS ? trimmed : codePoints.slice(0, MAX_DETAIL_CODE_POINTS).join('') + '…';
82
+ }
74
83
  /**
75
84
  * Builds the tool-response text for a failed evaluation: Strudel's message, the line when
76
85
  * known, and a truthful note about what is (or isn't) still playing.
@@ -189,6 +198,26 @@ export function takeoverCanceledLine(cycle, reason) {
189
198
  }
190
199
  return `${head} the page was closed, reloaded or replaced by another tab before then. get_code returns the code from before it.`;
191
200
  }
201
+ /**
202
+ * The note Claude gets, once, when a tempo ramp ended without Claude asking for it: a program stated another tempo, a
203
+ * program stated no tempo, a step failed, or the page that ran it was closed, reloaded or replaced by another tab.
204
+ * Stop, a finished ramp and a replaced ramp say nothing. Only the cause (a fixed word) and a number come in: no
205
+ * page text. `bpm` is the tempo that plays after the end; the wording leaves it out when the page gave none.
206
+ */
207
+ export function glideEndedLine(reason, bpm) {
208
+ const shown = bpm === undefined ? undefined : Number.isInteger(bpm) ? String(bpm) : bpm.toFixed(1);
209
+ const head = 'Note: the tempo ramp ended early:';
210
+ if (reason === 'other-tempo') {
211
+ return `${head} a program stated another tempo${shown === undefined ? '' : `, and ${shown} BPM plays now`}.`;
212
+ }
213
+ if (reason === 'no-tempo') {
214
+ return `${head} a program stated no tempo${shown === undefined ? '' : `, so the tempo stays at ${shown} BPM, where the ramp had got to`}.`;
215
+ }
216
+ if (reason === 'error') {
217
+ return `${head} a step failed in the page${shown === undefined ? '' : `, so the tempo stays at ${shown} BPM`}.`;
218
+ }
219
+ return `${head} the page was closed, reloaded or replaced by another tab, so the tempo of the code plays.`;
220
+ }
192
221
  /**
193
222
  * The note when a change that waited for its line took over and replaced code that had been edited by hand
194
223
  * in the page. Said at the takeover, never at the request: until the line the hand edit is still what plays, and a
@@ -261,6 +290,11 @@ export class Session {
261
290
  pendingChange = null;
262
291
  // The notes of waiting changes that did not take over, queued once and drained once, like historyNotes.
263
292
  takeoverNotes = [];
293
+ // The tempo ramp the page runs, as far as the server knows: set when a reply says a ramp started (or the takeover of a
294
+ // change whose ramp starts at its line), cleared by Stop, a page end and a page loss. Null when none runs.
295
+ glide = null;
296
+ // The notes of a ramp that ended early, queued once and drained once, like takeoverNotes.
297
+ glideNotes = [];
264
298
  // The notes of load failure reports a page sent, one per distinct text (at most three), drained once.
265
299
  loadFailureNotes = new Set();
266
300
  // BPM: the tempo (rounded to one decimal) the last set_code or restore_version reply stated; null before any.
@@ -280,6 +314,8 @@ export class Session {
280
314
  supersededPages = new Set();
281
315
  /** The page-to-Claude channel (tokens, trust, limits, queue, listener). */
282
316
  ask;
317
+ /** What became of each page request: started, waiting for its line, applied and the other states. */
318
+ requests;
283
319
  /** The MIDI facts of the active page, worded once per change into notice lines. */
284
320
  midi;
285
321
  // The setup file's store (null without one), the JSON of the last midi-rig each page was
@@ -311,12 +347,36 @@ export class Session {
311
347
  this.rig = options?.rig ?? null;
312
348
  this.build = options?.build ?? null;
313
349
  this.recordIntentTtlMs = options?.recordIntentTtlMs ?? RECORD_INTENT_TTL_MS;
350
+ const send = (conn, msg, what) => this.sendToPage(conn, msg, what);
351
+ this.requests = new RequestStatusBoard({ send });
314
352
  this.ask = new AskChannel({
315
- send: (conn, msg, what) => this.sendToPage(conn, msg, what),
353
+ send,
354
+ board: this.requests,
316
355
  ackTimeoutMs: options?.askAckTimeoutMs,
317
356
  });
318
357
  this.midi = new MidiChannel();
319
358
  }
359
+ /** A Moontide tool call began: the requests already handed to Claude are being worked on. */
360
+ noteToolCall() {
361
+ this.requests.noteToolCall();
362
+ }
363
+ /**
364
+ * Claude closes an open page request in words. The sentence is cleaned first; one that is empty after
365
+ * cleaning, and an id that is not an open request, are refused with a plain error and change nothing.
366
+ */
367
+ answerRequest(id, outcome, text) {
368
+ const sentence = cleanAnswerText(text);
369
+ if (sentence === '') {
370
+ throw new Error('The sentence is empty after removing spaces and control characters; give one sentence of words.');
371
+ }
372
+ if (!this.requests.closed(id, outcome, sentence)) {
373
+ throw new Error(NOT_OPEN_REQUEST_MESSAGE);
374
+ }
375
+ }
376
+ /** Whether a page request with this id is on the board and not finished. */
377
+ requestIsOpen(id) {
378
+ return this.requests.isOpen(id);
379
+ }
320
380
  setPageUrl(url) {
321
381
  this.pageUrl = url;
322
382
  }
@@ -362,6 +422,42 @@ export class Session {
362
422
  return;
363
423
  this.pendingChange = null;
364
424
  this.takeoverNotes.push(takeoverCanceledLine(waiting.cycle, reason));
425
+ const answering = waiting.intent?.answering;
426
+ if (answering === undefined)
427
+ return;
428
+ if (reason !== 'replaced') {
429
+ this.requests.canceled(answering);
430
+ }
431
+ else if (!this.answeringStillCarried(answering, waiting.id)) {
432
+ this.requests.replaced(answering);
433
+ }
434
+ }
435
+ /**
436
+ * The ramp ended. Clears the record and queues its one note for the next tool reply, except for a finished ramp and
437
+ * for Stop, which are not told. A no-op when no ramp is recorded, so a second cause (a cancel, then a closing page)
438
+ * never adds a second line.
439
+ */
440
+ dropGlide(reason, bpm) {
441
+ if (this.glide === null)
442
+ return;
443
+ this.glide = null;
444
+ if (reason === 'finished' || reason === 'stop')
445
+ return;
446
+ this.glideNotes.push(glideEndedLine(reason, bpm));
447
+ }
448
+ /**
449
+ * Whether a change other than this one still in flight answers the same request. A newer change that
450
+ * names the request carries its answer on, so the request must not end as replaced while that answer is
451
+ * still coming: the newer change settles it (applied, failed or replaced in its turn).
452
+ */
453
+ answeringStillCarried(answering, exceptEvaluateId) {
454
+ for (const [evaluateId, entry] of this.pending) {
455
+ if (entry.kind === 'evaluate' && evaluateId !== exceptEvaluateId) {
456
+ if (this.recordIntents.get(evaluateId)?.answering === answering)
457
+ return true;
458
+ }
459
+ }
460
+ return false;
365
461
  }
366
462
  /**
367
463
  * Keeps the change the page accepted for its line. One at most (last wins): a change that is
@@ -369,16 +465,32 @@ export class Session {
369
465
  * itself (takeover-canceled 'replaced').
370
466
  */
371
467
  keepWaitingChange(change) {
372
- if (this.pendingChange !== null && this.pendingChange.id !== change.id) {
373
- console.error(`[moontide] A waiting change (${this.pendingChange.id}) was replaced by request ${change.id} without a cancel message`);
468
+ const replaced = this.pendingChange;
469
+ if (replaced !== null && replaced.id !== change.id) {
470
+ console.error(`[moontide] A waiting change (${replaced.id}) was replaced by request ${change.id} without a cancel message`);
471
+ // The same rule as a cancel message: the request ends as replaced unless the new change answers it too.
472
+ const answering = replaced.intent?.answering;
473
+ if (answering !== undefined && answering !== change.intent?.answering) {
474
+ this.requests.replaced(answering);
475
+ }
374
476
  }
375
477
  this.pendingChange = change;
478
+ if (change.intent?.answering !== undefined) {
479
+ this.requests.line(change.intent.answering, change.at === 'phrase' ? 'phrase' : 'bar');
480
+ }
376
481
  }
377
482
  /**
378
483
  * BPM: rounds the tempo of a reply to one decimal and says whether it differs from the last tempo a reply
379
484
  * stated. A reply without a tempo leaves the last told tempo alone.
380
485
  */
381
- noteTempo(bpm) {
486
+ noteTempo(bpm, glide) {
487
+ // A reply that states a ramp says the ramp, not a tempo change: the ramp's target is the tempo told from here on, so the
488
+ // next reply never announces it as a change when the ramp gets there.
489
+ if (glide !== undefined) {
490
+ const target = Math.round(glide.toBpm * 10) / 10;
491
+ this.lastToldBpm = target;
492
+ return { bpm: bpm === undefined ? null : Math.round(bpm * 10) / 10, tempoChanged: false };
493
+ }
382
494
  if (bpm === undefined)
383
495
  return { bpm: null, tempoChanged: false };
384
496
  const rounded = Math.round(bpm * 10) / 10;
@@ -404,6 +516,7 @@ export class Session {
404
516
  // A change that waits does not survive a page taking the role: the page that held its gate is
405
517
  // gone or replays its code, so Claude is told once (the line is queued once however many causes meet).
406
518
  this.dropWaitingChange('page');
519
+ this.dropGlide('page');
407
520
  this.supersededPages.delete(conn);
408
521
  if (previous !== null && previous !== conn) {
409
522
  this.supersededPages.add(previous);
@@ -458,6 +571,7 @@ export class Session {
458
571
  if (this.activePage === conn) {
459
572
  this.activePage = null;
460
573
  this.dropWaitingChange('page');
574
+ this.dropGlide('page');
461
575
  this.midi.clear();
462
576
  this.lastMidiFacts = null;
463
577
  // A superseded tab says "playing in another tab": tell it that no tab plays any more, or it
@@ -582,6 +696,10 @@ export class Session {
582
696
  this.handleTakeoverCanceled(conn, msg);
583
697
  return;
584
698
  }
699
+ if (msg.type === 'glide-ended') {
700
+ this.handleGlideEnded(conn, msg);
701
+ return;
702
+ }
585
703
  if (msg.type === 'user-stopped') {
586
704
  this.handleUserStopped(conn);
587
705
  return;
@@ -623,6 +741,13 @@ export class Session {
623
741
  this.ask.handleAsk(conn, msg);
624
742
  return;
625
743
  }
744
+ if (msg.type === 'ask-board-request') {
745
+ const requests = this.requests.snapshotFor(conn);
746
+ // The board told the page where each request stands; the hand-over messages that follow reach it too.
747
+ this.ask.adoptOrigin(conn, requests.map((row) => row.id));
748
+ this.sendToPage(conn, { type: 'ask-board', requests }, 'the request board');
749
+ return;
750
+ }
626
751
  if (msg.type === 'history-request') {
627
752
  this.handleHistoryRequest(conn);
628
753
  return;
@@ -1165,6 +1290,8 @@ export class Session {
1165
1290
  return;
1166
1291
  }
1167
1292
  this.stopped = true;
1293
+ // Stop ends a ramp too; it is the user's own act, so Claude is not told.
1294
+ this.dropGlide('stop');
1168
1295
  // A stop the page reports ends a wait too; the page's takeover-canceled for the same id (sent
1169
1296
  // just before) already queued the line, and a second cause adds nothing.
1170
1297
  this.dropWaitingChange('stop');
@@ -1186,6 +1313,7 @@ export class Session {
1186
1313
  cycle: msg.pending.cycle,
1187
1314
  at: lateIntent?.at ?? 'bar',
1188
1315
  intent: lateIntent,
1316
+ ...(msg.glide?.state === 'at-line' ? { glide: msg.glide } : {}),
1189
1317
  });
1190
1318
  console.error(`[moontide] Late pending confirmation kept for request ${msg.id}`);
1191
1319
  }
@@ -1208,6 +1336,15 @@ export class Session {
1208
1336
  this.handFailureLine = null;
1209
1337
  const lateVersionId = this.recordConfirmed(conn, msg.code, lateOrigin, lateIntent);
1210
1338
  this.rememberVersion(msg.id, lateVersionId);
1339
+ // A reply whose audio is stopped records no ramp: Stop ends every ramp, so none runs.
1340
+ // An at-line report on a reply with no pending line is a waiting change that already took over: its ramp started at its line.
1341
+ if ((msg.glide?.state === 'started' || msg.glide?.state === 'at-line') && msg.audio !== 'stopped') {
1342
+ this.glide = { toBpm: msg.glide.toBpm };
1343
+ }
1344
+ // This branch does not pass through confirmSuccess, so it tells the page itself.
1345
+ if (lateIntent?.answering !== undefined) {
1346
+ this.requests.applied(lateIntent.answering, lateVersionId);
1347
+ }
1211
1348
  console.error(`[moontide] Late confirmation stored for request ${msg.id}`);
1212
1349
  }
1213
1350
  else if (msg.ok) {
@@ -1241,6 +1378,7 @@ export class Session {
1241
1378
  cycle: msg.pending.cycle,
1242
1379
  at: intent?.at ?? 'bar',
1243
1380
  intent,
1381
+ ...(msg.glide?.state === 'at-line' ? { glide: msg.glide } : {}),
1244
1382
  });
1245
1383
  entry.resolve({
1246
1384
  code: msg.code,
@@ -1249,17 +1387,25 @@ export class Session {
1249
1387
  replacedHandEdit: false,
1250
1388
  versionId: null,
1251
1389
  pending: msg.pending,
1252
- ...this.noteTempo(msg.bpm),
1390
+ ...this.noteTempo(msg.bpm, msg.glide),
1391
+ ...(msg.glide !== undefined ? { glide: msg.glide } : {}),
1253
1392
  });
1254
1393
  }
1255
1394
  else {
1256
1395
  const { replacedHandEdit, versionId } = this.confirmSuccess(conn, msg.id, msg.code, msg.audio, intent);
1396
+ // A reply whose audio is stopped states and records no ramp: Stop ends every ramp, so none runs.
1397
+ const glide = msg.audio === 'stopped' ? undefined : msg.glide;
1398
+ // A ramp this change started is the one the server tells about when it ends early; a running report leaves the record.
1399
+ // An at-line report here is a waiting change that already took over when it was answered: its ramp started at its line.
1400
+ if (glide?.state === 'started' || glide?.state === 'at-line')
1401
+ this.glide = { toBpm: glide.toBpm };
1257
1402
  entry.resolve({
1258
1403
  code: msg.code,
1259
1404
  audio: msg.audio,
1260
1405
  replacedHandEdit,
1261
1406
  versionId,
1262
- ...this.noteTempo(msg.bpm),
1407
+ ...this.noteTempo(msg.bpm, glide),
1408
+ ...(glide !== undefined ? { glide } : {}),
1263
1409
  });
1264
1410
  }
1265
1411
  }
@@ -1274,7 +1420,24 @@ export class Session {
1274
1420
  }
1275
1421
  }
1276
1422
  else {
1423
+ const failedIntent = this.recordIntents.get(msg.id);
1277
1424
  this.recordIntents.delete(msg.id);
1425
+ const failedFor = failedIntent?.answering;
1426
+ // The same rule as a replaced change: an earlier change for the same request that still waits for
1427
+ // its line, or is still on its way, carries the answer on and settles the request in its turn.
1428
+ // A ramp the page refused was refused whole and nothing changed, so the request stays open for the change Claude sends
1429
+ // next (without glideBars), which can still answer it.
1430
+ if (msg.glideRefused !== true &&
1431
+ failedFor !== undefined &&
1432
+ this.pendingChange?.intent?.answering !== failedFor &&
1433
+ !this.answeringStillCarried(failedFor, msg.id)) {
1434
+ this.requests.failed(failedFor, cleanErrorDetail(msg.error));
1435
+ }
1436
+ if (msg.glideRefused === true) {
1437
+ // The page's own refusal text, whole: it already says that nothing was changed, so no evaluation-failure tail is added.
1438
+ entry.reject(new Error(cleanRefusal(msg.error)));
1439
+ return;
1440
+ }
1278
1441
  const tail = this.code === '' ? NOTHING_PLAYING_TAIL : this.stopped ? STILL_STOPPED_TAIL : STILL_PLAYING_TAIL;
1279
1442
  entry.reject(new Error(evalFailedMessage(msg.error, msg.line, tail)));
1280
1443
  }
@@ -1299,12 +1462,17 @@ export class Session {
1299
1462
  this.handFailureLine = null;
1300
1463
  const versionId = this.recordConfirmed(conn, code, origin, intent);
1301
1464
  this.rememberVersion(id, versionId);
1465
+ if (intent?.answering !== undefined) {
1466
+ this.requests.applied(intent.answering, versionId);
1467
+ }
1302
1468
  return { replacedHandEdit, versionId };
1303
1469
  }
1304
1470
  /**
1305
1471
  * The page says the waiting change is audible now. Accepted only from the active page and only for
1306
1472
  * the id of the change that waits; anything else is one stderr line and changes nothing. The message carries
1307
- * only the id, so the recorded code is the one stored from the pending reply.
1473
+ * the id and, when the ramp the waiting change started at its line still runs, `glideRuns: true`; the recorded code
1474
+ * is the one stored from the pending reply, and the ramp record is set from `glideRuns` (or cleared without it) for a
1475
+ * change that asked for a ramp.
1308
1476
  */
1309
1477
  handleTakeover(conn, msg) {
1310
1478
  if (conn !== this.activePage) {
@@ -1318,6 +1486,11 @@ export class Session {
1318
1486
  }
1319
1487
  this.pendingChange = null;
1320
1488
  const { replacedHandEdit } = this.confirmSuccess(conn, waiting.id, waiting.code, 'playing', waiting.intent);
1489
+ // A change that asked for a ramp at its line ended any older ramp there. Its own ramp is the recorded ramp from here on only
1490
+ // when the page says it still runs; otherwise none runs (nothing to ramp between, or it already ended) and the record is
1491
+ // cleared with no line. A change replaced or canceled before its line never gets here.
1492
+ if (waiting.glide !== undefined)
1493
+ this.glide = msg.glideRuns === true ? { toBpm: waiting.glide.toBpm } : null;
1321
1494
  // An edit that carried a hand edit replaced nothing of it: the Mixer and Parts refuse while a change
1322
1495
  // waits and a hand evaluation cancels the wait, so the code playing at this takeover is still the hand-edited
1323
1496
  // text the edit was built on, and the note would be false.
@@ -1342,6 +1515,17 @@ export class Session {
1342
1515
  }
1343
1516
  this.dropWaitingChange(msg.reason);
1344
1517
  }
1518
+ /**
1519
+ * The page says its tempo ramp ended on its own. Only the active page is trusted; the numbers are bounded by the
1520
+ * protocol schema and the wording is the server's.
1521
+ */
1522
+ handleGlideEnded(conn, msg) {
1523
+ if (conn !== this.activePage) {
1524
+ console.error('[moontide] Ignoring glide-ended from a non-active page');
1525
+ return;
1526
+ }
1527
+ this.dropGlide(msg.reason, msg.bpm === undefined ? undefined : Math.round(msg.bpm * 10) / 10);
1528
+ }
1345
1529
  /**
1346
1530
  * The editedByHand flag after a confirmed evaluation: Claude's code makes the stored code
1347
1531
  * Claude's again (false). A change made from the page (origin 'page') is the user's:
@@ -1509,6 +1693,7 @@ export class Session {
1509
1693
  if (conn === this.activePage) {
1510
1694
  // A late stop confirmation still reflects reality — the page did stop.
1511
1695
  this.stopped = true;
1696
+ this.dropGlide('stop');
1512
1697
  this.dropWaitingChange('stop');
1513
1698
  console.error(`[moontide] Late stop confirmation stored for request ${msg.id}`);
1514
1699
  }
@@ -1526,6 +1711,7 @@ export class Session {
1526
1711
  this.pending.delete(msg.id);
1527
1712
  if (conn === this.activePage) {
1528
1713
  this.stopped = true;
1714
+ this.dropGlide('stop');
1529
1715
  this.dropWaitingChange('stop');
1530
1716
  entry.resolve(undefined);
1531
1717
  }
@@ -1880,6 +2066,9 @@ export class Session {
1880
2066
  // A change that waited for a line and did not take over, told once.
1881
2067
  lines.push(...this.takeoverNotes);
1882
2068
  this.takeoverNotes.length = 0;
2069
+ // A tempo ramp that ended early, told once.
2070
+ lines.push(...this.glideNotes);
2071
+ this.glideNotes.length = 0;
1883
2072
  // The build line comes before the setup file lines, then the MIDI lines, then the ask
1884
2073
  // lines (the arming line stays last). Runtime error lines and the history notes come earlier.
1885
2074
  lines.push(...this.buildNotes);
@@ -1949,6 +2138,8 @@ export class Session {
1949
2138
  force: false,
1950
2139
  at: options?.at,
1951
2140
  phraseCycles: options?.phraseCycles,
2141
+ glideBars: options?.glideBars,
2142
+ answering: options?.answering,
1952
2143
  });
1953
2144
  }
1954
2145
  /**
@@ -2007,6 +2198,8 @@ export class Session {
2007
2198
  force: false,
2008
2199
  at: options?.at,
2009
2200
  phraseCycles: options?.phraseCycles,
2201
+ glideBars: options?.glideBars,
2202
+ answering: options?.answering,
2010
2203
  carriesHandEdit,
2011
2204
  }).then((outcome) => ({ outcome, code: result.code, ranges: result.ranges, base, carriesHandEdit }));
2012
2205
  }
@@ -2054,6 +2247,7 @@ export class Session {
2054
2247
  label: restoredLabel(id),
2055
2248
  restoredFrom: id,
2056
2249
  origin: options?.origin,
2250
+ answering: options?.answering,
2057
2251
  force: true,
2058
2252
  });
2059
2253
  }
@@ -2073,6 +2267,8 @@ export class Session {
2073
2267
  message.at = intent.at;
2074
2268
  if (intent.phraseCycles !== undefined)
2075
2269
  message.phraseCycles = intent.phraseCycles;
2270
+ if (intent.glideBars !== undefined)
2271
+ message.glideBars = intent.glideBars;
2076
2272
  return message;
2077
2273
  });
2078
2274
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moontide-mcp",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "mcpName": "io.github.konni-arcadia/moontide",
5
5
  "description": "Claude steers live music by writing Strudel code played in a browser page",
6
6
  "keywords": [
package/page/ask-view.js CHANGED
@@ -1,12 +1,13 @@
1
1
  // The Ask Claude box's DOM: the small box a right-click opens near the pointer, its
2
- // chips, its field, its message line, the top-bar request status and the top-bar page-requests
2
+ // chips, its field, its message line and the top-bar page-requests
3
3
  // status (whether page requests reach Claude live) with its (i) button and the
4
4
  // short popover the (i) opens, and a refused page token on the top-bar error line as soon as the
5
5
  // server says so. Every text reaches the page through
6
6
  // textContent, title, placeholder or setAttribute, never as markup: the chips carry names and code
7
7
  // written by a person or by Claude. No DOM access at import time: main.js calls createAskView() with
8
- // its document and a send function. The pure rules (copy, limits, placement, status timing) live in
9
- // page/ask.js.
8
+ // its document and a send function. The pure rules (copy, limits, placement) live in page/ask.js. What became of
9
+ // each request is the Requests list's business (page/request-status-view.js): this module hands every request and
10
+ // every answer to the request tracker, and shows only the line under the Parts button.
10
11
 
11
12
  import {
12
13
  ASK_COPY,
@@ -16,7 +17,6 @@ import {
16
17
  pageRequestsText,
17
18
  askMessage,
18
19
  askTextProblem,
19
- createAskStatus,
20
20
  cutText,
21
21
  describeContext,
22
22
  fitGestureToLine,
@@ -32,21 +32,17 @@ import {
32
32
  * Creates the box controller. `document` is the page's document (named so on purpose: every element
33
33
  * lookup below reads `document.getElementById`, the shape test/page-hidden.test.js scans); `send`
34
34
  * sends one JSON-able message to the server; `reportError` and `clearError` write and clear the
35
- * top-bar error line; `setTimer`, `clearTimer` and `now` are the page's clock; `fit` is the top bar's fit step, run
36
- * after the status changes because a new text changes the bar's width (a ResizeObserver does not fire for content
37
- * growing inside a full bar).
35
+ * top-bar error line; `setTimer`, `clearTimer` and `now` are the page's clock. `requests` is the per-request tracker
36
+ * of page/request-status.js: every request sent is handed to it, and so is every server answer to one. The tracker
37
+ * tells the page (main.js) when a request was never confirmed; the page then calls `notConfirmed(id)` here.
38
38
  */
39
- export function createAskView({ document, send, reportError, clearError, setTimer, clearTimer, now, fit }) {
39
+ export function createAskView({ document, send, reportError, clearError, setTimer, clearTimer, now, requests }) {
40
40
  const askBox = document.getElementById('ask-box');
41
41
  const askContextChip = document.getElementById('ask-context-chip');
42
42
  const askCurrentChip = document.getElementById('ask-current-chip');
43
43
  const askField = document.getElementById('ask-field');
44
44
  const askMessageEl = document.getElementById('ask-message');
45
45
  const askHint = document.getElementById('ask-hint');
46
- const askStatus = document.getElementById('ask-status');
47
- // The status holds its long sentence and a short form (shown at fit step 3, aria-hidden).
48
- const askLong = askStatus === null ? null : askStatus.querySelector('.ask-long');
49
- const askShort = askStatus === null ? null : askStatus.querySelector('.ask-short');
50
46
  const partsAsk = document.getElementById('parts-ask');
51
47
  const partsAskStatus = document.getElementById('parts-ask-status');
52
48
  const pageRequests = document.getElementById('page-requests');
@@ -60,9 +56,6 @@ export function createAskView({ document, send, reportError, clearError, setTime
60
56
  !askField ||
61
57
  !askMessageEl ||
62
58
  !askHint ||
63
- !askStatus ||
64
- !askLong ||
65
- !askShort ||
66
59
  !partsAsk ||
67
60
  !partsAskStatus ||
68
61
  !pageRequests ||
@@ -72,8 +65,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
72
65
  ) {
73
66
  throw new Error('Moontide: the Ask Claude markup is missing from the page.');
74
67
  }
75
- if (typeof fit !== 'function') {
76
- throw new Error('Moontide: createAskView needs the top bar\'s fit function.');
68
+ if (requests === undefined || typeof requests.sent !== 'function' || typeof requests.serverMessage !== 'function') {
69
+ throw new Error('Moontide: createAskView needs the request tracker.');
77
70
  }
78
71
 
79
72
  // The field's description lists the message line only while it shows, so it is never empty.
@@ -139,36 +132,10 @@ export function createAskView({ document, send, reportError, clearError, setTime
139
132
  // The side the box opened on (above the pointer or below), kept while its height changes; null
140
133
  // means choose again.
141
134
  let placedAbove = null;
142
- // The Parts view's button: whether the code has no sections (so the button shows), the id of its
143
- // request while it waits for Claude to take it, and the top-bar status text now on screen.
135
+ // The Parts view's button: whether the code has no sections (so the button shows) and the id of its
136
+ // request while it waits for Claude to take it.
144
137
  let partsAvailable = false;
145
138
  let partsPendingId = null;
146
- let statusText = null;
147
-
148
- const status = createAskStatus({
149
- render: (text, short) => {
150
- statusText = text;
151
- askLong.textContent = text ?? '';
152
- askShort.textContent = short ?? '';
153
- askStatus.hidden = text === null;
154
- fit();
155
- renderPartsAsk();
156
- },
157
- onTimeout: (id) => {
158
- reportError(ASK_COPY.didNotConfirm);
159
- if (id === partsPendingId) {
160
- // The canned request needs no draft: the button just comes back.
161
- partsPendingId = null;
162
- renderPartsAsk();
163
- return;
164
- }
165
- notSent = true;
166
- renderMessage();
167
- },
168
- setTimer,
169
- clearTimer,
170
- now,
171
- });
172
139
 
173
140
  /** The fields of the request the box would send for `text` (trimmed), the id measured with the next one's shape. */
174
141
  function requestFields(text) {
@@ -202,6 +169,14 @@ export function createAskView({ document, send, reportError, clearError, setTime
202
169
  refit();
203
170
  }
204
171
 
172
+ /** The state phrase of the Parts request's row in the Requests list, or null when it has no row. */
173
+ function partsRowPhrase() {
174
+ if (partsPendingId === null) return null;
175
+ const model = requests.model();
176
+ const row = [...model.rows, ...model.recent].find((candidate) => candidate.id === partsPendingId);
177
+ return row === undefined ? null : row.phrase;
178
+ }
179
+
205
180
  /** The Parts button and the line under it, from partsAskState. */
206
181
  function renderPartsAsk() {
207
182
  partsAsk.hidden = !partsAvailable;
@@ -210,7 +185,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
210
185
  partsAskStatus.hidden = true;
211
186
  return;
212
187
  }
213
- const state = partsAskState({ connected, pending: partsPendingId !== null, statusText });
188
+ const state = partsAskState({ connected, pending: partsPendingId !== null, statusText: partsRowPhrase() });
214
189
  partsAsk.disabled = state.disabled;
215
190
  partsAskStatus.textContent = state.line ?? '';
216
191
  partsAskStatus.hidden = state.line === null;
@@ -344,7 +319,7 @@ export function createAskView({ document, send, reportError, clearError, setTime
344
319
  send(frame);
345
320
  // While the token is refused the line stays: the server's 'token' refusal writes it again, so it never blinks off.
346
321
  if (!authRefused) clearError();
347
- status.sent(id);
322
+ requests.sent(id, { text, chip: describedContext.chip });
348
323
  return id;
349
324
  }
350
325
 
@@ -478,13 +453,13 @@ export function createAskView({ document, send, reportError, clearError, setTime
478
453
  return;
479
454
  }
480
455
  if (msg.type === 'ask-received') {
481
- status.handedOver(msg.ids);
456
+ requests.serverMessage(msg);
482
457
  if (partsPendingId !== null && msg.ids.includes(partsPendingId)) partsPendingId = null;
483
458
  renderPartsAsk();
484
459
  return;
485
460
  }
486
461
  if (msg.type !== 'ask-result') return;
487
- status.answered(msg);
462
+ requests.serverMessage(msg);
488
463
  const fromParts = msg.id === partsPendingId;
489
464
  if (msg.status === 'refused') {
490
465
  reportError(refusalText(msg.reason, msg.detail));
@@ -505,10 +480,25 @@ export function createAskView({ document, send, reportError, clearError, setTime
505
480
  renderPartsAsk();
506
481
  }
507
482
 
483
+ /**
484
+ * The tracker gave up on request `id`: the server never confirmed it, or the connection closed first. The error line
485
+ * says so, and the draft comes back (or, for the Parts button's canned request, the button is released).
486
+ */
487
+ function notConfirmed(id) {
488
+ reportError(ASK_COPY.didNotConfirm);
489
+ if (id === partsPendingId) {
490
+ // The canned request needs no draft: the button just comes back.
491
+ partsPendingId = null;
492
+ renderPartsAsk();
493
+ return;
494
+ }
495
+ notSent = true;
496
+ renderMessage();
497
+ }
498
+
508
499
  function setConnected(state) {
509
500
  connected = state === 'connected';
510
501
  if (state === 'disconnected') {
511
- status.dropAll();
512
502
  partsPendingId = null;
513
503
  // The state is unknown once the socket is gone (the page does not reconnect by itself).
514
504
  pageRequests.hidden = true;
@@ -543,5 +533,8 @@ export function createAskView({ document, send, reportError, clearError, setTime
543
533
  setCurrentVersion,
544
534
  setPartsAvailable,
545
535
  onServerMessage,
536
+ notConfirmed,
537
+ /** Draws the line under the Parts button again from its request's row; the page calls it after every tracker change. */
538
+ refreshPartsLine: () => renderPartsAsk(),
546
539
  };
547
540
  }