@oddessentials/agent-guild 0.21.0 → 0.23.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 CHANGED
@@ -124,6 +124,11 @@ vX.Y.Z** switches over when you are ready. The version badge opens
124
124
 
125
125
  <img src="docs/images/whats-new.webp" alt="The What's new panel listing the release notes of each version">
126
126
 
127
+ **Keep notes at hand.** **Notes** in the top bar opens a notepad for prompts,
128
+ commands and to-dos, even while a terminal is open. It saves up to 100,000
129
+ characters as you type and keeps them in this browser, never on the manager;
130
+ every tab of the page shows the same notes.
131
+
127
132
  **Appearance.** The Appearance menu in the top bar picks a skin and light or
128
133
  dark mode, without a reload. **Guild** is the default fantasy look;
129
134
  **Professional** is a plain business look with no characters; **Orbital**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/web/app.js CHANGED
@@ -16,6 +16,8 @@ const GITHUB_ACCOUNT_KEY = 'agentGuild.githubAccount';
16
16
  const CLONE_PARENT_KEY = 'agentGuild.cloneParent';
17
17
  const SESSION_ORDER_KEY = 'agentGuild.sessionOrder';
18
18
  const SOUND_KEY = 'agentGuild.sound';
19
+ const VOICE_KEY = 'agentGuild.voice';
20
+ const NOTES_KEY = 'agentGuild.notes';
19
21
  const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
20
22
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
21
23
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
@@ -63,7 +65,7 @@ const state = {
63
65
  // ---- storage (may be unavailable, e.g. blocked site data) -----------------
64
66
 
65
67
  function load(key) { try { return localStorage.getItem(key); } catch { return null; } }
66
- function save(key, value) { try { value === null ? localStorage.removeItem(key) : localStorage.setItem(key, value); } catch { /* ignore */ } }
68
+ function save(key, value) { try { value === null ? localStorage.removeItem(key) : localStorage.setItem(key, value); return true; } catch { return false; } }
67
69
 
68
70
  // ---- helpers --------------------------------------------------------------
69
71
 
@@ -87,6 +89,12 @@ function toast(message, ms = 5000, action = null) {
87
89
  toastTimer = setTimeout(() => { el.hidden = true; }, ms);
88
90
  }
89
91
 
92
+ /**
93
+ * Handles a click, but not the second click of a double-click, which lands on whatever the first one
94
+ * opened or uncovered: Close over Notes, or Stop manager under a panel's Close.
95
+ */
96
+ const firstClick = (run) => (e) => { if (e.detail < 2) run(); };
97
+
90
98
  function setConnection(kind, label) {
91
99
  const el = $('connection');
92
100
  el.className = `connection ${kind}`;
@@ -290,8 +298,108 @@ function placeAppearanceMenu() {
290
298
  const menu = $('appearance-menu');
291
299
  if (!menu.matches(':popover-open')) return;
292
300
  const box = $('appearance').getBoundingClientRect();
293
- menu.style.top = `${Math.round(box.bottom + 6)}px`;
301
+ const top = Math.round(box.bottom + 6);
302
+ menu.style.top = `${top}px`;
303
+ menu.style.maxHeight = `${innerHeight - top - 8}px`;
304
+ menu.style.left = 'auto';
294
305
  menu.style.right = `${Math.max(8, Math.round(innerWidth - box.right))}px`;
306
+ if (menu.getBoundingClientRect().left < 8) {
307
+ menu.style.right = 'auto';
308
+ menu.style.left = `${Math.max(8, Math.round(box.left))}px`;
309
+ }
310
+ }
311
+
312
+ // ---- notes ----------------------------------------------------------------
313
+
314
+ /** Notes share the page's storage with every other setting, so they stay far below the browser's limit for it. */
315
+ const NOTES_LIMIT = 100000;
316
+ const NOTES_STATUS = {
317
+ saved: 'Saved in this browser as you type',
318
+ long: `Not saved: notes hold up to ${NOTES_LIMIT.toLocaleString('en-US')} characters. Shorten them to save.`,
319
+ refused: 'Not saved: the browser’s storage for this page is full or turned off. Copy what you need before you close the page.',
320
+ };
321
+ /**
322
+ * `saved`: the notes as this page last read or wrote them in storage. `status`: whether the text in the
323
+ * panel is saved, too `long` or `refused`; `shown`: the status the line under the title shows.
324
+ * `pressedOutside`: the last press on the panel was on its backdrop.
325
+ */
326
+ const notesView = { saved: '', status: 'saved', shown: 'saved', pressedOutside: false };
327
+
328
+ /** Shows notes another tab saved since this page last read or wrote them. Saved notes win over text this page could not save. */
329
+ function refreshNotes() {
330
+ let text;
331
+ // Unlike load(), a read that fails is not taken for empty notes.
332
+ try { text = localStorage.getItem(NOTES_KEY) ?? ''; } catch { return; }
333
+ if (text === notesView.saved) return;
334
+ notesView.saved = text;
335
+ notesView.status = 'saved';
336
+ $('notes-text').value = text;
337
+ renderNotesStatus();
338
+ guardLeaving();
339
+ }
340
+
341
+ /** Saves the notes on every change; emptying them removes the saved copy. */
342
+ function saveNotes() {
343
+ const text = $('notes-text').value;
344
+ if (text.length > NOTES_LIMIT) notesView.status = 'long';
345
+ else if (!save(NOTES_KEY, text || null)) notesView.status = 'refused';
346
+ else {
347
+ notesView.status = 'saved';
348
+ notesView.saved = text;
349
+ }
350
+ renderNotesStatus();
351
+ guardLeaving();
352
+ }
353
+
354
+ /** The line under the title is a live region, so it is rewritten only when the status changes. */
355
+ function renderNotesStatus() {
356
+ if (notesView.shown === notesView.status) return;
357
+ notesView.shown = notesView.status;
358
+ const sub = $('notes-sub');
359
+ sub.textContent = NOTES_STATUS[notesView.status];
360
+ sub.classList.toggle('warn', notesView.status !== 'saved');
361
+ }
362
+
363
+ /** Another tab saved the notes, or cleared this page's storage. */
364
+ function notesStored(e) {
365
+ if (e.key === NOTES_KEY || e.key === null) refreshNotes();
366
+ }
367
+
368
+ function openNotes() {
369
+ hideTip();
370
+ refreshNotes();
371
+ // Saving an empty note changes nothing, but shows at once when the browser keeps nothing.
372
+ if (notesView.status === 'saved' && !$('notes-text').value) saveNotes();
373
+ const dialog = $('notes');
374
+ if (!dialog.open) dialog.showModal();
375
+ $('notes-text').focus();
376
+ }
377
+
378
+ function closeNotes() {
379
+ if ($('notes').open) $('notes').close();
380
+ }
381
+
382
+ /** Focus goes back to the Notes button in every browser, never to the page itself. */
383
+ function notesClosed() {
384
+ $('notes-open').focus();
385
+ }
386
+
387
+ /** The second press of a double-click on Notes lands in the panel, where it must not take the focus from the text. */
388
+ const keepFocus = (e) => { if (e.detail > 1 && e.target !== $('notes-text')) e.preventDefault(); };
389
+
390
+ /** Whether a press or click on the panel was on its backdrop, outside the sheet. */
391
+ function isOutsideNotes(e) {
392
+ const box = e.currentTarget.getBoundingClientRect();
393
+ return e.target === e.currentTarget && (e.clientX < box.left || e.clientX >= box.right || e.clientY < box.top || e.clientY >= box.bottom);
394
+ }
395
+
396
+ function notesPressed(e) {
397
+ notesView.pressedOutside = isOutsideNotes(e);
398
+ }
399
+
400
+ /** Only a press and a release both outside close the notes: a drag into or out of them selects text. */
401
+ function notesClicked(e) {
402
+ if (notesView.pressedOutside && isOutsideNotes(e)) closeNotes();
295
403
  }
296
404
 
297
405
  // ---- upgrading the manager ------------------------------------------------
@@ -2858,7 +2966,9 @@ function confirmLeaving(event) {
2858
2966
 
2859
2967
  function guardLeaving() {
2860
2968
  const running = state.connected && [...state.sessions.values()].some((s) => s.status === 'running');
2861
- if (running) addEventListener('beforeunload', confirmLeaving);
2969
+ // Notes that could not be saved are lost with the page.
2970
+ const unsaved = notesView.status !== 'saved' && $('notes-text').value !== '';
2971
+ if (running || unsaved) addEventListener('beforeunload', confirmLeaving);
2862
2972
  else removeEventListener('beforeunload', confirmLeaving);
2863
2973
  }
2864
2974
 
@@ -3264,6 +3374,7 @@ class TerminalView {
3264
3374
  this.term.write(msg.data);
3265
3375
  break;
3266
3376
  case 'exit':
3377
+ if (dictation?.id === this.id) stopDictation();
3267
3378
  this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
3268
3379
  break;
3269
3380
  default:
@@ -3358,6 +3469,7 @@ class TerminalView {
3358
3469
 
3359
3470
  function openPanel(id) {
3360
3471
  if (!state.sessions.has(id)) return;
3472
+ if (dictation && dictation.id !== id) stopDictation();
3361
3473
  if (state.activeId && state.activeId !== id) state.views.get(state.activeId)?.unmount();
3362
3474
  state.activeId = id;
3363
3475
  let view = state.views.get(id);
@@ -3368,12 +3480,15 @@ function openPanel(id) {
3368
3480
  }
3369
3481
 
3370
3482
  function closePanel() {
3483
+ stopDictation();
3371
3484
  if (state.activeId) state.views.get(state.activeId)?.unmount();
3372
3485
  state.activeId = null;
3373
3486
  $('terminal-panel').hidden = true;
3487
+ renderVoice();
3374
3488
  }
3375
3489
 
3376
3490
  function updatePanel() {
3491
+ renderVoice();
3377
3492
  const s = state.sessions.get(state.activeId);
3378
3493
  if (!s) return;
3379
3494
  paintProviderIcon($('panel-icon'), s.provider);
@@ -3386,6 +3501,157 @@ function updatePanel() {
3386
3501
  stop.textContent = s.status !== 'running' ? 'Remove' : s.multiplexer ? 'Detach' : 'Stop';
3387
3502
  }
3388
3503
 
3504
+ // ---- voice input ----------------------------------------------------------
3505
+
3506
+ const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition;
3507
+ const VOICE_RETRY_DELAYS = [250, 500, 1000];
3508
+ let dictation = null;
3509
+
3510
+ function voiceOn() {
3511
+ return Boolean(Recognition) && load(VOICE_KEY) === 'on';
3512
+ }
3513
+
3514
+ function renderVoice() {
3515
+ if (dictation) keepDictating(dictation);
3516
+ $('voice-choice').hidden = !Recognition;
3517
+ $('voice').checked = voiceOn();
3518
+ $('panel-voice').hidden = !voiceOn() || state.sessions.get(state.activeId)?.status !== 'running';
3519
+ $('panel-voice').setAttribute('aria-pressed', String(Boolean(dictation)));
3520
+ }
3521
+
3522
+ function changeVoice(input) {
3523
+ save(VOICE_KEY, input.checked ? 'on' : null);
3524
+ renderVoice();
3525
+ }
3526
+
3527
+ /** Dictated words as terminal input: one line of text, no control characters, so nothing is ever submitted. */
3528
+ function dictatedText(text, first) {
3529
+ const clean = text.replace(/[\x00-\x1f\x7f-\x9f]+/g, ' ').replace(/\s+/g, ' ').trim();
3530
+ return clean && (first ? clean : ` ${clean}`);
3531
+ }
3532
+
3533
+ function canDictate(current) {
3534
+ const session = state.sessions.get(current.id);
3535
+ return voiceOn() && state.activeId === current.id && session?.status === 'running'
3536
+ && session.startedAt === current.startedAt && state.views.has(current.id)
3537
+ && !state.pageAway && !state.stopping && document.visibilityState === 'visible'
3538
+ && !$('app').hidden && !$('terminal-panel').hidden;
3539
+ }
3540
+
3541
+ /** Every async continuation belongs to one explicit click and one run of the session. */
3542
+ function keepDictating(current) {
3543
+ if (dictation !== current) return false;
3544
+ if (canDictate(current)) return true;
3545
+ stopDictation();
3546
+ return false;
3547
+ }
3548
+
3549
+ async function startDictation() {
3550
+ const id = state.activeId;
3551
+ const session = state.sessions.get(id);
3552
+ if (!session || dictation) return;
3553
+ const current = {
3554
+ id, startedAt: session.startedAt, lang: navigator.language || 'en-US',
3555
+ android: /Android/i.test(navigator.userAgent || ''), processLocally: false,
3556
+ rec: null, timer: null, first: true, emptyRetries: 0,
3557
+ };
3558
+ if (!canDictate(current)) return;
3559
+ // Register before awaiting availability so another click or leaving the terminal cancels startup too.
3560
+ dictation = current;
3561
+ renderVoice();
3562
+ state.views.get(id)?.term.focus();
3563
+ try {
3564
+ // Browsers without this API start in the click handler, without an unnecessary await.
3565
+ if (typeof Recognition.available === 'function') {
3566
+ current.processLocally = await Recognition.available({ langs: [current.lang], processLocally: true }) === 'available';
3567
+ }
3568
+ } catch { /* cloud recognition */ }
3569
+ listenForDictation(current);
3570
+ }
3571
+
3572
+ function listenForDictation(current) {
3573
+ if (!keepDictating(current)) return;
3574
+ let rec;
3575
+ try {
3576
+ rec = new Recognition();
3577
+ current.rec = rec;
3578
+ rec.lang = current.lang;
3579
+ rec.continuous = !current.android;
3580
+ rec.interimResults = true;
3581
+ if (current.processLocally) rec.processLocally = true;
3582
+ } catch {
3583
+ stopDictation();
3584
+ toast('Voice input could not start.');
3585
+ return;
3586
+ }
3587
+ // A new recognizer per Android phrase avoids cumulative transcripts from continuous mode.
3588
+ let committed = 0;
3589
+ let heard = false;
3590
+ const preview = $('voice-preview');
3591
+ rec.onresult = (e) => {
3592
+ if (current.rec !== rec || !keepDictating(current)) return;
3593
+ let interim = '';
3594
+ for (let i = Math.max(e.resultIndex, committed); i < e.results.length; i += 1) {
3595
+ const result = e.results[i];
3596
+ if (!result.isFinal) { interim += result[0].transcript; continue; }
3597
+ committed = i + 1;
3598
+ const text = dictatedText(result[0].transcript, current.first);
3599
+ if (!text) continue;
3600
+ state.views.get(current.id)?.term.paste(text);
3601
+ current.first = false;
3602
+ current.emptyRetries = 0;
3603
+ heard = true;
3604
+ }
3605
+ preview.textContent = interim.trim();
3606
+ preview.hidden = !preview.textContent;
3607
+ };
3608
+ rec.onerror = (e) => {
3609
+ if (current.rec !== rec || !keepDictating(current)) return;
3610
+ if (current.android && e.error === 'no-speech') return; // bounded retry after end
3611
+ stopDictation();
3612
+ if (e.error === 'not-allowed' || e.error === 'service-not-allowed') toast('Voice input needs permission to use the microphone.');
3613
+ else if (e.error === 'network') toast('Voice input could not reach the speech service. Check your connection.');
3614
+ else if (e.error === 'audio-capture') toast('No microphone was found.');
3615
+ else if (e.error === 'language-not-supported') toast(`Voice input does not support ${current.lang}.`);
3616
+ else if (e.error !== 'aborted' && e.error !== 'no-speech') toast('Voice input stopped. Press Dictate to try again.');
3617
+ };
3618
+ rec.onend = () => {
3619
+ if (current.rec !== rec || !keepDictating(current)) return;
3620
+ current.rec = null;
3621
+ preview.hidden = true;
3622
+ preview.textContent = '';
3623
+ if (!current.android) return stopDictation();
3624
+ if (!heard && current.emptyRetries >= VOICE_RETRY_DELAYS.length) {
3625
+ stopDictation();
3626
+ toast('Voice input stopped after repeated silence. Press Dictate to try again.');
3627
+ return;
3628
+ }
3629
+ const delay = heard ? VOICE_RETRY_DELAYS[0] : VOICE_RETRY_DELAYS[current.emptyRetries++];
3630
+ current.timer = setTimeout(() => {
3631
+ current.timer = null;
3632
+ listenForDictation(current);
3633
+ }, delay);
3634
+ };
3635
+ try {
3636
+ rec.start();
3637
+ } catch {
3638
+ if (current.rec !== rec || !keepDictating(current)) return;
3639
+ stopDictation();
3640
+ toast('Voice input could not start.');
3641
+ }
3642
+ }
3643
+
3644
+ function stopDictation() {
3645
+ const current = dictation;
3646
+ if (!current) return;
3647
+ dictation = null;
3648
+ clearTimeout(current.timer);
3649
+ try { current.rec?.abort(); } catch { /* already ended */ }
3650
+ $('voice-preview').hidden = true;
3651
+ $('voice-preview').textContent = '';
3652
+ $('panel-voice').setAttribute('aria-pressed', 'false');
3653
+ }
3654
+
3389
3655
  // ---- stopping the manager -------------------------------------------------
3390
3656
 
3391
3657
  /**
@@ -3641,6 +3907,7 @@ let newsTimer;
3641
3907
 
3642
3908
  function showAuth(message = '') {
3643
3909
  managerLoss.cancel();
3910
+ closePanel();
3644
3911
  closeModels();
3645
3912
  closeNews();
3646
3913
  closeChangelog();
@@ -3692,6 +3959,13 @@ $('auth-form').addEventListener('submit', (e) => {
3692
3959
  boot();
3693
3960
  });
3694
3961
  $('panel-close').addEventListener('click', closePanel);
3962
+ $('panel-voice').addEventListener('click', () => {
3963
+ if (dictation) {
3964
+ const { id } = dictation;
3965
+ stopDictation();
3966
+ state.views.get(id)?.term.focus();
3967
+ } else startDictation();
3968
+ });
3695
3969
  $('models-close').addEventListener('click', closeModels);
3696
3970
  $('history-close').addEventListener('click', closeHistory);
3697
3971
  $('history').addEventListener('click', (e) => { if (e.target === $('history')) closeHistory(); });
@@ -3761,6 +4035,16 @@ document.addEventListener('keydown', (e) => {
3761
4035
  }, true);
3762
4036
  addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
3763
4037
  addEventListener('resize', () => { if (tipFor) hideTip(); });
4038
+ $('notes-open').addEventListener('click', firstClick(openNotes));
4039
+ $('notes-close').addEventListener('click', firstClick(closeNotes));
4040
+ $('notes').addEventListener('mousedown', keepFocus);
4041
+ $('notes').addEventListener('pointerdown', notesPressed);
4042
+ $('notes').addEventListener('click', notesClicked);
4043
+ $('notes').addEventListener('close', notesClosed);
4044
+ $('notes-text').addEventListener('input', saveNotes);
4045
+ addEventListener('storage', notesStored);
4046
+ // On load, and again for a page back from the back/forward cache, which may have missed another tab's notes.
4047
+ addEventListener('pageshow', refreshNotes);
3764
4048
  $('news-all').addEventListener('click', openNews);
3765
4049
  $('news-close').addEventListener('click', closeNews);
3766
4050
  $('news-fresh').addEventListener('click', showFreshNews);
@@ -3774,10 +4058,12 @@ $('news').addEventListener('close', () => {
3774
4058
  newsView.opener = null;
3775
4059
  });
3776
4060
  document.addEventListener('visibilitychange', () => {
4061
+ if (document.visibilityState !== 'visible') stopDictation();
3777
4062
  if (document.visibilityState === 'visible' && state.connected && Date.now() - newsLoadedAt > 60000) loadNews();
3778
4063
  });
3779
4064
  addEventListener('pagehide', () => {
3780
4065
  state.pageAway = true;
4066
+ stopDictation();
3781
4067
  managerLoss.cancel();
3782
4068
  state.eventsSocket?.close();
3783
4069
  });
@@ -3805,6 +4091,7 @@ $('changelog-restart').addEventListener('click', () => {
3805
4091
  addEventListener('storage', (e) => {
3806
4092
  if (e.key === CHANGELOG_SEEN_KEY) renderVersion();
3807
4093
  else if (e.key === SOUND_KEY) { $('sound').checked = soundOn(); prepareSounds(); }
4094
+ else if (e.key === VOICE_KEY) renderVoice();
3808
4095
  // Another tab reordered the cards.
3809
4096
  else if (e.key === SESSION_ORDER_KEY && !drag) arrange(parseOrder(e.newValue));
3810
4097
  });
@@ -3843,14 +4130,15 @@ $('models-more').addEventListener('click', () => {
3843
4130
  renderModels();
3844
4131
  $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
3845
4132
  });
3846
- $('stop-manager').addEventListener('click', () => stopManager());
3847
- $('restart-manager').addEventListener('click', () => stopManager({ restart: true }));
4133
+ $('stop-manager').addEventListener('click', firstClick(() => stopManager()));
4134
+ $('restart-manager').addEventListener('click', firstClick(() => stopManager({ restart: true })));
3848
4135
  $('copy-command').addEventListener('click', copyCommand);
3849
4136
  $('upgrade').addEventListener('click', upgradeManager);
3850
4137
  $('appearance-menu').addEventListener('change', (e) => {
3851
4138
  if (e.target.name === 'skin') changeSkin(e.target);
3852
4139
  else if (e.target.name === 'theme') changeTheme(e.target);
3853
4140
  else if (e.target.name === 'sound') changeSound(e.target);
4141
+ else if (e.target.name === 'voice') changeVoice(e.target);
3854
4142
  });
3855
4143
  $('appearance-menu').addEventListener('toggle', (e) => {
3856
4144
  if (e.newState !== 'open') return;
@@ -3885,6 +4173,7 @@ $('providers').addEventListener('pointerout', (e) => {
3885
4173
  applyTheme(currentTheme());
3886
4174
  renderSkinChoices();
3887
4175
  $('sound').checked = soundOn();
4176
+ renderVoice();
3888
4177
  // Follow the system setting until the user picks a theme.
3889
4178
  window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
3890
4179
  if (!load(THEME_KEY)) applyTheme(e.matches ? 'dark' : 'light');
package/web/index.html CHANGED
@@ -40,7 +40,13 @@
40
40
  <legend>Sound</legend>
41
41
  <label class="choice" title="Plays when the session manager stops, remains unavailable after a brief recovery check, or a new Agent Guild update is discovered. Click or type on this page to allow playback."><input id="sound" type="checkbox" name="sound"><span class="sound-alerts">Alert sounds</span></label>
42
42
  </fieldset>
43
+ <fieldset id="voice-choice" hidden>
44
+ <legend>Voice</legend>
45
+ <label class="choice"><input id="voice" type="checkbox" name="voice"><span class="voice-input">Voice input</span></label>
46
+ <p class="voice-note">Adds a Dictate button to terminals. Your browser may send the audio to its maker, such as Google or Microsoft, to turn it into text. Nothing is heard until you press Dictate, and Enter is never pressed for you.</p>
47
+ </fieldset>
43
48
  </div>
49
+ <button id="notes-open" class="btn notes-open" type="button" aria-haspopup="dialog">Notes</button>
44
50
  <button id="restart-manager" class="btn restart-manager" type="button" hidden>Restart manager</button>
45
51
  <button id="stop-manager" class="btn danger stop-manager" type="button" hidden title="Stop the session manager. This ends every session.">Stop manager</button>
46
52
  </div>
@@ -118,11 +124,13 @@
118
124
  <div id="panel-agents" class="agents"></div>
119
125
  </div>
120
126
  <div class="panel-actions">
127
+ <button id="panel-voice" class="btn voice" type="button" hidden aria-pressed="false" title="Speak into this terminal. Review the text, then press Enter yourself.">Dictate</button>
121
128
  <button id="panel-stop" class="btn danger" type="button">Stop</button>
122
129
  <button id="panel-close" class="btn" type="button" title="Hide the terminal. The session keeps running.">Hide</button>
123
130
  </div>
124
131
  </div>
125
132
  <div id="terminal-host" class="terminal-host"></div>
133
+ <div id="voice-preview" class="voice-preview" hidden aria-live="polite"></div>
126
134
  </div>
127
135
 
128
136
  <dialog id="models" class="models" aria-labelledby="models-title">
@@ -179,6 +187,19 @@
179
187
  <p id="changelog-status" class="news-status" hidden></p>
180
188
  </dialog>
181
189
 
190
+ <dialog id="notes" class="news-panel notes-panel" aria-labelledby="notes-title">
191
+ <div class="news-bar">
192
+ <div class="news-heading">
193
+ <h2 id="notes-title">Notes</h2>
194
+ <p id="notes-sub" class="sub" aria-live="polite">Saved in this browser as you type</p>
195
+ </div>
196
+ <button id="notes-close" class="btn" type="button">Close</button>
197
+ </div>
198
+ <div class="news-body">
199
+ <textarea id="notes-text" class="notes-text" aria-labelledby="notes-title" aria-describedby="notes-sub" autocomplete="off" placeholder="Prompts, commands, to-dos: anything you want to keep at hand"></textarea>
200
+ </div>
201
+ </dialog>
202
+
182
203
  <dialog id="history" class="models history" aria-labelledby="history-title">
183
204
  <div class="models-bar">
184
205
  <span id="history-icon" class="provider-icon small"></span>
@@ -243,7 +243,7 @@
243
243
  }
244
244
  .btn.primary:hover { filter: brightness(1.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), inset 0 0 0 1px rgba(233, 196, 122, 0.6), 0 5px 16px rgba(40, 70, 30, 0.35); }
245
245
  .btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
246
- .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select { background: var(--panel-strong); }
246
+ .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--panel-strong); }
247
247
 
248
248
  .models, .news-panel, .auth, .news-latest, .appearance-menu { background: var(--panel-strong); backdrop-filter: blur(16px); }
249
249
  .models, .news-panel { box-shadow: var(--dialog-shadow); }
@@ -362,7 +362,7 @@
362
362
  .btn.primary { border-color: #6a8f3a; background: linear-gradient(#55924a, #35662f); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), inset 0 0 0 1px rgba(233, 196, 122, 0.3), 0 0 16px rgba(110, 170, 80, 0.3); }
363
363
  .btn.danger { color: #f59a8c; }
364
364
  .btn.danger:hover { border-color: #b8402f; box-shadow: 0 0 12px rgba(240, 123, 106, 0.3); }
365
- .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select { border-color: #4a3824; background: rgba(10, 7, 4, 0.7); }
365
+ .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #4a3824; background: rgba(10, 7, 4, 0.7); }
366
366
 
367
367
  .models::backdrop, .news-panel::backdrop { background: rgba(6, 4, 2, 0.66); }
368
368
  .terminal-panel { border-color: #5a4226; box-shadow: 0 0 0 1px rgba(233, 196, 122, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
@@ -244,7 +244,7 @@
244
244
  }
245
245
  .btn.primary:hover { filter: brightness(1.07); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 6px 20px rgba(45, 107, 69, 0.36); }
246
246
  .btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
247
- .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select { background: var(--paper-strong); }
247
+ .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--paper-strong); }
248
248
 
249
249
  .models, .news-panel, .auth, .news-latest, .appearance-menu { background: var(--paper-strong); backdrop-filter: blur(16px); }
250
250
  .models, .news-panel { box-shadow: var(--dialog-shadow); }
@@ -371,7 +371,7 @@
371
371
  .btn.primary { background: linear-gradient(135deg, #4a8c5e, #2a6340); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 18px rgba(80, 160, 105, 0.35); }
372
372
  .btn.danger { color: #ff9a8e; }
373
373
  .btn.danger:hover { border-color: #b8443a; box-shadow: 0 0 12px rgba(240, 122, 108, 0.3); }
374
- .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select { border-color: #2e4a3d; background: rgba(4, 10, 8, 0.7); }
374
+ .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #2e4a3d; background: rgba(4, 10, 8, 0.7); }
375
375
 
376
376
  .models::backdrop, .news-panel::backdrop { background: rgba(2, 6, 5, 0.66); }
377
377
  .terminal-panel { border-color: #2e4a3d; box-shadow: 0 0 0 1px rgba(140, 210, 160, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
@@ -376,7 +376,7 @@
376
376
  .btn.primary:hover { border-color: var(--gold); filter: brightness(1.08); }
377
377
  .btn.danger { color: #ff9a94; }
378
378
  .btn.danger:hover { border-color: #b8524d; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 0 12px rgba(239, 107, 102, 0.3); }
379
- .cwd input, .auth input { border-color: #4a3d25; background: rgba(12, 13, 17, 0.8); }
379
+ .cwd input, .auth input, .notes-text { border-color: #4a3d25; background: rgba(12, 13, 17, 0.8); }
380
380
  .auth {
381
381
  background: var(--card-bg);
382
382
  border: 1px solid #4a3d25; box-shadow: inset 0 0 0 1px rgba(232, 196, 124, 0.06), 0 8px 24px rgba(0, 0, 0, 0.35);
@@ -480,7 +480,7 @@
480
480
  }
481
481
  .btn.primary:hover { border-color: #b8892f; filter: brightness(1.08); }
482
482
  .btn.danger:hover { border-color: #c4625d; }
483
- .cwd input, .auth input, .history-tools input[type="search"], .history-form input {
483
+ .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .notes-text {
484
484
  border-color: #dccba5; background: #fffdf8; box-shadow: inset 0 1px 2px rgba(70, 48, 12, 0.06);
485
485
  }
486
486
  .auth { border-color: #e4d6b6; box-shadow: var(--card-shadow); }
@@ -246,7 +246,7 @@
246
246
  }
247
247
  .btn.primary:hover { filter: brightness(1.08); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 6px 20px rgba(122, 79, 232, 0.4); }
248
248
  .btn.danger:hover { border-color: var(--danger); box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent); }
249
- .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select { background: var(--glass-strong); }
249
+ .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { background: var(--glass-strong); }
250
250
 
251
251
  .models, .news-panel, .auth, .news-latest, .appearance-menu { background: var(--glass-strong); backdrop-filter: blur(16px); }
252
252
  .models, .news-panel { box-shadow: var(--dialog-shadow); }
@@ -367,7 +367,7 @@
367
367
  .btn.primary { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22), 0 0 18px rgba(106, 92, 240, 0.45); }
368
368
  .btn.danger { color: #ff8c9c; }
369
369
  .btn.danger:hover { border-color: #c23a52; box-shadow: 0 0 12px rgba(255, 107, 129, 0.3); }
370
- .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select { border-color: #26355c; background: rgba(4, 7, 16, 0.7); }
370
+ .cwd input, .auth input, .history-tools input[type="search"], .history-form input, .github-fields input, .github-fields select, .notes-text { border-color: #26355c; background: rgba(4, 7, 16, 0.7); }
371
371
 
372
372
  .models::backdrop, .news-panel::backdrop { background: rgba(2, 4, 10, 0.66); }
373
373
  .terminal-panel { border-color: #2a3a66; box-shadow: 0 0 0 1px rgba(120, 170, 255, 0.06), 0 30px 80px rgba(0, 0, 0, 0.7); }
package/web/styles.css CHANGED
@@ -22,6 +22,7 @@
22
22
  --font-mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
23
23
  --icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
24
24
  --icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
25
+ --icon-mic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='9' y='3' width='6' height='11' rx='3'/%3E%3Cpath d='M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21'/%3E%3C/svg%3E");
25
26
  --icon-sound: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 9.5h3.5L12 5.5v13l-4.5-4H4z'/%3E%3Cpath d='M15.5 9a4.2 4.2 0 0 1 0 6M18.3 6.5a8 8 0 0 1 0 11'/%3E%3C/svg%3E");
26
27
  --icon-external:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
27
28
  --icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
@@ -29,6 +30,7 @@
29
30
  --icon-github: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z'/%3E%3C/svg%3E");
30
31
  --icon-info: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpath d='M12 10.8v6.2' stroke-width='2.4'/%3E%3Ccircle cx='12' cy='7.3' r='1.2' fill='black' stroke='none'/%3E%3C/svg%3E");
31
32
  --icon-grip: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Ccircle cx='9' cy='5.5' r='1.9'/%3E%3Ccircle cx='15' cy='5.5' r='1.9'/%3E%3Ccircle cx='9' cy='12' r='1.9'/%3E%3Ccircle cx='15' cy='12' r='1.9'/%3E%3Ccircle cx='9' cy='18.5' r='1.9'/%3E%3Ccircle cx='15' cy='18.5' r='1.9'/%3E%3C/svg%3E");
33
+ --icon-notes: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5 4.5h14v9.5l-5.5 5.5H5z'/%3E%3Cpath d='M13.5 19.5V14H19M8.5 9h7M8.5 12.5h4'/%3E%3C/svg%3E");
32
34
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
33
35
  font-size: 15px;
34
36
  color-scheme: light;
@@ -336,19 +338,22 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
336
338
  .btn.danger { color: var(--danger); }
337
339
  .btn.danger:hover { border-color: var(--danger); }
338
340
  .btn:disabled { opacity: 0.6; cursor: progress; }
339
- .stop-manager, .restart-manager, .appearance, .upgrade { white-space: nowrap; }
340
- .appearance::before, .appearance-menu .choice > span::before {
341
+ .stop-manager, .restart-manager, .appearance, .notes-open, .upgrade { white-space: nowrap; }
342
+ .appearance::before, .notes-open::before, .appearance-menu .choice > span::before {
341
343
  content: ""; display: inline-block; width: 1em; height: 1em; margin-right: 0.4em; vertical-align: -0.15em;
342
344
  background: currentColor; mask: var(--icon-sun) center / contain no-repeat;
343
345
  }
344
346
  :root[data-theme="dark"] .appearance::before, .appearance-menu .choice > .mode-dark::before { mask-image: var(--icon-moon); }
345
347
  .appearance-menu .choice > .sound-alerts::before { mask-image: var(--icon-sound); }
348
+ .appearance-menu .choice > .voice-input::before { mask-image: var(--icon-mic); }
349
+ .voice-note { margin: 0 8px 6px; max-width: 26ch; color: var(--muted); font-size: 0.75rem; line-height: 1.35; }
350
+ .notes-open::before { mask-image: var(--icon-notes); }
346
351
  .appearance::after { margin-left: 0.45em; transform: rotate(90deg); transition: transform 0.15s var(--ease); }
347
352
  .appearance:has(+ :popover-open)::after { transform: rotate(-90deg); }
348
353
  .appearance-menu {
349
354
  position: fixed; inset: auto; margin: 0; min-width: 190px; padding: 6px; display: none;
350
355
  border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text);
351
- box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22); font-size: 0.85rem;
356
+ box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22); font-size: 0.85rem; overflow-y: auto;
352
357
  }
353
358
  .appearance-menu:popover-open { display: grid; gap: 4px; animation: fade-in 0.12s var(--ease); }
354
359
  .appearance-menu fieldset { margin: 0; padding: 0; border: 0; display: grid; }
@@ -393,10 +398,24 @@ body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after {
393
398
  display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px;
394
399
  background: var(--surface); border-bottom: 1px solid var(--border); flex-wrap: wrap;
395
400
  }
396
- .panel-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1; }
401
+ .panel-title { display: flex; align-items: center; gap: 10px; min-width: 0; flex: 1 1 16rem; }
397
402
  .panel-title .title { font-weight: 600; }
398
403
  .panel-title .sub { color: var(--muted); font-size: 0.78rem; }
399
404
  .panel-actions { display: flex; gap: 8px; }
405
+ .btn.voice { white-space: nowrap; }
406
+ .btn.voice::before {
407
+ content: ""; display: inline-block; width: 1em; height: 1em; margin-right: 0.35em; vertical-align: -0.15em;
408
+ background: currentColor; mask: var(--icon-mic) center / contain no-repeat;
409
+ }
410
+ .btn.voice[aria-pressed="true"] { color: var(--danger); border-color: var(--danger); }
411
+ .btn.voice[aria-pressed="true"]::before { animation: voice-pulse 1.2s ease-in-out infinite; }
412
+ @keyframes voice-pulse { 50% { opacity: 0.35; } }
413
+ .voice-preview {
414
+ position: absolute; left: 12px; right: 12px; bottom: 12px; max-height: 30%; overflow-y: auto; z-index: 1;
415
+ padding: 8px 12px; border: 1px solid var(--border); border-radius: calc(var(--radius) - 2px);
416
+ background: var(--surface); color: var(--muted); font-size: clamp(0.8rem, 1.6vw, 0.95rem); font-style: italic;
417
+ overflow-wrap: anywhere; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3); pointer-events: none;
418
+ }
400
419
  .terminal-host { flex: 1; min-height: 0; padding: 8px 4px 4px 10px; }
401
420
  .terminal-host > div { height: 100%; }
402
421
  @media (pointer: coarse) {
@@ -547,6 +566,20 @@ a.news-link:visited { color: var(--muted); }
547
566
  .changelog-changes li::marker { color: var(--muted); }
548
567
  .changelog-changes code { font-size: 0.8rem; padding: 0 4px; border-radius: 4px; background: var(--surface-2); }
549
568
 
569
+ /* Notes: the News side sheet, holding one text field that skins style with their other fields. */
570
+ .notes-panel .news-body { display: flex; flex-direction: column; padding-top: 12px; }
571
+ .notes-text {
572
+ flex: 1; min-height: 8em; margin: 0; padding: 10px 12px; resize: none;
573
+ border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text);
574
+ font: inherit; font-size: 0.9rem; line-height: 1.55;
575
+ }
576
+ .notes-text:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
577
+ .notes-panel .sub.warn { color: var(--warn); }
578
+ @media (pointer: coarse) {
579
+ /* iOS zooms in on a field with smaller text when it takes focus. */
580
+ .notes-text { font-size: 16px; }
581
+ }
582
+
550
583
  .history-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 14px 0; }
551
584
  .history-tools input[type="search"] {
552
585
  flex: 1 1 220px; min-width: 0; padding: 7px 10px; border-radius: var(--radius); border: 1px solid var(--border);
@@ -674,6 +707,7 @@ a.news-link:visited { color: var(--muted); }
674
707
  .model-name::before, .provider .copies summary::before, .appearance::after, .btn, .models, .news-panel, .models::backdrop, .news-panel::backdrop { transition: none; }
675
708
  .github-waiting::before, body:has(#app[hidden]):has(#auth[hidden]):has(#stopped[hidden])::after { animation: none; }
676
709
  .btn:active:not(:disabled) { transform: none; }
710
+ .btn.voice[aria-pressed="true"]::before { animation: none; }
677
711
  .drag-handle, .session-grid::before, .session-grid.reordering::before,
678
712
  .session-grid > .session-card.dragging, .session-grid > .session-card.settling { transition: none; }
679
713
  }