@oddessentials/agent-guild 0.20.0 → 0.22.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/web/app.js CHANGED
@@ -16,6 +16,7 @@ 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 NOTES_KEY = 'agentGuild.notes';
19
20
  const RELEASES_URL = 'https://github.com/oddessentials/agent-guild/releases';
20
21
  const isMac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
21
22
  const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)');
@@ -63,7 +64,7 @@ const state = {
63
64
  // ---- storage (may be unavailable, e.g. blocked site data) -----------------
64
65
 
65
66
  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 */ } }
67
+ function save(key, value) { try { value === null ? localStorage.removeItem(key) : localStorage.setItem(key, value); return true; } catch { return false; } }
67
68
 
68
69
  // ---- helpers --------------------------------------------------------------
69
70
 
@@ -87,6 +88,12 @@ function toast(message, ms = 5000, action = null) {
87
88
  toastTimer = setTimeout(() => { el.hidden = true; }, ms);
88
89
  }
89
90
 
91
+ /**
92
+ * Handles a click, but not the second click of a double-click, which lands on whatever the first one
93
+ * opened or uncovered: Close over Notes, or Stop manager under a panel's Close.
94
+ */
95
+ const firstClick = (run) => (e) => { if (e.detail < 2) run(); };
96
+
90
97
  function setConnection(kind, label) {
91
98
  const el = $('connection');
92
99
  el.className = `connection ${kind}`;
@@ -294,6 +301,99 @@ function placeAppearanceMenu() {
294
301
  menu.style.right = `${Math.max(8, Math.round(innerWidth - box.right))}px`;
295
302
  }
296
303
 
304
+ // ---- notes ----------------------------------------------------------------
305
+
306
+ /** Notes share the page's storage with every other setting, so they stay far below the browser's limit for it. */
307
+ const NOTES_LIMIT = 100000;
308
+ const NOTES_STATUS = {
309
+ saved: 'Saved in this browser as you type',
310
+ long: `Not saved: notes hold up to ${NOTES_LIMIT.toLocaleString('en-US')} characters. Shorten them to save.`,
311
+ refused: 'Not saved: the browser’s storage for this page is full or turned off. Copy what you need before you close the page.',
312
+ };
313
+ /**
314
+ * `saved`: the notes as this page last read or wrote them in storage. `status`: whether the text in the
315
+ * panel is saved, too `long` or `refused`; `shown`: the status the line under the title shows.
316
+ * `pressedOutside`: the last press on the panel was on its backdrop.
317
+ */
318
+ const notesView = { saved: '', status: 'saved', shown: 'saved', pressedOutside: false };
319
+
320
+ /** Shows notes another tab saved since this page last read or wrote them. Saved notes win over text this page could not save. */
321
+ function refreshNotes() {
322
+ let text;
323
+ // Unlike load(), a read that fails is not taken for empty notes.
324
+ try { text = localStorage.getItem(NOTES_KEY) ?? ''; } catch { return; }
325
+ if (text === notesView.saved) return;
326
+ notesView.saved = text;
327
+ notesView.status = 'saved';
328
+ $('notes-text').value = text;
329
+ renderNotesStatus();
330
+ guardLeaving();
331
+ }
332
+
333
+ /** Saves the notes on every change; emptying them removes the saved copy. */
334
+ function saveNotes() {
335
+ const text = $('notes-text').value;
336
+ if (text.length > NOTES_LIMIT) notesView.status = 'long';
337
+ else if (!save(NOTES_KEY, text || null)) notesView.status = 'refused';
338
+ else {
339
+ notesView.status = 'saved';
340
+ notesView.saved = text;
341
+ }
342
+ renderNotesStatus();
343
+ guardLeaving();
344
+ }
345
+
346
+ /** The line under the title is a live region, so it is rewritten only when the status changes. */
347
+ function renderNotesStatus() {
348
+ if (notesView.shown === notesView.status) return;
349
+ notesView.shown = notesView.status;
350
+ const sub = $('notes-sub');
351
+ sub.textContent = NOTES_STATUS[notesView.status];
352
+ sub.classList.toggle('warn', notesView.status !== 'saved');
353
+ }
354
+
355
+ /** Another tab saved the notes, or cleared this page's storage. */
356
+ function notesStored(e) {
357
+ if (e.key === NOTES_KEY || e.key === null) refreshNotes();
358
+ }
359
+
360
+ function openNotes() {
361
+ hideTip();
362
+ refreshNotes();
363
+ // Saving an empty note changes nothing, but shows at once when the browser keeps nothing.
364
+ if (notesView.status === 'saved' && !$('notes-text').value) saveNotes();
365
+ const dialog = $('notes');
366
+ if (!dialog.open) dialog.showModal();
367
+ $('notes-text').focus();
368
+ }
369
+
370
+ function closeNotes() {
371
+ if ($('notes').open) $('notes').close();
372
+ }
373
+
374
+ /** Focus goes back to the Notes button in every browser, never to the page itself. */
375
+ function notesClosed() {
376
+ $('notes-open').focus();
377
+ }
378
+
379
+ /** The second press of a double-click on Notes lands in the panel, where it must not take the focus from the text. */
380
+ const keepFocus = (e) => { if (e.detail > 1 && e.target !== $('notes-text')) e.preventDefault(); };
381
+
382
+ /** Whether a press or click on the panel was on its backdrop, outside the sheet. */
383
+ function isOutsideNotes(e) {
384
+ const box = e.currentTarget.getBoundingClientRect();
385
+ return e.target === e.currentTarget && (e.clientX < box.left || e.clientX >= box.right || e.clientY < box.top || e.clientY >= box.bottom);
386
+ }
387
+
388
+ function notesPressed(e) {
389
+ notesView.pressedOutside = isOutsideNotes(e);
390
+ }
391
+
392
+ /** Only a press and a release both outside close the notes: a drag into or out of them selects text. */
393
+ function notesClicked(e) {
394
+ if (notesView.pressedOutside && isOutsideNotes(e)) closeNotes();
395
+ }
396
+
297
397
  // ---- upgrading the manager ------------------------------------------------
298
398
 
299
399
  function renderUpgrade() {
@@ -580,7 +680,9 @@ function renderShells(card, provider) {
580
680
  const chip = host.children[i];
581
681
  chip.setAttribute('aria-selected', String(shell.id === selected));
582
682
  chip.textContent = shell.label;
583
- chip.title = `Start new sessions in ${shell.label}${shell.id === provider.defaultShell ? ', used unless you pick another' : ''}\n${shell.path}`;
683
+ chip.title = shell.multiplexer
684
+ ? `Start new sessions inside ${shell.label}. Stopping or closing one detaches it; it keeps running in ${shell.label}.\n${shell.path}`
685
+ : `Start new sessions in ${shell.label}${shell.id === provider.defaultShell ? ', used unless you pick another' : ''}\n${shell.path}`;
584
686
  });
585
687
  }
586
688
 
@@ -2689,6 +2791,8 @@ function accountLabel(s) {
2689
2791
 
2690
2792
  function statusText(s) {
2691
2793
  if (s.status === 'exited') {
2794
+ // A multiplexer's client exits 1 when detached by Stop: its exit code tells the user nothing.
2795
+ if (s.multiplexer) return 'Closed';
2692
2796
  if (s.signal) return `Exited (${s.signal})`;
2693
2797
  return s.exitCode === 0 || s.exitCode === null ? 'Exited' : `Exited (${s.exitCode})`;
2694
2798
  }
@@ -2732,8 +2836,24 @@ function resumable(s) {
2732
2836
  && !runningOn(s.provider.id, s.account?.id ?? 'default', id));
2733
2837
  }
2734
2838
 
2839
+ /** A stopped tmux or herdr card whose multiplexer still has its session. */
2840
+ function reattachable(s) {
2841
+ return Boolean(s.status === 'exited' && s.multiplexer?.reattachable);
2842
+ }
2843
+
2844
+ async function reattachSession(id) {
2845
+ try {
2846
+ upsertSession((await api('POST', `/sessions/${id}/reattach`)).session);
2847
+ openPanel(id);
2848
+ } catch (err) {
2849
+ if (err instanceof AuthError) return showAuth(err.message);
2850
+ toast(err.message, 8000);
2851
+ }
2852
+ }
2853
+
2735
2854
  function resumeCard(id) {
2736
2855
  const s = state.sessions.get(id);
2856
+ if (s && reattachable(s)) return reattachSession(id);
2737
2857
  if (!s || !resumable(s)) return;
2738
2858
  const provider = state.providers.find((p) => p.id === s.provider.id);
2739
2859
  const account = provider.accounts?.find((a) => a.id === s.account?.id)?.id;
@@ -2778,14 +2898,20 @@ function updateCard(node, s) {
2778
2898
  renderAgents(node.querySelector('.agents'), s.agents, s.shells || []);
2779
2899
  paintReporting(node.querySelector('.agents-row'), s);
2780
2900
  node.classList.toggle('exited', s.status === 'exited');
2781
- node.querySelector('.stop').hidden = s.status !== 'running';
2901
+ const stop = node.querySelector('.stop');
2902
+ stop.hidden = s.status !== 'running';
2903
+ stop.textContent = s.multiplexer ? 'Detach' : 'Stop';
2904
+ stop.title = s.multiplexer ? `Detach from ${s.multiplexer.label}; it keeps running. Reattach with: ${s.multiplexer.attach}` : '';
2782
2905
  node.querySelector('.remove').hidden = s.status === 'running';
2783
2906
  const useButton = node.querySelector('.use-folder');
2784
2907
  useButton.hidden = !clonedPath(s);
2785
2908
  useButton.title = clonedPath(s) ? `Make ${s.clone.path} the working folder, so new sessions start there` : '';
2786
2909
  const resume = node.querySelector('.resume');
2787
- resume.hidden = !resumable(s);
2788
- resume.title = `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
2910
+ resume.hidden = !resumable(s) && !reattachable(s);
2911
+ resume.textContent = s.multiplexer ? 'Reattach' : 'Resume';
2912
+ resume.title = s.multiplexer
2913
+ ? `Attach this card to its ${s.multiplexer.label} session again, as ${s.multiplexer.attach} would`
2914
+ : `Start ${s.provider.tool} again on this session${id ? ` (${id})` : ''} in ${s.cwd}`;
2789
2915
  const modelLabel = s.model ? `, model ${modelText(s)}` : '';
2790
2916
  const accountName = accountLabel(s) ? `, ${accountLabel(s)} account` : '';
2791
2917
  const shellCount = (s.shells || []).length;
@@ -2832,11 +2958,18 @@ function confirmLeaving(event) {
2832
2958
 
2833
2959
  function guardLeaving() {
2834
2960
  const running = state.connected && [...state.sessions.values()].some((s) => s.status === 'running');
2835
- if (running) addEventListener('beforeunload', confirmLeaving);
2961
+ // Notes that could not be saved are lost with the page.
2962
+ const unsaved = notesView.status !== 'saved' && $('notes-text').value !== '';
2963
+ if (running || unsaved) addEventListener('beforeunload', confirmLeaving);
2836
2964
  else removeEventListener('beforeunload', confirmLeaving);
2837
2965
  }
2838
2966
 
2839
2967
  function upsertSession(session) {
2968
+ // An exit is final for the process it ended. A reply sent before it, such as Stop's, can
2969
+ // arrive after the exit was announced and must not show the session running again; a
2970
+ // reattached tmux or herdr session runs a newer process, started after that exit.
2971
+ const known = state.sessions.get(session.id);
2972
+ if (known?.status === 'exited' && session.status !== 'exited' && !(Date.parse(session.startedAt) > Date.parse(known.exitedAt))) return;
2840
2973
  state.sessions.set(session.id, session);
2841
2974
  renderSessions();
2842
2975
  noticeClone(session);
@@ -2850,17 +2983,25 @@ function dropSession(id) {
2850
2983
  renderSessions();
2851
2984
  }
2852
2985
 
2986
+ /** What happens to a running session when it is stopped, for the confirmation. */
2987
+ function stopNote(s) {
2988
+ const mux = s.multiplexer;
2989
+ return mux ? `${mux.label} keeps running it. Reattach it from its card, or in a terminal with: ${mux.attach}` : `The ${s.provider.tool} process will be ended.`;
2990
+ }
2991
+
2853
2992
  async function stopSession(id) {
2854
2993
  const s = state.sessions.get(id);
2855
2994
  if (!s || s.status !== 'running') return;
2856
- if (!confirm(`Stop "${s.name}"? The ${s.provider.tool} process will be ended.`)) return;
2995
+ if (!confirm(`${s.multiplexer ? 'Detach' : 'Stop'} "${s.name}"? ${stopNote(s)}`)) return;
2857
2996
  try { upsertSession((await api('POST', `/sessions/${id}/stop`)).session); } catch (err) { toast(err.message); }
2858
2997
  }
2859
2998
 
2860
2999
  async function removeSession(id) {
2861
3000
  const s = state.sessions.get(id);
2862
3001
  if (!s) return;
2863
- if (s.status === 'running' && !confirm(`"${s.name}" is still running. End it and remove it?`)) return;
3002
+ // The card goes, so only the terminal can reattach it.
3003
+ const question = s.multiplexer ? `Detach it and remove it? ${s.multiplexer.label} keeps running it. Reattach it in a terminal with: ${s.multiplexer.attach}` : 'End it and remove it?';
3004
+ if (s.status === 'running' && !confirm(`"${s.name}" is still running. ${question}`)) return;
2864
3005
  try { await api('DELETE', `/sessions/${id}`); dropSession(id); } catch (err) { toast(err.message); }
2865
3006
  }
2866
3007
 
@@ -3131,6 +3272,23 @@ function suppressQueryReplies(term) {
3131
3272
  }
3132
3273
  }
3133
3274
 
3275
+ /** The text an OSC 52 sequence ("c;<base64>") asks to copy, or null for a clipboard query or anything unreadable. */
3276
+ function osc52Text(data) {
3277
+ const payload = data.slice(data.indexOf(';') + 1);
3278
+ if (!data.includes(';') || !payload || payload === '?' || payload.length > 4 * 1024 * 1024) return null;
3279
+ try {
3280
+ return new TextDecoder().decode(Uint8Array.from(atob(payload), (c) => c.charCodeAt(0)));
3281
+ } catch {
3282
+ return null;
3283
+ }
3284
+ }
3285
+
3286
+ /** The line a terminal shows when its process ends. A tmux or herdr client's exit code says nothing about the session it showed. */
3287
+ function exitLine(s, { exitCode, signal }) {
3288
+ if (s?.multiplexer) return '[closed]';
3289
+ return `[process exited with ${signal ? `signal ${signal}` : `code ${exitCode ?? 0}`}]`;
3290
+ }
3291
+
3134
3292
  class TerminalView {
3135
3293
  constructor(sessionId) {
3136
3294
  this.id = sessionId;
@@ -3141,6 +3299,8 @@ class TerminalView {
3141
3299
  fontSize: coarsePointer.matches ? 14 : 13,
3142
3300
  scrollback: 5000,
3143
3301
  macOptionIsMeta: true,
3302
+ // Option-drag selects text even while a program reads the mouse, as Shift-drag does elsewhere.
3303
+ macOptionClickForcesSelection: true,
3144
3304
  theme: TERMINAL_THEME,
3145
3305
  });
3146
3306
  this.fit = new window.FitAddon.FitAddon();
@@ -3148,6 +3308,13 @@ class TerminalView {
3148
3308
  this.term.loadAddon(new window.WebLinksAddon.WebLinksAddon((_e, uri) => window.open(uri, '_blank', 'noopener,noreferrer')));
3149
3309
  this.term.attachCustomKeyEventHandler((e) => this.handleKey(e));
3150
3310
  suppressQueryReplies(this.term);
3311
+ // Programs copy with OSC 52: tmux's copy mode, and herdr where it cannot reach the
3312
+ // system clipboard itself. Only the open terminal may write it; nothing reads it.
3313
+ this.term.parser.registerOscHandler(52, (data) => {
3314
+ const text = osc52Text(data);
3315
+ if (text !== null && state.activeId === this.id) navigator.clipboard?.writeText(text).catch(() => {});
3316
+ return true;
3317
+ });
3151
3318
  this.term.onData((data) => {
3152
3319
  this.send({ type: 'input', data });
3153
3320
  });
@@ -3198,11 +3365,9 @@ class TerminalView {
3198
3365
  case 'data':
3199
3366
  this.term.write(msg.data);
3200
3367
  break;
3201
- case 'exit': {
3202
- const how = msg.signal ? `signal ${msg.signal}` : `code ${msg.exitCode ?? 0}`;
3203
- this.term.write(`\r\n\x1b[2m[process exited with ${how}]\x1b[0m\r\n`);
3368
+ case 'exit':
3369
+ this.term.write(`\r\n\x1b[2m${exitLine(state.sessions.get(this.id), msg)}\x1b[0m\r\n`);
3204
3370
  break;
3205
- }
3206
3371
  default:
3207
3372
  break;
3208
3373
  }
@@ -3320,7 +3485,7 @@ function updatePanel() {
3320
3485
  $('panel-sub').title = modelTitle(s);
3321
3486
  renderAgents($('panel-agents'), s.agents, s.shells || []);
3322
3487
  const stop = $('panel-stop');
3323
- stop.textContent = s.status === 'running' ? 'Stop' : 'Remove';
3488
+ stop.textContent = s.status !== 'running' ? 'Remove' : s.multiplexer ? 'Detach' : 'Stop';
3324
3489
  }
3325
3490
 
3326
3491
  // ---- stopping the manager -------------------------------------------------
@@ -3698,6 +3863,16 @@ document.addEventListener('keydown', (e) => {
3698
3863
  }, true);
3699
3864
  addEventListener('scroll', () => { if (tipFor) hideTip(); }, true);
3700
3865
  addEventListener('resize', () => { if (tipFor) hideTip(); });
3866
+ $('notes-open').addEventListener('click', firstClick(openNotes));
3867
+ $('notes-close').addEventListener('click', firstClick(closeNotes));
3868
+ $('notes').addEventListener('mousedown', keepFocus);
3869
+ $('notes').addEventListener('pointerdown', notesPressed);
3870
+ $('notes').addEventListener('click', notesClicked);
3871
+ $('notes').addEventListener('close', notesClosed);
3872
+ $('notes-text').addEventListener('input', saveNotes);
3873
+ addEventListener('storage', notesStored);
3874
+ // On load, and again for a page back from the back/forward cache, which may have missed another tab's notes.
3875
+ addEventListener('pageshow', refreshNotes);
3701
3876
  $('news-all').addEventListener('click', openNews);
3702
3877
  $('news-close').addEventListener('click', closeNews);
3703
3878
  $('news-fresh').addEventListener('click', showFreshNews);
@@ -3780,8 +3955,8 @@ $('models-more').addEventListener('click', () => {
3780
3955
  renderModels();
3781
3956
  $('models-list').children[before]?.querySelector('.model-toggle')?.focus();
3782
3957
  });
3783
- $('stop-manager').addEventListener('click', () => stopManager());
3784
- $('restart-manager').addEventListener('click', () => stopManager({ restart: true }));
3958
+ $('stop-manager').addEventListener('click', firstClick(() => stopManager()));
3959
+ $('restart-manager').addEventListener('click', firstClick(() => stopManager({ restart: true })));
3785
3960
  $('copy-command').addEventListener('click', copyCommand);
3786
3961
  $('upgrade').addEventListener('click', upgradeManager);
3787
3962
  $('appearance-menu').addEventListener('change', (e) => {
package/web/index.html CHANGED
@@ -41,6 +41,7 @@
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
43
  </div>
44
+ <button id="notes-open" class="btn notes-open" type="button" aria-haspopup="dialog">Notes</button>
44
45
  <button id="restart-manager" class="btn restart-manager" type="button" hidden>Restart manager</button>
45
46
  <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
47
  </div>
@@ -179,6 +180,19 @@
179
180
  <p id="changelog-status" class="news-status" hidden></p>
180
181
  </dialog>
181
182
 
183
+ <dialog id="notes" class="news-panel notes-panel" aria-labelledby="notes-title">
184
+ <div class="news-bar">
185
+ <div class="news-heading">
186
+ <h2 id="notes-title">Notes</h2>
187
+ <p id="notes-sub" class="sub" aria-live="polite">Saved in this browser as you type</p>
188
+ </div>
189
+ <button id="notes-close" class="btn" type="button">Close</button>
190
+ </div>
191
+ <div class="news-body">
192
+ <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>
193
+ </div>
194
+ </dialog>
195
+
182
196
  <dialog id="history" class="models history" aria-labelledby="history-title">
183
197
  <div class="models-bar">
184
198
  <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
@@ -29,6 +29,7 @@
29
29
  --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
30
  --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
31
  --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");
32
+ --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
33
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
33
34
  font-size: 15px;
34
35
  color-scheme: light;
@@ -336,13 +337,14 @@ main { max-width: 1200px; margin: 0 auto; padding: 26px 16px 56px; display: grid
336
337
  .btn.danger { color: var(--danger); }
337
338
  .btn.danger:hover { border-color: var(--danger); }
338
339
  .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 {
340
+ .stop-manager, .restart-manager, .appearance, .notes-open, .upgrade { white-space: nowrap; }
341
+ .appearance::before, .notes-open::before, .appearance-menu .choice > span::before {
341
342
  content: ""; display: inline-block; width: 1em; height: 1em; margin-right: 0.4em; vertical-align: -0.15em;
342
343
  background: currentColor; mask: var(--icon-sun) center / contain no-repeat;
343
344
  }
344
345
  :root[data-theme="dark"] .appearance::before, .appearance-menu .choice > .mode-dark::before { mask-image: var(--icon-moon); }
345
346
  .appearance-menu .choice > .sound-alerts::before { mask-image: var(--icon-sound); }
347
+ .notes-open::before { mask-image: var(--icon-notes); }
346
348
  .appearance::after { margin-left: 0.45em; transform: rotate(90deg); transition: transform 0.15s var(--ease); }
347
349
  .appearance:has(+ :popover-open)::after { transform: rotate(-90deg); }
348
350
  .appearance-menu {
@@ -547,6 +549,20 @@ a.news-link:visited { color: var(--muted); }
547
549
  .changelog-changes li::marker { color: var(--muted); }
548
550
  .changelog-changes code { font-size: 0.8rem; padding: 0 4px; border-radius: 4px; background: var(--surface-2); }
549
551
 
552
+ /* Notes: the News side sheet, holding one text field that skins style with their other fields. */
553
+ .notes-panel .news-body { display: flex; flex-direction: column; padding-top: 12px; }
554
+ .notes-text {
555
+ flex: 1; min-height: 8em; margin: 0; padding: 10px 12px; resize: none;
556
+ border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); color: var(--text);
557
+ font: inherit; font-size: 0.9rem; line-height: 1.55;
558
+ }
559
+ .notes-text:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
560
+ .notes-panel .sub.warn { color: var(--warn); }
561
+ @media (pointer: coarse) {
562
+ /* iOS zooms in on a field with smaller text when it takes focus. */
563
+ .notes-text { font-size: 16px; }
564
+ }
565
+
550
566
  .history-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 10px 14px 0; }
551
567
  .history-tools input[type="search"] {
552
568
  flex: 1 1 220px; min-width: 0; padding: 7px 10px; border-radius: var(--radius); border: 1px solid var(--border);