@miadi/chronicle-tui 0.3.0 → 0.4.1

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,18 +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, attentionOverlay } from "./actions.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";
13
18
  import { TOKEN_ENV, answerAttention, connectApi } from "./api.js";
14
- import { loadChronicle, loadDetail, loadText, openUrl, qrLines, search } from "./data.js";
15
- import { ageOf, clamp, fileView, formatDate, filterRows, screenLineOf, windowOf, } from "./model.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";
16
23
  const DIRECTION_MARK = { east: "E", south: "S", west: "W", north: "N" };
17
24
  const DIRECTION_COLOR = { east: "yellow", south: "red", west: "blue", north: "white" };
18
25
  function Segments({ segments, selectedRef }) {
@@ -30,341 +37,542 @@ function Segments({ segments, selectedRef }) {
30
37
  return _jsx(Text, { children: segment.text }, index);
31
38
  }) }));
32
39
  }
33
- function matchRow(rows, ref) {
34
- const text = ref.trim().toLowerCase().replace(/^ep/, "");
35
- 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);
36
44
  if (exact !== -1)
37
- return exact;
38
- const number = Number(text);
45
+ return [exact];
46
+ const number = Number(wanted.replace(/^ep/, ""));
39
47
  if (Number.isInteger(number)) {
40
- const byNumber = rows.findIndex((row) => row.number === number);
41
- if (byNumber !== -1)
48
+ const byNumber = rows.flatMap((row, index) => (row.number === number ? [index] : []));
49
+ if (byNumber.length)
42
50
  return byNumber;
43
51
  }
44
- 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)}`;
45
62
  }
46
- export function App({ root, query: initialQuery = "", search: initialSearch, episode, actions: extra = [], url }) {
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;
47
87
  const { exit } = useApp();
48
- const { stdout } = useStdout();
49
- const columns = stdout?.columns ?? 100;
50
- const rows = stdout?.rows ?? 30;
51
- const [chronicle, setChronicle] = useState(null);
52
- const [loadError, setLoadError] = useState(null);
53
- const [query, setQuery] = useState(initialSearch ?? initialQuery);
54
- const [inputMode, setInputMode] = useState(false);
55
- const [searchMode, setSearchMode] = useState(initialSearch ? "full" : "title");
56
- const [view, setView] = useState(initialSearch ? "hits" : "list");
57
- const [listSel, setListSel] = useState(0);
58
- const [fileSel, setFileSel] = useState(0);
59
- const [hits, setHits] = useState(null);
60
- const [hitSel, setHitSel] = useState(0);
61
- const [searching, setSearching] = useState(false);
62
- const [open, setOpen] = useState(null);
63
- const [overlay, setOverlay] = useState(null);
64
- const [status, setStatus] = useState("");
65
- const [api, setApi] = useState(null);
66
- const [reader, setReader] = useState(null);
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
+ });
67
114
  const details = useRef(new Map());
115
+ const overlaySeq = useRef(0);
68
116
  const searchTurn = useRef(0);
117
+ const searchTimer = useRef(null);
118
+ const searchAbort = useRef(null);
69
119
  const actions = useMemo(() => [...DEFAULT_ACTIONS, ...extra], [extra]);
70
- useEffect(() => {
71
- loadChronicle(root).then((loaded) => {
72
- setChronicle(loaded);
73
- if (episode) {
74
- const index = matchRow(loaded.rows, episode);
75
- if (index === -1)
76
- setStatus(`No episode matches ${episode}.`);
77
- else {
78
- setListSel(index);
79
- setView("files");
80
- }
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);
153
+ }
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)
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;
81
175
  }
82
- }, (error) => setLoadError(error.message));
83
- }, [root, episode]);
84
- // The app is found once. Reading the chronicle never waits for it.
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
+ };
85
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 }));
86
194
  let live = true;
87
195
  connectApi(url)
88
- .then(async (found) => {
196
+ .then(async ({ api, warning }) => {
89
197
  if (!live)
90
198
  return;
91
- setApi(found);
92
- if (!found?.signed)
199
+ set({ api, status: warning ? cleanLine(warning) : ui.current.status });
200
+ if (!api?.signed)
93
201
  return;
94
202
  try {
95
- const me = await found.me();
96
- if (live)
97
- setReader(me);
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();
98
210
  }
99
211
  catch (error) {
100
212
  if (live)
101
- setStatus(`${TOKEN_ENV} was refused by ${found.base}: ${error.message}`);
213
+ set({ status: cleanLine(`The token was refused by ${api.base}: ${error.message}`) });
102
214
  }
103
215
  })
104
216
  .catch(() => { });
105
217
  return () => {
106
218
  live = false;
107
219
  };
108
- }, [url]);
109
- const filtered = useMemo(() => (chronicle ? (searchMode === "title" ? filterRows(chronicle.rows, query) : chronicle.rows) : []), [chronicle, query, searchMode]);
110
- // Full-text search runs a moment after typing stops, and only the latest answer is kept.
111
- useEffect(() => {
112
- if (!chronicle || searchMode !== "full")
113
- return;
114
- const text = query.trim();
115
- if (text.length < 2)
116
- return;
220
+ }, [root, url]);
221
+ // ── full-text search: debounced, cancelled when superseded ───────────────
222
+ const scheduleSearch = () => {
117
223
  const turn = ++searchTurn.current;
118
- const timer = setTimeout(() => {
119
- setSearching(true);
120
- search(chronicle.root, text, chronicle.rows).then((result) => {
121
- 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")
122
240
  return;
123
- setHits(result);
124
- setHitSel(0);
125
- setSearching(false);
126
- 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 });
127
243
  }, (error) => {
128
- if (turn !== searchTurn.current)
244
+ if (turn !== searchTurn.current || error.name === "AbortError")
129
245
  return;
130
- setSearching(false);
131
- setStatus(error.message);
246
+ set({ searching: false, status: cleanLine(error.message) });
132
247
  });
133
248
  }, 250);
134
- return () => clearTimeout(timer);
135
- }, [chronicle, query, searchMode]);
136
- const rowByName = (name) => chronicle?.rows.find((row) => row.name === name);
137
- const hit = view === "hits" || open?.from === "hits" ? hits?.hits[hitSel] : undefined;
138
- const currentRow = view === "file" && open
139
- ? rowByName(open.name)
140
- : view === "hits"
141
- ? hit && rowByName(hit.episodePath)
142
- : filtered[clamp(listSel, filtered.length)];
143
- const detailOf = (name) => {
144
- const cached = details.current.get(name);
145
- if (cached)
146
- return cached;
147
- const loaded = loadDetail(chronicle.root, name);
148
- details.current.set(name, loaded);
149
- return loaded;
150
249
  };
151
- const detail = chronicle && currentRow ? detailOf(currentRow.name) : null;
152
- const files = detail?.files ?? [];
153
- const openFile = (name, path, from, line) => {
154
- 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);
155
325
  if ("error" in text)
156
- return setStatus(text.error);
326
+ return set({ status: cleanLine(text.error) });
157
327
  const shaped = fileView(text.content, columns - 2);
158
- setOpen({ name, path, view: shaped, scroll: line ? Math.max(0, screenLineOf(shaped, line) - 2) : 0, refSel: -1, from });
159
- setView("file");
160
- 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
+ });
161
333
  };
162
- const context = chronicle
163
- ? {
164
- view,
165
- chronicle,
166
- episode: currentRow,
167
- file: view === "file" ? open?.path : view === "files" ? files[clamp(fileSel, files.length)]?.relativePath : hit?.file,
168
- reference: view === "file" && open && open.refSel >= 0 ? open.view.refs[open.refSel] : undefined,
169
- hit,
170
- show: setOverlay,
171
- status: setStatus,
172
- jumpTo: (name) => {
173
- let index = filtered.findIndex((row) => row.name === name);
174
- if (index === -1) {
175
- setQuery("");
176
- setSearchMode("title");
177
- index = chronicle.rows.findIndex((row) => row.name === name);
178
- }
179
- setListSel(Math.max(0, index));
180
- setFileSel(0);
181
- setOpen(null);
182
- setView("files");
183
- },
184
- api,
185
- reader,
186
- }
187
- : null;
188
- const bodyHeight = Math.max(5, rows - 4);
189
334
  const move = (delta, key) => {
190
- 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;
191
338
  const by = delta * step;
192
- if (view === "list")
193
- setListSel((i) => clamp(i + by, filtered.length));
194
- else if (view === "files")
195
- setFileSel((i) => clamp(i + by, files.length));
196
- else if (view === "hits")
197
- setHitSel((i) => clamp(i + by, hits?.hits.length ?? 0));
198
- else if (view === "file")
199
- 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)) } });
200
349
  };
201
350
  const back = () => {
202
- if (view === "file" && open) {
203
- setView(open.from);
204
- 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;
205
356
  }
206
- else if (view === "files" || view === "hits")
207
- 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
+ }, () => { });
208
371
  };
209
372
  const forward = () => {
210
- if (view === "list" && currentRow) {
211
- setFileSel(0);
212
- 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" });
213
378
  }
214
- else if (view === "files" && currentRow && files[fileSel]) {
215
- 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;
216
383
  if (file.kind !== "text")
217
- return setStatus(`${file.relativePath} is ${file.kind}: press o for its doors.`);
218
- 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");
219
386
  }
220
- else if (view === "hits" && hit)
221
- 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);
222
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 ─────────────────────────────────────────────────────────────────
223
423
  useInput((input, key) => {
224
- // A held or fast-typed key arrives as one chunk ("jjj"): outside the
225
- // filter, each character is its own key press.
226
- if (!inputMode && !key.ctrl && !key.meta && input.length > 1 && /^([jkgG])\1+$/.test(input)) {
227
- for (const char of input)
228
- 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
+ }
229
439
  return;
230
440
  }
231
441
  handleKey(input, key);
232
442
  });
233
443
  function handleKey(input, key) {
234
- if (!chronicle || !context) {
444
+ const s = ui.current;
445
+ if (!s.chronicle) {
235
446
  if (input === "q" || key.escape)
236
447
  exit();
237
448
  return;
238
449
  }
239
- if (overlay?.type === "input") {
240
- if (overlay.busy)
450
+ const top = s.overlays[s.overlays.length - 1]?.overlay;
451
+ if (top?.type === "input") {
452
+ if (top.busy)
241
453
  return;
242
454
  if (key.escape)
243
- return setOverlay(overlay.back ?? null);
455
+ return closeTop();
244
456
  if (key.return) {
245
- const value = overlay.value;
457
+ const value = top.value;
246
458
  if (!value.trim())
247
- return setOverlay({ ...overlay, error: "Nothing to send yet." });
248
- setOverlay({ ...overlay, busy: true, error: undefined });
249
- overlay.submit(value).then(async (message) => {
250
- setStatus(message);
251
- setOverlay(null);
252
- await overlay.after?.();
253
- }, (error) => setOverlay({ ...overlay, busy: false, error: error.message }));
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) }));
254
470
  return;
255
471
  }
256
472
  if (key.backspace || key.delete)
257
- return setOverlay({ ...overlay, value: overlay.value.slice(0, -1), error: undefined });
258
- const text = input.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").replace(/\r?\n/g, " ");
259
- if (text && !key.ctrl && !key.meta)
260
- setOverlay({ ...overlay, value: overlay.value + text, error: undefined });
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 });
261
477
  return;
262
478
  }
263
- if (overlay) {
264
- if (key.escape || input === "q" || input === "h" || key.backspace)
265
- return setOverlay(null);
266
- if (overlay.type === "attention") {
267
- const count = overlay.items.length;
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;
268
486
  if (key.downArrow || input === "j")
269
- return setOverlay({ ...overlay, selected: clamp(overlay.selected + 1, count) });
487
+ return updateTop({ ...top, selected: clamp(top.selected + 1, count) });
270
488
  if (key.upArrow || input === "k")
271
- return setOverlay({ ...overlay, selected: clamp(overlay.selected - 1, count) });
272
- const item = overlay.items[overlay.selected];
273
- if (item && key.return) {
274
- if (item.state !== "open")
275
- return setStatus(`Already answered${item.answered_at ? ` on ${item.answered_at.slice(0, 10)}` : ""}.`);
276
- if (!overlay.canAnswer || !api)
277
- return setStatus(`Answering needs the Miadi app and ${TOKEN_ENV} (or --env <file>).`);
278
- const back = overlay;
279
- return setOverlay({
280
- type: "input",
281
- title: `Answer · ${item.id}`,
282
- back,
283
- hint: item.question,
284
- value: "",
285
- submit: async (value) => {
286
- await answerAttention(api, back.episode, item.id, value.trim());
287
- return `Answered ${item.id} in ${rowByName(back.episode)?.number ?? back.episode}.`;
288
- },
289
- after: async () => {
290
- const fresh = await attentionOverlay(context, back.episode);
291
- setOverlay(fresh.type === "attention" ? { ...fresh, selected: back.selected } : fresh);
292
- },
293
- });
489
+ return updateTop({ ...top, selected: clamp(top.selected - 1, count) });
490
+ const door = top.doors.doors[top.selected];
491
+ if (door && key.return) {
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)}` });
294
494
  }
495
+ if (door && input === "r")
496
+ return void openOverlay({ type: "qr", title: top.doors.title, door, invert: false });
295
497
  return;
296
498
  }
297
- if (overlay.type === "card" && input === "o")
298
- return setOverlay({ type: "doors", doors: overlay.doors, selected: 0 });
299
- if (overlay.type === "doors") {
300
- const count = overlay.doors.doors.length;
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 });
301
507
  if (key.downArrow || input === "j")
302
- return setOverlay({ ...overlay, selected: clamp(overlay.selected + 1, count) });
508
+ return updateTop({ ...top, selected: clamp(top.selected + 1, shown.length) });
303
509
  if (key.upArrow || input === "k")
304
- return setOverlay({ ...overlay, selected: clamp(overlay.selected - 1, count) });
305
- const door = overlay.doors.doors[overlay.selected];
306
- if (door && key.return) {
307
- const used = openUrl(door.url);
308
- return setStatus(used ? `Opened ${door.label} with ${used}.` : `No opener here. The address is ${door.url}`);
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);
309
525
  }
310
- if (door && input === "r")
311
- return setOverlay({ type: "qr", title: overlay.doors.title, door, invert: false });
312
526
  }
313
- if (overlay.type === "qr" && input === "i")
314
- return setOverlay({ ...overlay, invert: !overlay.invert });
315
- if (overlay.type === "help")
316
- return setOverlay(null);
317
527
  return;
318
528
  }
319
- if (inputMode) {
529
+ if (s.inputMode) {
320
530
  if (key.escape || key.return)
321
- return setInputMode(false);
322
- // A Tab typed fast or pasted can arrive inside the text chunk.
323
- if (key.tab || input.includes("\t"))
324
- setSearchMode((mode) => (mode === "title" ? "full" : "title"));
325
- input = input.replace(/[\u0000-\u001f\u007f]/g, "");
326
- if (key.tab && !input)
327
- 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
+ }
328
539
  if (key.upArrow || key.downArrow)
329
540
  return move(key.downArrow ? 1 : -1, key);
330
541
  if (key.backspace || key.delete) {
331
- setListSel(0);
332
- return setQuery((text) => text.slice(0, -1));
542
+ set({ query: s.query.slice(0, -1), listSel: 0, boardSel: 0 });
543
+ return scheduleSearch();
333
544
  }
334
- if (input && !key.ctrl && !key.meta) {
335
- setListSel(0);
336
- 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();
337
549
  }
338
550
  return;
339
551
  }
340
- if (input === "/" && view !== "file") {
341
- if (view === "files")
342
- setView("list");
343
- return setInputMode(true);
552
+ if (input === "/" && s.view !== "file") {
553
+ return set({ inputMode: true, view: s.view === "files" ? "list" : s.view });
344
554
  }
345
- if (key.tab && view === "file" && open) {
346
- 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;
347
559
  if (count === 0)
348
560
  return;
349
- const refSel = key.shift ? (open.refSel <= 0 ? count - 1 : open.refSel - 1) : (open.refSel + 1) % count;
350
- const ref = open.view.refs[refSel];
351
- const line = open.view.lines.findIndex((screen) => screen.segments.some((s) => s.ref === refSel));
352
- const scroll = line < open.scroll || line >= open.scroll + bodyHeight ? Math.max(0, line - 2) : open.scroll;
353
- setStatus(`${ref.kind} · ${ref.uri}`);
354
- 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}`) });
355
566
  }
356
- if (key.tab && (view === "list" || view === "hits")) {
357
- const next = searchMode === "title" ? "full" : "title";
358
- setSearchMode(next);
359
- if (next === "title")
360
- setView("list");
361
- else if (hits && query.trim().length >= 2)
362
- setView("hits");
363
- 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();
364
571
  }
365
- const action = actionFor(actions, input, key, context);
366
- if (action) {
367
- 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) }));
368
576
  return;
369
577
  }
370
578
  if (key.downArrow || input === "j" || key.pageDown)
@@ -372,103 +580,169 @@ export function App({ root, query: initialQuery = "", search: initialSearch, epi
372
580
  if (key.upArrow || input === "k" || key.pageUp)
373
581
  return move(-1, key);
374
582
  if (key.home || input === "g")
375
- return move(-100000, { ...key, pageUp: false });
583
+ return move(-1_000_000, PLAIN_KEY);
376
584
  if (key.end || input === "G")
377
- return move(100000, { ...key, pageDown: false });
585
+ return move(1_000_000, PLAIN_KEY);
378
586
  if (key.return || key.rightArrow || input === "l")
379
587
  return forward();
380
588
  if (key.escape || key.leftArrow || input === "h" || key.backspace)
381
589
  return back();
382
590
  if (input === "?")
383
- return setOverlay({ type: "help" });
591
+ return void openOverlay({ type: "help" });
384
592
  if (input === "q")
385
593
  exit();
386
594
  }
387
- if (loadError)
388
- return _jsxs(Text, { color: "red", children: ["miadi-chronicle: ", loadError] });
389
- if (!chronicle)
390
- return _jsxs(Text, { children: ["Reading the chronicle at ", root, "\u2026"] });
391
- const withManifest = chronicle.rows.filter((row) => row.structured).length;
392
- // Under 90 columns (a phone, a split pane) one pane shows at a time and the
393
- // list gives its whole width to titles. Wider, the list and the episode sit
394
- // side by side.
395
- 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();
396
603
  const leftWidth = compact ? columns : Math.min(64, Math.max(36, Math.floor(columns * 0.4)));
397
604
  const rightWidth = compact ? columns : Math.max(20, columns - leftWidth - 1);
398
605
  const available = actions.filter((action) => action.available(context));
399
- 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;
400
607
  const showAge = columns >= 44;
401
- const ageWidth = Math.max(2, ...chronicle.rows.map((row) => ageOf(row.date).length));
402
- 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));
403
- const selectedRow = clamp(listSel, filtered.length);
404
- const selected = filtered[selectedRow];
405
- // A compact list ends with the selected episode's whole title, which the
406
- // 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.
407
613
  const peekLines = compact && selected ? 3 : 0;
408
- const listWindow = windowOf(filtered, selectedRow, bodyHeight - peekLines);
409
- 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) => {
410
616
  const isSelected = listWindow.start + index === selectedRow;
411
- return rowLine(row, isSelected && view === "list", isSelected && view === "files");
412
- }), 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] }));
413
619
  const hitRows = compact ? 2 : 1;
414
- const hitWindow = hits ? windowOf(hits.hits, hitSel, Math.max(1, Math.floor((bodyHeight - 1) / hitRows))) : null;
415
- 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) => {
416
- const number = rowByName(item.episodePath)?.number;
417
- const isSelected = hitWindow.start + index === hitSel;
418
- const where = (_jsxs(_Fragment, { children: [_jsx(Text, { color: "yellow", children: String(number ?? "·").padStart(numberWidth) }), " ", _jsxs(Text, { color: "blue", children: [item.file, ":", item.line] })] }));
419
- 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}`));
420
627
  })] }));
421
- const goal = detail?.summary?.goal?.replace(/^\s*(["'])([\s\S]*)\1\s*$/, "$2").replace(/\s+/g, " ").trim();
422
- // The goal gets the lines the file list does not need, and never all of them.
423
- const goalLines = goal ? Math.min(Math.ceil(goal.length / Math.max(20, rightWidth - 2)), compact ? 6 : 8) : 0;
424
- const fileWindow = windowOf(files, clamp(fileSel, files.length), Math.max(3, bodyHeight - 5 - goalLines));
425
- 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) => {
426
- const isSelected = view === "files" && fileWindow.start + index === clamp(fileSel, files.length);
427
- return (_jsxs(Text, { wrap: "truncate-start", inverse: isSelected, color: file.kind === "text" ? "blue" : "gray", children: [file.relativePath, file.kind === "text" ? "" : ` · ${file.kind}`] }, file.relativePath));
428
- })] })) : (_jsx(Text, { color: "gray", children: "Nothing selected." })) }));
429
- 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;
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}`));
636
+ })] }));
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 }) }));
430
654
  const overlayBody = (() => {
431
- if (!overlay)
655
+ const top = s.overlays[s.overlays.length - 1]?.overlay;
656
+ if (!top)
432
657
  return null;
433
- if (overlay.type === "doors") {
434
- 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" })] }));
435
660
  }
436
- if (overlay.type === "qr") {
437
- 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" })] }));
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] }));
438
671
  }
439
- if (overlay.type === "input") {
440
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingLeft: 1, paddingRight: 1, flexGrow: 1, children: [_jsx(Text, { bold: true, wrap: "truncate-end", children: overlay.title }), overlay.hint ? _jsx(Text, { color: "gray", wrap: "wrap", children: overlay.hint }) : null, _jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { color: "cyan", children: "\u203A " }), overlay.value, overlay.busy ? null : _jsx(Text, { inverse: true, children: " " })] }), overlay.busy ? _jsx(Text, { color: "gray", children: "Sending\u2026" }) : null, overlay.error ? _jsx(Text, { color: "red", wrap: "wrap", children: overlay.error }) : null, _jsx(Text, { color: "gray", children: "Enter sends \u00B7 Esc cancels" })] }));
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" })] }));
441
674
  }
442
- if (overlay.type === "attention") {
443
- const itemWindow = windowOf(overlay.items, overlay.selected, Math.max(2, Math.floor((bodyHeight - 4) / 3)));
444
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingLeft: 1, paddingRight: 1, flexGrow: 1, children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["Attention \u00B7 ", rowByName(overlay.episode)?.number ?? overlay.episode, " ", _jsxs(Text, { color: "gray", children: [overlay.items.filter((item) => item.state === "open").length, " open \u00B7 ", overlay.source === "app" ? "from the app" : "from disk"] })] }), itemWindow.items.map((item, index) => {
445
- const selected = itemWindow.start + index === overlay.selected;
446
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { wrap: "truncate-end", inverse: selected, children: [_jsx(Text, { color: item.state === "open" ? "yellow" : "green", children: item.state === "open" ? "?" : "✓" }), " ", item.id] }), _jsx(Box, { height: compact ? 2 : 1, overflow: "hidden", children: _jsxs(Text, { wrap: "wrap", color: selected ? undefined : "gray", children: [" ", item.state === "open" ? item.question : item.answer ?? item.question] }) })] }, item.id));
447
- }), _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u2191\u2193 \u00B7 ", overlay.canAnswer ? "Enter answers an open item" : `answering needs ${TOKEN_ENV}`, " \u00B7 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" })] }));
448
677
  }
449
- if (overlay.type === "card") {
450
- const card = overlay.card;
451
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "magenta", paddingLeft: 1, paddingRight: 1, flexGrow: 1, children: [card.kind === "ceremony" ? (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["\u25C6 ", card.type ?? "ceremony", " \u00B7 ", card.direction ?? "no direction", " \u00B7 ", card.closed ? "closed" : "open"] }), card.intentions.map((line, index) => (_jsx(Text, { wrap: "wrap", children: line }, index))), card.episode ? _jsxs(Text, { color: "cyan", wrap: "truncate-end", children: ["\u2197 ", rowByName(card.episode)?.number ?? "", " ", rowByName(card.episode)?.title ?? card.episode] }) : null, card.circle ? _jsxs(Text, { color: "gray", wrap: "truncate-end", children: ["\u25CB held in ", card.circle] }) : _jsx(Text, { color: "gray", children: "held in no circle" })] })) : (_jsxs(_Fragment, { children: [_jsxs(Text, { bold: true, wrap: "truncate-end", children: ["\u25CB ", card.name, " \u00B7 ", card.direction ?? "no direction", " \u00B7 ", card.active ? "active" : "inactive"] }), card.intention ? _jsx(Text, { wrap: "wrap", children: 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"}`].filter(Boolean).join(" · ") })] })), _jsxs(Text, { color: "green", wrap: "truncate-end", children: ["your seat: ", card.can.length ? card.can.join(", ") : "read"] }), _jsx(Text, { color: "gray", wrap: "truncate-end", children: "o doors \u00B7 Esc" })] }));
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" })] }));
452
685
  }
453
- if (overlay.type === "proof") {
454
- 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" })] }));
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"] })] }));
455
688
  }
456
- 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." })] }));
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";
457
720
  })();
458
- const where = view === "file" && open
459
- ? `${rowByName(open.name)?.number ?? ""} · ${open.path} · l.${open.view.lines[open.scroll]?.source ?? 1}`
460
- : compact
461
- ? `${chronicle.rows.length} episodes`
462
- : `${chronicle.root} · ${chronicle.rows.length} episodes · ${withManifest} with episode.yaml`;
463
721
  const body = (() => {
464
722
  if (overlayBody)
465
723
  return overlayBody;
466
- if (view === "file")
724
+ if (s.view === "file")
467
725
  return fileBody;
468
- if (compact)
469
- return view === "hits" ? hitsPane : view === "files" ? detailPane : listPane;
470
- 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] }));
471
736
  })();
472
- 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 }), api ? (_jsxs(Text, { color: reader ? "green" : "gray", children: [" · ", reader ? `as ${reader.name}` : "read-only"] })) : null] }), _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 })] })] }));
473
747
  }
474
748
  //# sourceMappingURL=App.js.map