@miadi/chronicle-tui 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # @miadi/chronicle-tui
2
2
 
3
- Browse the Miadi Chronicle from a terminal. `miadi-chronicle` lists every
4
- episode, filters them as you type, searches their text, reads a vessel's
5
- files, and opens the room, the wheel or the vessel.
3
+ Browse the Miadi Chronicle from a terminal, and take part in its talking
4
+ circles. `miadi-chronicle` lists every episode, filters them as you type,
5
+ searches their text, reads a vessel's files, and opens the room, the wheel or
6
+ the vessel. With your person token it enters the circles you sit in: you read
7
+ the turns and the diary, speak, witness, write in the diary and close a
8
+ ceremony, as yourself.
6
9
 
7
10
  ```bash
8
11
  npm i -g @miadi/chronicle-tui
@@ -22,13 +25,14 @@ On Termux, install with `--ignore-scripts`. Node 22 or later.
22
25
  | Tab | title filter or full-text search. In an open file: the next reference (Shift+Tab: the previous one) |
23
26
  | ↑ ↓ `j` `k`, PgUp PgDn, `g` `G` | move |
24
27
  | Enter, `l`, → | open: an episode's files, a file, a search hit |
25
- | Enter on a reference | a chronicle reference opens that episode. A ceremony or circle shows its doors |
28
+ | Enter on a reference | a chronicle reference opens that episode. A ceremony or circle opens as your seat allows (else its pages) |
26
29
  | Esc, `h`, ← | back |
27
30
  | `o` | the doors of the reference, file or episode in focus: room (local, tailnet, public), wheel, vessel. Enter opens one |
28
31
  | `r` | the phone door as a QR code (`i` inverts it for a light terminal) |
29
32
  | `p` | the five-stage proof (git and the wheel, on demand) |
30
33
  | `a` | the episode's attention items, Open and History (Tab), each with what it cites. Enter reads one; Enter again answers an open one |
31
34
  | `!` | the chronicle's attention board: every open item, newest asked first, filtered by `/`. `e` on an item opens its episode |
35
+ | `c` | talking circles: the ceremonies held in the episode in focus, the circles gathered for it, yours, the others |
32
36
  | `v` | hold a Miadi review in the episode (writer token) |
33
37
  | `?` | all keys · `q` quits |
34
38
 
@@ -71,10 +75,9 @@ miadi-chronicle --url https://miadi.tail3b11eb.ts.net
71
75
  visitor sees no `community` episode and nothing under `captures/` or
72
76
  `ceremonies/`. A reader the app names sees community episodes (◇); a reader
73
77
  the app lets write sees the room's private folders too.
74
- - **A ceremony or circle reference** opens as a card for a person the app
75
- seats (`GET /api/ceremony/{id}`, `GET /api/circles/{id}`): its kind,
76
- direction, intention, episode or facilitator, and what your seat may do.
77
- Never its turns. Otherwise it shows its pages, and never the wheel's record.
78
+ - **A ceremony or circle reference** opens as the ceremony or circle itself
79
+ for a person the app seats, as its page would. Otherwise it shows its pages,
80
+ and never the wheel's record.
78
81
  - **Attention** works as in the web room, through `@miadi/attention-core`: the
79
82
  board (`!`) and each episode's panel (`a`) read the vessels exactly as the
80
83
  room does; an answer is checked the same way and posted to
@@ -85,6 +88,39 @@ miadi-chronicle --url https://miadi.tail3b11eb.ts.net
85
88
  Write actions declare `effect: "write"` and are offered only when the app said
86
89
  it would record them.
87
90
 
91
+ ## Talking circles
92
+
93
+ `c` opens the circles, from the episode in focus: the ceremonies held in it
94
+ that you may read, the circles gathered for it, the circles you sit in, then
95
+ the others. It is the web's `/circles` and `/ceremony/{id}` pages, through
96
+ their routes:
97
+
98
+ | where | key | does | route |
99
+ |---|---|---|---|
100
+ | circles | Enter | enter a ceremony, or a circle | `GET /api/circles`, `GET /api/chronicle/episodes/{ref}/wheel` |
101
+ | a circle | Enter, `n` | enter a ceremony; open one (kind, direction, and the episode it binds to) | `GET /api/circles/{id}`, `POST /api/circles/{id}/ceremonies` |
102
+ | a ceremony | Tab | its turns, or its diary | `GET /api/ceremony/{id}`, read again every 10 s |
103
+ | a ceremony | `s` | speak a turn | `POST /api/ceremony/{id}/turns` |
104
+ | a ceremony | `w` | witness the selected turn (not your own, once) | `POST …/turns/{turn}/witness` |
105
+ | a ceremony | `d` | write in the diary, in a phase and a kind | `POST /api/ceremony/{id}/diary` |
106
+ | a ceremony | `x` | close it, with what the circle learned | `POST /api/ceremony/{id}/close` |
107
+ | a ceremony | `e`, `o` | its episode; its pages | |
108
+
109
+ - **Each act is yours.** A turn, a witness, a diary entry, a close and a new
110
+ ceremony go with `MIADI_PERSON_TOKEN` as the only bearer: those routes take
111
+ the act from whoever the bearer is and refuse the writer token. Without a
112
+ person token, `c` says what to set and sends nothing.
113
+ - **Offered as the page offers it.** Each key appears only when the app's
114
+ `can` says the route would accept it from you now: a seat in the circle and
115
+ the role's permission, an open ceremony, a facilitator to close.
116
+ - **A turn is a person's words.** It shows inside its ceremony to a reader the
117
+ app lets read that ceremony, never in a list (jgwill/Miadi#680, D3). Its
118
+ text passes the same sanitizer as every file.
119
+ - **Typing a turn.** Enter sends, Esc cancels. A paste arrives whole, line
120
+ breaks kept. ↑↓ change a choice (a diary phase, a direction) and Tab moves
121
+ between choices. Learnings for a close go one per line, or separated by
122
+ ` | `.
123
+
88
124
  ## Safe to point at any chronicle
89
125
 
90
126
  Chronicle files are written by many agents and people. Every string the
@@ -106,10 +142,11 @@ The browser holds no chronicle rules of its own:
106
142
  | references in a file: `miadi-chronicle:`, `miadi-ceremony:`, `miadi-circle:`, `{{ … }}` | `@miadi/inquiry-weave` (`findTextReferences`) |
107
143
  | the doors, as OSC 8 links | `@miadi/inquiry-weave` (`resolveForTerminal`, `resolveWheelRefForTerminal`) |
108
144
  | the five-stage proof | `@miadi/inquiry-weave` (`episodeDoorStatus`) |
145
+ | circles, ceremonies, turns and the diary | the Miadi app's circle routes, as the person; closings folded into what they close as the web folds them (`lib/ceremony-view.ts`) |
109
146
 
110
- A ceremony or circle shows its addresses only. Ceremonies are private until
111
- their page sees the reader's seat, so nothing the wheel holds about one is
112
- read here (jgwill/Miadi#680).
147
+ A ceremony or circle is read only through the app, as the person whose seat
148
+ it is. Ceremonies are private until their page sees the reader's seat, so
149
+ nothing is read from the wheel directly (jgwill/Miadi#680).
113
150
 
114
151
  Readings are guesses from keywords in an episode's name and title, and an
115
152
  episode with no keyword reads by its number's turn of the wheel. The detail
@@ -140,6 +177,8 @@ render(<App root={process.env.MIADI_CHRONICLE_ROOT!} actions={[mine]} />)
140
177
 
141
178
  An action that changes anything declares `effect: "write"` and calls the
142
179
  existing door for it (the episode API, inquiry-weave, the ceremony routes),
143
- never the files directly. Version 0.1 registers reading actions only.
180
+ never the files directly. A person's acts inside a ceremony (speak, witness,
181
+ the diary, close) are keys of its overlay, offered where the app's `can`
182
+ allows them.
144
183
 
145
184
  Ref: jgwill/Miadi#706
package/dist/App.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../src/App.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAiD,MAAM,OAAO,CAAA;AAGrE,OAAO,EAIL,KAAK,eAAe,EAGrB,MAAM,cAAc,CAAA;AA0CrB,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,oCAAoC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,gDAAgD;IAChD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,SAAS,eAAe,EAAE,CAAA;IACpC,kFAAkF;IAClF,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,sEAAsE;IACtE,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAoHD,wBAAgB,GAAG,CAAC,KAAK,EAAE,QAAQ,qBA+9BlC"}
1
+ {"version":3,"file":"App.d.ts","sourceRoot":"","sources":["../src/App.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAiD,MAAM,OAAO,CAAA;AAIrE,OAAO,EAML,KAAK,eAAe,EAIrB,MAAM,cAAc,CAAA;AAmErB,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,oCAAoC;IACpC,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,4DAA4D;IAC5D,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,gDAAgD;IAChD,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,2CAA2C;IAC3C,OAAO,CAAC,EAAE,SAAS,eAAe,EAAE,CAAA;IACpC,kFAAkF;IAClF,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,sEAAsE;IACtE,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAoHD,wBAAgB,GAAG,CAAC,KAAK,EAAE,QAAQ,qBAi6ClC"}
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" };
@@ -204,8 +206,18 @@ export function App(props) {
204
206
  const mayAnswer = await api.mayAnswer();
205
207
  if (!live)
206
208
  return;
209
+ // Signing in can show more episodes (community ones), so the selection is kept by name, not by row.
210
+ const before = derive();
211
+ const keep = before.s.view === "attention" ? undefined : before.currentRow?.name;
212
+ const keepItem = before.boardItem;
207
213
  details.current.clear();
208
214
  set({ reader, mayAnswer, access: { signedIn: Boolean(reader), mayWrite: mayAnswer } });
215
+ const after = derive();
216
+ const listSel = keep ? after.filtered.findIndex((row) => row.name === keep) : -1;
217
+ const boardSel = keepItem ? after.board.findIndex((item) => item.episode === keepItem.episode && item.id === keepItem.id) : -1;
218
+ if (listSel !== -1 || boardSel !== -1) {
219
+ set({ ...(listSel !== -1 ? { listSel } : {}), ...(boardSel !== -1 ? { boardSel } : {}), status: ui.current.status });
220
+ }
209
221
  resolvePending();
210
222
  }
211
223
  catch (error) {
@@ -419,7 +431,169 @@ export function App(props) {
419
431
  set({ overlays: stack });
420
432
  },
421
433
  });
434
+ // ── talking circles: the overlays, and the person's acts in them ─────────
435
+ const host = { open: openOverlay, replace: replaceOverlay };
436
+ const topHandle = () => ui.current.overlays[ui.current.overlays.length - 1]?.id ?? 0;
437
+ /** Change overlay `handle` wherever it sits in the stack; a closed one is left alone. */
438
+ const updateById = (handle, change) => {
439
+ const stack = ui.current.overlays;
440
+ if (!stack.some((entry) => entry.id === handle))
441
+ return;
442
+ set({ overlays: stack.map((entry) => (entry.id === handle ? { id: handle, overlay: change(entry.overlay) } : entry)) });
443
+ };
444
+ const episodeLabel = (path) => {
445
+ if (!path)
446
+ return "";
447
+ const row = derive().rowByName(path);
448
+ return row?.number !== undefined ? `ep ${row.number}` : path;
449
+ };
450
+ /**
451
+ * How a ceremony fills the overlay: inside the border, a title, its
452
+ * intention, a line of where it is held, the tabs, the lines, the keys.
453
+ * Shared by the keys and the screen, so a scroll is measured as drawn.
454
+ */
455
+ const ceremonyLayout = (view, tab) => {
456
+ const inner = Math.max(10, columns - 4);
457
+ const intention = view.intentions.join(" · ");
458
+ const intentLines = intention ? Math.min(compact ? 2 : 3, wrapSegments([{ text: intention, style: "text" }], inner).length) : 0;
459
+ const room = Math.max(1, bodyHeight - 2 - (4 + intentLines));
460
+ return { inner, intention, intentLines, room, screen: ceremonyLines(view, tab, inner - 1) };
461
+ };
462
+ const refreshing = useRef(false);
463
+ /** Read the ceremony again. A reader on the newest turn stays on the newest, so new turns come into view. */
464
+ const reloadCeremony = async (handle) => {
465
+ const entry = ui.current.overlays.find((candidate) => candidate.id === handle);
466
+ if (!entry || entry.overlay.type !== "ceremony" || !ui.current.api || refreshing.current)
467
+ return;
468
+ refreshing.current = true;
469
+ try {
470
+ const view = await readCeremony(ui.current.api, entry.overlay.id);
471
+ updateById(handle, (o) => {
472
+ if (o.type !== "ceremony")
473
+ return o;
474
+ const count = o.tab === "turns" ? (o.view?.turns.length ?? 0) : (o.view?.diary.length ?? 0);
475
+ const following = o.selected < 0 || (o.tab === "turns" && o.selected >= count - 1);
476
+ return { ...o, view, error: undefined, selected: following && o.tab === "turns" ? -1 : o.selected };
477
+ });
478
+ }
479
+ catch (error) {
480
+ updateById(handle, (o) => (o.type === "ceremony" ? { ...o, error: cleanLine(error.message) } : o));
481
+ }
482
+ finally {
483
+ refreshing.current = false;
484
+ }
485
+ };
486
+ const reloadCircle = async (handle) => {
487
+ const entry = ui.current.overlays.find((candidate) => candidate.id === handle);
488
+ if (!entry || entry.overlay.type !== "circle" || !ui.current.api)
489
+ return;
490
+ const view = await readCircle(ui.current.api, entry.overlay.id);
491
+ updateById(handle, (o) => (o.type === "circle" ? { ...o, view, error: undefined } : o));
492
+ };
493
+ // A talking circle is live: the ceremony on top is read again every ten seconds.
494
+ useEffect(() => {
495
+ const timer = setInterval(() => {
496
+ const top = ui.current.overlays[ui.current.overlays.length - 1];
497
+ if (top?.overlay.type === "ceremony" && top.overlay.view)
498
+ void reloadCeremony(top.id);
499
+ }, 10_000);
500
+ return () => clearInterval(timer);
501
+ }, []);
502
+ const speakInput = (handle, view) => ({
503
+ type: "input",
504
+ title: `Your turn · ${typeLabel(view.type)}${view.circle ? ` · ${view.circle.name}` : ""}`,
505
+ hint: "Speak. The talking piece is yours. A paste keeps its lines.",
506
+ value: "",
507
+ submit: async (value) => {
508
+ await speak(ui.current.api, view.id, value.trim());
509
+ return "Spoken in the circle.";
510
+ },
511
+ after: () => reloadCeremony(handle),
512
+ });
513
+ const diaryInput = (handle, view) => ({
514
+ type: "input",
515
+ title: `Diary · ${typeLabel(view.type)}`,
516
+ hint: "In your own voice. One entry is one voice, in one phase.",
517
+ value: "",
518
+ choices: [
519
+ { label: "phase", values: DIARY_PHASES, shown: DIARY_PHASES.map((phase) => PHASE_LABELS[phase] ?? phase), index: 1 },
520
+ { label: "kind", values: ENTRY_TYPES, index: ENTRY_TYPES.indexOf("reflection") },
521
+ ],
522
+ submit: async (value, [phase, entryType]) => {
523
+ await writeDiary(ui.current.api, view.id, { phase: phase, entryType: entryType, content: value.trim() });
524
+ return `Written in the diary (${phase}, ${entryType}).`;
525
+ },
526
+ after: () => reloadCeremony(handle),
527
+ });
528
+ const closeInput = (handle, view) => ({
529
+ type: "input",
530
+ title: `Close the circle · ${typeLabel(view.type)}${view.circle ? ` · ${view.circle.name}` : ""}`,
531
+ 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.",
532
+ value: "",
533
+ allowEmpty: true,
534
+ submit: async (value) => {
535
+ const result = await closeCeremony(ui.current.api, view.id, splitLearnings(value));
536
+ return result.already ? "It was already closed." : "The circle is closed.";
537
+ },
538
+ after: () => reloadCeremony(handle),
539
+ });
540
+ const openInput = (handle, circle, focus) => {
541
+ const episodes = [...new Set([focus, circle.episode].filter((path) => Boolean(path))), ""];
542
+ const directions = DIRECTIONS;
543
+ let opened = "";
544
+ return {
545
+ type: "input",
546
+ title: `Open a ceremony · ${circle.name}`,
547
+ hint: "Its intention, in your words. The circle's members are its participants.",
548
+ value: "",
549
+ choices: [
550
+ { label: "kind", values: CEREMONY_TYPES, shown: CEREMONY_TYPES.map(typeLabel), index: 0 },
551
+ { label: "direction", values: DIRECTIONS, index: Math.max(0, directions.indexOf(circle.direction ?? "east")) },
552
+ { label: "episode", values: episodes, shown: episodes.map((path) => (path ? episodeLabel(path) : "none")), index: 0 },
553
+ ],
554
+ submit: async (value, [type, direction, episode]) => {
555
+ const result = await openCeremony(ui.current.api, circle.id, { type: type, direction: direction, intention: value.trim(), ...(episode ? { episode_path: episode } : {}) });
556
+ opened = result.ceremony.id;
557
+ return `Opened a ${typeLabel(type)} in ${circle.name}${episode ? `, bound to ${episodeLabel(episode)}` : ""}.`;
558
+ },
559
+ after: async () => {
560
+ await reloadCircle(handle);
561
+ if (opened)
562
+ enterCeremony(host, ui.current.api, opened);
563
+ },
564
+ };
565
+ };
566
+ const witnessTurn = (handle, view, index) => {
567
+ const turn = view.turns[index];
568
+ if (!turn)
569
+ return;
570
+ if (!mayWitness(view, turn)) {
571
+ const why = !view.can.witness
572
+ ? view.closed
573
+ ? "The ceremony is closed."
574
+ : "Your seat does not witness here."
575
+ : turn.speaker === view.me?.id
576
+ ? "You spoke this turn; another witnesses it."
577
+ : "You witnessed this turn.";
578
+ return set({ status: why });
579
+ }
580
+ set({ status: `Witnessing ${turn.speakerName}'s turn…` });
581
+ witness(ui.current.api, view.id, turn.id).then(async () => {
582
+ set({ status: `Witnessed ${turn.speakerName}'s turn.` });
583
+ await reloadCeremony(handle);
584
+ }, (error) => set({ status: cleanLine(error.message) }));
585
+ };
422
586
  // ── keys ─────────────────────────────────────────────────────────────────
587
+ // A paste into an input arrives whole, line breaks kept (bracketed paste).
588
+ const pasteActive = ui.current.overlays[ui.current.overlays.length - 1]?.overlay.type === "input";
589
+ usePaste((text) => {
590
+ const top = ui.current.overlays[ui.current.overlays.length - 1]?.overlay;
591
+ if (top?.type !== "input" || top.busy)
592
+ return;
593
+ const added = text.replace(/\r\n?/g, "\n").replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
594
+ if (added)
595
+ updateTop({ ...top, value: top.value + added, error: undefined });
596
+ }, { isActive: pasteActive });
423
597
  useInput((input, key) => {
424
598
  if (key.ctrl || key.meta)
425
599
  return;
@@ -453,12 +627,25 @@ export function App(props) {
453
627
  return;
454
628
  if (key.escape)
455
629
  return closeTop();
630
+ const choices = top.choices ?? [];
631
+ const focus = clamp(top.focus ?? 0, choices.length);
632
+ if (choices.length && (key.upArrow || key.downArrow)) {
633
+ const choice = choices[focus];
634
+ const count = choice.values.length;
635
+ const index = (choice.index + (key.downArrow ? 1 : count - 1)) % count;
636
+ return updateTop({ ...top, choices: choices.map((c, i) => (i === focus ? { ...c, index } : c)) });
637
+ }
638
+ if (key.tab) {
639
+ if (choices.length > 1)
640
+ updateTop({ ...top, focus: (focus + (key.shift ? choices.length - 1 : 1)) % choices.length });
641
+ return;
642
+ }
456
643
  if (key.return) {
457
644
  const value = top.value;
458
- if (!value.trim())
645
+ if (!value.trim() && !top.allowEmpty)
459
646
  return updateTop({ ...top, error: "Nothing to send yet." });
460
647
  updateTop({ ...top, busy: true, error: undefined });
461
- top.submit(value).then(async (message) => {
648
+ top.submit(value, choices.map((c) => c.values[c.index])).then(async (message) => {
462
649
  set({ status: cleanLine(message), overlays: ui.current.overlays.slice(0, -1) });
463
650
  try {
464
651
  await top.after?.();
@@ -498,8 +685,113 @@ export function App(props) {
498
685
  }
499
686
  if (top.type === "qr" && input === "i")
500
687
  return updateTop({ ...top, invert: !top.invert });
501
- if (top.type === "card" && input === "o")
502
- return void openOverlay({ type: "doors", doors: top.doors, selected: 0 });
688
+ if (top.type === "circles" && top.index) {
689
+ const entries = circleEntries(top.index);
690
+ const step = (from, by) => {
691
+ let at = from;
692
+ for (let i = from + by; i >= 0 && i < entries.length; i += by) {
693
+ if (entries[i].kind !== "heading") {
694
+ at = i;
695
+ break;
696
+ }
697
+ }
698
+ return at;
699
+ };
700
+ if (key.downArrow || input === "j")
701
+ return updateTop({ ...top, selected: step(top.selected, 1) });
702
+ if (key.upArrow || input === "k")
703
+ return updateTop({ ...top, selected: step(top.selected, -1) });
704
+ const entry = entries[top.selected];
705
+ if (entry && (key.return || input === "l" || key.rightArrow) && s.api) {
706
+ if (entry.kind === "ceremony")
707
+ return void enterCeremony(host, s.api, entry.held.ceremony.id);
708
+ if (entry.kind === "circle")
709
+ return void enterCircle(host, s.api, entry.circle.id, top.episode);
710
+ }
711
+ return;
712
+ }
713
+ if (top.type === "circle" && top.view) {
714
+ const handle = topHandle();
715
+ const held = top.view.held;
716
+ if (key.downArrow || input === "j")
717
+ return updateTop({ ...top, selected: clamp(top.selected + 1, held.length) });
718
+ if (key.upArrow || input === "k")
719
+ return updateTop({ ...top, selected: clamp(top.selected - 1, held.length) });
720
+ const chosen = held[clamp(top.selected, held.length)];
721
+ if (chosen && (key.return || input === "l" || key.rightArrow) && s.api)
722
+ return void enterCeremony(host, s.api, chosen.ceremony.id);
723
+ if (input === "n") {
724
+ if (!top.view.seat || !top.view.can.open_ceremony)
725
+ return set({ status: top.view.circle.active ? "Opening a ceremony here needs a seat that facilitates." : "The circle is inactive." });
726
+ return void openOverlay(openInput(handle, top.view.circle, top.episode));
727
+ }
728
+ if (input === "o") {
729
+ wheelDoors("circle", top.id).then((doors) => openOverlay({ type: "doors", doors, selected: 0 }), () => { });
730
+ return;
731
+ }
732
+ return;
733
+ }
734
+ if (top.type === "ceremony" && top.view) {
735
+ const handle = topHandle();
736
+ const view = top.view;
737
+ const layout = ceremonyLayout(view, top.tab);
738
+ const count = top.tab === "turns" ? view.turns.length : view.diary.length;
739
+ const selected = top.selected < 0 ? count - 1 : clamp(top.selected, count);
740
+ const scroll = top.selected < 0 ? Math.max(0, layout.screen.lines.length - layout.room) : top.scroll;
741
+ const select = (index) => {
742
+ const next = clamp(index, count);
743
+ updateTop({ ...top, selected: next, scroll: scrollTo(layout.screen, next, scroll, layout.room) });
744
+ };
745
+ if (key.tab) {
746
+ const tab = top.tab === "turns" ? "diary" : "turns";
747
+ return updateTop({ ...top, tab, selected: tab === "turns" ? -1 : 0, scroll: 0 });
748
+ }
749
+ if (key.downArrow || input === "j")
750
+ return select(selected + 1);
751
+ if (key.upArrow || input === "k")
752
+ return select(selected - 1);
753
+ if (input === "g" || key.home)
754
+ return select(0);
755
+ if (input === "G" || key.end)
756
+ return select(count - 1);
757
+ if (key.pageDown || key.pageUp) {
758
+ const max = Math.max(0, layout.screen.lines.length - layout.room);
759
+ const next = clamp(scroll + (key.pageDown ? layout.room - 1 : 1 - layout.room), max + 1);
760
+ const at = layout.screen.ends.findIndex((end) => end > next);
761
+ return updateTop({ ...top, scroll: next, selected: at === -1 ? Math.max(0, count - 1) : at });
762
+ }
763
+ if (input === "s") {
764
+ if (!view.can.speak)
765
+ return set({ status: view.closed ? "The ceremony is closed." : "Only those seated in the circle may speak." });
766
+ return void openOverlay(speakInput(handle, view));
767
+ }
768
+ if (input === "w") {
769
+ if (top.tab !== "turns")
770
+ return set({ status: "Witnessing is of a turn: Tab to the turns." });
771
+ return witnessTurn(handle, view, selected);
772
+ }
773
+ if (input === "d") {
774
+ if (!view.can.diary)
775
+ return set({ status: "Writing in the diary needs a seat in the circle." });
776
+ return void openOverlay(diaryInput(handle, view));
777
+ }
778
+ if (input === "x") {
779
+ if (!view.can.close)
780
+ return set({ status: view.closed ? "The ceremony is closed." : "Only the facilitator closes the circle." });
781
+ return void openOverlay(closeInput(handle, view));
782
+ }
783
+ if (input === "e" && view.episode) {
784
+ if (!derive().rowByName(view.episode.path))
785
+ return set({ status: `${episodeLabel(view.episode.path)} is not among the episodes you see.` });
786
+ set({ overlays: [] });
787
+ return jumpTo(view.episode.path);
788
+ }
789
+ if (input === "o") {
790
+ wheelDoors("ceremony", view.id).then((doors) => openOverlay({ type: "doors", doors, selected: 0 }), () => { });
791
+ return;
792
+ }
793
+ return;
794
+ }
503
795
  if (top.type === "panel") {
504
796
  const { shown, view } = panelView(top.items, top.view, ROOM_LABELS, top.parseError);
505
797
  if (key.tab)
@@ -673,7 +965,21 @@ export function App(props) {
673
965
  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
966
  }
675
967
  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" })] }));
968
+ const inner = Math.max(10, columns - 4);
969
+ const choices = top.choices ?? [];
970
+ const focus = clamp(top.focus ?? 0, choices.length);
971
+ const hintLines = top.hint ? Math.min(3, wrapSegments([{ text: cleanLine(top.hint), style: "text" }], inner).length) : 0;
972
+ const choiceText = (choice) => cleanLine(choice.shown?.[choice.index] ?? choice.values[choice.index] ?? "");
973
+ // Choices share a line when they fit, else take one each (a phone).
974
+ const stacked = stringWidth(choices.map((choice) => `${choice.label} ${choiceText(choice)}`).join(" ")) > inner;
975
+ const choiceLines = choices.length ? (stacked ? choices.length : 1) : 0;
976
+ // The end of what is typed stays in view, however long a turn grows.
977
+ const room = Math.max(1, bodyHeight - 2 - (1 + hintLines + choiceLines + (top.busy || top.error ? 1 : 0) + 1));
978
+ const typed = clean(top.value)
979
+ .split("\n")
980
+ .flatMap((paragraph) => wrapSegments([{ text: paragraph, style: "text" }], inner - 2).map((line) => line.map((part) => part.text).join("")));
981
+ const shown = typed.slice(-room);
982
+ 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
983
  }
678
984
  if (top.type === "panel") {
679
985
  const view = panelView(top.items, top.view, ROOM_LABELS, top.parseError);
@@ -686,14 +992,67 @@ export function App(props) {
686
992
  if (top.type === "item") {
687
993
  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
994
  }
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" })] }));
995
+ if (top.type === "circles") {
996
+ const entries = top.index ? circleEntries(top.index) : [];
997
+ const listWindow = windowOf(entries, clamp(top.selected, entries.length), Math.max(1, bodyHeight - 4));
998
+ 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) => {
999
+ const at = listWindow.start + index;
1000
+ const isSelected = at === top.selected;
1001
+ if (entry.kind === "heading")
1002
+ return _jsx(Text, { color: "gray", bold: true, wrap: "truncate-end", children: cleanLine(entry.text) }, `h${at}`);
1003
+ if (entry.kind === "ceremony") {
1004
+ const c = entry.held.ceremony;
1005
+ 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));
1006
+ }
1007
+ const circle = entry.circle;
1008
+ 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));
1009
+ }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: "\u2191\u2193 \u00B7 \u23CE enter \u00B7 Esc" })] }));
1010
+ }
1011
+ if (top.type === "circle") {
1012
+ const view = top.view;
1013
+ const inner = Math.max(10, columns - 4);
1014
+ const intentLines = view?.circle.intention ? Math.min(compact ? 2 : 3, wrapSegments([{ text: view.circle.intention, style: "text" }], inner).length) : 0;
1015
+ const rows = Math.max(1, bodyHeight - 2 - (4 + intentLines));
1016
+ const held = view?.held ?? [];
1017
+ const heldWindow = windowOf(held, clamp(top.selected, held.length), rows);
1018
+ const people = view ? view.members.map((m) => (m.role === "member" ? m.name : `${m.name} (${m.role})`)).join(", ") : "";
1019
+ const canOpen = Boolean(view?.seat && view.can.open_ceremony);
1020
+ 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) => {
1021
+ const isSelected = heldWindow.start + index === clamp(top.selected, held.length);
1022
+ const c = h.ceremony;
1023
+ const where = c.episode_path ? episodeLabel(c.episode_path) : "";
1024
+ 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));
1025
+ }), _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" }))] }));
1026
+ }
1027
+ if (top.type === "ceremony") {
1028
+ const view = top.view;
1029
+ if (!view) {
1030
+ 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" })] }));
1031
+ }
1032
+ const layout = ceremonyLayout(view, top.tab);
1033
+ const count = top.tab === "turns" ? view.turns.length : view.diary.length;
1034
+ const selected = top.selected < 0 ? count - 1 : clamp(top.selected, count);
1035
+ 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));
1036
+ const where = [
1037
+ view.episode ? `↗ ${episodeLabel(view.episode.path)}` : "",
1038
+ view.circle ? `○ ${view.circle.name}` : "held in no circle",
1039
+ view.subject ? `about ${view.subject.name}` : "",
1040
+ `opened ${whenOf(view.timestamp)}`,
1041
+ ].filter(Boolean);
1042
+ const keys = [
1043
+ ["Tab", top.tab === "turns" ? "diary" : "turns"],
1044
+ ...(view.can.speak ? [["s", "speak"]] : []),
1045
+ ...(top.tab === "turns" && view.turns[selected] && mayWitness(view, view.turns[selected]) ? [["w", "witness"]] : []),
1046
+ ...(view.can.diary ? [["d", "diary"]] : []),
1047
+ ...(view.can.close ? [["x", "close"]] : []),
1048
+ ...(view.episode ? [["e", "episode"]] : []),
1049
+ ["o", "pages"],
1050
+ ["Esc", ""],
1051
+ ];
1052
+ const toneColor = { mark: "yellow", name: undefined, meta: "gray", text: undefined, title: "cyan", witness: "green" };
1053
+ 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
1054
  }
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." })] }));
1055
+ 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
1056
  })();
698
1057
  const where = (() => {
699
1058
  if (s.view === "file" && s.open) {
@@ -702,7 +1061,7 @@ export function App(props) {
702
1061
  // The line number before the path, so a narrow screen keeps it (UI review, F24).
703
1062
  return `${number} · ${line} · ${middle(cleanLine(s.open.path), Math.max(12, columns - 30))}`;
704
1063
  }
705
- const count = `${d.visible.length} episodes`;
1064
+ const count = `${d.visible.length} episode${d.visible.length === 1 ? "" : "s"}`;
706
1065
  const waiting = d.openCount ? ` · ! ${d.openCount}` : "";
707
1066
  return compact ? `${count}${waiting}` : `${cleanLine(s.chronicle.root)} · ${count}${waiting}`;
708
1067
  })();
@@ -743,6 +1102,6 @@ export function App(props) {
743
1102
  ? "…"
744
1103
  : "read-only"
745
1104
  : "";
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 })] })] }));
1105
+ 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: [(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
1106
  }
748
1107
  //# sourceMappingURL=App.js.map