@miadi/chronicle-tui 0.2.0 → 0.4.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
@@ -1,17 +1,25 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * The terminal browser's screen. It draws the panes and routes keys; every
4
- * read goes through `data.ts`, every operation through the action registry.
4
+ * read goes through `data.ts` and `attention.ts`, every operation through the
5
+ * action registry, every printed string through `text.ts`.
5
6
  *
6
- * Keys are plain letters, arrows, Tab, Enter and Esc. No Ctrl chord is taken,
7
- * so tmux (C-b) and herdr (prefix+…) keep all of theirs.
7
+ * State lives in one ref and is read from there, so keys that arrive together
8
+ * (fast typing, a paste, ssh) each act on the state the previous one left, not
9
+ * on the last render (jgwill/Miadi#706 UI review, F5 and F13).
10
+ *
11
+ * Keys are plain letters, arrows, Tab, Enter and Esc. No Ctrl or Meta chord is
12
+ * taken, so tmux (C-b) and herdr (prefix+…) keep all of theirs.
8
13
  */
9
- import { useEffect, useMemo, useRef, useState } from "react";
10
- import { Box, Text, useApp, useInput, useStdout } from "ink";
11
- import { osc8 } from "@miadi/inquiry-weave";
12
- import { DEFAULT_ACTIONS, actionFor } from "./actions.js";
13
- import { loadChronicle, loadDetail, loadText, openUrl, qrLines, search } from "./data.js";
14
- import { ageOf, clamp, fileView, formatDate, filterRows, screenLineOf, windowOf, } from "./model.js";
14
+ import { useEffect, useMemo, useReducer, useRef } from "react";
15
+ import { Box, Text, useApp, useInput, useWindowSize } from "ink";
16
+ import { MAX_FILES } from "@miadi/episode-vessel";
17
+ import { DEFAULT_ACTIONS, actionFor, } from "./actions.js";
18
+ import { TOKEN_ENV, answerAttention, connectApi } from "./api.js";
19
+ 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 { ageOf, clamp, fileView, filterRows, formatDate, screenLineOf, windowOf, wrapSegments, } from "./model.js";
22
+ import { clean, cleanLine, link } from "./text.js";
15
23
  const DIRECTION_MARK = { east: "E", south: "S", west: "W", north: "N" };
16
24
  const DIRECTION_COLOR = { east: "yellow", south: "red", west: "blue", north: "white" };
17
25
  function Segments({ segments, selectedRef }) {
@@ -29,255 +37,542 @@ function Segments({ segments, selectedRef }) {
29
37
  return _jsx(Text, { children: segment.text }, index);
30
38
  }) }));
31
39
  }
32
- function matchRow(rows, ref) {
33
- const text = ref.trim().toLowerCase().replace(/^ep/, "");
34
- const exact = rows.findIndex((row) => row.name.toLowerCase() === ref.trim().toLowerCase());
40
+ /** Rows an episode argument names: the folder name, a number (all folders with it), or a slug fragment. */
41
+ function matchRows(rows, ref) {
42
+ const wanted = ref.trim().toLowerCase();
43
+ const exact = rows.findIndex((row) => row.name.toLowerCase() === wanted);
35
44
  if (exact !== -1)
36
- return exact;
37
- const number = Number(text);
45
+ return [exact];
46
+ const number = Number(wanted.replace(/^ep/, ""));
38
47
  if (Number.isInteger(number)) {
39
- const byNumber = rows.findIndex((row) => row.number === number);
40
- if (byNumber !== -1)
48
+ const byNumber = rows.flatMap((row, index) => (row.number === number ? [index] : []));
49
+ if (byNumber.length)
41
50
  return byNumber;
42
51
  }
43
- return rows.findIndex((row) => row.name.toLowerCase().includes(text));
52
+ const bySlug = rows.findIndex((row) => row.name.toLowerCase().includes(wanted));
53
+ return bySlug === -1 ? [] : [bySlug];
54
+ }
55
+ /** A path cut in the middle, keeping its file name. */
56
+ function middle(text, width) {
57
+ if (text.length <= width)
58
+ return text;
59
+ const keep = Math.max(4, width - 1);
60
+ const tail = Math.ceil(keep * 0.6);
61
+ return `${text.slice(0, keep - tail)}…${text.slice(text.length - tail)}`;
44
62
  }
45
- export function App({ root, query: initialQuery = "", search: initialSearch, episode, actions: extra = [] }) {
63
+ const PLAIN_KEY = {
64
+ upArrow: false,
65
+ downArrow: false,
66
+ leftArrow: false,
67
+ rightArrow: false,
68
+ pageDown: false,
69
+ pageUp: false,
70
+ home: false,
71
+ end: false,
72
+ return: false,
73
+ escape: false,
74
+ ctrl: false,
75
+ shift: false,
76
+ tab: false,
77
+ backspace: false,
78
+ delete: false,
79
+ meta: false,
80
+ super: false,
81
+ hyper: false,
82
+ capsLock: false,
83
+ numLock: false,
84
+ };
85
+ export function App(props) {
86
+ const { root, actions: extra = [], url, withheld = [] } = props;
46
87
  const { exit } = useApp();
47
- const { stdout } = useStdout();
48
- const columns = stdout?.columns ?? 100;
49
- const rows = stdout?.rows ?? 30;
50
- const [chronicle, setChronicle] = useState(null);
51
- const [loadError, setLoadError] = useState(null);
52
- const [query, setQuery] = useState(initialSearch ?? initialQuery);
53
- const [inputMode, setInputMode] = useState(false);
54
- const [searchMode, setSearchMode] = useState(initialSearch ? "full" : "title");
55
- const [view, setView] = useState(initialSearch ? "hits" : "list");
56
- const [listSel, setListSel] = useState(0);
57
- const [fileSel, setFileSel] = useState(0);
58
- const [hits, setHits] = useState(null);
59
- const [hitSel, setHitSel] = useState(0);
60
- const [searching, setSearching] = useState(false);
61
- const [open, setOpen] = useState(null);
62
- const [overlay, setOverlay] = useState(null);
63
- const [status, setStatus] = useState("");
88
+ const { columns, rows } = useWindowSize();
89
+ const [, render] = useReducer((n) => n + 1, 0);
90
+ const ui = useRef({
91
+ chronicle: null,
92
+ loadError: null,
93
+ query: props.search ?? props.query ?? "",
94
+ inputMode: false,
95
+ searchMode: props.search ? "full" : "title",
96
+ view: props.attention ? "attention" : props.search ? "hits" : "list",
97
+ listSel: 0,
98
+ fileSel: 0,
99
+ hits: null,
100
+ hitSel: 0,
101
+ searching: false,
102
+ open: null,
103
+ overlays: [],
104
+ status: "",
105
+ api: null,
106
+ reader: null,
107
+ access: VISITOR,
108
+ mayAnswer: false,
109
+ board: null,
110
+ boardSel: 0,
111
+ history: [],
112
+ pendingEpisode: props.episode,
113
+ });
64
114
  const details = useRef(new Map());
115
+ const overlaySeq = useRef(0);
65
116
  const searchTurn = useRef(0);
117
+ const searchTimer = useRef(null);
118
+ const searchAbort = useRef(null);
66
119
  const actions = useMemo(() => [...DEFAULT_ACTIONS, ...extra], [extra]);
67
- useEffect(() => {
68
- loadChronicle(root).then((loaded) => {
69
- setChronicle(loaded);
70
- if (episode) {
71
- const index = matchRow(loaded.rows, episode);
72
- if (index === -1)
73
- setStatus(`No episode matches ${episode}.`);
74
- else {
75
- setListSel(index);
76
- setView("files");
77
- }
120
+ /** Change state; a new view or episode clears a stale status line (UI review, F22). */
121
+ const set = (patch) => {
122
+ const before = ui.current;
123
+ const next = { ...before, ...patch };
124
+ if (patch.status === undefined && (next.view !== before.view || next.listSel !== before.listSel || next.open !== before.open))
125
+ next.status = "";
126
+ ui.current = next;
127
+ render();
128
+ };
129
+ // ── derived state, always from the ref ───────────────────────────────────
130
+ const derive = () => {
131
+ const s = ui.current;
132
+ const visible = s.chronicle ? visibleRows(s.chronicle.rows, s.access) : [];
133
+ const visibleNames = new Set(visible.map((row) => row.name));
134
+ const filtered = s.searchMode === "title" ? filterRows(visible, s.query) : visible;
135
+ const board = filterBoard((s.board ?? []).filter((item) => visibleNames.has(item.episode)), s.view === "attention" ? s.query : "");
136
+ const boardItem = board[clamp(s.boardSel, board.length)];
137
+ const rowByName = (name) => visible.find((row) => row.name === name);
138
+ const hit = s.view === "hits" || s.open?.from === "hits" ? s.hits?.hits[s.hitSel] : undefined;
139
+ const currentRow = s.view === "file" && s.open
140
+ ? rowByName(s.open.name)
141
+ : s.view === "hits"
142
+ ? hit && rowByName(hit.episodePath)
143
+ : s.view === "attention"
144
+ ? boardItem && rowByName(boardItem.episode)
145
+ : filtered[clamp(s.listSel, filtered.length)];
146
+ let detail = null;
147
+ if (s.chronicle && currentRow && s.view !== "attention") {
148
+ const key = `${s.access.mayWrite}:${currentRow.name}`;
149
+ detail = details.current.get(key) ?? null;
150
+ if (!detail) {
151
+ detail = loadDetail(s.chronicle.root, currentRow.name, s.access);
152
+ details.current.set(key, detail);
78
153
  }
79
- }, (error) => setLoadError(error.message));
80
- }, [root, episode]);
81
- const filtered = useMemo(() => (chronicle ? (searchMode === "title" ? filterRows(chronicle.rows, query) : chronicle.rows) : []), [chronicle, query, searchMode]);
82
- // Full-text search runs a moment after typing stops, and only the latest answer is kept.
83
- useEffect(() => {
84
- if (!chronicle || searchMode !== "full")
85
- return;
86
- const text = query.trim();
87
- if (text.length < 2)
154
+ }
155
+ const files = detail?.files ?? [];
156
+ const openCount = (s.board ?? []).filter((item) => visibleNames.has(item.episode)).length;
157
+ return { s, visible, filtered, board, boardItem, rowByName, hit, currentRow, detail, files, openCount };
158
+ };
159
+ const compact = columns < 90;
160
+ const bodyHeight = Math.max(3, rows - 4);
161
+ // ── loading: the chronicle, the board, the app ───────────────────────────
162
+ const resolvePending = () => {
163
+ const { s, filtered } = derive();
164
+ if (!s.pendingEpisode || !s.chronicle)
88
165
  return;
166
+ const matches = matchRows(filtered, s.pendingEpisode);
167
+ if (matches.length === 0) {
168
+ // Not under the filter: look without it (UI review, F12).
169
+ const all = visibleRows(s.chronicle.rows, s.access);
170
+ const unfiltered = matchRows(all, s.pendingEpisode);
171
+ if (unfiltered.length === 0) {
172
+ if (s.api === null || s.access.signedIn)
173
+ set({ status: `No episode you can see matches ${cleanLine(s.pendingEpisode)}.`, pendingEpisode: undefined });
174
+ return;
175
+ }
176
+ set({ query: "", searchMode: "title" });
177
+ return resolvePending();
178
+ }
179
+ const names = matches.map((index) => filtered[index].number ?? filtered[index].name);
180
+ set({
181
+ listSel: matches[0],
182
+ fileSel: 0,
183
+ view: "files",
184
+ pendingEpisode: undefined,
185
+ status: matches.length > 1 ? `${cleanLine(s.pendingEpisode)} names ${matches.length} folders; showing the first of ${names.join(", ")}.` : "",
186
+ });
187
+ };
188
+ useEffect(() => {
189
+ loadChronicle(root).then((chronicle) => {
190
+ set({ chronicle });
191
+ resolvePending();
192
+ loadBoard(chronicle.root).then((board) => set({ board }), () => set({ board: [] }));
193
+ }, (error) => set({ loadError: error.message }));
194
+ let live = true;
195
+ connectApi(url)
196
+ .then(async ({ api, warning }) => {
197
+ if (!live)
198
+ return;
199
+ set({ api, status: warning ? cleanLine(warning) : ui.current.status });
200
+ if (!api?.signed)
201
+ return;
202
+ try {
203
+ const reader = await api.me();
204
+ const mayAnswer = await api.mayAnswer();
205
+ if (!live)
206
+ return;
207
+ details.current.clear();
208
+ set({ reader, mayAnswer, access: { signedIn: Boolean(reader), mayWrite: mayAnswer } });
209
+ resolvePending();
210
+ }
211
+ catch (error) {
212
+ if (live)
213
+ set({ status: cleanLine(`The token was refused by ${api.base}: ${error.message}`) });
214
+ }
215
+ })
216
+ .catch(() => { });
217
+ return () => {
218
+ live = false;
219
+ };
220
+ }, [root, url]);
221
+ // ── full-text search: debounced, cancelled when superseded ───────────────
222
+ const scheduleSearch = () => {
89
223
  const turn = ++searchTurn.current;
90
- const timer = setTimeout(() => {
91
- setSearching(true);
92
- search(chronicle.root, text, chronicle.rows).then((result) => {
93
- if (turn !== searchTurn.current)
224
+ if (searchTimer.current)
225
+ clearTimeout(searchTimer.current);
226
+ searchAbort.current?.abort();
227
+ const s = ui.current;
228
+ if (!s.chronicle || s.searchMode !== "full" || s.query.trim().length < 2) {
229
+ if (s.searching)
230
+ set({ searching: false });
231
+ return;
232
+ }
233
+ searchTimer.current = setTimeout(() => {
234
+ const controller = new AbortController();
235
+ searchAbort.current = controller;
236
+ set({ searching: true });
237
+ const now = ui.current;
238
+ search(now.chronicle.root, now.query.trim(), now.chronicle.rows, now.access, controller.signal).then((result) => {
239
+ if (turn !== searchTurn.current || ui.current.searchMode !== "full")
94
240
  return;
95
- setHits(result);
96
- setHitSel(0);
97
- setSearching(false);
98
- setView((current) => (current === "file" ? current : "hits"));
241
+ const view = ui.current.view === "file" || ui.current.view === "attention" ? ui.current.view : "hits";
242
+ set({ hits: result, hitSel: 0, searching: false, view });
99
243
  }, (error) => {
100
- if (turn !== searchTurn.current)
244
+ if (turn !== searchTurn.current || error.name === "AbortError")
101
245
  return;
102
- setSearching(false);
103
- setStatus(error.message);
246
+ set({ searching: false, status: cleanLine(error.message) });
104
247
  });
105
248
  }, 250);
106
- return () => clearTimeout(timer);
107
- }, [chronicle, query, searchMode]);
108
- const rowByName = (name) => chronicle?.rows.find((row) => row.name === name);
109
- const hit = view === "hits" || open?.from === "hits" ? hits?.hits[hitSel] : undefined;
110
- const currentRow = view === "file" && open
111
- ? rowByName(open.name)
112
- : view === "hits"
113
- ? hit && rowByName(hit.episodePath)
114
- : filtered[clamp(listSel, filtered.length)];
115
- const detailOf = (name) => {
116
- const cached = details.current.get(name);
117
- if (cached)
118
- return cached;
119
- const loaded = loadDetail(chronicle.root, name);
120
- details.current.set(name, loaded);
121
- return loaded;
122
249
  };
123
- const detail = chronicle && currentRow ? detailOf(currentRow.name) : null;
124
- const files = detail?.files ?? [];
125
- const openFile = (name, path, from, line) => {
126
- const text = loadText(chronicle.root, name, path);
250
+ useEffect(() => {
251
+ if (props.search)
252
+ scheduleSearch();
253
+ }, [ui.current.chronicle]);
254
+ // ── overlays: a stack, with ids so a late result never lands on the wrong one
255
+ const topId = () => ui.current.overlays[ui.current.overlays.length - 1]?.id ?? 0;
256
+ const openOverlay = (overlay) => {
257
+ const id = ++overlaySeq.current;
258
+ set({ overlays: [...ui.current.overlays, { id, overlay }] });
259
+ return id;
260
+ };
261
+ const replaceOverlay = (id, overlay) => {
262
+ const stack = ui.current.overlays;
263
+ if (stack[stack.length - 1]?.id !== id)
264
+ return false;
265
+ set({ overlays: [...stack.slice(0, -1), { id, overlay }] });
266
+ return true;
267
+ };
268
+ const updateTop = (overlay) => {
269
+ const stack = ui.current.overlays;
270
+ const top = stack[stack.length - 1];
271
+ if (top)
272
+ set({ overlays: [...stack.slice(0, -1), { id: top.id, overlay }] });
273
+ };
274
+ const closeTop = () => set({ overlays: ui.current.overlays.slice(0, -1) });
275
+ const refreshBoard = async () => {
276
+ const chronicle = ui.current.chronicle;
277
+ if (!chronicle)
278
+ return;
279
+ set({ board: await loadBoard(chronicle.root) });
280
+ };
281
+ const place = () => {
282
+ const s = ui.current;
283
+ return { view: s.view, open: s.open, listSel: s.listSel, fileSel: s.fileSel, query: s.query, searchMode: s.searchMode };
284
+ };
285
+ const jumpTo = (name) => {
286
+ const s = ui.current;
287
+ const history = [...s.history, place()];
288
+ let { filtered } = derive();
289
+ let index = filtered.findIndex((row) => row.name === name);
290
+ if (index === -1) {
291
+ ui.current = { ...ui.current, query: "", searchMode: "title" };
292
+ filtered = derive().filtered;
293
+ index = filtered.findIndex((row) => row.name === name);
294
+ }
295
+ details.current.delete(`${s.access.mayWrite}:${name}`);
296
+ set({ history, listSel: Math.max(0, index), fileSel: 0, open: null, view: "files", overlays: [] });
297
+ };
298
+ const contextFor = () => {
299
+ const d = derive();
300
+ const s = d.s;
301
+ if (!s.chronicle)
302
+ return null;
303
+ return {
304
+ view: s.view,
305
+ chronicle: s.chronicle,
306
+ rows: d.visible,
307
+ access: s.access,
308
+ episode: d.currentRow,
309
+ file: s.view === "file" ? s.open?.path : s.view === "files" ? d.files[clamp(s.fileSel, d.files.length)]?.relativePath : d.hit?.file,
310
+ reference: s.view === "file" && s.open && s.open.refSel >= 0 ? s.open.view.refs[s.open.refSel] : undefined,
311
+ hit: d.hit,
312
+ open: openOverlay,
313
+ replace: replaceOverlay,
314
+ topId,
315
+ status: (message) => set({ status: cleanLine(message) }),
316
+ jumpTo,
317
+ api: s.api,
318
+ reader: s.reader,
319
+ mayAnswer: s.mayAnswer,
320
+ };
321
+ };
322
+ const openFile = (name, file, from, line) => {
323
+ const s = ui.current;
324
+ const text = loadText(s.chronicle.root, name, file, s.access);
127
325
  if ("error" in text)
128
- return setStatus(text.error);
326
+ return set({ status: cleanLine(text.error) });
129
327
  const shaped = fileView(text.content, columns - 2);
130
- setOpen({ name, path, view: shaped, scroll: line ? Math.max(0, screenLineOf(shaped, line) - 2) : 0, refSel: -1, from });
131
- setView("file");
132
- setStatus(shaped.refs.length ? `${shaped.refs.length} reference(s): Tab moves between them, Enter follows.` : "");
328
+ set({
329
+ open: { name, path: file, view: shaped, scroll: line ? Math.max(0, screenLineOf(shaped, line) - 2) : 0, refSel: -1, from, empty: text.content.length === 0 },
330
+ view: "file",
331
+ status: shaped.refs.length ? `${shaped.refs.length} reference(s): Tab moves between them, Enter follows.` : "",
332
+ });
133
333
  };
134
- const context = chronicle
135
- ? {
136
- view,
137
- chronicle,
138
- episode: currentRow,
139
- file: view === "file" ? open?.path : view === "files" ? files[clamp(fileSel, files.length)]?.relativePath : hit?.file,
140
- reference: view === "file" && open && open.refSel >= 0 ? open.view.refs[open.refSel] : undefined,
141
- hit,
142
- show: setOverlay,
143
- status: setStatus,
144
- jumpTo: (name) => {
145
- let index = filtered.findIndex((row) => row.name === name);
146
- if (index === -1) {
147
- setQuery("");
148
- setSearchMode("title");
149
- index = chronicle.rows.findIndex((row) => row.name === name);
150
- }
151
- setListSel(Math.max(0, index));
152
- setFileSel(0);
153
- setOpen(null);
154
- setView("files");
155
- },
156
- }
157
- : null;
158
- const bodyHeight = Math.max(5, rows - 4);
159
334
  const move = (delta, key) => {
160
- const step = key.pageDown || key.pageUp ? bodyHeight - 1 : 1;
335
+ const d = derive();
336
+ const s = d.s;
337
+ const step = key.pageDown || key.pageUp ? Math.max(1, bodyHeight - 1) : 1;
161
338
  const by = delta * step;
162
- if (view === "list")
163
- setListSel((i) => clamp(i + by, filtered.length));
164
- else if (view === "files")
165
- setFileSel((i) => clamp(i + by, files.length));
166
- else if (view === "hits")
167
- setHitSel((i) => clamp(i + by, hits?.hits.length ?? 0));
168
- else if (view === "file")
169
- setOpen((file) => file ? { ...file, scroll: clamp(file.scroll + by, Math.max(1, file.view.lines.length - bodyHeight + 1)) } : file);
339
+ if (s.view === "list")
340
+ set({ listSel: clamp(s.listSel + by, d.filtered.length) });
341
+ else if (s.view === "files")
342
+ set({ fileSel: clamp(s.fileSel + by, d.files.length) });
343
+ else if (s.view === "hits")
344
+ set({ hitSel: clamp(s.hitSel + by, s.hits?.hits.length ?? 0) });
345
+ else if (s.view === "attention")
346
+ set({ boardSel: clamp(s.boardSel + by, d.board.length) });
347
+ else if (s.view === "file" && s.open)
348
+ set({ open: { ...s.open, scroll: clamp(s.open.scroll + by, Math.max(1, s.open.view.lines.length - bodyHeight + 1)) } });
170
349
  };
171
350
  const back = () => {
172
- if (view === "file" && open) {
173
- setView(open.from);
174
- setOpen(null);
351
+ const s = ui.current;
352
+ const last = s.history[s.history.length - 1];
353
+ if (last && s.view === "files") {
354
+ set({ ...last, history: s.history.slice(0, -1) });
355
+ return;
175
356
  }
176
- else if (view === "files" || view === "hits")
177
- setView("list");
357
+ if (s.view === "file" && s.open)
358
+ set({ view: s.open.from, open: null });
359
+ else if (s.view === "files" || s.view === "hits" || s.view === "attention")
360
+ set({ view: "list" });
361
+ };
362
+ const showItem = (item) => {
363
+ // The board lists open items without context; the item shows what it cites.
364
+ const chronicle = ui.current.chronicle;
365
+ const id = openOverlay({ type: "item", item });
366
+ loadEpisodePanel(chronicle.root, item.episode).then((panel) => {
367
+ const full = panel.items.find((candidate) => candidate.id === item.id);
368
+ if (full)
369
+ replaceOverlay(id, { type: "item", item: full });
370
+ }, () => { });
178
371
  };
179
372
  const forward = () => {
180
- if (view === "list" && currentRow) {
181
- setFileSel(0);
182
- setView("files");
373
+ const d = derive();
374
+ const s = d.s;
375
+ if (s.view === "list" && d.currentRow) {
376
+ details.current.delete(`${s.access.mayWrite}:${d.currentRow.name}`);
377
+ set({ fileSel: 0, view: "files" });
183
378
  }
184
- else if (view === "files" && currentRow && files[fileSel]) {
185
- const file = files[fileSel];
379
+ else if (s.view === "files" && d.currentRow) {
380
+ const file = d.files[clamp(s.fileSel, d.files.length)];
381
+ if (!file)
382
+ return;
186
383
  if (file.kind !== "text")
187
- return setStatus(`${file.relativePath} is ${file.kind}: press o for its doors.`);
188
- openFile(currentRow.name, file.relativePath, "files");
384
+ return set({ status: `${cleanLine(file.relativePath)} is ${file.kind}: press o for its doors.` });
385
+ openFile(d.currentRow.name, file.relativePath, "files");
189
386
  }
190
- else if (view === "hits" && hit)
191
- openFile(hit.episodePath, hit.file, "hits", hit.line);
387
+ else if (s.view === "hits" && d.hit)
388
+ openFile(d.hit.episodePath, d.hit.file, "hits", d.hit.line);
389
+ else if (s.view === "attention" && d.boardItem)
390
+ showItem(d.boardItem);
192
391
  };
392
+ const answerInput = (item) => ({
393
+ type: "input",
394
+ title: `Answer · ${cleanLine(item.id)}`,
395
+ hint: cleanLine(item.question),
396
+ value: "",
397
+ submit: async (value) => {
398
+ const checked = checkAnswer(value);
399
+ if (!checked.ok)
400
+ throw new Error(checked.message);
401
+ const result = await answerAttention(ui.current.api, item.episode, item.id, checked.answer);
402
+ return answeredMessage(result.result).message;
403
+ },
404
+ after: async () => {
405
+ await refreshBoard();
406
+ // The item and the panel under it show the recorded answer.
407
+ const chronicle = ui.current.chronicle;
408
+ const panel = await loadEpisodePanel(chronicle.root, item.episode);
409
+ const stack = ui.current.overlays.map((entry) => {
410
+ const o = entry.overlay;
411
+ if (o.type === "item" && o.item.episode === item.episode && o.item.id === item.id) {
412
+ const fresh = panel.items.find((candidate) => candidate.id === item.id);
413
+ return fresh ? { id: entry.id, overlay: { type: "item", item: fresh } } : entry;
414
+ }
415
+ if (o.type === "panel" && o.episode === item.episode)
416
+ return { id: entry.id, overlay: { ...o, items: panel.items } };
417
+ return entry;
418
+ });
419
+ set({ overlays: stack });
420
+ },
421
+ });
422
+ // ── keys ─────────────────────────────────────────────────────────────────
193
423
  useInput((input, key) => {
194
- // A held or fast-typed key arrives as one chunk ("jjj"): outside the
195
- // filter, each character is its own key press.
196
- if (!inputMode && !key.ctrl && !key.meta && input.length > 1 && /^([jkgG])\1+$/.test(input)) {
197
- for (const char of input)
198
- handleKey(char, key);
424
+ if (key.ctrl || key.meta)
425
+ return;
426
+ // Several keys in one read: each character is its own press (UI review, F13).
427
+ const special = key.upArrow || key.downArrow || key.leftArrow || key.rightArrow || key.pageUp || key.pageDown || key.return || key.escape || key.tab || key.backspace || key.delete || key.home || key.end;
428
+ if (!special && input.length > 1) {
429
+ for (const char of input) {
430
+ if (char === "\t")
431
+ handleKey("", { ...PLAIN_KEY, tab: true });
432
+ else if (char === "\r" || char === "\n")
433
+ handleKey("", { ...PLAIN_KEY, return: true });
434
+ else if (char === "\u007f" || char === "\b")
435
+ handleKey("", { ...PLAIN_KEY, backspace: true });
436
+ else
437
+ handleKey(char, PLAIN_KEY);
438
+ }
199
439
  return;
200
440
  }
201
441
  handleKey(input, key);
202
442
  });
203
443
  function handleKey(input, key) {
204
- if (!chronicle || !context) {
444
+ const s = ui.current;
445
+ if (!s.chronicle) {
205
446
  if (input === "q" || key.escape)
206
447
  exit();
207
448
  return;
208
449
  }
209
- if (overlay) {
210
- if (key.escape || input === "q" || input === "h" || key.backspace)
211
- return setOverlay(null);
212
- if (overlay.type === "doors") {
213
- const count = overlay.doors.doors.length;
450
+ const top = s.overlays[s.overlays.length - 1]?.overlay;
451
+ if (top?.type === "input") {
452
+ if (top.busy)
453
+ return;
454
+ if (key.escape)
455
+ return closeTop();
456
+ if (key.return) {
457
+ const value = top.value;
458
+ if (!value.trim())
459
+ return updateTop({ ...top, error: "Nothing to send yet." });
460
+ updateTop({ ...top, busy: true, error: undefined });
461
+ top.submit(value).then(async (message) => {
462
+ set({ status: cleanLine(message), overlays: ui.current.overlays.slice(0, -1) });
463
+ try {
464
+ await top.after?.();
465
+ }
466
+ catch (error) {
467
+ set({ status: cleanLine(`Sent, but the refresh failed: ${error.message}`) });
468
+ }
469
+ }, (error) => updateTop({ ...top, busy: false, error: cleanLine(error.message) }));
470
+ return;
471
+ }
472
+ if (key.backspace || key.delete)
473
+ return updateTop({ ...top, value: top.value.slice(0, -1), error: undefined });
474
+ const text = input.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
475
+ if (text)
476
+ updateTop({ ...top, value: top.value + text, error: undefined });
477
+ return;
478
+ }
479
+ if (top) {
480
+ if (top.type === "help")
481
+ return closeTop();
482
+ if (key.escape || input === "q" || input === "h" || key.backspace || key.leftArrow)
483
+ return closeTop();
484
+ if (top.type === "doors") {
485
+ const count = top.doors.doors.length;
214
486
  if (key.downArrow || input === "j")
215
- return setOverlay({ ...overlay, selected: clamp(overlay.selected + 1, count) });
487
+ return updateTop({ ...top, selected: clamp(top.selected + 1, count) });
216
488
  if (key.upArrow || input === "k")
217
- return setOverlay({ ...overlay, selected: clamp(overlay.selected - 1, count) });
218
- const door = overlay.doors.doors[overlay.selected];
489
+ return updateTop({ ...top, selected: clamp(top.selected - 1, count) });
490
+ const door = top.doors.doors[top.selected];
219
491
  if (door && key.return) {
220
- const used = openUrl(door.url);
221
- return setStatus(used ? `Opened ${door.label} with ${used}.` : `No opener here. The address is ${door.url}`);
492
+ const result = openUrl(door.url, s.chronicle.root, withheld);
493
+ return set({ status: "opened" in result ? `Opened ${cleanLine(door.label)} with ${result.opened}.` : `Not opened: ${result.refused}. The address: ${cleanLine(door.url)}` });
222
494
  }
223
495
  if (door && input === "r")
224
- return setOverlay({ type: "qr", title: overlay.doors.title, door, invert: false });
496
+ return void openOverlay({ type: "qr", title: top.doors.title, door, invert: false });
497
+ return;
498
+ }
499
+ if (top.type === "qr" && input === "i")
500
+ return updateTop({ ...top, invert: !top.invert });
501
+ if (top.type === "card" && input === "o")
502
+ return void openOverlay({ type: "doors", doors: top.doors, selected: 0 });
503
+ if (top.type === "panel") {
504
+ const { shown, view } = panelView(top.items, top.view, ROOM_LABELS, top.parseError);
505
+ if (key.tab)
506
+ return updateTop({ ...top, view: view === "history" ? "open" : "history", selected: 0 });
507
+ if (key.downArrow || input === "j")
508
+ return updateTop({ ...top, selected: clamp(top.selected + 1, shown.length) });
509
+ if (key.upArrow || input === "k")
510
+ return updateTop({ ...top, selected: clamp(top.selected - 1, shown.length) });
511
+ const item = shown[clamp(top.selected, shown.length)];
512
+ if (item && (key.return || input === "l"))
513
+ return void openOverlay({ type: "item", item });
514
+ return;
515
+ }
516
+ if (top.type === "item") {
517
+ if (key.return && top.item.state === "open") {
518
+ if (!s.mayAnswer || !s.api)
519
+ return set({ status: readOnlyLabel(TOKEN_ENV) });
520
+ return void openOverlay(answerInput(top.item));
521
+ }
522
+ if (input === "e") {
523
+ set({ overlays: [] });
524
+ return jumpTo(top.item.episode);
525
+ }
225
526
  }
226
- if (overlay.type === "qr" && input === "i")
227
- return setOverlay({ ...overlay, invert: !overlay.invert });
228
- if (overlay.type === "help")
229
- return setOverlay(null);
230
527
  return;
231
528
  }
232
- if (inputMode) {
529
+ if (s.inputMode) {
233
530
  if (key.escape || key.return)
234
- return setInputMode(false);
235
- // A Tab typed fast or pasted can arrive inside the text chunk.
236
- if (key.tab || input.includes("\t"))
237
- setSearchMode((mode) => (mode === "title" ? "full" : "title"));
238
- input = input.replace(/[\u0000-\u001f\u007f]/g, "");
239
- if (key.tab && !input)
240
- return;
531
+ return set({ inputMode: false });
532
+ if (key.tab) {
533
+ // The view follows the mode (UI review, F7).
534
+ const searchMode = s.searchMode === "title" ? "full" : "title";
535
+ const view = s.view === "attention" ? "attention" : searchMode === "title" ? "list" : s.hits ? "hits" : "list";
536
+ set({ searchMode, view });
537
+ return scheduleSearch();
538
+ }
241
539
  if (key.upArrow || key.downArrow)
242
540
  return move(key.downArrow ? 1 : -1, key);
243
541
  if (key.backspace || key.delete) {
244
- setListSel(0);
245
- return setQuery((text) => text.slice(0, -1));
542
+ set({ query: s.query.slice(0, -1), listSel: 0, boardSel: 0 });
543
+ return scheduleSearch();
246
544
  }
247
- if (input && !key.ctrl && !key.meta) {
248
- setListSel(0);
249
- setQuery((text) => text + input);
545
+ const text = input.replace(/[\u0000-\u001f\u007f]/g, "");
546
+ if (text) {
547
+ set({ query: s.query + text, listSel: 0, boardSel: 0 });
548
+ scheduleSearch();
250
549
  }
251
550
  return;
252
551
  }
253
- if (input === "/" && view !== "file") {
254
- if (view === "files")
255
- setView("list");
256
- return setInputMode(true);
552
+ if (input === "/" && s.view !== "file") {
553
+ return set({ inputMode: true, view: s.view === "files" ? "list" : s.view });
257
554
  }
258
- if (key.tab && view === "file" && open) {
259
- const count = open.view.refs.length;
555
+ if (input === "!")
556
+ return set({ view: s.view === "attention" ? "list" : "attention", boardSel: 0 });
557
+ if (key.tab && s.view === "file" && s.open) {
558
+ const count = s.open.view.refs.length;
260
559
  if (count === 0)
261
560
  return;
262
- const refSel = key.shift ? (open.refSel <= 0 ? count - 1 : open.refSel - 1) : (open.refSel + 1) % count;
263
- const ref = open.view.refs[refSel];
264
- const line = open.view.lines.findIndex((screen) => screen.segments.some((s) => s.ref === refSel));
265
- const scroll = line < open.scroll || line >= open.scroll + bodyHeight ? Math.max(0, line - 2) : open.scroll;
266
- setStatus(`${ref.kind} · ${ref.uri}`);
267
- return setOpen({ ...open, refSel, scroll });
561
+ const refSel = key.shift ? (s.open.refSel <= 0 ? count - 1 : s.open.refSel - 1) : (s.open.refSel + 1) % count;
562
+ const ref = s.open.view.refs[refSel];
563
+ const line = s.open.view.lines.findIndex((screen) => screen.segments.some((segment) => segment.ref === refSel));
564
+ const scroll = line < s.open.scroll || line >= s.open.scroll + bodyHeight ? Math.max(0, line - 2) : s.open.scroll;
565
+ return set({ open: { ...s.open, refSel, scroll }, status: cleanLine(`${ref.kind} · ${ref.uri}`) });
268
566
  }
269
- if (key.tab && (view === "list" || view === "hits")) {
270
- const next = searchMode === "title" ? "full" : "title";
271
- setSearchMode(next);
272
- if (next === "title")
273
- setView("list");
274
- else if (hits && query.trim().length >= 2)
275
- setView("hits");
276
- return;
567
+ if (key.tab && (s.view === "list" || s.view === "hits")) {
568
+ const searchMode = s.searchMode === "title" ? "full" : "title";
569
+ set({ searchMode, view: searchMode === "title" ? "list" : s.hits ? "hits" : "list" });
570
+ return scheduleSearch();
277
571
  }
278
- const action = actionFor(actions, input, key, context);
279
- if (action) {
280
- Promise.resolve(action.run(context)).catch((error) => setStatus(error.message));
572
+ const context = contextFor();
573
+ const action = context ? actionFor(actions, input, key, context) : undefined;
574
+ if (action && context) {
575
+ Promise.resolve(action.run(context)).catch((error) => set({ status: cleanLine(error.message) }));
281
576
  return;
282
577
  }
283
578
  if (key.downArrow || input === "j" || key.pageDown)
@@ -285,89 +580,169 @@ export function App({ root, query: initialQuery = "", search: initialSearch, epi
285
580
  if (key.upArrow || input === "k" || key.pageUp)
286
581
  return move(-1, key);
287
582
  if (key.home || input === "g")
288
- return move(-100000, { ...key, pageUp: false });
583
+ return move(-1_000_000, PLAIN_KEY);
289
584
  if (key.end || input === "G")
290
- return move(100000, { ...key, pageDown: false });
585
+ return move(1_000_000, PLAIN_KEY);
291
586
  if (key.return || key.rightArrow || input === "l")
292
587
  return forward();
293
588
  if (key.escape || key.leftArrow || input === "h" || key.backspace)
294
589
  return back();
295
590
  if (input === "?")
296
- return setOverlay({ type: "help" });
591
+ return void openOverlay({ type: "help" });
297
592
  if (input === "q")
298
593
  exit();
299
594
  }
300
- if (loadError)
301
- return _jsxs(Text, { color: "red", children: ["miadi-chronicle: ", loadError] });
302
- if (!chronicle)
303
- return _jsxs(Text, { children: ["Reading the chronicle at ", root, "\u2026"] });
304
- const withManifest = chronicle.rows.filter((row) => row.structured).length;
305
- // Under 90 columns (a phone, a split pane) one pane shows at a time and the
306
- // list gives its whole width to titles. Wider, the list and the episode sit
307
- // side by side.
308
- const compact = columns < 90;
595
+ // ── render ───────────────────────────────────────────────────────────────
596
+ const d = derive();
597
+ const s = d.s;
598
+ if (s.loadError)
599
+ return _jsxs(Text, { color: "red", children: ["miadi-chronicle: ", cleanLine(s.loadError)] });
600
+ if (!s.chronicle)
601
+ return _jsxs(Text, { children: ["Reading the chronicle at ", cleanLine(root), "\u2026"] });
602
+ const context = contextFor();
309
603
  const leftWidth = compact ? columns : Math.min(64, Math.max(36, Math.floor(columns * 0.4)));
310
604
  const rightWidth = compact ? columns : Math.max(20, columns - leftWidth - 1);
311
605
  const available = actions.filter((action) => action.available(context));
312
- const numberWidth = String(Math.max(0, ...chronicle.rows.map((row) => row.number ?? 0))).length;
606
+ const numberWidth = String(Math.max(0, ...s.chronicle.rows.map((row) => row.number ?? 0))).length;
313
607
  const showAge = columns >= 44;
314
- const ageWidth = Math.max(2, ...chronicle.rows.map((row) => ageOf(row.date).length));
315
- const rowLine = (row, selected, marked) => (_jsxs(Text, { wrap: "truncate-end", inverse: selected, bold: marked, children: [_jsx(Text, { color: "yellow", children: String(row.number ?? "·").padStart(numberWidth) }), " ", _jsx(Text, { color: DIRECTION_COLOR[row.readings.direction], children: DIRECTION_MARK[row.readings.direction] }), " ", showAge ? _jsxs(Text, { color: "gray", children: [ageOf(row.date).padStart(ageWidth), " "] }) : null, row.structured ? row.title : _jsx(Text, { italic: true, color: "gray", children: row.slug ?? row.name })] }, row.name));
316
- const selectedRow = clamp(listSel, filtered.length);
317
- const selected = filtered[selectedRow];
318
- // A compact list ends with the selected episode's whole title, which the
319
- // rows above can only show cut.
608
+ const ageWidth = Math.max(2, ...s.chronicle.rows.map((row) => ageOf(row.date).length));
609
+ const rowLine = (row, selected, marked) => (_jsxs(Text, { wrap: "truncate-end", inverse: selected, bold: marked, children: [_jsx(Text, { color: "yellow", children: String(row.number ?? "·").padStart(numberWidth) }), " ", _jsx(Text, { color: DIRECTION_COLOR[row.readings.direction], children: DIRECTION_MARK[row.readings.direction] }), " ", showAge ? _jsxs(Text, { color: "gray", children: [ageOf(row.date).padStart(ageWidth), " "] }) : null, row.structured ? row.title : _jsx(Text, { italic: true, color: "gray", children: row.slug ?? row.name }), row.audience === "community" ? _jsx(Text, { color: "magenta", children: " \u25C7" }) : null] }, row.name));
610
+ const selectedRow = clamp(s.listSel, d.filtered.length);
611
+ const selected = d.filtered[selectedRow];
612
+ // A compact list ends with the selected episode's whole title.
320
613
  const peekLines = compact && selected ? 3 : 0;
321
- const listWindow = windowOf(filtered, selectedRow, bodyHeight - peekLines);
322
- const listPane = (_jsxs(Box, { flexDirection: "column", width: leftWidth, children: [listWindow.items.map((row, index) => {
614
+ const listWindow = windowOf(d.filtered, selectedRow, Math.max(1, bodyHeight - peekLines));
615
+ const listPane = (_jsxs(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", flexShrink: 0, children: [listWindow.items.map((row, index) => {
323
616
  const isSelected = listWindow.start + index === selectedRow;
324
- return rowLine(row, isSelected && view === "list", isSelected && view === "files");
325
- }), filtered.length === 0 ? _jsxs(Text, { color: "gray", children: ["No episode matches \u201C", query, "\u201D."] }) : null, peekLines && selected ? (_jsxs(Box, { flexDirection: "column", marginTop: Math.max(0, bodyHeight - peekLines - listWindow.items.length), children: [_jsx(Box, { height: 2, overflow: "hidden", children: _jsx(Text, { bold: true, wrap: "wrap", children: selected.title }) }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [_jsxs(Text, { color: "green", children: [selected.readings.direction, " \u00B7 ", selected.readings.theme] }), " \u00B7 ", formatDate(selected.date), selected.status ? ` · ${selected.status}` : ""] })] })) : null] }));
617
+ return rowLine(row, isSelected && s.view === "list", isSelected && s.view === "files");
618
+ }), d.filtered.length === 0 ? _jsxs(Text, { color: "gray", children: ["No episode matches \u201C", cleanLine(s.query), "\u201D."] }) : null, peekLines && selected ? (_jsxs(Box, { flexDirection: "column", marginTop: Math.max(0, bodyHeight - peekLines - listWindow.items.length), children: [_jsx(Box, { height: 2, overflow: "hidden", children: _jsx(Text, { bold: true, wrap: "wrap", children: selected.title }) }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [_jsxs(Text, { color: "green", children: [selected.readings.direction, " \u00B7 ", selected.readings.theme] }), " \u00B7 ", formatDate(selected.date), selected.status ? ` · ${selected.status}` : ""] })] })) : null] }));
326
619
  const hitRows = compact ? 2 : 1;
327
- const hitWindow = hits ? windowOf(hits.hits, hitSel, Math.max(1, Math.floor((bodyHeight - 1) / hitRows))) : null;
328
- const hitsPane = (_jsxs(Box, { flexDirection: "column", width: leftWidth, children: [searching ? _jsx(Text, { color: "gray", children: "Searching\u2026" }) : null, hits ? (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u201C", hits.query, "\u201D ", hits.hits.length, hits.truncated ? "+" : "", " hits \u00B7 ", new Set(hits.hits.map((h) => h.episodePath)).size, " episodes \u00B7 ", hits.engine] })) : null, hitWindow?.items.map((item, index) => {
329
- const number = rowByName(item.episodePath)?.number;
330
- const isSelected = hitWindow.start + index === hitSel;
331
- const where = (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: String(number ?? "·").padStart(numberWidth) }), " ", _jsxs(Text, { color: "blue", children: [item.file, ":", item.line] })] }));
332
- return compact ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "truncate-middle", inverse: isSelected, children: where }), _jsxs(Text, { wrap: "truncate-end", color: isSelected ? undefined : "gray", children: [" ", item.text.trim()] })] }, `${item.episodePath}/${item.file}:${item.line}`)) : (_jsxs(Text, { wrap: "truncate-end", inverse: isSelected, children: [where, " ", item.text.trim()] }, `${item.episodePath}/${item.file}:${item.line}`));
620
+ const hitsTop = (s.searching ? 1 : 0) + (s.hits ? 1 : 0);
621
+ const hitWindow = s.hits ? windowOf(s.hits.hits, s.hitSel, Math.max(1, Math.floor((bodyHeight - hitsTop) / hitRows))) : null;
622
+ const hitsPane = (_jsxs(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", flexShrink: 0, children: [s.searching ? _jsx(Text, { color: "gray", children: "Searching\u2026" }) : null, s.hits ? (_jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u201C", cleanLine(s.hits.query), "\u201D ", s.hits.hits.length, s.hits.truncated ? "+" : "", " hits \u00B7 ", new Set(s.hits.hits.map((h) => h.episodePath)).size, " episodes \u00B7 newest first \u00B7 ", s.hits.engine, s.hits.partial ? " · some files unreadable" : ""] })) : null, hitWindow?.items.map((item, index) => {
623
+ const number = d.rowByName(item.episodePath)?.number;
624
+ const isSelected = hitWindow.start + index === s.hitSel;
625
+ const where = (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: String(number ?? "·").padStart(numberWidth) }), " ", _jsxs(Text, { color: "blue", children: [cleanLine(item.file), ":", item.line] })] }));
626
+ return compact ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "truncate-middle", inverse: isSelected, children: where }), _jsxs(Text, { wrap: "truncate-end", color: isSelected ? undefined : "gray", children: [" ", cleanLine(item.text).trim()] })] }, `${item.episodePath}/${item.file}:${item.line}`)) : (_jsxs(Text, { wrap: "truncate-end", inverse: isSelected, children: [where, " ", cleanLine(item.text).trim()] }, `${item.episodePath}/${item.file}:${item.line}`));
627
+ })] }));
628
+ const boardRows = compact ? 2 : 1;
629
+ const boardWindow = windowOf(d.board, clamp(s.boardSel, d.board.length), Math.max(1, Math.floor((bodyHeight - 1) / boardRows)));
630
+ const boardHeading = panelView(d.board, "open", BOARD_LABELS);
631
+ const boardPane = (_jsxs(Box, { flexDirection: "column", width: leftWidth, height: bodyHeight, overflow: "hidden", flexShrink: 0, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "yellow", children: boardHeading.heading }), " ", _jsx(Text, { color: "gray", children: boardHeading.meta })] }), s.board === null ? _jsx(Text, { color: "gray", children: "Reading attention\u2026" }) : null, s.board !== null && d.board.length === 0 && d.openCount > 0 ? (_jsxs(Text, { color: "gray", children: ["No open item matches this search \u2014 ", d.openCount, " still wait across the chronicle."] })) : null, boardWindow.items.map((item, index) => {
632
+ const isSelected = boardWindow.start + index === clamp(s.boardSel, d.board.length);
633
+ const number = d.rowByName(item.episode)?.number;
634
+ const head = (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: "?" }), " ", _jsx(Text, { color: "yellow", children: String(number ?? "·").padStart(numberWidth) }), " ", cleanLine(item.id), _jsxs(Text, { color: "gray", children: [" \u00B7 ", item.asked ?? ""] })] }));
635
+ return compact ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { wrap: "truncate-end", inverse: isSelected, children: head }), _jsxs(Text, { wrap: "truncate-end", color: isSelected ? undefined : "gray", children: [" ", cleanLine(item.question)] })] }, `${item.episode}:${item.id}`)) : (_jsxs(Text, { wrap: "truncate-end", inverse: isSelected, children: [head, " ", _jsx(Text, { children: cleanLine(item.question) })] }, `${item.episode}:${item.id}`));
333
636
  })] }));
334
- const goal = detail?.summary?.goal?.replace(/^\s*(["'])([\s\S]*)\1\s*$/, "$2").replace(/\s+/g, " ").trim();
335
- // The goal gets the lines the file list does not need, and never all of them.
336
- const goalLines = goal ? Math.min(Math.ceil(goal.length / Math.max(20, rightWidth - 2)), compact ? 6 : 8) : 0;
337
- const fileWindow = windowOf(files, clamp(fileSel, files.length), Math.max(3, bodyHeight - 5 - goalLines));
338
- const detailPane = (_jsx(Box, { flexDirection: "column", width: rightWidth, paddingLeft: compact ? 0 : 1, children: currentRow ? (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "wrap", children: [_jsx(Text, { color: "yellow", children: currentRow.number ?? "·" }), " ", currentRow.title] }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [[formatDate(currentRow.date), currentRow.status, currentRow.type].filter(Boolean).join(" · "), currentRow.structured ? "" : " · no episode.yaml"] }), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["reading: ", currentRow.readings.direction, " \u00B7 ", currentRow.readings.theme, " \u00B7 ", currentRow.readings.arc] }), goal ? (_jsx(Box, { height: goalLines, overflow: "hidden", children: _jsx(Text, { wrap: "wrap", children: goal }) })) : null, detail?.error ? _jsx(Text, { color: "red", children: detail.error }) : null, _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["── ", files.length, " file", files.length === 1 ? "" : "s", view === "files" ? "" : " · Enter", " ──"] }), fileWindow.items.map((file, index) => {
339
- const isSelected = view === "files" && fileWindow.start + index === clamp(fileSel, files.length);
340
- return (_jsxs(Text, { wrap: "truncate-start", inverse: isSelected, color: file.kind === "text" ? "blue" : "gray", children: [file.relativePath, file.kind === "text" ? "" : ` · ${file.kind}`] }, file.relativePath));
341
- })] })) : (_jsx(Text, { color: "gray", children: "Nothing selected." })) }));
342
- const fileBody = open ? (_jsx(Box, { flexDirection: "column", children: open.view.lines.slice(open.scroll, open.scroll + bodyHeight).map((line, index) => (_jsx(Segments, { segments: line.segments, selectedRef: open.refSel }, open.scroll + index))) })) : null;
637
+ const itemBody = (item, width) => (_jsxs(_Fragment, { children: [_jsx(Text, { bold: true, wrap: "wrap", children: clean(item.question) }), item.unlocks ? _jsxs(Text, { wrap: "wrap", color: "green", children: ["unlocks: ", clean(item.unlocks)] }) : null, _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [cleanLine(item.id), " \u00B7 ", d.rowByName(item.episode)?.number ?? "", " ", cleanLine(d.rowByName(item.episode)?.title ?? item.episode), item.asked ? ` · asked ${item.asked}` : ""] }), item.state === "answered" ? (_jsxs(Text, { wrap: "wrap", color: "cyan", children: ["answered", item.answered_at ? ` ${item.answered_at.slice(0, 10)}` : "", ": ", clean(item.answer ?? "")] })) : null, (item.context ?? []).length ? _jsx(Text, { color: "gray", children: ROOM_LABELS.context }) : null, (item.context ?? []).map((cited) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "blue", wrap: "truncate-middle", children: ["\u203A ", cleanLine(cited.ref), cited.exists ? "" : " (missing)"] }), cited.excerpt ? (_jsx(Box, { height: Math.min(6, clean(cited.excerpt).split("\n").length), overflow: "hidden", width: width, children: _jsx(Text, { wrap: "wrap", color: "gray", children: clean(cited.excerpt) }) })) : null] }, cited.ref)))] }));
638
+ const goal = d.detail?.summary?.goal ? cleanLine(d.detail.summary.goal.replace(/^\s*(["'])([\s\S]*)\1\s*$/, "$2")).replace(/\s+/g, " ").trim() : "";
639
+ const openHere = d.currentRow ? (s.board ?? []).filter((item) => item.episode === d.currentRow.name).length : 0;
640
+ const textWidth = Math.max(10, rightWidth - (compact ? 0 : 1));
641
+ const linesOf = (text, cap) => Math.min(cap, Math.max(1, wrapSegments([{ text, style: "text" }], textWidth).length));
642
+ const titleLines = d.currentRow ? linesOf(`${d.currentRow.number ?? "·"} ${d.currentRow.title}`, 3) : 0;
643
+ const goalLines = goal ? linesOf(goal, compact ? 6 : 8) : 0;
644
+ const fixedLines = titleLines + 2 + (openHere ? 1 : 0) + goalLines + (d.detail?.error ? 1 : 0) + 1;
645
+ 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) => {
647
+ const isSelected = s.view === "files" && fileWindow.start + index === clamp(s.fileSel, d.files.length);
648
+ 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
+ })] })) : (_jsx(Text, { color: "gray", children: "Nothing selected." })) }) }));
650
+ const fileBody = s.open ? (_jsxs(Box, { flexDirection: "column", height: bodyHeight, overflow: "hidden", children: [s.open.empty ? _jsx(Text, { color: "gray", children: "(empty file)" }) : null, s.open.view.lines.slice(s.open.scroll, s.open.scroll + bodyHeight).map((line, index) => (_jsx(Segments, { segments: line.segments, selectedRef: s.open.refSel }, s.open.scroll + index)))] })) : null;
651
+ // Children never shrink: a fixed-height box clips what does not fit instead
652
+ // of squeezing rows to zero height, where Ink draws them over each other.
653
+ const box = (color, children) => (_jsx(Box, { flexDirection: "column", borderStyle: "round", borderColor: color, paddingLeft: 1, paddingRight: 1, flexGrow: 1, height: bodyHeight, overflow: "hidden", children: _jsx(Box, { flexDirection: "column", flexShrink: 0, children: children }) }));
343
654
  const overlayBody = (() => {
344
- if (!overlay)
655
+ const top = s.overlays[s.overlays.length - 1]?.overlay;
656
+ if (!top)
345
657
  return null;
346
- if (overlay.type === "doors") {
347
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingLeft: 1, paddingRight: 1, flexGrow: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Doors \u00B7 ", overlay.doors.title] }), overlay.doors.note ? _jsx(Text, { color: "gray", wrap: "wrap", children: overlay.doors.note }) : null, overlay.doors.doors.map((door, index) => compact ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { inverse: index === overlay.selected, color: "yellow", children: door.label }), _jsxs(Text, { wrap: "truncate-end", children: [" ", osc8(door.url, door.url)] })] }, `${door.label}-${index}`)) : (_jsxs(Text, { wrap: "truncate-end", inverse: index === overlay.selected, children: [_jsx(Text, { color: "yellow", children: door.label.padEnd(16) }), " ", osc8(door.url, door.url)] }, `${door.label}-${index}`))), _jsx(Text, { color: "gray", children: "\u2191\u2193 \u00B7 Enter opens \u00B7 r QR \u00B7 Esc" })] }));
658
+ if (top.type === "doors") {
659
+ return box("yellow", _jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Doors \u00B7 ", cleanLine(top.doors.title)] }), top.doors.note ? _jsx(Text, { color: "gray", wrap: "wrap", children: cleanLine(top.doors.note) }) : null, top.doors.doors.map((door, index) => compact ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { inverse: index === top.selected, color: "yellow", children: cleanLine(door.label) }), _jsxs(Text, { wrap: "truncate-end", children: [" ", link(door.url)] })] }, `${door.label}-${index}`)) : (_jsxs(Text, { wrap: "truncate-end", inverse: index === top.selected, children: [_jsx(Text, { color: "yellow", children: cleanLine(door.label).padEnd(16) }), " ", link(door.url)] }, `${door.label}-${index}`))), _jsx(Text, { color: "gray", children: "\u2191\u2193 \u00B7 Enter opens \u00B7 r QR \u00B7 Esc" })] }));
660
+ }
661
+ if (top.type === "qr") {
662
+ const lines = qrLines(top.door.url, top.invert);
663
+ const local = /^https?:\/\/(127\.0\.0\.1|localhost)[:/]/.test(top.door.url) || top.door.url.startsWith("file:");
664
+ // A QR cut by the screen does not scan: say so instead (UI review, F9).
665
+ const room = bodyHeight - 2;
666
+ if (lines.length > room) {
667
+ return box("yellow", _jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, children: ["QR \u00B7 ", cleanLine(top.door.label)] }), _jsxs(Text, { wrap: "wrap", color: "yellow", children: ["This code needs ", lines.length + 2, " rows and the screen has ", bodyHeight, ". Enlarge the terminal, or open the address:"] }), _jsx(Text, { wrap: "wrap", children: link(top.door.url) }), _jsx(Text, { color: "gray", children: "Esc" })] }));
668
+ }
669
+ const spare = room - lines.length;
670
+ return box("yellow", _jsxs(_Fragment, { children: [spare >= 1 ? _jsxs(Text, { bold: true, wrap: "truncate-end", children: ["QR \u00B7 ", cleanLine(top.door.label)] }) : null, lines.map((line, index) => (_jsx(Text, { children: line }, index))), spare >= 2 ? _jsx(Text, { wrap: "truncate-end", children: link(top.door.url) }) : null, local && spare >= 4 ? (_jsx(Text, { color: "yellow", wrap: "wrap", children: "This address opens only on this machine. Set MIADI_URL_BASE_INTERNAL for a room a phone can open." })) : null, spare >= 3 ? _jsx(Text, { color: "gray", children: "i inverts \u00B7 Esc" }) : null] }));
348
671
  }
349
- if (overlay.type === "qr") {
350
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingLeft: 1, paddingRight: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["QR \u00B7 ", overlay.door.label] }), qrLines(overlay.door.url, overlay.invert).map((line, index) => (_jsx(Text, { children: line }, index))), _jsx(Text, { wrap: "truncate-end", children: osc8(overlay.door.url, overlay.door.url) }), /^https?:\/\/(127\.0\.0\.1|localhost)[:/]/.test(overlay.door.url) || overlay.door.url.startsWith("file:") ? (_jsx(Text, { color: "yellow", wrap: "wrap", children: "This address opens only on this machine. Set MIADI_URL_BASE_INTERNAL (tailnet) or MIADI_URL_BASE (public) for a room a phone can open." })) : null, _jsx(Text, { color: "gray", children: "i inverts \u00B7 Esc" })] }));
672
+ if (top.type === "proof") {
673
+ 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" })] }));
351
674
  }
352
- if (overlay.type === "proof") {
353
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingLeft: 1, paddingRight: 1, flexGrow: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Proof \u00B7 ", rowByName(overlay.episode)?.number ?? overlay.episode] }), !overlay.status && !overlay.error ? _jsx(Text, { color: "gray", children: "Asking git and the wheel\u2026" }) : null, overlay.error ? _jsx(Text, { color: "red", wrap: "wrap", children: overlay.error }) : null, overlay.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: [" ", stage.observed] })] }), compact ? _jsxs(Text, { color: "gray", wrap: "truncate-end", children: [" ", stage.observed] }) : null] }, stage.stage))), overlay.status?.drift.map((line) => (_jsxs(Text, { color: "yellow", wrap: "wrap", children: ["drift: ", line] }, line))), _jsx(Text, { color: "gray", children: "Esc" })] }));
675
+ 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" })] }));
354
677
  }
355
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingLeft: 1, paddingRight: 1, flexGrow: 1, 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: "\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." })] }));
678
+ if (top.type === "panel") {
679
+ const view = panelView(top.items, top.view, ROOM_LABELS, top.parseError);
680
+ const panelWindow = windowOf(view.shown, clamp(top.selected, view.shown.length), Math.max(1, Math.floor((bodyHeight - 5) / 2)));
681
+ return box("yellow", _jsxs(_Fragment, { children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, children: view.heading }), " ", _jsxs(Text, { color: "gray", children: ["\u00B7 ", d.rowByName(top.episode)?.number ?? cleanLine(top.episode), " \u00B7 ", view.meta] })] }), _jsxs(Text, { children: [_jsx(Text, { color: view.view === "open" ? "yellow" : "gray", children: ROOM_LABELS.openTab }), _jsx(Text, { color: "gray", children: " | " }), _jsx(Text, { color: view.view === "history" ? "yellow" : "gray", children: ROOM_LABELS.historyTab })] }), panelWindow.items.map((item, index) => {
682
+ const isSelected = panelWindow.start + index === clamp(top.selected, view.shown.length);
683
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", inverse: isSelected, children: [_jsx(Text, { color: item.state === "open" ? "yellow" : "green", children: item.state === "open" ? "?" : "✓" }), " ", cleanLine(item.id)] }), _jsxs(Text, { wrap: "truncate-end", color: isSelected ? undefined : "gray", children: [" ", cleanLine(item.state === "open" ? item.question : item.answer ?? item.question)] })] }, item.id));
684
+ }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: "Tab open | history \u00B7 Enter reads \u00B7 Esc" })] }));
685
+ }
686
+ if (top.type === "item") {
687
+ 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
+ }
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" })] }));
695
+ }
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." })] }));
697
+ })();
698
+ const where = (() => {
699
+ if (s.view === "file" && s.open) {
700
+ const number = d.rowByName(s.open.name)?.number ?? "";
701
+ const line = `l.${s.open.view.lines[s.open.scroll]?.source ?? 1}`;
702
+ // The line number before the path, so a narrow screen keeps it (UI review, F24).
703
+ return `${number} · ${line} · ${middle(cleanLine(s.open.path), Math.max(12, columns - 30))}`;
704
+ }
705
+ const count = `${d.visible.length} episodes`;
706
+ const waiting = d.openCount ? ` · ! ${d.openCount}` : "";
707
+ return compact ? `${count}${waiting}` : `${cleanLine(s.chronicle.root)} · ${count}${waiting}`;
708
+ })();
709
+ const hints = (() => {
710
+ const view = s.view;
711
+ if (s.overlays.length)
712
+ return "Esc back";
713
+ if (view === "file")
714
+ return compact ? "Tab ref · Esc · q" : "Tab next reference ⇧Tab previous Esc back ? keys q quit";
715
+ if (view === "attention")
716
+ return compact ? "/ ⏎ ! Esc q" : "/ filter ⏎ read ! episodes Esc back ? keys q quit";
717
+ if (view === "files")
718
+ return compact ? "⏎ ! ? q" : "⏎ open ! attention Esc back ? keys q quit";
719
+ return compact ? "/ Tab ! ? q" : "/ filter Tab mode ⏎ open ! attention ? keys q quit";
356
720
  })();
357
- const where = view === "file" && open
358
- ? `${rowByName(open.name)?.number ?? ""} · ${open.path} · l.${open.view.lines[open.scroll]?.source ?? 1}`
359
- : compact
360
- ? `${chronicle.rows.length} episodes`
361
- : `${chronicle.root} · ${chronicle.rows.length} episodes · ${withManifest} with episode.yaml`;
362
721
  const body = (() => {
363
722
  if (overlayBody)
364
723
  return overlayBody;
365
- if (view === "file")
724
+ if (s.view === "file")
366
725
  return fileBody;
367
- if (compact)
368
- return view === "hits" ? hitsPane : view === "files" ? detailPane : listPane;
369
- return (_jsxs(_Fragment, { children: [view === "hits" ? hitsPane : listPane, detailPane] }));
726
+ if (compact) {
727
+ if (s.view === "hits")
728
+ return hitsPane;
729
+ if (s.view === "attention")
730
+ return boardPane;
731
+ if (s.view === "files")
732
+ return detailPane;
733
+ return listPane;
734
+ }
735
+ return (_jsxs(_Fragment, { children: [s.view === "hits" ? hitsPane : s.view === "attention" ? boardPane : listPane, detailPane] }));
370
736
  })();
371
- return (_jsxs(Box, { flexDirection: "column", height: rows, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: "yellow", children: "miadi-chronicle" }), " ", _jsx(Text, { color: "gray", children: where })] }), _jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: inputMode ? "yellow" : "gray", children: "/ " }), query, inputMode ? _jsx(Text, { inverse: true, children: " " }) : null, " ", _jsx(Text, { color: searchMode === "title" ? "yellow" : "gray", children: "title" }), _jsx(Text, { color: "gray", children: compact ? "|" : " | " }), _jsx(Text, { color: searchMode === "full" ? "yellow" : "gray", children: compact ? "full" : "full text" }), view === "list" ? _jsxs(Text, { color: "gray", children: [" \u00B7 ", filtered.length] }) : null] }), _jsx(Box, { flexGrow: 1, flexDirection: "row", height: bodyHeight, children: body }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: status }), _jsxs(Text, { wrap: "truncate-end", children: [available.map((action) => (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: action.key === "Enter" ? "⏎" : action.key }), " ", action.label, " "] }, action.id))), _jsx(Text, { color: "gray", children: compact ? "/ Tab ? q" : "/ filter Tab mode ⏎ open Esc back ? keys q quit" })] })] }));
737
+ const readerLabel = s.api
738
+ ? s.reader
739
+ ? `as ${cleanLine(s.reader.name)}`
740
+ : s.api.accepted === false
741
+ ? "token refused"
742
+ : s.api.signed
743
+ ? "…"
744
+ : "read-only"
745
+ : "";
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 })] })] }));
372
747
  }
373
748
  //# sourceMappingURL=App.js.map