@miadi/chronicle-tui 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/App.js CHANGED
@@ -12,12 +12,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
12
12
  * taken, so tmux (C-b) and herdr (prefix+…) keep all of theirs.
13
13
  */
14
14
  import { useEffect, useMemo, useReducer, useRef } from "react";
15
- import { Box, Text, useApp, useInput, useWindowSize } from "ink";
15
+ import stringWidth from "string-width";
16
+ import { Box, Text, useApp, useInput, usePaste, useWindowSize } from "ink";
16
17
  import { MAX_FILES } from "@miadi/episode-vessel";
17
- import { DEFAULT_ACTIONS, actionFor, } from "./actions.js";
18
+ import { DEFAULT_ACTIONS, actionFor, enterCeremony, enterCircle, } from "./actions.js";
18
19
  import { TOKEN_ENV, answerAttention, connectApi } from "./api.js";
19
20
  import { BOARD_LABELS, ROOM_LABELS, answeredMessage, checkAnswer, filterBoard, loadBoard, loadEpisodePanel, panelView, readOnlyLabel, } from "./attention.js";
20
- import { VISITOR, loadChronicle, loadDetail, loadText, openUrl, qrLines, search, visibleRows, } from "./data.js";
21
+ import { CEREMONY_TYPES, DIARY_PHASES, DIRECTIONS, ENTRY_TYPES, PHASE_LABELS, ceremonyHeadline, ceremonyLines, circleEntries, closeCeremony, mayWitness, openCeremony, readCeremony, readCircle, scrollTo, speak, splitLearnings, typeLabel, whenOf, witness, writeDiary, } from "./circles.js";
22
+ import { VISITOR, wheelDoors, loadChronicle, loadDetail, loadText, openUrl, qrLines, search, visibleRows, } from "./data.js";
21
23
  import { ageOf, clamp, fileView, filterRows, formatDate, screenLineOf, windowOf, wrapSegments, } from "./model.js";
22
24
  import { clean, cleanLine, link } from "./text.js";
23
25
  const DIRECTION_MARK = { east: "E", south: "S", west: "W", north: "N" };
@@ -106,6 +108,7 @@ export function App(props) {
106
108
  reader: null,
107
109
  access: VISITOR,
108
110
  mayAnswer: false,
111
+ mayMint: false,
109
112
  board: null,
110
113
  boardSel: 0,
111
114
  history: [],
@@ -201,11 +204,21 @@ export function App(props) {
201
204
  return;
202
205
  try {
203
206
  const reader = await api.me();
204
- const mayAnswer = await api.mayAnswer();
207
+ const [mayAnswer, mayMint] = await Promise.all([api.mayAnswer(), api.mayMint()]);
205
208
  if (!live)
206
209
  return;
210
+ // Signing in can show more episodes (community ones), so the selection is kept by name, not by row.
211
+ const before = derive();
212
+ const keep = before.s.view === "attention" ? undefined : before.currentRow?.name;
213
+ const keepItem = before.boardItem;
207
214
  details.current.clear();
208
- set({ reader, mayAnswer, access: { signedIn: Boolean(reader), mayWrite: mayAnswer } });
215
+ set({ reader, mayAnswer, mayMint, access: { signedIn: Boolean(reader), mayWrite: mayAnswer } });
216
+ const after = derive();
217
+ const listSel = keep ? after.filtered.findIndex((row) => row.name === keep) : -1;
218
+ const boardSel = keepItem ? after.board.findIndex((item) => item.episode === keepItem.episode && item.id === keepItem.id) : -1;
219
+ if (listSel !== -1 || boardSel !== -1) {
220
+ set({ ...(listSel !== -1 ? { listSel } : {}), ...(boardSel !== -1 ? { boardSel } : {}), status: ui.current.status });
221
+ }
209
222
  resolvePending();
210
223
  }
211
224
  catch (error) {
@@ -317,8 +330,22 @@ export function App(props) {
317
330
  api: s.api,
318
331
  reader: s.reader,
319
332
  mayAnswer: s.mayAnswer,
333
+ mayMint: s.mayMint,
334
+ relateFrom: s.relateFrom,
335
+ setRelateFrom: (name) => set({ relateFrom: name }),
336
+ reload: reloadChronicle,
320
337
  };
321
338
  };
339
+ /** Read the chronicle again after a write landed in it, keeping the episode in focus. */
340
+ async function reloadChronicle() {
341
+ const keep = derive().currentRow?.name;
342
+ const chronicle = await loadChronicle(root);
343
+ details.current.clear();
344
+ set({ chronicle, status: ui.current.status });
345
+ const index = keep ? derive().filtered.findIndex((row) => row.name === keep) : -1;
346
+ if (index !== -1)
347
+ set({ listSel: index, status: ui.current.status });
348
+ }
322
349
  const openFile = (name, file, from, line) => {
323
350
  const s = ui.current;
324
351
  const text = loadText(s.chronicle.root, name, file, s.access);
@@ -419,7 +446,169 @@ export function App(props) {
419
446
  set({ overlays: stack });
420
447
  },
421
448
  });
449
+ // ── talking circles: the overlays, and the person's acts in them ─────────
450
+ const host = { open: openOverlay, replace: replaceOverlay };
451
+ const topHandle = () => ui.current.overlays[ui.current.overlays.length - 1]?.id ?? 0;
452
+ /** Change overlay `handle` wherever it sits in the stack; a closed one is left alone. */
453
+ const updateById = (handle, change) => {
454
+ const stack = ui.current.overlays;
455
+ if (!stack.some((entry) => entry.id === handle))
456
+ return;
457
+ set({ overlays: stack.map((entry) => (entry.id === handle ? { id: handle, overlay: change(entry.overlay) } : entry)) });
458
+ };
459
+ const episodeLabel = (path) => {
460
+ if (!path)
461
+ return "";
462
+ const row = derive().rowByName(path);
463
+ return row?.number !== undefined ? `ep ${row.number}` : path;
464
+ };
465
+ /**
466
+ * How a ceremony fills the overlay: inside the border, a title, its
467
+ * intention, a line of where it is held, the tabs, the lines, the keys.
468
+ * Shared by the keys and the screen, so a scroll is measured as drawn.
469
+ */
470
+ const ceremonyLayout = (view, tab) => {
471
+ const inner = Math.max(10, columns - 4);
472
+ const intention = view.intentions.join(" · ");
473
+ const intentLines = intention ? Math.min(compact ? 2 : 3, wrapSegments([{ text: intention, style: "text" }], inner).length) : 0;
474
+ const room = Math.max(1, bodyHeight - 2 - (4 + intentLines));
475
+ return { inner, intention, intentLines, room, screen: ceremonyLines(view, tab, inner - 1) };
476
+ };
477
+ const refreshing = useRef(false);
478
+ /** Read the ceremony again. A reader on the newest turn stays on the newest, so new turns come into view. */
479
+ const reloadCeremony = async (handle) => {
480
+ const entry = ui.current.overlays.find((candidate) => candidate.id === handle);
481
+ if (!entry || entry.overlay.type !== "ceremony" || !ui.current.api || refreshing.current)
482
+ return;
483
+ refreshing.current = true;
484
+ try {
485
+ const view = await readCeremony(ui.current.api, entry.overlay.id);
486
+ updateById(handle, (o) => {
487
+ if (o.type !== "ceremony")
488
+ return o;
489
+ const count = o.tab === "turns" ? (o.view?.turns.length ?? 0) : (o.view?.diary.length ?? 0);
490
+ const following = o.selected < 0 || (o.tab === "turns" && o.selected >= count - 1);
491
+ return { ...o, view, error: undefined, selected: following && o.tab === "turns" ? -1 : o.selected };
492
+ });
493
+ }
494
+ catch (error) {
495
+ updateById(handle, (o) => (o.type === "ceremony" ? { ...o, error: cleanLine(error.message) } : o));
496
+ }
497
+ finally {
498
+ refreshing.current = false;
499
+ }
500
+ };
501
+ const reloadCircle = async (handle) => {
502
+ const entry = ui.current.overlays.find((candidate) => candidate.id === handle);
503
+ if (!entry || entry.overlay.type !== "circle" || !ui.current.api)
504
+ return;
505
+ const view = await readCircle(ui.current.api, entry.overlay.id);
506
+ updateById(handle, (o) => (o.type === "circle" ? { ...o, view, error: undefined } : o));
507
+ };
508
+ // A talking circle is live: the ceremony on top is read again every ten seconds.
509
+ useEffect(() => {
510
+ const timer = setInterval(() => {
511
+ const top = ui.current.overlays[ui.current.overlays.length - 1];
512
+ if (top?.overlay.type === "ceremony" && top.overlay.view)
513
+ void reloadCeremony(top.id);
514
+ }, 10_000);
515
+ return () => clearInterval(timer);
516
+ }, []);
517
+ const speakInput = (handle, view) => ({
518
+ type: "input",
519
+ title: `Your turn · ${typeLabel(view.type)}${view.circle ? ` · ${view.circle.name}` : ""}`,
520
+ hint: "Speak. The talking piece is yours. A paste keeps its lines.",
521
+ value: "",
522
+ submit: async (value) => {
523
+ await speak(ui.current.api, view.id, value.trim());
524
+ return "Spoken in the circle.";
525
+ },
526
+ after: () => reloadCeremony(handle),
527
+ });
528
+ const diaryInput = (handle, view) => ({
529
+ type: "input",
530
+ title: `Diary · ${typeLabel(view.type)}`,
531
+ hint: "In your own voice. One entry is one voice, in one phase.",
532
+ value: "",
533
+ choices: [
534
+ { label: "phase", values: DIARY_PHASES, shown: DIARY_PHASES.map((phase) => PHASE_LABELS[phase] ?? phase), index: 1 },
535
+ { label: "kind", values: ENTRY_TYPES, index: ENTRY_TYPES.indexOf("reflection") },
536
+ ],
537
+ submit: async (value, [phase, entryType]) => {
538
+ await writeDiary(ui.current.api, view.id, { phase: phase, entryType: entryType, content: value.trim() });
539
+ return `Written in the diary (${phase}, ${entryType}).`;
540
+ },
541
+ after: () => reloadCeremony(handle),
542
+ });
543
+ const closeInput = (handle, view) => ({
544
+ type: "input",
545
+ title: `Close the circle · ${typeLabel(view.type)}${view.circle ? ` · ${view.circle.name}` : ""}`,
546
+ hint: "What the circle learned: one per line (a paste keeps its lines) or separated by ' | '. Nothing is fine. Enter closes it; Esc keeps it open.",
547
+ value: "",
548
+ allowEmpty: true,
549
+ submit: async (value) => {
550
+ const result = await closeCeremony(ui.current.api, view.id, splitLearnings(value));
551
+ return result.already ? "It was already closed." : "The circle is closed.";
552
+ },
553
+ after: () => reloadCeremony(handle),
554
+ });
555
+ const openInput = (handle, circle, focus) => {
556
+ const episodes = [...new Set([focus, circle.episode].filter((path) => Boolean(path))), ""];
557
+ const directions = DIRECTIONS;
558
+ let opened = "";
559
+ return {
560
+ type: "input",
561
+ title: `Open a ceremony · ${circle.name}`,
562
+ hint: "Its intention, in your words. The circle's members are its participants.",
563
+ value: "",
564
+ choices: [
565
+ { label: "kind", values: CEREMONY_TYPES, shown: CEREMONY_TYPES.map(typeLabel), index: 0 },
566
+ { label: "direction", values: DIRECTIONS, index: Math.max(0, directions.indexOf(circle.direction ?? "east")) },
567
+ { label: "episode", values: episodes, shown: episodes.map((path) => (path ? episodeLabel(path) : "none")), index: 0 },
568
+ ],
569
+ submit: async (value, [type, direction, episode]) => {
570
+ const result = await openCeremony(ui.current.api, circle.id, { type: type, direction: direction, intention: value.trim(), ...(episode ? { episode_path: episode } : {}) });
571
+ opened = result.ceremony.id;
572
+ return `Opened a ${typeLabel(type)} in ${circle.name}${episode ? `, bound to ${episodeLabel(episode)}` : ""}.`;
573
+ },
574
+ after: async () => {
575
+ await reloadCircle(handle);
576
+ if (opened)
577
+ enterCeremony(host, ui.current.api, opened);
578
+ },
579
+ };
580
+ };
581
+ const witnessTurn = (handle, view, index) => {
582
+ const turn = view.turns[index];
583
+ if (!turn)
584
+ return;
585
+ if (!mayWitness(view, turn)) {
586
+ const why = !view.can.witness
587
+ ? view.closed
588
+ ? "The ceremony is closed."
589
+ : "Your seat does not witness here."
590
+ : turn.speaker === view.me?.id
591
+ ? "You spoke this turn; another witnesses it."
592
+ : "You witnessed this turn.";
593
+ return set({ status: why });
594
+ }
595
+ set({ status: `Witnessing ${turn.speakerName}'s turn…` });
596
+ witness(ui.current.api, view.id, turn.id).then(async () => {
597
+ set({ status: `Witnessed ${turn.speakerName}'s turn.` });
598
+ await reloadCeremony(handle);
599
+ }, (error) => set({ status: cleanLine(error.message) }));
600
+ };
422
601
  // ── keys ─────────────────────────────────────────────────────────────────
602
+ // A paste into an input arrives whole, line breaks kept (bracketed paste).
603
+ const pasteActive = ui.current.overlays[ui.current.overlays.length - 1]?.overlay.type === "input";
604
+ usePaste((text) => {
605
+ const top = ui.current.overlays[ui.current.overlays.length - 1]?.overlay;
606
+ if (top?.type !== "input" || top.busy)
607
+ return;
608
+ const added = text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
609
+ if (added)
610
+ updateTop({ ...top, value: top.value + added, error: undefined });
611
+ }, { isActive: pasteActive });
423
612
  useInput((input, key) => {
424
613
  if (key.ctrl || key.meta)
425
614
  return;
@@ -453,12 +642,29 @@ export function App(props) {
453
642
  return;
454
643
  if (key.escape)
455
644
  return closeTop();
645
+ const choices = top.choices ?? [];
646
+ const focus = clamp(top.focus ?? 0, choices.length);
647
+ if (choices.length && (key.upArrow || key.downArrow)) {
648
+ const choice = choices[focus];
649
+ const count = choice.values.length;
650
+ const index = (choice.index + (key.downArrow ? 1 : count - 1)) % count;
651
+ return updateTop({ ...top, choices: choices.map((c, i) => (i === focus ? { ...c, index } : c)) });
652
+ }
653
+ if (key.tab) {
654
+ if (choices.length > 1)
655
+ updateTop({ ...top, focus: (focus + (key.shift ? choices.length - 1 : 1)) % choices.length });
656
+ return;
657
+ }
456
658
  if (key.return) {
457
659
  const value = top.value;
458
- if (!value.trim())
660
+ if (!value.trim() && !top.allowEmpty)
459
661
  return updateTop({ ...top, error: "Nothing to send yet." });
460
662
  updateTop({ ...top, busy: true, error: undefined });
461
- top.submit(value).then(async (message) => {
663
+ top.submit(value, choices.map((c) => c.values[c.index])).then(async (result) => {
664
+ // A two-part form: the next input takes this one's place.
665
+ if (typeof result !== "string")
666
+ return updateTop(result.next);
667
+ const message = result;
462
668
  set({ status: cleanLine(message), overlays: ui.current.overlays.slice(0, -1) });
463
669
  try {
464
670
  await top.after?.();
@@ -498,8 +704,113 @@ export function App(props) {
498
704
  }
499
705
  if (top.type === "qr" && input === "i")
500
706
  return updateTop({ ...top, invert: !top.invert });
501
- if (top.type === "card" && input === "o")
502
- return void openOverlay({ type: "doors", doors: top.doors, selected: 0 });
707
+ if (top.type === "circles" && top.index) {
708
+ const entries = circleEntries(top.index);
709
+ const step = (from, by) => {
710
+ let at = from;
711
+ for (let i = from + by; i >= 0 && i < entries.length; i += by) {
712
+ if (entries[i].kind !== "heading") {
713
+ at = i;
714
+ break;
715
+ }
716
+ }
717
+ return at;
718
+ };
719
+ if (key.downArrow || input === "j")
720
+ return updateTop({ ...top, selected: step(top.selected, 1) });
721
+ if (key.upArrow || input === "k")
722
+ return updateTop({ ...top, selected: step(top.selected, -1) });
723
+ const entry = entries[top.selected];
724
+ if (entry && (key.return || input === "l" || key.rightArrow) && s.api) {
725
+ if (entry.kind === "ceremony")
726
+ return void enterCeremony(host, s.api, entry.held.ceremony.id);
727
+ if (entry.kind === "circle")
728
+ return void enterCircle(host, s.api, entry.circle.id, top.episode);
729
+ }
730
+ return;
731
+ }
732
+ if (top.type === "circle" && top.view) {
733
+ const handle = topHandle();
734
+ const held = top.view.held;
735
+ if (key.downArrow || input === "j")
736
+ return updateTop({ ...top, selected: clamp(top.selected + 1, held.length) });
737
+ if (key.upArrow || input === "k")
738
+ return updateTop({ ...top, selected: clamp(top.selected - 1, held.length) });
739
+ const chosen = held[clamp(top.selected, held.length)];
740
+ if (chosen && (key.return || input === "l" || key.rightArrow) && s.api)
741
+ return void enterCeremony(host, s.api, chosen.ceremony.id);
742
+ if (input === "n") {
743
+ if (!top.view.seat || !top.view.can.open_ceremony)
744
+ return set({ status: top.view.circle.active ? "Opening a ceremony here needs a seat that facilitates." : "The circle is inactive." });
745
+ return void openOverlay(openInput(handle, top.view.circle, top.episode));
746
+ }
747
+ if (input === "o") {
748
+ wheelDoors("circle", top.id).then((doors) => openOverlay({ type: "doors", doors, selected: 0 }), () => { });
749
+ return;
750
+ }
751
+ return;
752
+ }
753
+ if (top.type === "ceremony" && top.view) {
754
+ const handle = topHandle();
755
+ const view = top.view;
756
+ const layout = ceremonyLayout(view, top.tab);
757
+ const count = top.tab === "turns" ? view.turns.length : view.diary.length;
758
+ const selected = top.selected < 0 ? count - 1 : clamp(top.selected, count);
759
+ const scroll = top.selected < 0 ? Math.max(0, layout.screen.lines.length - layout.room) : top.scroll;
760
+ const select = (index) => {
761
+ const next = clamp(index, count);
762
+ updateTop({ ...top, selected: next, scroll: scrollTo(layout.screen, next, scroll, layout.room) });
763
+ };
764
+ if (key.tab) {
765
+ const tab = top.tab === "turns" ? "diary" : "turns";
766
+ return updateTop({ ...top, tab, selected: tab === "turns" ? -1 : 0, scroll: 0 });
767
+ }
768
+ if (key.downArrow || input === "j")
769
+ return select(selected + 1);
770
+ if (key.upArrow || input === "k")
771
+ return select(selected - 1);
772
+ if (input === "g" || key.home)
773
+ return select(0);
774
+ if (input === "G" || key.end)
775
+ return select(count - 1);
776
+ if (key.pageDown || key.pageUp) {
777
+ const max = Math.max(0, layout.screen.lines.length - layout.room);
778
+ const next = clamp(scroll + (key.pageDown ? layout.room - 1 : 1 - layout.room), max + 1);
779
+ const at = layout.screen.ends.findIndex((end) => end > next);
780
+ return updateTop({ ...top, scroll: next, selected: at === -1 ? Math.max(0, count - 1) : at });
781
+ }
782
+ if (input === "s") {
783
+ if (!view.can.speak)
784
+ return set({ status: view.closed ? "The ceremony is closed." : "Only those seated in the circle may speak." });
785
+ return void openOverlay(speakInput(handle, view));
786
+ }
787
+ if (input === "w") {
788
+ if (top.tab !== "turns")
789
+ return set({ status: "Witnessing is of a turn: Tab to the turns." });
790
+ return witnessTurn(handle, view, selected);
791
+ }
792
+ if (input === "d") {
793
+ if (!view.can.diary)
794
+ return set({ status: "Writing in the diary needs a seat in the circle." });
795
+ return void openOverlay(diaryInput(handle, view));
796
+ }
797
+ if (input === "x") {
798
+ if (!view.can.close)
799
+ return set({ status: view.closed ? "The ceremony is closed." : "Only the facilitator closes the circle." });
800
+ return void openOverlay(closeInput(handle, view));
801
+ }
802
+ if (input === "e" && view.episode) {
803
+ if (!derive().rowByName(view.episode.path))
804
+ return set({ status: `${episodeLabel(view.episode.path)} is not among the episodes you see.` });
805
+ set({ overlays: [] });
806
+ return jumpTo(view.episode.path);
807
+ }
808
+ if (input === "o") {
809
+ wheelDoors("ceremony", view.id).then((doors) => openOverlay({ type: "doors", doors, selected: 0 }), () => { });
810
+ return;
811
+ }
812
+ return;
813
+ }
503
814
  if (top.type === "panel") {
504
815
  const { shown, view } = panelView(top.items, top.view, ROOM_LABELS, top.parseError);
505
816
  if (key.tab)
@@ -585,6 +896,8 @@ export function App(props) {
585
896
  return move(1_000_000, PLAIN_KEY);
586
897
  if (key.return || key.rightArrow || input === "l")
587
898
  return forward();
899
+ if (key.escape && s.relateFrom)
900
+ return set({ relateFrom: undefined, status: "No longer relating." });
588
901
  if (key.escape || key.leftArrow || input === "h" || key.backspace)
589
902
  return back();
590
903
  if (input === "?")
@@ -641,9 +954,19 @@ export function App(props) {
641
954
  const linesOf = (text, cap) => Math.min(cap, Math.max(1, wrapSegments([{ text, style: "text" }], textWidth).length));
642
955
  const titleLines = d.currentRow ? linesOf(`${d.currentRow.number ?? "·"} ${d.currentRow.title}`, 3) : 0;
643
956
  const goalLines = goal ? linesOf(goal, compact ? 6 : 8) : 0;
644
- const fixedLines = titleLines + 2 + (openHere ? 1 : 0) + goalLines + (d.detail?.error ? 1 : 0) + 1;
957
+ // The episodes this one continues from or relates to, by number where it has one.
958
+ const lineage = d.currentRow?.lineage.length
959
+ ? ["continues from", "relates to", "branch of"]
960
+ .map((kind) => {
961
+ const targets = d.currentRow.lineage.filter((link) => link.kind === kind).map((link) => (link.number !== undefined ? String(link.number) : link.target));
962
+ return targets.length ? `${kind} ${targets.join(", ")}` : "";
963
+ })
964
+ .filter(Boolean)
965
+ .join(" · ")
966
+ : "";
967
+ const fixedLines = titleLines + 2 + (openHere ? 1 : 0) + (lineage ? 1 : 0) + goalLines + (d.detail?.error ? 1 : 0) + 1;
645
968
  const fileWindow = windowOf(d.files, clamp(s.fileSel, d.files.length), Math.max(1, bodyHeight - fixedLines));
646
- const detailPane = (_jsx(Box, { flexDirection: "column", width: rightWidth, height: bodyHeight, overflow: "hidden", paddingLeft: compact ? 0 : 1, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, children: s.view === "attention" ? (d.boardItem ? itemBody(d.boardItem, rightWidth - 1) : _jsx(Text, { color: "gray", children: "Nothing selected." })) : d.currentRow ? (_jsxs(_Fragment, { children: [_jsx(Box, { height: titleLines, overflow: "hidden", flexShrink: 0, children: _jsxs(Text, { bold: true, wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: d.currentRow.number ?? "·" }), " ", d.currentRow.title] }) }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [[formatDate(d.currentRow.date), d.currentRow.status, d.currentRow.type].filter(Boolean).join(" · "), d.currentRow.structured ? "" : " · no episode.yaml", d.currentRow.audience === "community" ? " · community" : ""] }), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["reading: ", d.currentRow.readings.direction, " \u00B7 ", d.currentRow.readings.theme, " \u00B7 ", d.currentRow.readings.arc] }), openHere ? _jsxs(Text, { color: "yellow", children: ["? ", openHere, " open question", openHere === 1 ? "" : "s", " \u00B7 a"] }) : null, goal ? (_jsx(Box, { height: goalLines, overflow: "hidden", flexShrink: 0, children: _jsx(Text, { wrap: "wrap", children: goal }) })) : null, d.detail?.error ? _jsx(Text, { color: "red", children: cleanLine(d.detail.error) }) : null, _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["── ", d.files.length, d.files.length >= MAX_FILES ? "+" : "", " file", d.files.length === 1 ? "" : "s", s.view === "files" ? "" : " · Enter", " ──"] }), fileWindow.items.map((file, index) => {
969
+ const detailPane = (_jsx(Box, { flexDirection: "column", width: rightWidth, height: bodyHeight, overflow: "hidden", paddingLeft: compact ? 0 : 1, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, children: s.view === "attention" ? (d.boardItem ? itemBody(d.boardItem, rightWidth - 1) : _jsx(Text, { color: "gray", children: "Nothing selected." })) : d.currentRow ? (_jsxs(_Fragment, { children: [_jsx(Box, { height: titleLines, overflow: "hidden", flexShrink: 0, children: _jsxs(Text, { bold: true, wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: d.currentRow.number ?? "·" }), " ", d.currentRow.title] }) }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [[formatDate(d.currentRow.date), d.currentRow.status, d.currentRow.type].filter(Boolean).join(" · "), d.currentRow.structured ? "" : " · no episode.yaml", d.currentRow.audience === "community" ? " · community" : ""] }), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["reading: ", d.currentRow.readings.direction, " \u00B7 ", d.currentRow.readings.theme, " \u00B7 ", d.currentRow.readings.arc] }), openHere ? _jsxs(Text, { color: "yellow", children: ["? ", openHere, " open question", openHere === 1 ? "" : "s", " \u00B7 a"] }) : null, lineage ? _jsxs(Text, { color: "cyan", wrap: "truncate-end", children: ["\u2197 ", lineage] }) : null, goal ? (_jsx(Box, { height: goalLines, overflow: "hidden", flexShrink: 0, children: _jsx(Text, { wrap: "wrap", children: goal }) })) : null, d.detail?.error ? _jsx(Text, { color: "red", children: cleanLine(d.detail.error) }) : null, _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["── ", d.files.length, d.files.length >= MAX_FILES ? "+" : "", " file", d.files.length === 1 ? "" : "s", s.view === "files" ? "" : " · Enter", " ──"] }), fileWindow.items.map((file, index) => {
647
970
  const isSelected = s.view === "files" && fileWindow.start + index === clamp(s.fileSel, d.files.length);
648
971
  return (_jsxs(Text, { wrap: "truncate-start", inverse: isSelected, color: file.kind === "text" ? "blue" : "gray", children: [cleanLine(file.relativePath), file.kind === "text" ? "" : ` · ${file.kind}`] }, file.relativePath));
649
972
  })] })) : (_jsx(Text, { color: "gray", children: "Nothing selected." })) }) }));
@@ -673,7 +996,21 @@ export function App(props) {
673
996
  return box("yellow", _jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Proof \u00B7 ", d.rowByName(top.episode)?.number ?? cleanLine(top.episode)] }), !top.status && !top.error ? _jsx(Text, { color: "gray", children: "Asking git and the wheel\u2026" }) : null, top.error ? _jsx(Text, { color: "red", wrap: "wrap", children: cleanLine(top.error) }) : null, top.status?.closing.map((stage) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: stage.state === "proven" ? "green" : stage.state === "owed" ? "yellow" : "red", children: [stage.state === "proven" ? "✓" : stage.state === "owed" ? "…" : "✗", " ", stage.stage] }), compact ? null : _jsxs(Text, { color: "gray", children: [" ", cleanLine(stage.observed)] })] }), compact ? _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [" ", cleanLine(stage.observed)] }) : null] }, stage.stage))), top.status?.drift.map((line) => (_jsxs(Text, { color: "yellow", wrap: "wrap", children: ["drift: ", cleanLine(line)] }, line))), _jsx(Text, { color: "gray", children: "Esc" })] }));
674
997
  }
675
998
  if (top.type === "input") {
676
- return box("cyan", _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: cleanLine(top.title) }), top.hint ? _jsx(Text, { color: "gray", wrap: "wrap", children: cleanLine(top.hint) }) : null, _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "cyan", children: "\u203A " }), cleanLine(top.value), top.busy ? null : _jsx(Text, { inverse: true, children: " " })] }), top.busy ? _jsx(Text, { color: "gray", children: "Sending\u2026" }) : null, top.error ? _jsx(Text, { color: "red", wrap: "wrap", children: cleanLine(top.error) }) : null, _jsx(Text, { color: "gray", children: "Enter sends \u00B7 Esc cancels" })] }));
999
+ const inner = Math.max(10, columns - 4);
1000
+ const choices = top.choices ?? [];
1001
+ const focus = clamp(top.focus ?? 0, choices.length);
1002
+ const hintLines = top.hint ? Math.min(3, wrapSegments([{ text: cleanLine(top.hint), style: "text" }], inner).length) : 0;
1003
+ const choiceText = (choice) => cleanLine(choice.shown?.[choice.index] ?? choice.values[choice.index] ?? "");
1004
+ // Choices share a line when they fit, else take one each (a phone).
1005
+ const stacked = stringWidth(choices.map((choice) => `${choice.label} ${choiceText(choice)}`).join(" ")) > inner;
1006
+ const choiceLines = choices.length ? (stacked ? choices.length : 1) : 0;
1007
+ // The end of what is typed stays in view, however long a turn grows.
1008
+ const room = Math.max(1, bodyHeight - 2 - (1 + hintLines + choiceLines + (top.busy || top.error ? 1 : 0) + 1));
1009
+ const typed = clean(top.value)
1010
+ .split("\n")
1011
+ .flatMap((paragraph) => wrapSegments([{ text: paragraph, style: "text" }], inner - 2).map((line) => line.map((part) => part.text).join("")));
1012
+ const shown = typed.slice(-room);
1013
+ return box("cyan", _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: cleanLine(top.title) }), top.hint ? (_jsx(Box, { height: hintLines, overflow: "hidden", flexShrink: 0, children: _jsx(Text, { color: "gray", wrap: "wrap", children: cleanLine(top.hint) }) })) : null, shown.map((line, index) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: index === 0 && typed.length <= room ? "› " : " " }), line, index === shown.length - 1 && !top.busy ? _jsx(Text, { inverse: true, children: " " }) : null] }, index))), choices.length ? (_jsx(Box, { flexDirection: stacked ? "column" : "row", flexShrink: 0, children: choices.map((choice, index) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsxs(Text, { color: index === focus ? "yellow" : "gray", children: [choice.label, " "] }), _jsx(Text, { inverse: index === focus, children: choiceText(choice) }), stacked ? "" : " "] }, choice.label))) })) : null, top.busy ? _jsx(Text, { color: "gray", children: "Sending\u2026" }) : top.error ? _jsx(Text, { color: "red", wrap: "truncate-end", children: cleanLine(top.error) }) : null, _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["Enter sends", choices.length ? ` · ↑↓ ${choices.length > 1 ? "change · Tab next" : "change"}` : "", " \u00B7 Esc cancels"] })] }));
677
1014
  }
678
1015
  if (top.type === "panel") {
679
1016
  const view = panelView(top.items, top.view, ROOM_LABELS, top.parseError);
@@ -686,14 +1023,67 @@ export function App(props) {
686
1023
  if (top.type === "item") {
687
1024
  return box("yellow", _jsxs(_Fragment, { children: [itemBody(top.item, columns - 4), _jsxs(Text, { color: "gray", wrap: "wrap", children: [top.item.state === "open" ? (s.mayAnswer ? "Enter answers · " : `${readOnlyLabel(TOKEN_ENV)} · `) : "", "e opens its episode \u00B7 Esc"] })] }));
688
1025
  }
689
- if (top.type === "card") {
690
- const card = top.card;
691
- return box("magenta", _jsxs(_Fragment, { children: [card.kind === "ceremony" ? (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["\u25C6 ", cleanLine(card.type ?? "ceremony"), " \u00B7 ", cleanLine(card.direction ?? "no direction"), " \u00B7 ", card.closed ? "closed" : "open"] }), card.intentions.map((line, index) => (_jsx(Text, { wrap: "wrap", children: cleanLine(line) }, index))), card.episode ? _jsxs(Text, { color: "cyan", wrap: "truncate-end", children: ["\u2197 ", d.rowByName(card.episode)?.number ?? "", " ", cleanLine(d.rowByName(card.episode)?.title ?? card.episode)] }) : null, card.circle ? _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u25CB held in ", cleanLine(card.circle)] }) : _jsx(Text, { color: "gray", children: "held in no circle" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["\u25CB ", cleanLine(card.name), " \u00B7 ", cleanLine(card.direction ?? "no direction"), " \u00B7 ", card.active ? "active" : "inactive"] }), card.intention ? _jsx(Text, { wrap: "wrap", children: cleanLine(card.intention) }) : null, _jsx(Text, { color: "gray", wrap: "truncate-end", children: [card.type, card.facilitator ? `facilitated by ${card.facilitator}` : "", `${card.ceremonies} ceremon${card.ceremonies === 1 ? "y" : "ies"}`]
692
- .filter(Boolean)
693
- .map((part) => cleanLine(String(part)))
694
- .join(" · ") })] })), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["your seat: ", card.can.length ? card.can.map(cleanLine).join(", ") : "read"] }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: "o doors \u00B7 Esc" })] }));
1026
+ if (top.type === "circles") {
1027
+ const entries = top.index ? circleEntries(top.index) : [];
1028
+ const listWindow = windowOf(entries, clamp(top.selected, entries.length), Math.max(1, bodyHeight - 4));
1029
+ return box("magenta", _jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Circles", top.index?.episode ? _jsxs(Text, { color: "gray", children: [" \u00B7 from ", episodeLabel(top.index.episode.path)] }) : null] }), !top.index && !top.error ? _jsx(Text, { color: "gray", children: "Asking the wheel\u2026" }) : null, top.error ? _jsx(Text, { color: "red", wrap: "wrap", children: cleanLine(top.error) }) : null, listWindow.items.map((entry, index) => {
1030
+ const at = listWindow.start + index;
1031
+ const isSelected = at === top.selected;
1032
+ if (entry.kind === "heading")
1033
+ return _jsx(Text, { color: "gray", bold: true, wrap: "truncate-end", children: cleanLine(entry.text) }, `h${at}`);
1034
+ if (entry.kind === "ceremony") {
1035
+ const c = entry.held.ceremony;
1036
+ return (_jsxs(Text, { wrap: "truncate-end", inverse: isSelected, children: [_jsxs(Text, { color: entry.held.open ? "green" : "gray", children: ["\u25C6 ", entry.held.open ? "open " : "closed"] }), " ", ceremonyHeadline(c), _jsxs(Text, { color: "gray", children: [" \u00B7 ", typeLabel(c.type), " \u00B7 ", cleanLine(c.direction ?? ""), " \u00B7 ", whenOf(c.timestamp)] })] }, c.id));
1037
+ }
1038
+ const circle = entry.circle;
1039
+ return (_jsxs(Text, { wrap: "truncate-end", inverse: isSelected, children: [_jsx(Text, { color: "magenta", children: "\u25CB" }), " ", circle.name, _jsxs(Text, { color: "gray", children: [" · ", circle.member ? "you sit here" : circle.facilitator ? `facilitated by ${circle.facilitator}` : "a circle", circle.active ? "" : " · inactive", !compact && circle.intention ? ` · ${circle.intention}` : ""] })] }, circle.id));
1040
+ }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: "\u2191\u2193 \u00B7 \u23CE enter \u00B7 Esc" })] }));
1041
+ }
1042
+ if (top.type === "circle") {
1043
+ const view = top.view;
1044
+ const inner = Math.max(10, columns - 4);
1045
+ const intentLines = view?.circle.intention ? Math.min(compact ? 2 : 3, wrapSegments([{ text: view.circle.intention, style: "text" }], inner).length) : 0;
1046
+ const rows = Math.max(1, bodyHeight - 2 - (4 + intentLines));
1047
+ const held = view?.held ?? [];
1048
+ const heldWindow = windowOf(held, clamp(top.selected, held.length), rows);
1049
+ const people = view ? view.members.map((m) => (m.role === "member" ? m.name : `${m.name} (${m.role})`)).join(", ") : "";
1050
+ const canOpen = Boolean(view?.seat && view.can.open_ceremony);
1051
+ return box("magenta", _jsxs(_Fragment, { children: [!view && !top.error ? _jsx(Text, { color: "gray", children: "Entering the circle\u2026" }) : null, top.error ? _jsx(Text, { color: "red", wrap: "wrap", children: cleanLine(top.error) }) : null, view ? (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["\u25CB ", view.circle.name, " ", _jsxs(Text, { color: "gray", children: ["\u00B7 ", cleanLine(view.circle.direction ?? "no direction"), " \u00B7 ", view.circle.active ? "active" : "inactive"] })] }), intentLines ? (_jsx(Box, { height: intentLines, overflow: "hidden", flexShrink: 0, children: _jsx(Text, { wrap: "wrap", children: view.circle.intention }) })) : null, _jsx(Text, { color: "gray", wrap: "truncate-end", children: [view.seat ? `you: ${view.seat}` : "you are not in this circle", people ? `with ${people}` : "", view.circle.episode ? `for ${episodeLabel(view.circle.episode)}` : ""].filter(Boolean).join(" · ") }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: "\u2500\u2500 ceremonies \u2500\u2500" }), held.length === 0 ? _jsx(Text, { color: "gray", children: "None yet." }) : null, heldWindow.items.map((h, index) => {
1052
+ const isSelected = heldWindow.start + index === clamp(top.selected, held.length);
1053
+ const c = h.ceremony;
1054
+ const where = c.episode_path ? episodeLabel(c.episode_path) : "";
1055
+ return (_jsxs(Text, { wrap: "truncate-end", inverse: isSelected, children: [_jsxs(Text, { color: h.open ? "green" : "gray", children: ["\u25C6 ", h.open ? "open " : "closed"] }), " ", ceremonyHeadline(c), _jsxs(Text, { color: "gray", children: [" \u00B7 ", [typeLabel(c.type), c.direction, where, whenOf(c.timestamp)].filter(Boolean).map((part) => cleanLine(String(part))).join(" · ")] })] }, c.id));
1056
+ }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u2191\u2193 \u00B7 \u23CE enter", canOpen ? " · n open a ceremony" : "", " \u00B7 o pages \u00B7 Esc"] })] })) : (_jsx(Text, { color: "gray", children: "Esc" }))] }));
1057
+ }
1058
+ if (top.type === "ceremony") {
1059
+ const view = top.view;
1060
+ if (!view) {
1061
+ return box("magenta", _jsxs(_Fragment, { children: [top.error ? _jsx(Text, { color: "red", wrap: "wrap", children: cleanLine(top.error) }) : _jsx(Text, { color: "gray", children: "Entering the ceremony\u2026" }), _jsx(Text, { color: "gray", children: "Esc" })] }));
1062
+ }
1063
+ const layout = ceremonyLayout(view, top.tab);
1064
+ const count = top.tab === "turns" ? view.turns.length : view.diary.length;
1065
+ const selected = top.selected < 0 ? count - 1 : clamp(top.selected, count);
1066
+ const scroll = top.selected < 0 ? Math.max(0, layout.screen.lines.length - layout.room) : Math.min(top.scroll, Math.max(0, layout.screen.lines.length - layout.room));
1067
+ const where = [
1068
+ view.episode ? `↗ ${episodeLabel(view.episode.path)}` : "",
1069
+ view.circle ? `○ ${view.circle.name}` : "held in no circle",
1070
+ view.subject ? `about ${view.subject.name}` : "",
1071
+ `opened ${whenOf(view.timestamp)}`,
1072
+ ].filter(Boolean);
1073
+ const keys = [
1074
+ ["Tab", top.tab === "turns" ? "diary" : "turns"],
1075
+ ...(view.can.speak ? [["s", "speak"]] : []),
1076
+ ...(top.tab === "turns" && view.turns[selected] && mayWitness(view, view.turns[selected]) ? [["w", "witness"]] : []),
1077
+ ...(view.can.diary ? [["d", "diary"]] : []),
1078
+ ...(view.can.close ? [["x", "close"]] : []),
1079
+ ...(view.episode ? [["e", "episode"]] : []),
1080
+ ["o", "pages"],
1081
+ ["Esc", ""],
1082
+ ];
1083
+ const toneColor = { mark: "yellow", name: undefined, meta: "gray", text: undefined, title: "cyan", witness: "green" };
1084
+ return box("magenta", _jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["\u25C6 ", typeLabel(view.type), " ", _jsxs(Text, { color: "gray", children: ["\u00B7 ", cleanLine(view.direction ?? "no direction"), " \u00B7 "] }), _jsx(Text, { color: view.closed ? "gray" : "green", children: view.closed ? "closed" : "open" })] }), layout.intentLines ? (_jsx(Box, { height: layout.intentLines, overflow: "hidden", flexShrink: 0, children: _jsx(Text, { wrap: "wrap", children: layout.intention }) })) : null, _jsx(Text, { color: "gray", wrap: "truncate-end", children: where.map(cleanLine).join(" · ") }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: top.tab === "turns" ? "yellow" : "gray", children: "Turns" }), _jsx(Text, { color: "gray", children: " | " }), _jsx(Text, { color: top.tab === "diary" ? "yellow" : "gray", children: "Diary" }), _jsxs(Text, { color: "gray", children: [view.me ? ` · you: ${view.me.facilitator ? "facilitator" : view.me.member ? "member" : "reader"}` : "", top.error ? ` · ${cleanLine(top.error)}` : ""] })] }), _jsx(Box, { flexDirection: "column", height: layout.room, overflow: "hidden", flexShrink: 0, children: layout.screen.lines.slice(scroll, scroll + layout.room).map((line, index) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: "cyan", children: line.item !== undefined && line.item === selected ? "▌" : " " }), line.parts.map((part, at) => (_jsx(Text, { color: toneColor[part.tone], bold: part.tone === "name", children: part.text }, at)))] }, scroll + index))) }), _jsx(Text, { wrap: "truncate-end", children: keys.map(([k, label]) => (_jsxs(Text, { children: [_jsx(Text, { color: ["s", "w", "d", "x"].includes(k) ? "red" : "yellow", children: k }), label && (!compact || ["Tab", "s", "w", "d", "x"].includes(k)) ? ` ${label}` : "", compact ? " " : " "] }, k))) })] }));
695
1085
  }
696
- return box("yellow", _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Keys" }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: "/" }), " filter \u00B7 ", _jsx(Text, { color: "yellow", children: "Tab" }), " title or full text (in a file: next reference)"] }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: "!" }), " the chronicle's attention board"] }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193 j k" }), " move \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp PgDn g G" }), " jump"] }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: "Enter l \u2192" }), " open \u00B7 ", _jsx(Text, { color: "yellow", children: "Esc h \u2190" }), " back \u00B7 ", _jsx(Text, { color: "yellow", children: "q" }), " quit"] }), actions.map((action) => (_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: action.key }), " ", action.description ?? action.label, action.effect === "write" ? _jsx(Text, { color: "red", children: " (writes)" }) : null] }, action.id))), _jsx(Text, { color: "gray", children: "Any key closes." })] }));
1086
+ return box("yellow", _jsxs(_Fragment, { children: [_jsx(Text, { bold: true, children: "Keys" }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: "/" }), " filter \u00B7 ", _jsx(Text, { color: "yellow", children: "Tab" }), " title or full text (in a file: next reference)"] }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: "!" }), " the chronicle's attention board"] }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: "\u2191\u2193 j k" }), " move \u00B7 ", _jsx(Text, { color: "yellow", children: "PgUp PgDn g G" }), " jump"] }), _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: "Enter l \u2192" }), " open \u00B7 ", _jsx(Text, { color: "yellow", children: "Esc h \u2190" }), " back \u00B7 ", _jsx(Text, { color: "yellow", children: "q" }), " quit"] }), actions.map((action) => (_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: action.key }), " ", action.description ?? action.label, action.effect === "write" ? _jsx(Text, { color: "red", children: " (writes)" }) : null] }, action.id))), _jsxs(Text, { wrap: "wrap", children: ["In a circle: ", _jsx(Text, { color: "yellow", children: "\u23CE" }), " enters a ceremony \u00B7 ", _jsx(Text, { color: "yellow", children: "n" }), " opens one. In a ceremony:", " ", _jsx(Text, { color: "yellow", children: "Tab" }), " turns or diary \u00B7 ", _jsx(Text, { color: "red", children: "s" }), " speak \u00B7 ", _jsx(Text, { color: "red", children: "w" }), " witness \u00B7", " ", _jsx(Text, { color: "red", children: "d" }), " diary \u00B7 ", _jsx(Text, { color: "red", children: "x" }), " close \u00B7 ", _jsx(Text, { color: "yellow", children: "e" }), " its episode \u00B7", " ", _jsx(Text, { color: "yellow", children: "o" }), " its pages. Turns are read again every 10 s."] }), _jsx(Text, { color: "gray", children: "Any key closes." })] }));
697
1087
  })();
698
1088
  const where = (() => {
699
1089
  if (s.view === "file" && s.open) {
@@ -702,7 +1092,7 @@ export function App(props) {
702
1092
  // The line number before the path, so a narrow screen keeps it (UI review, F24).
703
1093
  return `${number} · ${line} · ${middle(cleanLine(s.open.path), Math.max(12, columns - 30))}`;
704
1094
  }
705
- const count = `${d.visible.length} episodes`;
1095
+ const count = `${d.visible.length} episode${d.visible.length === 1 ? "" : "s"}`;
706
1096
  const waiting = d.openCount ? ` · ! ${d.openCount}` : "";
707
1097
  return compact ? `${count}${waiting}` : `${cleanLine(s.chronicle.root)} · ${count}${waiting}`;
708
1098
  })();
@@ -743,6 +1133,6 @@ export function App(props) {
743
1133
  ? "…"
744
1134
  : "read-only"
745
1135
  : "";
746
- return (_jsxs(Box, { flexDirection: "column", height: rows, width: columns, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "yellow", children: "miadi-chronicle" }), " ", _jsx(Text, { color: "gray", children: where }), readerLabel ? _jsx(Text, { color: s.reader ? "green" : "gray", children: ` · ${readerLabel}` }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: s.inputMode ? "yellow" : "gray", children: "/ " }), cleanLine(s.query), s.inputMode ? _jsx(Text, { inverse: true, children: " " }) : null, " ", s.view === "attention" ? (_jsx(Text, { color: "gray", children: "attention" })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: s.searchMode === "title" ? "yellow" : "gray", children: "title" }), _jsx(Text, { color: "gray", children: compact ? "|" : " | " }), _jsx(Text, { color: s.searchMode === "full" ? "yellow" : "gray", children: compact ? "full" : "full text" })] })), s.view === "list" ? _jsxs(Text, { color: "gray", children: [" \u00B7 ", d.filtered.length] }) : null, s.view === "attention" ? _jsxs(Text, { color: "gray", children: [" \u00B7 ", d.board.length] }) : null] }), _jsx(Box, { flexDirection: "row", height: bodyHeight, overflow: "hidden", children: body }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: s.status }), _jsxs(Text, { wrap: "truncate-end", children: [available.map((action) => (_jsxs(Text, { children: [_jsx(Text, { color: action.effect === "write" ? "red" : "yellow", children: action.key === "Enter" ? "⏎" : action.key }), " ", action.label, " "] }, action.id))), _jsx(Text, { color: "gray", children: hints })] })] }));
1136
+ return (_jsxs(Box, { flexDirection: "column", height: rows, width: columns, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "yellow", children: "miadi-chronicle" }), " ", _jsx(Text, { color: "gray", children: where }), readerLabel ? _jsx(Text, { color: s.reader ? "green" : "gray", children: ` · ${readerLabel}` }) : null] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: s.inputMode ? "yellow" : "gray", children: "/ " }), cleanLine(s.query), s.inputMode ? _jsx(Text, { inverse: true, children: " " }) : null, " ", s.view === "attention" ? (_jsx(Text, { color: "gray", children: "attention" })) : (_jsxs(_Fragment, { children: [_jsx(Text, { color: s.searchMode === "title" ? "yellow" : "gray", children: "title" }), _jsx(Text, { color: "gray", children: compact ? "|" : " | " }), _jsx(Text, { color: s.searchMode === "full" ? "yellow" : "gray", children: compact ? "full" : "full text" })] })), s.view === "list" ? _jsxs(Text, { color: "gray", children: [" \u00B7 ", d.filtered.length] }) : null, s.relateFrom ? _jsxs(Text, { color: "red", children: [" \u00B7 relating ", episodeLabel(s.relateFrom), " \u2192 L"] }) : null, s.view === "attention" ? _jsxs(Text, { color: "gray", children: [" \u00B7 ", d.board.length] }) : null] }), _jsx(Box, { flexDirection: "row", height: bodyHeight, overflow: "hidden", children: body }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: s.status }), _jsxs(Text, { wrap: "truncate-end", children: [(s.overlays.length ? [] : available).map((action) => (_jsxs(Text, { children: [_jsx(Text, { color: action.effect === "write" ? "red" : "yellow", children: action.key === "Enter" ? "⏎" : action.key }), " ", action.label, " "] }, action.id))), _jsx(Text, { color: "gray", children: hints })] })] }));
747
1137
  }
748
1138
  //# sourceMappingURL=App.js.map