mdmaid-desk 0.1.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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +215 -0
  3. package/dist/api-client.d.ts +20 -0
  4. package/dist/api-client.js +264 -0
  5. package/dist/api-client.js.map +1 -0
  6. package/dist/api-types.d.ts +51 -0
  7. package/dist/api-types.js +2 -0
  8. package/dist/api-types.js.map +1 -0
  9. package/dist/auth-state.d.ts +2 -0
  10. package/dist/auth-state.js +113 -0
  11. package/dist/auth-state.js.map +1 -0
  12. package/dist/catalog.d.ts +46 -0
  13. package/dist/catalog.js +659 -0
  14. package/dist/catalog.js.map +1 -0
  15. package/dist/cli.d.ts +16 -0
  16. package/dist/cli.js +370 -0
  17. package/dist/cli.js.map +1 -0
  18. package/dist/daemon-state.d.ts +17 -0
  19. package/dist/daemon-state.js +204 -0
  20. package/dist/daemon-state.js.map +1 -0
  21. package/dist/domain.d.ts +49 -0
  22. package/dist/domain.js +44 -0
  23. package/dist/domain.js.map +1 -0
  24. package/dist/fs-durability.d.ts +2 -0
  25. package/dist/fs-durability.js +35 -0
  26. package/dist/fs-durability.js.map +1 -0
  27. package/dist/package-info.d.ts +1 -0
  28. package/dist/package-info.js +17 -0
  29. package/dist/package-info.js.map +1 -0
  30. package/dist/server.d.ts +18 -0
  31. package/dist/server.js +702 -0
  32. package/dist/server.js.map +1 -0
  33. package/dist/sqlite-storage.d.ts +17 -0
  34. package/dist/sqlite-storage.js +342 -0
  35. package/dist/sqlite-storage.js.map +1 -0
  36. package/dist/storage.d.ts +12 -0
  37. package/dist/storage.js +2 -0
  38. package/dist/storage.js.map +1 -0
  39. package/dist/terminal-text.d.ts +9 -0
  40. package/dist/terminal-text.js +156 -0
  41. package/dist/terminal-text.js.map +1 -0
  42. package/dist/tui.d.ts +63 -0
  43. package/dist/tui.js +912 -0
  44. package/dist/tui.js.map +1 -0
  45. package/dist/web-client.d.ts +21 -0
  46. package/dist/web-client.js +337 -0
  47. package/dist/web-client.js.map +1 -0
  48. package/dist/web-styles.d.ts +1 -0
  49. package/dist/web-styles.js +249 -0
  50. package/dist/web-styles.js.map +1 -0
  51. package/package.json +60 -0
package/dist/tui.js ADDED
@@ -0,0 +1,912 @@
1
+ import { Chalk } from "chalk";
2
+ import sliceAnsi from "slice-ansi";
3
+ import stringWidth from "string-width";
4
+ import { sanitizeTerminalText } from "./terminal-text.js";
5
+ export function createTuiState(documents, workspaces) {
6
+ const state = {
7
+ documents,
8
+ mode: "queue",
9
+ search: "",
10
+ searching: false,
11
+ selectedIndex: 0,
12
+ statusFilter: "all",
13
+ visibleDocuments: [],
14
+ workspaces,
15
+ scroll: 0,
16
+ };
17
+ return applyFilters(state);
18
+ }
19
+ export function applyTuiReader(state, document, content, backend, warnings) {
20
+ return {
21
+ ...state,
22
+ mode: "reader",
23
+ reader: { backend, content, document, warnings },
24
+ scroll: 0,
25
+ searching: false,
26
+ message: undefined,
27
+ };
28
+ }
29
+ export function replaceTuiDocuments(state, documents) {
30
+ const selectedId = state.visibleDocuments[state.selectedIndex]?.id;
31
+ let next = applyFilters({ ...state, documents });
32
+ const selectedIndex = selectedId
33
+ ? next.visibleDocuments.findIndex(({ id }) => id === selectedId)
34
+ : -1;
35
+ next = {
36
+ ...next,
37
+ selectedIndex: selectedIndex >= 0
38
+ ? selectedIndex
39
+ : clamp(next.selectedIndex, 0, Math.max(0, next.visibleDocuments.length - 1)),
40
+ };
41
+ if (next.reader) {
42
+ const updated = documents.find(({ id }) => id === next.reader?.document.id);
43
+ if (updated) {
44
+ next = { ...next, reader: { ...next.reader, document: updated } };
45
+ }
46
+ }
47
+ return next;
48
+ }
49
+ export function handleTuiKey(state, key) {
50
+ if (key === "ctrl-c") {
51
+ return { state, effects: [{ type: "quit" }] };
52
+ }
53
+ if (state.searching) {
54
+ return handleSearchKey(state, key);
55
+ }
56
+ if (key === "q") {
57
+ return { state, effects: [{ type: "quit" }] };
58
+ }
59
+ if (state.mode === "reader") {
60
+ return handleReaderKey(state, key);
61
+ }
62
+ return handleQueueKey(state, key);
63
+ }
64
+ export function handleTuiMouse(state, event, width, height) {
65
+ if (!Number.isSafeInteger(event.x) ||
66
+ !Number.isSafeInteger(event.y) ||
67
+ event.x < 1 ||
68
+ event.y < 1) {
69
+ return { state, effects: [] };
70
+ }
71
+ if (event.button === "wheel-down") {
72
+ return state.mode === "reader"
73
+ ? {
74
+ state: clampReaderScroll({ ...state, scroll: state.scroll + 3 }, height),
75
+ effects: [],
76
+ }
77
+ : moveQueueSelection(state, 3);
78
+ }
79
+ if (event.button === "wheel-up") {
80
+ return state.mode === "reader"
81
+ ? {
82
+ state: clampReaderScroll({ ...state, scroll: Math.max(0, state.scroll - 3) }, height),
83
+ effects: [],
84
+ }
85
+ : moveQueueSelection(state, -3);
86
+ }
87
+ const safeWidth = clamp(Math.floor(width), 40, 1_000);
88
+ const safeHeight = clamp(Math.floor(height), 8, 500);
89
+ if (event.y === safeHeight - 1) {
90
+ const key = footerKeyAt(state, event.x);
91
+ return key ? handleTuiKey(state, key) : { state, effects: [] };
92
+ }
93
+ if (state.mode === "reader") {
94
+ return { state, effects: [] };
95
+ }
96
+ return handleQueueMouse(state, event, safeWidth, safeHeight);
97
+ }
98
+ export function renderTui(state, width, height, options = {}) {
99
+ const safeWidth = clamp(Math.floor(width), 40, 1_000);
100
+ const safeHeight = clamp(Math.floor(height), 8, 500);
101
+ const innerWidth = safeWidth - 2;
102
+ const borders = createBorders(options.unicode !== false);
103
+ const theme = createTuiTheme(options.color === true);
104
+ const top = theme.line(`${borders.topLeft}${borders.horizontal.repeat(innerWidth)}${borders.topRight}`);
105
+ const bottom = theme.line(`${borders.bottomLeft}${borders.horizontal.repeat(innerWidth)}${borders.bottomRight}`);
106
+ const divider = theme.line(`${borders.middleLeft}${borders.horizontal.repeat(innerWidth)}${borders.middleRight}`);
107
+ const title = renderWorkspaceTitle(state, innerWidth, theme, options.unicode !== false);
108
+ const footer = state.mode === "reader"
109
+ ? renderShortcutBar([["j/k", "scroll"], ["m", "read"], ["u", "unread"], ["a", "archive"], ["b", "queue"], ["q", "quit"]], theme)
110
+ : state.searching
111
+ ? `${theme.accent("SEARCH")} ${theme.ink(`${sanitizeTerminalText(state.search)}_`)} ${theme.muted("enter apply esc clear")}`
112
+ : renderShortcutBar([["j/k", "move"], ["enter", "open"], ["s", "status"], ["p", "project"], ["/", "search"], ["q", "quit"]], theme);
113
+ const bodyHeight = safeHeight - 6;
114
+ const body = state.mode === "reader"
115
+ ? readerLines(state, innerWidth, bodyHeight, theme, borders)
116
+ : queueLines(state, innerWidth, bodyHeight, theme, borders);
117
+ const lines = [top, boxLine(title, innerWidth, borders, theme), divider];
118
+ for (let index = 0; index < bodyHeight; index += 1) {
119
+ lines.push(boxLine(body[index] ?? "", innerWidth, borders, theme));
120
+ }
121
+ lines.push(divider, boxLine(footer, innerWidth, borders, theme), bottom);
122
+ return lines.join("\n");
123
+ }
124
+ export async function runTui(client, io = {}) {
125
+ const input = io.input ?? process.stdin;
126
+ const output = io.output ?? process.stdout;
127
+ const env = io.env ?? process.env;
128
+ const color = !("NO_COLOR" in env) &&
129
+ (io.color ?? Boolean(output.isTTY && env.TERM !== "dumb"));
130
+ const unicode = io.unicode ?? env.TERM !== "dumb";
131
+ let state = createTuiState(await client.listDocuments(), await client.listWorkspaces());
132
+ const wasRaw = input.isRaw;
133
+ const eventController = new AbortController();
134
+ let finished = false;
135
+ let processing = Promise.resolve();
136
+ let refreshing = Promise.resolve();
137
+ let resizeTimer;
138
+ let renderedWidth = readerRenderWidth(output.columns ?? 100);
139
+ let inputBuffer = "";
140
+ let previousFrame;
141
+ const draw = () => {
142
+ const width = output.columns ?? 100;
143
+ const height = output.rows ?? 30;
144
+ const frame = renderTui(state, width, height, { color, unicode });
145
+ const lines = frame.split("\n");
146
+ if (!previousFrame) {
147
+ output.write(`\u001b[H\u001b[2J${frame}`);
148
+ }
149
+ else {
150
+ const updates = [];
151
+ for (let index = 0; index < lines.length; index += 1) {
152
+ if (lines[index] !== previousFrame[index]) {
153
+ updates.push(`\u001b[${index + 1};1H${lines[index] ?? ""}\u001b[K`);
154
+ }
155
+ }
156
+ if (updates.length > 0) {
157
+ output.write(updates.join(""));
158
+ }
159
+ }
160
+ previousFrame = lines;
161
+ };
162
+ const cleanup = () => {
163
+ if (finished) {
164
+ return;
165
+ }
166
+ finished = true;
167
+ input.off("data", onData);
168
+ process.off("SIGWINCH", onResize);
169
+ eventController.abort();
170
+ if (resizeTimer) {
171
+ clearTimeout(resizeTimer);
172
+ }
173
+ if (input.isTTY) {
174
+ input.setRawMode(Boolean(wasRaw));
175
+ }
176
+ input.pause();
177
+ output.write("\u001b[?1006l\u001b[?1000l\u001b[?25h\u001b[?1049l");
178
+ };
179
+ const execute = async (effect) => {
180
+ if (effect.type === "quit") {
181
+ cleanup();
182
+ return;
183
+ }
184
+ try {
185
+ if (effect.type === "open") {
186
+ const rendered = await client.renderDocument(effect.documentId, "terminal", readerRenderWidth(output.columns ?? 100), { color, unicode });
187
+ renderedWidth = readerRenderWidth(output.columns ?? 100);
188
+ state = applyTuiReader(state, rendered.document, rendered.content, rendered.backend, rendered.warnings);
189
+ draw();
190
+ await client.act(effect.documentId, "opened");
191
+ }
192
+ else {
193
+ await client.act(effect.documentId, effect.action);
194
+ if (effect.action === "archive") {
195
+ state = { ...state, mode: "queue", reader: undefined, scroll: 0 };
196
+ }
197
+ }
198
+ state = replaceTuiDocuments(state, await client.listDocuments());
199
+ draw();
200
+ }
201
+ catch (error) {
202
+ state = {
203
+ ...state,
204
+ message: error instanceof Error ? error.message : "TUI request failed",
205
+ };
206
+ draw();
207
+ }
208
+ };
209
+ const onData = (chunk) => {
210
+ inputBuffer += chunk.toString();
211
+ const decoded = decodeInput(inputBuffer);
212
+ inputBuffer = decoded.remainder;
213
+ let changed = false;
214
+ for (const inputEvent of decoded.events) {
215
+ if (finished) {
216
+ return;
217
+ }
218
+ const transition = inputEvent.type === "mouse"
219
+ ? handleTuiMouse(state, inputEvent.event, output.columns ?? 100, output.rows ?? 30)
220
+ : handleTuiKey(state, inputEvent.key);
221
+ state = clampReaderScroll(transition.state, output.rows ?? 30);
222
+ changed = true;
223
+ for (const effect of transition.effects) {
224
+ processing = processing.then(() => execute(effect));
225
+ }
226
+ }
227
+ if (changed) {
228
+ draw();
229
+ }
230
+ };
231
+ const onResize = () => {
232
+ draw();
233
+ if (!state.reader) {
234
+ return;
235
+ }
236
+ const nextWidth = readerRenderWidth(output.columns ?? 100);
237
+ if (nextWidth === renderedWidth) {
238
+ return;
239
+ }
240
+ if (resizeTimer) {
241
+ clearTimeout(resizeTimer);
242
+ }
243
+ resizeTimer = setTimeout(() => {
244
+ const documentId = state.reader?.document.id;
245
+ if (!documentId || finished) {
246
+ return;
247
+ }
248
+ processing = processing.then(async () => {
249
+ try {
250
+ const rendered = await client.renderDocument(documentId, "terminal", nextWidth, { color, unicode });
251
+ renderedWidth = nextWidth;
252
+ const scroll = state.scroll;
253
+ state = applyTuiReader(state, rendered.document, rendered.content, rendered.backend, rendered.warnings);
254
+ state = { ...state, scroll };
255
+ draw();
256
+ }
257
+ catch (error) {
258
+ state = {
259
+ ...state,
260
+ message: error instanceof Error ? error.message : "Resize render failed",
261
+ };
262
+ draw();
263
+ }
264
+ });
265
+ }, 80);
266
+ };
267
+ output.write("\u001b[?1049h\u001b[?25l\u001b[?1000h\u001b[?1006h");
268
+ if (input.isTTY) {
269
+ input.setRawMode(true);
270
+ }
271
+ input.resume();
272
+ input.on("data", onData);
273
+ process.on("SIGWINCH", onResize);
274
+ draw();
275
+ void client
276
+ .subscribeCatalog(() => {
277
+ refreshing = refreshing
278
+ .then(async () => {
279
+ const [documents, workspaces] = await Promise.all([
280
+ client.listDocuments(),
281
+ client.listWorkspaces(),
282
+ ]);
283
+ state = {
284
+ ...replaceTuiDocuments(state, documents),
285
+ workspaces,
286
+ };
287
+ draw();
288
+ })
289
+ .catch((error) => {
290
+ if (!finished) {
291
+ state = {
292
+ ...state,
293
+ message: error instanceof Error ? error.message : "Live refresh failed",
294
+ };
295
+ draw();
296
+ }
297
+ });
298
+ }, { signal: eventController.signal })
299
+ .catch((error) => {
300
+ if (!finished) {
301
+ state = {
302
+ ...state,
303
+ message: error instanceof Error ? error.message : "Live events failed",
304
+ };
305
+ draw();
306
+ }
307
+ });
308
+ await new Promise((resolve) => {
309
+ const interval = setInterval(() => {
310
+ if (finished) {
311
+ clearInterval(interval);
312
+ void processing.finally(resolve);
313
+ }
314
+ }, 25);
315
+ });
316
+ }
317
+ function handleSearchKey(state, key) {
318
+ if (key === "enter") {
319
+ return { state: { ...state, searching: false }, effects: [] };
320
+ }
321
+ if (key === "escape") {
322
+ return {
323
+ state: applyFilters({ ...state, search: "", searching: false }),
324
+ effects: [],
325
+ };
326
+ }
327
+ if (key === "backspace") {
328
+ return {
329
+ state: applyFilters({ ...state, search: state.search.slice(0, -1) }),
330
+ effects: [],
331
+ };
332
+ }
333
+ if (key.length === 1 && key >= " " && key <= "~") {
334
+ return {
335
+ state: applyFilters({ ...state, search: `${state.search}${key}` }),
336
+ effects: [],
337
+ };
338
+ }
339
+ return { state, effects: [] };
340
+ }
341
+ function handleQueueKey(state, key) {
342
+ if (key === "j" || key === "down") {
343
+ return moveQueueSelection(state, 1);
344
+ }
345
+ if (key === "k" || key === "up") {
346
+ return moveQueueSelection(state, -1);
347
+ }
348
+ if (key === "enter") {
349
+ const document = state.visibleDocuments[state.selectedIndex];
350
+ return {
351
+ state,
352
+ effects: document ? [{ type: "open", documentId: document.id }] : [],
353
+ };
354
+ }
355
+ if (key === "s") {
356
+ const filters = ["all", "unread", "reading", "done"];
357
+ const index = filters.indexOf(state.statusFilter);
358
+ return {
359
+ state: applyFilters({
360
+ ...state,
361
+ statusFilter: filters[(index + 1) % filters.length] ?? "all",
362
+ }),
363
+ effects: [],
364
+ };
365
+ }
366
+ if (key === "p") {
367
+ const workspaceIds = [undefined, ...state.workspaces.map(({ id }) => id)];
368
+ const index = workspaceIds.indexOf(state.workspaceFilter);
369
+ const workspaceFilter = workspaceIds[(index + 1) % workspaceIds.length];
370
+ return {
371
+ state: applyFilters({ ...state, workspaceFilter }),
372
+ effects: [],
373
+ };
374
+ }
375
+ if (key === "/") {
376
+ return { state: { ...state, searching: true }, effects: [] };
377
+ }
378
+ return { state, effects: [] };
379
+ }
380
+ function handleReaderKey(state, key) {
381
+ if (key === "b" || key === "escape") {
382
+ return {
383
+ state: { ...state, mode: "queue", reader: undefined, scroll: 0 },
384
+ effects: [],
385
+ };
386
+ }
387
+ if (key === "j" || key === "down") {
388
+ return { state: { ...state, scroll: state.scroll + 1 }, effects: [] };
389
+ }
390
+ if (key === "k" || key === "up") {
391
+ return {
392
+ state: { ...state, scroll: Math.max(0, state.scroll - 1) },
393
+ effects: [],
394
+ };
395
+ }
396
+ if (key === "pagedown") {
397
+ return { state: { ...state, scroll: state.scroll + 10 }, effects: [] };
398
+ }
399
+ if (key === "pageup") {
400
+ return {
401
+ state: { ...state, scroll: Math.max(0, state.scroll - 10) },
402
+ effects: [],
403
+ };
404
+ }
405
+ const actions = {
406
+ m: "read",
407
+ u: "unread",
408
+ a: "archive",
409
+ };
410
+ const action = actions[key];
411
+ if (action && state.reader) {
412
+ return {
413
+ state,
414
+ effects: [
415
+ { type: "action", action, documentId: state.reader.document.id },
416
+ ],
417
+ };
418
+ }
419
+ return { state, effects: [] };
420
+ }
421
+ function moveQueueSelection(state, amount) {
422
+ return {
423
+ state: {
424
+ ...state,
425
+ selectedIndex: clamp(state.selectedIndex + amount, 0, Math.max(0, state.visibleDocuments.length - 1)),
426
+ },
427
+ effects: [],
428
+ };
429
+ }
430
+ function clampReaderScroll(state, terminalHeight) {
431
+ if (!state.reader) {
432
+ return state;
433
+ }
434
+ const safeHeight = clamp(Math.floor(terminalHeight), 8, 500);
435
+ const bodyHeight = safeHeight - 6;
436
+ const headerLength = state.message ? 4 : 3;
437
+ const available = Math.max(1, bodyHeight - headerLength);
438
+ const contentLines = sanitizeTerminalText(state.reader.content).split("\n").length;
439
+ const warningLines = state.reader.warnings.length > 0
440
+ ? state.reader.warnings.length + 1
441
+ : 0;
442
+ const maximum = Math.max(0, contentLines + warningLines - available);
443
+ return { ...state, scroll: clamp(state.scroll, 0, maximum) };
444
+ }
445
+ function applyFilters(state) {
446
+ const terms = state.search.trim().toLowerCase().split(/\s+/).filter(Boolean);
447
+ const visibleDocuments = state.documents.filter((document) => {
448
+ if (state.statusFilter !== "all" && document.status !== state.statusFilter) {
449
+ return false;
450
+ }
451
+ if (state.workspaceFilter !== undefined &&
452
+ document.workspaceId !== state.workspaceFilter) {
453
+ return false;
454
+ }
455
+ const searchable = [
456
+ document.title,
457
+ document.workspaceId,
458
+ document.taskId ?? "",
459
+ document.producer ?? "",
460
+ document.kind,
461
+ document.attention,
462
+ ...document.tags,
463
+ ].join(" ").toLowerCase();
464
+ return terms.every((term) => searchable.includes(term));
465
+ });
466
+ return {
467
+ ...state,
468
+ selectedIndex: clamp(state.selectedIndex, 0, Math.max(0, visibleDocuments.length - 1)),
469
+ visibleDocuments,
470
+ };
471
+ }
472
+ function createBorders(unicode) {
473
+ return unicode
474
+ ? {
475
+ bottomLeft: "└",
476
+ bottomRight: "┘",
477
+ horizontal: "─",
478
+ middleLeft: "├",
479
+ middleRight: "┤",
480
+ topLeft: "┌",
481
+ topRight: "┐",
482
+ vertical: "│",
483
+ }
484
+ : {
485
+ bottomLeft: "+",
486
+ bottomRight: "+",
487
+ horizontal: "-",
488
+ middleLeft: "+",
489
+ middleRight: "+",
490
+ topLeft: "+",
491
+ topRight: "+",
492
+ vertical: "|",
493
+ };
494
+ }
495
+ function createTuiTheme(color) {
496
+ const styles = new Chalk({ level: color ? 3 : 0 });
497
+ return {
498
+ accent: styles.rgb(255, 119, 88),
499
+ brand: styles.rgb(255, 119, 88).bold,
500
+ color,
501
+ done: styles.rgb(105, 198, 154),
502
+ ink: styles,
503
+ line: styles.rgb(143, 181, 175),
504
+ muted: styles.rgb(170, 166, 154),
505
+ reading: styles.rgb(124, 160, 255),
506
+ styles,
507
+ };
508
+ }
509
+ function renderWorkspaceTitle(state, width, theme, unicode) {
510
+ const section = state.mode === "reader" ? "DOCUMENT READER" : "DOCUMENT INBOX";
511
+ const left = `${theme.brand("mdmaid.desk")} ${theme.muted("/")} ${theme.styles.bold(section)}`;
512
+ const right = theme.done(`${unicode ? "●" : "*"} LIVE`);
513
+ return spread(left, right, width);
514
+ }
515
+ function renderShortcutBar(shortcuts, theme) {
516
+ return shortcuts
517
+ .map(([key, action]) => `${theme.accent(theme.styles.bold(key))} ${theme.muted(action)}`)
518
+ .join(theme.muted(" "));
519
+ }
520
+ function queueLines(state, width, height, theme, borders) {
521
+ const showSidebar = width >= 96;
522
+ if (!showSidebar) {
523
+ return queueMainLines(state, width, height, theme, borders);
524
+ }
525
+ const sidebarWidth = 25;
526
+ const mainWidth = width - sidebarWidth - 1;
527
+ const sidebar = sidebarLines(state, sidebarWidth, height, theme, borders.vertical === "│");
528
+ const main = queueMainLines(state, mainWidth, height, theme, borders);
529
+ return Array.from({ length: height }, (_, index) => `${fitLine(sidebar[index] ?? "", sidebarWidth)}${theme.line(borders.vertical)}${fitLine(main[index] ?? "", mainWidth)}`);
530
+ }
531
+ function handleQueueMouse(state, event, safeWidth, safeHeight) {
532
+ const innerWidth = safeWidth - 2;
533
+ const bodyHeight = safeHeight - 6;
534
+ const bodyIndex = event.y - 4;
535
+ if (bodyIndex < 0 || bodyIndex >= bodyHeight) {
536
+ return { state, effects: [] };
537
+ }
538
+ const showSidebar = innerWidth >= 96;
539
+ const sidebarWidth = showSidebar ? 25 : 0;
540
+ if (showSidebar &&
541
+ event.x >= 2 &&
542
+ event.x < 2 + sidebarWidth) {
543
+ if (bodyIndex === 2) {
544
+ return {
545
+ state: applyFilters({ ...state, workspaceFilter: undefined }),
546
+ effects: [],
547
+ };
548
+ }
549
+ const workspaceIndex = bodyIndex - 3;
550
+ const workspace = state.workspaces[workspaceIndex];
551
+ if (workspace) {
552
+ return {
553
+ state: applyFilters({ ...state, workspaceFilter: workspace.id }),
554
+ effects: [],
555
+ };
556
+ }
557
+ const statusIndex = bodyIndex - (6 + state.workspaces.length);
558
+ const statuses = ["all", "unread", "reading", "done"];
559
+ const status = statuses[statusIndex];
560
+ if (status) {
561
+ return {
562
+ state: applyFilters({ ...state, statusFilter: status }),
563
+ effects: [],
564
+ };
565
+ }
566
+ return { state, effects: [] };
567
+ }
568
+ const mainX = showSidebar ? 2 + sidebarWidth + 1 : 2;
569
+ const mainWidth = showSidebar ? innerWidth - sidebarWidth - 1 : innerWidth;
570
+ const relativeX = event.x - mainX;
571
+ const cardLine = bodyIndex - 2;
572
+ if (relativeX < 0 || relativeX >= mainWidth || cardLine < 0) {
573
+ return { state, effects: [] };
574
+ }
575
+ const lineInCard = cardLine % 5;
576
+ if (lineInCard >= 4) {
577
+ return { state, effects: [] };
578
+ }
579
+ const metrics = queuePageMetrics(state, mainWidth, bodyHeight);
580
+ let column = 0;
581
+ if (metrics.columns === 2) {
582
+ if (relativeX === metrics.firstWidth) {
583
+ return { state, effects: [] };
584
+ }
585
+ column = relativeX > metrics.firstWidth ? 1 : 0;
586
+ }
587
+ const row = Math.floor(cardLine / 5);
588
+ const documentIndex = (metrics.startRow + row) * metrics.columns + column;
589
+ const document = state.visibleDocuments[documentIndex];
590
+ if (!document) {
591
+ return { state, effects: [] };
592
+ }
593
+ return {
594
+ state: { ...state, selectedIndex: documentIndex },
595
+ effects: [{ type: "open", documentId: document.id }],
596
+ };
597
+ }
598
+ function footerKeyAt(state, x) {
599
+ if (state.searching || x < 2) {
600
+ return undefined;
601
+ }
602
+ const shortcuts = state.mode === "reader"
603
+ ? [
604
+ ["j/k", "scroll", undefined],
605
+ ["m", "read", "m"],
606
+ ["u", "unread", "u"],
607
+ ["a", "archive", "a"],
608
+ ["b", "queue", "b"],
609
+ ["q", "quit", "q"],
610
+ ]
611
+ : [
612
+ ["j/k", "move", undefined],
613
+ ["enter", "open", "enter"],
614
+ ["s", "status", "s"],
615
+ ["p", "project", "p"],
616
+ ["/", "search", "/"],
617
+ ["q", "quit", "q"],
618
+ ];
619
+ let cursor = 2;
620
+ for (const [label, action, key] of shortcuts) {
621
+ const end = cursor + stringWidth(`${label} ${action}`);
622
+ if (x >= cursor && x < end) {
623
+ return key;
624
+ }
625
+ cursor = end + 3;
626
+ }
627
+ return undefined;
628
+ }
629
+ function sidebarLines(state, width, height, theme, unicode) {
630
+ const lines = [theme.accent(theme.styles.bold(" PROJECTS")), ""];
631
+ lines.push(navigationLine("All documents", state.documents.length, state.workspaceFilter === undefined, width, theme, unicode));
632
+ for (const workspace of state.workspaces) {
633
+ lines.push(navigationLine(workspace.name, workspace.documentCount, state.workspaceFilter === workspace.id, width, theme, unicode));
634
+ }
635
+ lines.push("", theme.accent(theme.styles.bold(" STATUS")), "");
636
+ const counts = {
637
+ all: state.documents.length,
638
+ unread: state.documents.filter(({ status }) => status === "unread").length,
639
+ reading: state.documents.filter(({ status }) => status === "reading").length,
640
+ done: state.documents.filter(({ status }) => status === "done").length,
641
+ };
642
+ const labels = {
643
+ all: "All",
644
+ unread: "New",
645
+ reading: "Reading",
646
+ done: "Done",
647
+ };
648
+ for (const status of ["all", "unread", "reading", "done"]) {
649
+ lines.push(navigationLine(labels[status], counts[status], state.statusFilter === status, width, theme, unicode, status === "all" ? undefined : status));
650
+ }
651
+ return lines.slice(0, height);
652
+ }
653
+ function navigationLine(label, count, selected, width, theme, unicode, status) {
654
+ const marker = selected ? (unicode ? "●" : "*") : " ";
655
+ const markerStyle = status ? statusStyle(theme, status) : theme.accent;
656
+ const safeLabel = sanitizeTerminalText(label).replace(/\s+/g, " ");
657
+ const text = selected ? theme.styles.bold(safeLabel) : theme.muted(safeLabel);
658
+ return spread(` ${markerStyle(marker)} ${text}`, theme.muted(String(count)), width - 1);
659
+ }
660
+ function queuePageMetrics(state, width, height) {
661
+ const columns = width >= 84 ? 2 : 1;
662
+ const gap = columns === 2 ? 1 : 0;
663
+ const firstWidth = columns === 2 ? Math.floor((width - gap) / 2) : width;
664
+ const rowsPerPage = Math.max(1, Math.floor((height - 2 + 1) / 5));
665
+ const selectedRow = Math.floor(state.selectedIndex / columns);
666
+ const totalRows = Math.ceil(state.visibleDocuments.length / columns);
667
+ const startRow = clamp(selectedRow - Math.floor(rowsPerPage / 2), 0, Math.max(0, totalRows - rowsPerPage));
668
+ return { columns, firstWidth, rowsPerPage, startRow, totalRows };
669
+ }
670
+ function queueMainLines(state, width, height, theme, borders) {
671
+ const count = state.visibleDocuments.length;
672
+ const project = state.workspaceFilter
673
+ ? state.workspaces.find(({ id }) => id === state.workspaceFilter)?.name ?? state.workspaceFilter
674
+ : "All projects";
675
+ const filter = state.statusFilter === "all" ? "All statuses" : statusLabel(state.statusFilter);
676
+ const view = state.search ? `${project} · ${filter} · “${state.search}”` : `${project} · ${filter}`;
677
+ const lines = [
678
+ spread(theme.styles.bold(`${count} ${count === 1 ? "document" : "documents"}`), theme.muted(view), width),
679
+ state.message ? theme.accent(`! ${sanitizeTerminalText(state.message)}`) : "",
680
+ ];
681
+ if (count === 0) {
682
+ lines.push("", theme.muted("No documents match this view."));
683
+ return lines;
684
+ }
685
+ const metrics = queuePageMetrics(state, width, height);
686
+ const { columns, firstWidth, rowsPerPage, startRow, totalRows } = metrics;
687
+ const gap = columns === 2 ? 1 : 0;
688
+ const secondWidth = width - firstWidth - gap;
689
+ for (let row = startRow; row < Math.min(totalRows, startRow + rowsPerPage); row += 1) {
690
+ const firstIndex = row * columns;
691
+ const first = renderDocumentCard(state.visibleDocuments[firstIndex], firstWidth, firstIndex === state.selectedIndex, theme, borders);
692
+ const secondDocument = columns === 2
693
+ ? state.visibleDocuments[firstIndex + 1]
694
+ : undefined;
695
+ const second = secondDocument
696
+ ? renderDocumentCard(secondDocument, secondWidth, firstIndex + 1 === state.selectedIndex, theme, borders)
697
+ : Array.from({ length: first.length }, () => " ".repeat(secondWidth));
698
+ for (let line = 0; line < first.length; line += 1) {
699
+ lines.push(columns === 2
700
+ ? `${first[line] ?? ""}${" ".repeat(gap)}${second[line] ?? ""}`
701
+ : first[line] ?? "");
702
+ }
703
+ if (row < Math.min(totalRows, startRow + rowsPerPage) - 1) {
704
+ lines.push("");
705
+ }
706
+ }
707
+ return lines.slice(0, height);
708
+ }
709
+ function renderDocumentCard(document, width, selected, theme, borders) {
710
+ const borderStyle = selected ? theme.accent : theme.line;
711
+ const status = statusStyle(theme, document.status);
712
+ const label = status(`${statusSymbol(document.status, borders.vertical === "│")} ${statusLabel(document.status).toUpperCase()}`);
713
+ const topPrefix = `${borderStyle(`${borders.topLeft}${borders.horizontal}`)} ${label} `;
714
+ const topFill = Math.max(0, width - stringWidth(topPrefix) - 1);
715
+ const top = `${topPrefix}${borderStyle(`${borders.horizontal.repeat(topFill)}${borders.topRight}`)}`;
716
+ const marker = selected ? theme.accent("› ") : " ";
717
+ const title = selected
718
+ ? theme.styles.bold(sanitizeTerminalText(document.title))
719
+ : theme.ink(sanitizeTerminalText(document.title));
720
+ const meta = [document.workspaceId, document.kind, document.taskId]
721
+ .filter((value) => Boolean(value))
722
+ .map((value) => sanitizeTerminalText(value))
723
+ .join(" · ");
724
+ const bodyWidth = Math.max(0, width - 2);
725
+ const left = status(borders.vertical);
726
+ const right = borderStyle(borders.vertical);
727
+ const titleLine = `${left}${fitLine(`${marker}${title}`, bodyWidth)}${right}`;
728
+ const metaLine = `${left}${fitLine(` ${theme.muted(meta)}`, bodyWidth)}${right}`;
729
+ const bottom = borderStyle(`${borders.bottomLeft}${borders.horizontal.repeat(Math.max(0, width - 2))}${borders.bottomRight}`);
730
+ return [top, titleLine, metaLine, bottom].map((line) => fitLine(line, width));
731
+ }
732
+ function readerLines(state, width, height, theme, borders) {
733
+ if (!state.reader) {
734
+ return [theme.muted("Document is not loaded.")];
735
+ }
736
+ const { document } = state.reader;
737
+ const title = sanitizeTerminalText(document.title).replace(/\s+/g, " ");
738
+ const pill = statusStyle(theme, document.status)(`${statusSymbol(document.status, borders.vertical === "│")} ${statusLabel(document.status).toUpperCase()}`);
739
+ const meta = [
740
+ document.workspaceId,
741
+ document.taskId,
742
+ document.kind,
743
+ state.reader.backend,
744
+ ].filter((value) => Boolean(value)).join(" · ");
745
+ const heading = spread(`${pill} ${theme.styles.bold(title)}`, theme.muted(`revision ${document.revision}`), width);
746
+ const warningLines = state.reader.warnings.map((warning) => theme.accent(`warning: ${sanitizeTerminalText(warning)}`));
747
+ const content = sanitizeTerminalText(state.reader.content, {
748
+ preserveSgr: theme.color,
749
+ }).split("\n");
750
+ const all = warningLines.length > 0
751
+ ? [...warningLines, "", ...content]
752
+ : content;
753
+ const headerLength = state.message ? 4 : 3;
754
+ const available = Math.max(1, height - headerLength);
755
+ const maxScroll = Math.max(0, all.length - available);
756
+ const scroll = clamp(state.scroll, 0, maxScroll);
757
+ const visible = all.slice(scroll, scroll + available);
758
+ const progress = `${scroll + 1}-${Math.min(all.length, scroll + available)} / ${all.length}`;
759
+ const header = [
760
+ heading,
761
+ spread(theme.muted(sanitizeTerminalText(meta)), theme.muted(progress), width),
762
+ ];
763
+ if (state.message) {
764
+ header.push(theme.accent(`! ${sanitizeTerminalText(state.message)}`));
765
+ }
766
+ header.push("");
767
+ return [
768
+ ...header,
769
+ ...visible.map((line) => fitLine(line, width)),
770
+ ].slice(0, height);
771
+ }
772
+ function statusStyle(theme, status) {
773
+ if (status === "reading") {
774
+ return theme.reading;
775
+ }
776
+ if (status === "done") {
777
+ return theme.done;
778
+ }
779
+ return theme.accent;
780
+ }
781
+ function statusLabel(status) {
782
+ if (status === "unread") {
783
+ return "New";
784
+ }
785
+ if (status === "reading") {
786
+ return "Reading";
787
+ }
788
+ return "Done";
789
+ }
790
+ function statusSymbol(status, unicode) {
791
+ if (!unicode) {
792
+ if (status === "reading") {
793
+ return ">";
794
+ }
795
+ if (status === "done") {
796
+ return "x";
797
+ }
798
+ return "*";
799
+ }
800
+ if (status === "reading") {
801
+ return "◐";
802
+ }
803
+ if (status === "done") {
804
+ return "✓";
805
+ }
806
+ return "●";
807
+ }
808
+ function boxLine(value, width, borders, theme) {
809
+ return `${theme.line(borders.vertical)}${fitLine(value, width)}${theme.line(borders.vertical)}`;
810
+ }
811
+ function fitLine(value, width) {
812
+ const fitted = truncate(value, width);
813
+ return `${fitted}${" ".repeat(Math.max(0, width - stringWidth(fitted)))}`;
814
+ }
815
+ function spread(left, right, width) {
816
+ if (width <= 0) {
817
+ return "";
818
+ }
819
+ const safeRight = truncate(right, Math.max(0, Math.floor(width / 2)));
820
+ const leftWidth = Math.max(0, width - stringWidth(safeRight) - 1);
821
+ const safeLeft = truncate(left, leftWidth);
822
+ const gap = Math.max(1, width - stringWidth(safeLeft) - stringWidth(safeRight));
823
+ return truncate(`${safeLeft}${" ".repeat(gap)}${safeRight}`, width);
824
+ }
825
+ function truncate(value, width) {
826
+ if (width <= 0) {
827
+ return "";
828
+ }
829
+ const safe = sanitizeTerminalText(value, { preserveSgr: true })
830
+ .replace(/[\r\n]/g, " ");
831
+ if (stringWidth(safe) <= width) {
832
+ return safe;
833
+ }
834
+ if (width === 1) {
835
+ return "…";
836
+ }
837
+ return `${sliceAnsi(safe, 0, width - 1)}…`;
838
+ }
839
+ function readerRenderWidth(width) {
840
+ const safeWidth = clamp(Math.floor(width), 40, 1_000);
841
+ return clamp(safeWidth - 2, 20, 1_000);
842
+ }
843
+ function decodeInput(value) {
844
+ const events = [];
845
+ for (let index = 0; index < value.length; index += 1) {
846
+ const rest = value.slice(index);
847
+ const mouse = rest.match(/^\u001b\[<(\d+);(\d+);(\d+)([Mm])/);
848
+ if (mouse) {
849
+ const code = Number(mouse[1]);
850
+ const x = Number(mouse[2]);
851
+ const y = Number(mouse[3]);
852
+ const terminator = mouse[4];
853
+ const button = decodeMouseButton(code, terminator);
854
+ if (button) {
855
+ events.push({ type: "mouse", event: { button, x, y } });
856
+ }
857
+ index += mouse[0].length - 1;
858
+ continue;
859
+ }
860
+ if (rest.startsWith("\u001b[<")) {
861
+ return { events, remainder: rest };
862
+ }
863
+ if (rest.startsWith("\u001b[A")) {
864
+ events.push({ type: "key", key: "up" });
865
+ index += 2;
866
+ }
867
+ else if (rest.startsWith("\u001b[B")) {
868
+ events.push({ type: "key", key: "down" });
869
+ index += 2;
870
+ }
871
+ else if (rest.startsWith("\u001b[5~")) {
872
+ events.push({ type: "key", key: "pageup" });
873
+ index += 3;
874
+ }
875
+ else if (rest.startsWith("\u001b[6~")) {
876
+ events.push({ type: "key", key: "pagedown" });
877
+ index += 3;
878
+ }
879
+ else {
880
+ const character = value[index] ?? "";
881
+ if (character === "\r" || character === "\n") {
882
+ events.push({ type: "key", key: "enter" });
883
+ }
884
+ else if (character === "\u001b") {
885
+ events.push({ type: "key", key: "escape" });
886
+ }
887
+ else if (character === "\u0003") {
888
+ events.push({ type: "key", key: "ctrl-c" });
889
+ }
890
+ else if (character === "\u007f" || character === "\b") {
891
+ events.push({ type: "key", key: "backspace" });
892
+ }
893
+ else {
894
+ events.push({ type: "key", key: character });
895
+ }
896
+ }
897
+ }
898
+ return { events, remainder: "" };
899
+ }
900
+ function decodeMouseButton(code, terminator) {
901
+ if (!Number.isSafeInteger(code) || code < 0 || terminator !== "M") {
902
+ return undefined;
903
+ }
904
+ if ((code & 64) !== 0) {
905
+ return (code & 1) === 0 ? "wheel-up" : "wheel-down";
906
+ }
907
+ return (code & 3) === 0 ? "left" : undefined;
908
+ }
909
+ function clamp(value, minimum, maximum) {
910
+ return Math.min(Math.max(value, minimum), maximum);
911
+ }
912
+ //# sourceMappingURL=tui.js.map