@tiagojct/subsub 0.22.0 → 0.24.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.css CHANGED
@@ -115,9 +115,13 @@ body {
115
115
  }
116
116
 
117
117
  button, input, select, textarea { font: inherit; color: inherit; }
118
- a { color: var(--accent); text-underline-offset: 0.18em; text-decoration-thickness: 1px; }
118
+ a { color: var(--accent); text-underline-offset: 0.2em; text-decoration-thickness: 1px; }
119
119
  a:hover { text-decoration-thickness: 2px; }
120
120
  svg { flex: none; }
121
+ /* Scrollbars as fine rules, not as grey bars. */
122
+ * { scrollbar-width: thin; }
123
+ /* The name breaks nowhere, least of all at its hyphen. */
124
+ .nobr { white-space: nowrap; }
121
125
 
122
126
  .visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
123
127
  [hidden] { display: none !important; }
@@ -164,20 +168,20 @@ button:disabled { opacity: 0.45; cursor: default; }
164
168
  .icon-btn:hover { color: var(--text); background: var(--surface); }
165
169
  .icon-btn[aria-pressed="true"] { color: var(--accent); background: var(--accent-soft); }
166
170
 
167
- .model-btn.needs { color: var(--warn); border-color: var(--warn); font-family: var(--sans); }
171
+ /* Top bar text buttons, as the website's navigation: muted, inked and underlined on hover or when open. */
168
172
  .chip-btn {
169
- max-width: 16rem;
170
- overflow: hidden;
171
- text-overflow: ellipsis;
172
- white-space: nowrap;
173
- font-family: var(--mono);
174
- font-size: 0.78rem;
173
+ flex: none;
174
+ padding: 0.35rem 0.5rem;
175
+ font-size: 0.9rem;
175
176
  color: var(--muted);
176
- border-color: var(--rule);
177
- background: var(--raised);
178
- padding: 0.35rem 0.65rem;
177
+ white-space: nowrap;
178
+ text-underline-offset: 0.35em;
179
+ text-decoration-thickness: 1px;
179
180
  }
180
- .chip-btn:hover { color: var(--text); border-color: var(--rule-strong); }
181
+ .chip-btn:hover { color: var(--text); text-decoration-line: underline; }
182
+ .chip-btn.model-btn { flex: 0 1 auto; max-width: 16rem; overflow: hidden; text-overflow: ellipsis; font-family: var(--mono); font-size: 0.8rem; }
183
+ /* No model yet: pending, so gamboge and dashed. */
184
+ .model-btn.needs { color: var(--warn); border-color: var(--warn); border-style: dashed; font-family: var(--sans); }
181
185
 
182
186
  select {
183
187
  appearance: none;
@@ -319,7 +323,7 @@ select:hover { border-color: var(--rule-strong); }
319
323
  .new-btn:hover { border-color: var(--text); }
320
324
  .new-btn svg { width: 16px; height: 16px; color: var(--accent); }
321
325
 
322
- .actions { display: flex; flex-direction: column; }
326
+ .actions, .actions > div { display: flex; flex-direction: column; }
323
327
  .actions button {
324
328
  text-align: left;
325
329
  padding: 0.32rem 0.5rem;
@@ -329,9 +333,35 @@ select:hover { border-color: var(--rule-strong); }
329
333
  }
330
334
  .actions button:hover { background: var(--bg); }
331
335
  .actions .group { margin: 0.6rem 0 0.15rem; padding: 0 0.5rem; font-family: var(--serif); font-style: italic; font-size: 0.86rem; color: var(--muted); }
332
- .actions .group:first-child { margin-top: 0; }
336
+ .actions > :first-child .group { margin-top: 0; }
337
+
338
+ /* Sections and action groups fold at their heading; a fine chevron points down when open, right when folded. */
339
+ .side .fold {
340
+ display: inline-flex;
341
+ align-items: center;
342
+ gap: 0.55em;
343
+ padding: 0;
344
+ border: 0;
345
+ border-radius: 0;
346
+ font-size: inherit;
347
+ letter-spacing: inherit;
348
+ color: inherit;
349
+ text-align: left;
350
+ }
351
+ .side .fold:hover { color: var(--text); background: none; }
352
+ .side .fold::after {
353
+ content: "";
354
+ width: 0.34em;
355
+ height: 0.34em;
356
+ border-right: 1px solid currentColor;
357
+ border-bottom: 1px solid currentColor;
358
+ transform: translateY(-0.15em) rotate(45deg);
359
+ }
360
+ .side .fold[aria-expanded="false"]::after { transform: rotate(-45deg); }
361
+ [data-fold].folded > :not(h2, .side-subhead, .group) { display: none; }
333
362
 
334
363
  .side .grow { flex: 1; display: flex; flex-direction: column; min-height: 8rem; }
364
+ .side .grow.folded { flex: none; min-height: 0; }
335
365
  .side-subhead { display: flex; justify-content: space-between; align-items: baseline; padding-right: 0.3rem; }
336
366
 
337
367
  .search {
@@ -423,7 +453,6 @@ body.layout-full { --measure: 72rem; }
423
453
  padding: 0.6rem 0.6rem 0.6rem 0.9rem;
424
454
  border: 1px solid var(--rule);
425
455
  border-top: 2px solid var(--warn);
426
- border-radius: var(--radius);
427
456
  background: var(--raised);
428
457
  display: flex;
429
458
  gap: 0.75rem;
@@ -454,14 +483,16 @@ body.layout-full { --measure: 72rem; }
454
483
  overflow-wrap: anywhere;
455
484
  font-size: 0.95rem;
456
485
  }
486
+ /* A command is code: it is set in the mono, as the website's plate draws it. */
487
+ .msg.user .bubble.cmd { font-family: var(--mono); font-size: 0.86rem; }
457
488
  .msg.user details.bubble summary { cursor: pointer; color: var(--muted); }
458
489
  .msg.user details.bubble[open] summary { margin-bottom: 0.4rem; }
459
490
 
460
491
  /* Replies read as text, in the serif. */
461
492
  .md {
462
493
  font-family: var(--serif);
463
- font-size: 1.02rem;
464
- line-height: 1.68;
494
+ font-size: 1.0625rem;
495
+ line-height: 1.65;
465
496
  font-variant-numeric: oldstyle-nums proportional-nums;
466
497
  overflow-wrap: anywhere;
467
498
  }
@@ -470,16 +501,20 @@ body.layout-full { --measure: 72rem; }
470
501
  .md p, .md ul, .md ol { margin: 0.7rem 0; }
471
502
  .md li { margin: 0.2rem 0; }
472
503
  .md li::marker { color: var(--faint); }
504
+ /* Numbered steps as on the website: the number in the accent, lining figures. */
505
+ .md ol > li::marker { color: var(--accent); font-weight: 600; font-variant-numeric: lining-nums tabular-nums; }
506
+ /* Headings as on the website: roman, weight 500 for the large ones, a fine rule above a section. */
473
507
  .md h1, .md h2, .md h3, .md h4 {
474
508
  font-family: var(--serif);
475
- line-height: 1.3;
476
- margin: 1.6rem 0 0.5rem;
509
+ line-height: 1.25;
510
+ margin: 1.8rem 0 0.6rem;
477
511
  font-weight: 600;
512
+ text-wrap: balance;
478
513
  }
479
- .md h1 { font-size: 1.3rem; }
480
- .md h2 { font-size: 1.12rem; }
481
- .md h3 { font-size: 1rem; }
482
- .md h4 { font-size: 0.92rem; color: var(--muted); }
514
+ .md h1 { font-size: 1.5rem; font-weight: 500; line-height: 1.15; letter-spacing: -0.012em; }
515
+ .md h2 { font-size: 1.25rem; font-weight: 500; letter-spacing: -0.008em; padding-top: 0.5rem; border-top: 1px solid var(--rule-strong); }
516
+ .md h3 { font-size: 1.04rem; }
517
+ .md h4 { font-size: 0.95rem; color: var(--muted); }
483
518
  .md strong { font-weight: 600; }
484
519
  .md hr { border: 0; border-top: 1px solid var(--rule); margin: 1.5rem 0; }
485
520
  .msg.assistant + .msg.assistant { margin-top: 0.9rem; }
@@ -568,7 +603,7 @@ body.layout-full { --measure: 72rem; }
568
603
 
569
604
  .msg-actions { display: flex; gap: 0.25rem; margin-top: 0.4rem; opacity: 0; transition: opacity 0.12s ease; }
570
605
  .msg.assistant:hover .msg-actions, .msg-actions:focus-within { opacity: 1; }
571
- .msg-action-btn { font-size: 0.75rem; padding: 0.15rem 0.45rem; color: var(--faint); }
606
+ .msg-action-btn { font-family: var(--sans); font-size: 0.75rem; padding: 0.15rem 0.45rem; color: var(--faint); }
572
607
  .msg-action-btn:hover { color: var(--text); background: var(--surface); }
573
608
  .msg-action-btn.copied { color: var(--add); }
574
609
  .table-actions { display: flex; justify-content: flex-end; margin: -0.35rem 0 0.9rem; }
@@ -633,7 +668,6 @@ body.layout-full { --measure: 72rem; }
633
668
  .note {
634
669
  margin-top: 1rem;
635
670
  padding: 0.6rem 0.9rem;
636
- border-radius: var(--radius);
637
671
  background: var(--surface);
638
672
  border: 1px solid var(--rule);
639
673
  font-size: 0.88rem;
@@ -659,44 +693,48 @@ body.layout-full { --measure: 72rem; }
659
693
  gap: 0.55rem;
660
694
  align-items: center;
661
695
  }
696
+ /* Working is drawn by line, as in the terminal: dotted, dashed, solid. Without motion it stays dashed: pending. */
662
697
  .working::before {
663
698
  content: "";
664
699
  width: 28px;
665
- height: 3px;
666
- border-radius: 2px;
667
- background: linear-gradient(90deg, var(--accent) 0 40%, var(--rule) 40% 100%);
668
- background-size: 250% 100%;
669
- animation: sweep 1.3s ease-in-out infinite alternate;
700
+ border-top: 2px dashed var(--accent);
701
+ animation: lines 1.5s steps(1, end) infinite;
670
702
  }
671
- @keyframes sweep { from { background-position: 100% 0; } to { background-position: 0 0; } }
703
+ @keyframes lines { 0% { border-top-style: dotted; } 33% { border-top-style: dashed; } 67% { border-top-style: solid; } }
672
704
  @keyframes pulse { 50% { opacity: 0.3; } }
673
705
 
674
706
  /* Empty conversation. */
675
707
  .empty { padding-top: 11vh; }
676
- .empty .kicker {
708
+ /* The running head of the website's plates: the name, an italic middle, the mode in its colour. */
709
+ .empty .runhead {
677
710
  display: flex;
678
711
  justify-content: space-between;
679
- margin: 0 0 1.5rem;
680
- padding-bottom: 0.3rem;
712
+ align-items: baseline;
713
+ gap: 0.5rem 1.5rem;
714
+ margin: 0 0 1.75rem;
715
+ padding-bottom: 0.35rem;
681
716
  border-bottom: 1px solid var(--text);
682
717
  font-family: var(--serif);
683
- font-size: 1rem;
718
+ font-size: 1.02rem;
719
+ line-height: 1.3;
684
720
  font-variant-caps: all-small-caps;
685
721
  letter-spacing: 0.08em;
686
- color: var(--accent);
722
+ color: var(--muted);
687
723
  }
688
- .empty .kicker::before { content: "Sub-Sub"; color: var(--muted); order: -1; }
724
+ .empty .runhead .mid { font-style: italic; font-variant-caps: normal; letter-spacing: 0; text-align: center; }
725
+ .empty .runhead .mode { color: var(--accent); }
689
726
  .empty h1 {
690
727
  font-family: var(--serif);
691
728
  font-weight: 500;
692
- font-size: clamp(1.6rem, 3.2vw, 2.15rem);
693
- line-height: 1.2;
694
- letter-spacing: -0.015em;
695
- margin: 0 0 0.6rem;
729
+ font-size: clamp(2rem, 3.6vw, 2.75rem);
730
+ line-height: 1.06;
731
+ letter-spacing: -0.018em;
732
+ margin: 0 0 0.9rem;
733
+ text-wrap: balance;
696
734
  }
697
- .empty .lede { color: var(--muted); max-width: 34rem; margin: 0 0 1.75rem; }
735
+ .empty .lede { font-family: var(--serif); font-size: 1.08rem; line-height: 1.55; color: var(--muted); max-width: 36rem; margin: 0 0 2rem; text-wrap: pretty; }
698
736
  /* Suggestions: a short list of lines to start from, not a row of cards. */
699
- .chips { display: grid; max-width: 34rem; border-bottom: 1px solid var(--rule-strong); }
737
+ .chips { display: grid; max-width: 36rem; border-bottom: 1px solid var(--rule-strong); }
700
738
  .chips button {
701
739
  text-align: left;
702
740
  border: 0;
@@ -792,7 +830,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
792
830
  }
793
831
  .modal-kicker::after { content: "Sub-Sub"; color: var(--muted); }
794
832
  .modal-kicker:empty { display: none; }
795
- .modal h2 { font-family: var(--serif); font-size: 1.25rem; font-weight: 600; line-height: 1.3; margin: 0 0 0.3rem; overflow-wrap: anywhere; }
833
+ .modal h2 { font-family: var(--serif); font-size: 1.4rem; font-weight: 500; line-height: 1.2; letter-spacing: -0.01em; margin: 0 0 0.35rem; overflow-wrap: anywhere; }
796
834
  .modal h3 { font-family: var(--serif); font-size: 0.98rem; font-weight: 400; font-variant-caps: all-small-caps; letter-spacing: 0.08em; color: var(--muted); margin: 1.4rem 0 0.5rem; }
797
835
  .modal .hint { color: var(--muted); margin: 0 0 1rem; font-size: 0.88rem; }
798
836
 
@@ -888,8 +926,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
888
926
 
889
927
  /* ---------------------------------------------------------------- library search and notes */
890
928
 
891
- #notes-btn { font-family: var(--sans); }
892
- #notes-btn[aria-expanded="true"] { color: var(--accent); border-color: var(--accent); }
929
+ #notes-btn[aria-expanded="true"] { color: var(--text); text-decoration-line: underline; }
893
930
 
894
931
  .items { list-style: none; margin: 0; padding: 0; max-height: 18rem; overflow-y: auto; }
895
932
  .items:empty { display: none; }
@@ -910,11 +947,12 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
910
947
  .session-tools button { width: auto; padding: 0.1rem 0.35rem; font-size: 0.7rem; color: var(--muted); }
911
948
  .session-tools button:hover { color: var(--text); background: var(--bg); }
912
949
 
950
+ /* A note is a page: the lighter plate field, as on the website. */
913
951
  .note-pane {
914
952
  grid-row: 2;
915
953
  grid-column: 3;
916
- border-left: 1px solid var(--rule);
917
- background: var(--surface);
954
+ border-left: 1px solid var(--rule-strong);
955
+ background: var(--raised);
918
956
  display: flex;
919
957
  flex-direction: column;
920
958
  min-height: 0;
@@ -923,7 +961,7 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
923
961
  .note-pane[hidden] { display: none; }
924
962
  .note-head { display: flex; align-items: center; gap: 0.6rem; padding: 0.6rem 0.9rem; border-bottom: 1px solid var(--rule); }
925
963
  .note-name { flex: 1; min-width: 0; font-family: var(--mono); font-size: 0.8rem; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
926
- .note-pane > .search { margin: 0.6rem 0.9rem 0; width: auto; }
964
+ .note-pane > .search { margin: 0.6rem 0.9rem 0; width: auto; background: var(--bg); }
927
965
  .note-body { flex: 1; overflow-y: auto; padding: 0.9rem 1.1rem 1.5rem; }
928
966
  .note-body .front { font-size: 0.74rem; color: var(--muted); margin: 0 0 1rem; }
929
967
  /* What a note was written from, drawn by line: solid for the full text, dashed for the abstract,
@@ -940,11 +978,25 @@ body > p.empty { text-align: center; color: var(--muted); padding: 30vh 1rem 0;
940
978
  #note-read[aria-pressed="true"] { color: var(--accent); text-decoration: underline; }
941
979
  .note-body .front summary { cursor: pointer; }
942
980
  .note-body .front pre { background: var(--bg); border: 1px solid var(--rule); border-radius: var(--radius); padding: 0.5rem 0.7rem; margin: 0.4rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
943
- .note-list { list-style: none; margin: 0; padding: 0; }
944
- .note-list button { width: 100%; text-align: left; padding: 0.4rem 0.5rem; border-radius: 0; display: grid; gap: 0.1rem; }
981
+ /* The notes, one folder per section under a small-capitals head, rows ruled as in a table. */
982
+ .note-list section + section { margin-top: 1.4rem; }
983
+ .note-list h3 {
984
+ margin: 0;
985
+ padding: 0 0.5rem 0.25rem;
986
+ border-bottom: 1px solid var(--text);
987
+ font-family: var(--serif);
988
+ font-size: 1rem;
989
+ font-weight: 400;
990
+ font-variant-caps: all-small-caps;
991
+ letter-spacing: 0.08em;
992
+ color: var(--muted);
993
+ }
994
+ .note-list ul { list-style: none; margin: 0; padding: 0; }
995
+ .note-list li + li { border-top: 1px solid var(--rule); }
996
+ .note-list button { width: 100%; text-align: left; padding: 0.5rem; border-radius: 0; display: flex; justify-content: space-between; align-items: baseline; gap: 1rem; }
945
997
  .note-list button:hover { background: var(--bg); }
946
- .note-list .rel { font-size: 0.84rem; overflow-wrap: anywhere; }
947
- .note-list .when { font-size: 0.72rem; color: var(--faint); }
998
+ .note-list .rel { min-width: 0; font-size: 0.9rem; overflow-wrap: anywhere; }
999
+ .note-list .when { flex: none; font-size: 0.74rem; color: var(--faint); font-variant-numeric: tabular-nums; }
948
1000
  .note-body img.note-img { display: block; max-width: 100%; height: auto; margin: 0.8rem 0; border: 1px solid var(--rule); border-radius: var(--radius); background: #fff; }
949
1001
  a[data-note] { color: var(--accent); text-decoration: underline; text-decoration-color: var(--accent-soft); text-underline-offset: 0.18em; cursor: pointer; }
950
1002
 
@@ -980,6 +1032,7 @@ a[data-note] { color: var(--accent); text-decoration: underline; text-decoration
980
1032
  @media (max-width: 30rem) {
981
1033
  .brand > span { display: none; }
982
1034
  .modes button { padding: 0.28rem 0.6rem; }
1035
+ .empty .runhead .mid { display: none; }
983
1036
  }
984
1037
 
985
1038
  @media (prefers-reduced-motion: reduce) {
package/web/app.js CHANGED
@@ -12,6 +12,8 @@ let session = null; // pi's session state
12
12
  let busy = false;
13
13
  let commands = [];
14
14
  let providers = [];
15
+ /** The login form's choice for a provider that pi does not include (src/providers.ts CUSTOM_DEFAULT_NAME). */
16
+ const OTHER_PROVIDER = "custom";
15
17
  const dialogs = []; // waiting dialog requests, oldest first
16
18
  let shownDialog = null;
17
19
  let currentMsg = null; // assistant message being streamed
@@ -255,7 +257,10 @@ function renderHeader() {
255
257
  $("starbuck-label").title = t("starbuckHint");
256
258
  const m = state?.model ?? session?.model;
257
259
  const none = !m || !m.id || m.id === "unknown";
258
- $("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id;
260
+ // With a cost limit in the settings, the conversation's cost and the limit follow the model.
261
+ const c = state?.cost;
262
+ const cost = c && typeof c.spent === "number" ? ` · $${c.spent.toFixed(2)}/$${c.limit.toFixed(2)}` : "";
263
+ $("model-btn").textContent = $("model-btn-side").textContent = none ? t("noModel") : m.id + cost;
259
264
  for (const b of [$("model-btn"), $("model-btn-side")]) b.classList.toggle("needs", none);
260
265
  const lib = $("library");
261
266
  const txt = lib.querySelector(".library-text") ?? lib;
@@ -296,6 +301,16 @@ function hasCommand(name) {
296
301
  return commands.length === 0 || commands.some((c) => c.name === base);
297
302
  }
298
303
 
304
+ // Side panel sections and action groups fold at their heading; the folded ones are remembered on this computer.
305
+ const folded = new Set((localStorage.getItem("subsub-folded") || "").split(" ").filter(Boolean));
306
+ function setFold(box, on) {
307
+ box.classList.toggle("folded", on);
308
+ box.querySelector(".fold").setAttribute("aria-expanded", String(!on));
309
+ if (on) folded.add(box.dataset.fold);
310
+ else folded.delete(box.dataset.fold);
311
+ localStorage.setItem("subsub-folded", [...folded].join(" "));
312
+ }
313
+
299
314
  function renderActions() {
300
315
  const box = $("actions");
301
316
  box.replaceChildren();
@@ -305,8 +320,9 @@ function renderActions() {
305
320
  for (const [group, label] of groups) {
306
321
  const list = ACTIONS[group].filter(shown);
307
322
  if (!list.length) continue;
308
- box.append(el("p", { class: "group", text: t(label) }));
309
- for (const a of list) box.append(actionButton(a));
323
+ const g = el("div", { "data-fold": `actions-${group}` }, el("p", { class: "group" }, el("button", { type: "button", class: "fold", text: t(label) })), list.map(actionButton));
324
+ setFold(g, folded.has(g.dataset.fold));
325
+ box.append(g);
310
326
  }
311
327
  }
312
328
 
@@ -315,7 +331,10 @@ function actionButton(a) {
315
331
  type: "button",
316
332
  text: a[lang] ?? a.en,
317
333
  onclick: () => {
318
- if (a.item) return $("library-search").focus();
334
+ if (a.item) {
335
+ setFold($("library-search").closest("[data-fold]"), false);
336
+ return $("library-search").focus();
337
+ }
319
338
  closeSide();
320
339
  if (a.send) sendMessage(`/${a.cmd}`);
321
340
  else {
@@ -349,7 +368,9 @@ function renderItems(items, message) {
349
368
  if (message) return ul.append(el("li", { class: "muted", text: message }));
350
369
  const researcher = mode() === "researcher";
351
370
  for (const it of items) {
352
- const ref = it.citekey || it.key;
371
+ // A group library's item says its library (keep in step with itemRef in src/screens.ts).
372
+ const ref = it.library ? `${it.citekey || it.key} in ${it.library}` : it.citekey || it.key;
373
+ const scope = it.library ? `groups/${it.library.split(":")[1]}` : "library";
353
374
  const run = (cmd) => () => {
354
375
  closeSide();
355
376
  sendMessage(`/${cmd} ${ref}`);
@@ -359,13 +380,13 @@ function renderItems(items, message) {
359
380
  "li",
360
381
  {},
361
382
  el("div", { class: "item-title", text: it.title || it.key }),
362
- el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey].filter(Boolean).join(" · ") }),
383
+ el("div", { class: "item-meta", text: [it.authors, it.year, it.citekey, it.group].filter(Boolean).join(" · ") }),
363
384
  el(
364
385
  "div",
365
386
  { class: "item-actions" },
366
387
  researcher ? el("button", { type: "button", text: t("readItem"), onclick: run("read") }) : null,
367
388
  researcher ? el("button", { type: "button", text: t("noteItem"), onclick: run("lit-note") }) : null,
368
- el("a", { href: `zotero://select/library/items/${encodeURIComponent(it.key)}`, text: "Zotero" }),
389
+ el("a", { href: `zotero://select/${scope}/items/${encodeURIComponent(it.key)}`, text: "Zotero" }),
369
390
  ),
370
391
  ),
371
392
  );
@@ -407,16 +428,34 @@ function renderNoteList() {
407
428
  const q = $("note-filter").value.trim().toLowerCase();
408
429
  const list = notesList.filter((n) => !q || n.rel.toLowerCase().includes(q));
409
430
  const fmt = new Intl.DateTimeFormat(lang === "pt" ? "pt-PT" : "en-GB", { day: "numeric", month: "short", year: "numeric" });
431
+ // One group per folder (Inbox, Literature, ...), newest first inside each, as the server sorts them.
432
+ const groups = new Map();
433
+ for (const n of list) {
434
+ const [folder, ...rest] = n.rel.split(/[\\/]/);
435
+ if (!groups.has(folder)) groups.set(folder, []);
436
+ groups.get(folder).push({ ...n, name: rest.join("/").replace(/\.md$/i, "") || folder });
437
+ }
410
438
  body.replaceChildren(
411
439
  list.length
412
440
  ? el(
413
- "ul",
441
+ "div",
414
442
  { class: "note-list" },
415
- list.map((n) =>
443
+ [...groups].map(([folder, notes]) =>
416
444
  el(
417
- "li",
445
+ "section",
418
446
  {},
419
- el("button", { type: "button", onclick: () => openNote(n.path) }, el("span", { class: "rel", text: n.rel }), el("span", { class: "when", text: fmt.format(new Date(n.modified)) })),
447
+ el("h3", { text: folder }),
448
+ el(
449
+ "ul",
450
+ {},
451
+ notes.map((n) =>
452
+ el(
453
+ "li",
454
+ {},
455
+ el("button", { type: "button", title: n.rel, onclick: () => openNote(n.path) }, el("span", { class: "rel", text: n.name }), el("span", { class: "when", text: fmt.format(new Date(n.modified)) })),
456
+ ),
457
+ ),
458
+ ),
420
459
  ),
421
460
  ),
422
461
  )
@@ -692,7 +731,7 @@ function renderEmpty() {
692
731
  el(
693
732
  "div",
694
733
  { class: "empty" },
695
- el("p", { class: "kicker", text: t(mode()) }),
734
+ el("p", { class: "runhead" }, el("span", { class: "nobr", text: "Sub-Sub" }), el("span", { class: "mid", text: t("tagline") }), el("span", { class: "mode", text: t(mode()) })),
696
735
  el("h1", { text: t("emptyTitle") }),
697
736
  el("p", { class: "lede", text: t(mode() === "librarian" ? "emptyLibrarian" : "emptyResearcher") }),
698
737
  setupList(),
@@ -732,7 +771,7 @@ function userMessage(text) {
732
771
  if (text.length > 700) {
733
772
  const first = text.split("\n").find((l) => l.trim()) ?? "";
734
773
  bubble = el("details", { class: "bubble" }, el("summary", { text: first.length > 120 ? `${first.slice(0, 117)}...` : first }), el("div", { text }));
735
- } else bubble = el("div", { class: "bubble", text });
774
+ } else bubble = el("div", { class: /^\/\S/.test(text) ? "bubble cmd" : "bubble", text });
736
775
  log.append(el("div", { class: "msg user" }, bubble));
737
776
  scrollDown(true);
738
777
  }
@@ -1228,15 +1267,22 @@ async function openModels() {
1228
1267
  const ul = $("models");
1229
1268
  ul.replaceChildren(el("li", { class: "empty-models", text: "…" }));
1230
1269
  const sel = $("login-provider");
1231
- sel.replaceChildren(...providers.map((p) => el("option", { value: p.id, text: p.label })));
1270
+ sel.replaceChildren(...providers.map((p) => el("option", { value: p.id, text: p.label })), el("option", { value: OTHER_PROVIDER, text: t("otherProvider") }));
1232
1271
  const link = $("login-link");
1233
1272
  const setLink = () => {
1234
1273
  const p = providers.find((x) => x.id === sel.value);
1274
+ const other = sel.value === OTHER_PROVIDER;
1275
+ link.hidden = other;
1235
1276
  link.href = p?.url ?? "#";
1277
+ $("login-url-row").hidden = !other;
1278
+ $("login-model-row").hidden = !other;
1279
+ $("login-url").required = other;
1280
+ $("login-model").required = other;
1281
+ $("login-key").required = !other;
1236
1282
  const free = { google: "freeGoogle", openrouter: "freeOpenRouter" }[sel.value];
1237
1283
  $("login-free").hidden = !free;
1238
1284
  $("login-free").textContent = free ? t(free) : "";
1239
- $("login-where").textContent = p?.where ? t(`where_${p.where}`) : "";
1285
+ $("login-where").textContent = other ? t("otherNote") : p?.where ? t(`where_${p.where}`) : "";
1240
1286
  };
1241
1287
  sel.onchange = setLink;
1242
1288
  setLink();
@@ -1329,7 +1375,7 @@ $("login-form")?.addEventListener("submit", async (e) => {
1329
1375
  btn.disabled = true;
1330
1376
  btn.textContent = t("saving");
1331
1377
  try {
1332
- await api("/api/login", { provider: $("login-provider").value, key: $("login-key").value });
1378
+ await api("/api/login", { provider: $("login-provider").value, key: $("login-key").value, baseUrl: $("login-url").value, model: $("login-model").value });
1333
1379
  $("login-key").value = "";
1334
1380
  note(t("saved"));
1335
1381
  await openModels();
@@ -1517,6 +1563,11 @@ $("theme-btn-side")?.addEventListener("click", () => {
1517
1563
  closeSide();
1518
1564
  toggleTheme();
1519
1565
  });
1566
+ $("side").addEventListener("click", (e) => {
1567
+ const box = e.target.closest(".fold")?.closest("[data-fold]");
1568
+ if (box) setFold(box, !box.classList.contains("folded"));
1569
+ });
1570
+ for (const box of document.querySelectorAll(".side section[data-fold]")) setFold(box, folded.has(box.dataset.fold));
1520
1571
  $("session-search")?.addEventListener("input", (e) => renderSessionsList(e.target.value));
1521
1572
  $("library-search").addEventListener("input", searchLibrary);
1522
1573
  $("notes-btn").addEventListener("click", () => ($("note-pane").hidden ? showNoteList() : showPane(false)));
package/web/i18n.js CHANGED
@@ -24,7 +24,7 @@ export const STRINGS = {
24
24
  starbuckReport: "Write the Starbuck report to {x}?",
25
25
  approveHint: "Nothing changes until you select Yes.",
26
26
  zoteroChecking: "Zotero: checking",
27
- zoteroDown: "Zotero is not running",
27
+ zoteroDown: "Zotero is closed: search only",
28
28
  items: "{n} items",
29
29
  toReview: "{n} to check",
30
30
  toReviewHint: "Items that Sub-Sub tagged and that wait for your check (they carry the _agent tag). Select Library overview, or ask Sub-Sub to list them.",
@@ -59,6 +59,10 @@ export const STRINGS = {
59
59
  save: "Save",
60
60
  getKey: "Get a key",
61
61
  keyNote: "The key is saved on this computer, in Sub-Sub's settings folder, and is sent only to that provider.",
62
+ otherProvider: "Another provider (OpenAI API)",
63
+ serverUrl: "Server address",
64
+ modelId: "Model id",
65
+ otherNote: "A server that speaks the OpenAI API: your institution's, or a local one (Ollama, LM Studio). The key can stay empty for a local server. Your messages go to that server.",
62
66
  freeGoogle: "Free to try: a Google AI Studio key needs no card. The free tier limits the text sent each minute and the requests each day, enough to try Sub-Sub, and gemini-3.1-flash-lite is the tested free model. You must be 18 or older. In the EU, the UK and Switzerland, Google does not use your prompts to improve its products; elsewhere, on the free tier, it may.",
63
67
  freeOpenRouter: "Free to try: models whose names end in :free cost nothing, with 50 requests a day (1000 after you buy 10 dollars of credit). The providers of some free models may keep your prompts.",
64
68
  trouble_quota: "The model provider refused the request: a limit of your account was reached. Free tiers limit the text sent each minute and the requests each day. Wait a minute and ask Sub-Sub to continue; if it happens again, the daily limit is used up (it resets the next day). Or choose another model with the model button.",
@@ -84,6 +88,7 @@ export const STRINGS = {
84
88
  saved: "Saved.",
85
89
  busy: "Wait until Sub-Sub finishes.",
86
90
  emptyTitle: "What would you like to do?",
91
+ tagline: "A research assistant for Zotero",
87
92
  quit: "Close Sub-Sub",
88
93
  quitDone: "Sub-Sub is closed. You can close this tab.",
89
94
  earlier: "Earlier messages were summarised.",
@@ -166,7 +171,7 @@ export const STRINGS = {
166
171
  starbuckReport: "Escrever o relatório do Starbuck em {x}?",
167
172
  approveHint: "Nada muda até selecionar Sim.",
168
173
  zoteroChecking: "Zotero: a verificar",
169
- zoteroDown: "O Zotero não está aberto",
174
+ zoteroDown: "O Zotero está fechado: só pesquisa",
170
175
  items: "{n} itens",
171
176
  toReview: "{n} por verificar",
172
177
  toReviewHint: "Itens que o Sub-Sub etiquetou e que esperam a sua verificação (têm a etiqueta _agent). Selecione Resumo da biblioteca, ou peça ao Sub-Sub para os listar.",
@@ -201,6 +206,10 @@ export const STRINGS = {
201
206
  save: "Guardar",
202
207
  getKey: "Obter uma chave",
203
208
  keyNote: "A chave fica guardada neste computador, na pasta de definições do Sub-Sub, e só é enviada a esse fornecedor.",
209
+ otherProvider: "Outro fornecedor (API da OpenAI)",
210
+ serverUrl: "Endereço do servidor",
211
+ modelId: "Identificador do modelo",
212
+ otherNote: "Um servidor que fala a API da OpenAI: o da sua instituição, ou um servidor local (Ollama, LM Studio). Num servidor local, a chave pode ficar vazia. As suas mensagens vão para esse servidor.",
204
213
  freeGoogle: "Para experimentar sem pagar: uma chave do Google AI Studio não pede cartão. O nível gratuito limita o texto enviado por minuto e os pedidos por dia, o que chega para experimentar o Sub-Sub; o modelo gratuito testado é o gemini-3.1-flash-lite. Tem de ter 18 anos ou mais. Na UE, no Reino Unido e na Suíça, a Google não usa os seus pedidos para melhorar os produtos; noutros países, no nível gratuito, pode usar.",
205
214
  freeOpenRouter: "Para experimentar sem pagar: os modelos cujo nome termina em :free não custam nada, com 50 pedidos por dia (1000 depois de comprar 10 dólares de crédito). Os fornecedores de alguns modelos gratuitos podem guardar os seus pedidos.",
206
215
  trouble_quota: "O fornecedor do modelo recusou o pedido: atingiu um limite da sua conta. Os níveis gratuitos limitam o texto enviado por minuto e os pedidos por dia. Espere um minuto e peça ao Sub-Sub para continuar; se voltar a acontecer, esgotou o limite diário (renova-se no dia seguinte). Ou escolha outro modelo no botão do modelo.",
@@ -226,6 +235,7 @@ export const STRINGS = {
226
235
  saved: "Guardado.",
227
236
  busy: "Espere que o Sub-Sub termine.",
228
237
  emptyTitle: "O que quer fazer?",
238
+ tagline: "Um assistente de investigação para o Zotero",
229
239
  quit: "Fechar o Sub-Sub",
230
240
  quitDone: "O Sub-Sub está fechado. Pode fechar este separador.",
231
241
  earlier: "As mensagens anteriores foram resumidas.",
@@ -314,6 +324,7 @@ export const ACTIONS = {
314
324
  { cmd: "subsub", send: true, en: "Library overview", pt: "Resumo da biblioteca" },
315
325
  { cmd: "tag-batch 10", send: true, en: "Tag 10 items", pt: "Etiquetar 10 itens" },
316
326
  { cmd: "clean-tags", send: true, en: "Clean up tags", pt: "Limpar etiquetas" },
327
+ { cmd: "check-library", send: true, en: "Check the library", pt: "Verificar a biblioteca" },
317
328
  { cmd: "import-queue", send: true, en: "Import the queue", pt: "Importar a fila" },
318
329
  { cmd: "history", send: true, en: "Recent changes", pt: "Alterações recentes" },
319
330
  { cmd: "undo", send: true, en: "Undo the last change", pt: "Desfazer a última alteração" },
@@ -339,6 +350,7 @@ export const SUGGESTIONS = {
339
350
  export const TOOLS = {
340
351
  en: {
341
352
  find_items: "search the library",
353
+ list_libraries: "list the libraries",
342
354
  get_figure: "cut a figure out of the PDF",
343
355
  standalone_items: "list files without a parent item",
344
356
  set_parent_items: "give files a parent item",
@@ -400,6 +412,7 @@ export const TOOLS = {
400
412
  },
401
413
  pt: {
402
414
  find_items: "pesquisar na biblioteca",
415
+ list_libraries: "listar as bibliotecas",
403
416
  get_figure: "recortar uma figura do PDF",
404
417
  standalone_items: "listar ficheiros sem item principal",
405
418
  set_parent_items: "dar um item principal aos ficheiros",
package/web/index.html CHANGED
@@ -24,7 +24,7 @@
24
24
  <button type="button" class="chip-btn" id="notes-btn" aria-controls="note-pane" aria-expanded="false" data-i18n="notes" data-i18n-title="notesHint">Notes</button>
25
25
  <button type="button" class="chip-btn model-btn wide-only" id="model-btn"></button>
26
26
  <button type="button" class="icon-btn wide-only" id="width-btn" aria-pressed="false"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 12h16M4 12l3.5-3.5M4 12l3.5 3.5M20 12l-3.5-3.5M20 12l-3.5 3.5"/></svg><span class="visually-hidden" id="width-btn-text">Wide</span></button>
27
- <button type="button" class="icon-btn" id="theme-btn"></button>
27
+ <button type="button" class="icon-btn wide-only" id="theme-btn"></button>
28
28
  </div>
29
29
  </header>
30
30
 
@@ -38,18 +38,18 @@
38
38
  <button type="button" class="secondary" id="theme-btn-side"></button>
39
39
  </label>
40
40
  </section>
41
- <section>
42
- <h2 data-i18n="groupLibrary">Library</h2>
41
+ <section data-fold="library">
42
+ <h2><button type="button" class="fold" aria-expanded="true" data-i18n="groupLibrary">Library</button></h2>
43
43
  <input type="search" id="library-search" class="search" data-i18n-placeholder="librarySearch" placeholder="Search your library" autocomplete="off">
44
44
  <ul class="items" id="items" aria-live="polite"></ul>
45
45
  </section>
46
- <section>
47
- <h2 data-i18n="actions">Actions</h2>
46
+ <section data-fold="actions">
47
+ <h2><button type="button" class="fold" aria-expanded="true" data-i18n="actions">Actions</button></h2>
48
48
  <div class="actions" id="actions"></div>
49
49
  </section>
50
- <section class="grow">
50
+ <section class="grow" data-fold="conversations">
51
51
  <div class="side-subhead">
52
- <h2 data-i18n="conversations">Conversations</h2>
52
+ <h2><button type="button" class="fold" aria-expanded="true" data-i18n="conversations">Conversations</button></h2>
53
53
  <button type="button" class="link-btn" id="export-btn" data-i18n="export" data-i18n-title="exportTitle">Export</button>
54
54
  </div>
55
55
  <input type="search" id="session-search" class="search" data-i18n-placeholder="searchConversations" placeholder="Search conversations" autocomplete="off">
@@ -120,6 +120,12 @@
120
120
  <label><span data-i18n="provider">Provider</span>
121
121
  <select id="login-provider"></select>
122
122
  </label>
123
+ <label id="login-url-row" hidden><span data-i18n="serverUrl">Server address</span>
124
+ <input type="url" id="login-url" autocomplete="off" spellcheck="false" placeholder="https://llm.example.org/v1">
125
+ </label>
126
+ <label id="login-model-row" hidden><span data-i18n="modelId">Model id</span>
127
+ <input type="text" id="login-model" autocomplete="off" spellcheck="false">
128
+ </label>
123
129
  <label><span data-i18n="apiKey">API key</span>
124
130
  <input type="password" id="login-key" autocomplete="off" spellcheck="false" required>
125
131
  </label>