imsg-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js ADDED
@@ -0,0 +1,2706 @@
1
+ #!/usr/bin/env node
2
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
3
+ import { parseArgs } from "node:util";
4
+ import { useScreenSize, withFullScreen } from "fullscreen-ink";
5
+ import { g as getImsgDbPath, a as getContactsDbPaths, b as getSlugsDbPath, j as sendToChat, k as sendToChatId, m as sendMessageAlt, r as registerCleanup, c as checkLocalAccess, f as formatAccessReport, q as installShutdownHandlers, u as enableOrphanWatchdog } from "./shutdown-B9ClCyco.js";
6
+ import { resolveTuiConfig } from "./tui-config-Crn6TZPg.js";
7
+ import { r as readWatchdogState, o as onMemorySample, i as installWatchdog } from "./watchdog-V3lgEhMp.js";
8
+ import { execSync } from "node:child_process";
9
+ import { existsSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { useInput, Box, Text, useStdin, useApp } from "ink";
13
+ import React, { createContext, useContext, useState, useMemo, useRef, useEffect, useCallback, useReducer } from "react";
14
+ import { TextInput } from "@inkjs/ui";
15
+ import { parseUserDate, formatJumpTarget } from "./dateParse-DJXMfq3a.js";
16
+ import { e as extensionFor, t as toJSON, a as toCSV, b as toMarkdown } from "./exportFormats-CWWiy5uz.js";
17
+ import { h as hasNativeModule, I as IMessageDB } from "./imessage-db-BVDtx0Sn.js";
18
+ const APPS = [
19
+ {
20
+ name: "Messages",
21
+ appPath: "/System/Applications/Messages.app",
22
+ buildUri: (handle) => `imessage://${encodeURIComponent(handle)}`,
23
+ supportsBody: false
24
+ },
25
+ {
26
+ name: "FaceTime",
27
+ appPath: "/System/Applications/FaceTime.app",
28
+ buildUri: (handle) => `facetime://${encodeURIComponent(handle)}`,
29
+ supportsBody: false
30
+ },
31
+ {
32
+ name: "FaceTime Audio",
33
+ appPath: "/System/Applications/FaceTime.app",
34
+ buildUri: (handle) => `facetime-audio://${encodeURIComponent(handle)}`,
35
+ supportsBody: false
36
+ },
37
+ {
38
+ name: "Signal",
39
+ appPath: "/Applications/Signal.app",
40
+ buildUri: (handle) => {
41
+ const phone = handle.replace(/[^\d+]/g, "");
42
+ if (!phone || !phone.startsWith("+")) return null;
43
+ return `sgnl://send?phone=${encodeURIComponent(phone)}`;
44
+ },
45
+ supportsBody: false
46
+ },
47
+ {
48
+ name: "WhatsApp",
49
+ appPath: "/Applications/WhatsApp.app",
50
+ buildUri: (handle, body) => {
51
+ const phone = handle.replace(/[^\d+]/g, "").replace(/^\+/, "");
52
+ if (!phone) return null;
53
+ const base = `whatsapp://send?phone=${encodeURIComponent(phone)}`;
54
+ return body ? `${base}&text=${encodeURIComponent(body)}` : base;
55
+ },
56
+ supportsBody: true
57
+ },
58
+ {
59
+ name: "Telegram",
60
+ appPath: "/Applications/Telegram.app",
61
+ buildUri: (handle, body) => {
62
+ const phone = handle.replace(/[^\d+]/g, "");
63
+ if (!phone) return null;
64
+ const base = `tg://resolve?phone=${encodeURIComponent(phone)}`;
65
+ return body ? `${base}&text=${encodeURIComponent(body)}` : base;
66
+ },
67
+ supportsBody: true
68
+ },
69
+ {
70
+ name: "SMS",
71
+ appPath: "/System/Applications/Messages.app",
72
+ buildUri: (handle, body) => {
73
+ const base = `sms:${encodeURIComponent(handle)}`;
74
+ return body ? `${base}&body=${encodeURIComponent(body)}` : base;
75
+ },
76
+ supportsBody: true
77
+ }
78
+ ];
79
+ function getInstalledChatApps() {
80
+ return APPS.filter((a) => existsSync(a.appPath));
81
+ }
82
+ const ThemeCtx = createContext(null);
83
+ function ThemeProvider({ value, children }) {
84
+ return /* @__PURE__ */ jsx(ThemeCtx.Provider, { value, children });
85
+ }
86
+ function useTheme() {
87
+ const t = useContext(ThemeCtx);
88
+ if (!t) {
89
+ throw new Error("useTheme(): no <ThemeProvider> in the component tree above this hook");
90
+ }
91
+ return t;
92
+ }
93
+ const ORDER = ["year", "month", "day"];
94
+ const MIN_YEAR = 1900;
95
+ const MAX_YEAR = 2100;
96
+ function daysInMonth(year, monthOneBased) {
97
+ return new Date(year, monthOneBased, 0).getDate();
98
+ }
99
+ function clamp(n, lo, hi) {
100
+ return Math.min(Math.max(n, lo), hi);
101
+ }
102
+ function DatePicker({ initial, focused, onSubmit, onCancel }) {
103
+ const theme = useTheme();
104
+ const now = initial ?? /* @__PURE__ */ new Date();
105
+ const [year, setYear] = useState(now.getFullYear());
106
+ const [month, setMonth] = useState(now.getMonth() + 1);
107
+ const [day, setDay] = useState(now.getDate());
108
+ const [active, setActive] = useState("year");
109
+ const fieldValue = (f) => f === "year" ? year : f === "month" ? month : day;
110
+ const setField = (f, v) => {
111
+ if (f === "year") {
112
+ const y = clamp(v, MIN_YEAR, MAX_YEAR);
113
+ setYear(y);
114
+ setDay((d) => clamp(d, 1, daysInMonth(y, month)));
115
+ } else if (f === "month") {
116
+ const m = clamp(v, 1, 12);
117
+ setMonth(m);
118
+ setDay((d) => clamp(d, 1, daysInMonth(year, m)));
119
+ } else {
120
+ setDay(clamp(v, 1, daysInMonth(year, month)));
121
+ }
122
+ };
123
+ useInput(
124
+ (input, key) => {
125
+ if (!focused) return;
126
+ if (key.escape) {
127
+ onCancel();
128
+ return;
129
+ }
130
+ if (key.return) {
131
+ const iso = `${String(year).padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
132
+ onSubmit(iso);
133
+ return;
134
+ }
135
+ if (key.leftArrow) {
136
+ const i = ORDER.indexOf(active);
137
+ setActive(ORDER[(i + ORDER.length - 1) % ORDER.length]);
138
+ return;
139
+ }
140
+ if (key.rightArrow) {
141
+ const i = ORDER.indexOf(active);
142
+ setActive(ORDER[(i + 1) % ORDER.length]);
143
+ return;
144
+ }
145
+ if (key.upArrow) {
146
+ setField(active, fieldValue(active) + 1);
147
+ return;
148
+ }
149
+ if (key.downArrow) {
150
+ setField(active, fieldValue(active) - 1);
151
+ return;
152
+ }
153
+ if (input && /^[0-9]$/.test(input)) {
154
+ const digit = Number.parseInt(input, 10);
155
+ if (active === "year") {
156
+ const next = year % 1e3 * 10 + digit;
157
+ setField("year", next);
158
+ } else if (active === "month") {
159
+ const next = month % 10 * 10 + digit;
160
+ setField("month", next < 1 ? 1 : next);
161
+ } else {
162
+ const next = day % 10 * 10 + digit;
163
+ setField("day", next < 1 ? 1 : next);
164
+ }
165
+ return;
166
+ }
167
+ if (key.backspace || key.delete) {
168
+ if (active === "year") setField("year", Math.floor(year / 10) || MIN_YEAR);
169
+ else if (active === "month") setField("month", Math.floor(month / 10) || 1);
170
+ else setField("day", Math.floor(day / 10) || 1);
171
+ }
172
+ },
173
+ { isActive: focused }
174
+ );
175
+ const fieldStr = (f, width) => String(fieldValue(f)).padStart(width, "0");
176
+ const renderField = (f, width) => {
177
+ const isActive = active === f;
178
+ return /* @__PURE__ */ jsx(
179
+ Text,
180
+ {
181
+ color: isActive ? theme.status.accent : theme.drawer.value,
182
+ bold: isActive,
183
+ inverse: isActive,
184
+ children: fieldStr(f, width)
185
+ }
186
+ );
187
+ };
188
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
189
+ /* @__PURE__ */ jsxs(Box, { children: [
190
+ renderField("year", 4),
191
+ /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "-" }),
192
+ renderField("month", 2),
193
+ /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "-" }),
194
+ renderField("day", 2)
195
+ ] }),
196
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "←/→ field · ↑/↓ adjust · digits to type · Enter to jump" }) })
197
+ ] });
198
+ }
199
+ function DateJumpModal({ value, error, onChange, onSubmit }) {
200
+ const theme = useTheme();
201
+ const [mode, setMode] = useState("picker");
202
+ useInput((_input, key) => {
203
+ if (key.tab && !key.shift) {
204
+ setMode((m) => m === "picker" ? "text" : "picker");
205
+ }
206
+ });
207
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: theme.status.accent, paddingX: 1, children: [
208
+ /* @__PURE__ */ jsxs(Box, { children: [
209
+ /* @__PURE__ */ jsx(Text, { color: theme.status.accent, bold: true, children: "Jump to date" }),
210
+ /* @__PURE__ */ jsxs(Text, { color: theme.help.desc, children: [
211
+ " ",
212
+ "[",
213
+ mode,
214
+ "] · Tab to switch"
215
+ ] })
216
+ ] }),
217
+ mode === "picker" ? /* @__PURE__ */ jsx(
218
+ DatePicker,
219
+ {
220
+ focused: true,
221
+ onSubmit,
222
+ onCancel: () => {
223
+ }
224
+ }
225
+ ) : /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
226
+ /* @__PURE__ */ jsxs(Box, { children: [
227
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Date: " }),
228
+ /* @__PURE__ */ jsx(
229
+ TextInput,
230
+ {
231
+ defaultValue: value,
232
+ onChange,
233
+ onSubmit,
234
+ placeholder: "2024-03-15 | 3/15 | yesterday | 2 weeks ago | 1y"
235
+ }
236
+ )
237
+ ] }),
238
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "Formats: YYYY-MM-DD · M/D · today · yesterday · N days/weeks/months/years ago · 5d / 2w / 3m / 1y" }) })
239
+ ] }),
240
+ error && /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: theme.edited, children: error }) }),
241
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "Enter: jump · Esc: cancel · Tab: switch mode" }) })
242
+ ] });
243
+ }
244
+ function DevStats({ stats, width }) {
245
+ const theme = useTheme();
246
+ const engineColor = stats.engine.startsWith("Rust") ? theme.rustEngine : theme.status.accent;
247
+ const cpuColor = stats.cpuPercent > 50 ? theme.cpuHigh : stats.cpuPercent > 20 ? theme.edited : theme.info.value;
248
+ return /* @__PURE__ */ jsxs(
249
+ Box,
250
+ {
251
+ flexDirection: "column",
252
+ width,
253
+ borderStyle: "single",
254
+ borderColor: theme.border,
255
+ overflow: "hidden",
256
+ children: [
257
+ /* @__PURE__ */ jsx(Box, { paddingX: 1, backgroundColor: theme.header.dim.bg, children: /* @__PURE__ */ jsx(Text, { color: theme.header.dim.fg, bold: true, children: "Stats" }) }),
258
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, children: [
259
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
260
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Engine" }),
261
+ /* @__PURE__ */ jsx(Text, { color: engineColor, bold: true, children: stats.engine })
262
+ ] }),
263
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
264
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "CPU" }),
265
+ /* @__PURE__ */ jsxs(Text, { color: cpuColor, children: [
266
+ stats.cpuPercent,
267
+ "%"
268
+ ] })
269
+ ] }),
270
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
271
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Mem" }),
272
+ /* @__PURE__ */ jsxs(Text, { color: theme.info.value, children: [
273
+ stats.memMB,
274
+ "MB"
275
+ ] })
276
+ ] }),
277
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
278
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "PID" }),
279
+ /* @__PURE__ */ jsx(Text, { color: theme.info.value, children: stats.pid })
280
+ ] }),
281
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
282
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Up" }),
283
+ /* @__PURE__ */ jsx(Text, { color: theme.info.value, children: stats.uptime })
284
+ ] }),
285
+ stats.lastQueryMs !== null && /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
286
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Query" }),
287
+ /* @__PURE__ */ jsxs(Text, { color: stats.lastQueryMs > 500 ? theme.edited : theme.sms, children: [
288
+ stats.lastQueryMs,
289
+ "ms"
290
+ ] })
291
+ ] }),
292
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
293
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Lag" }),
294
+ /* @__PURE__ */ jsxs(
295
+ Text,
296
+ {
297
+ color: stats.eventLoopP99Ms > 500 ? theme.cpuHigh : stats.eventLoopP99Ms > 100 ? theme.edited : theme.info.value,
298
+ children: [
299
+ stats.eventLoopP99Ms,
300
+ "ms"
301
+ ]
302
+ }
303
+ )
304
+ ] }),
305
+ /* @__PURE__ */ jsxs(Box, { justifyContent: "space-between", children: [
306
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Active" }),
307
+ /* @__PURE__ */ jsx(Text, { color: theme.info.value, children: stats.lastActivityAgo })
308
+ ] })
309
+ ] })
310
+ ]
311
+ }
312
+ );
313
+ }
314
+ function CompactStats({ stats }) {
315
+ const theme = useTheme();
316
+ const engineColor = stats.engine.startsWith("Rust") ? theme.rustEngine : theme.status.accent;
317
+ return /* @__PURE__ */ jsxs(Box, { gap: 1, children: [
318
+ /* @__PURE__ */ jsx(Text, { color: engineColor, children: stats.engine }),
319
+ /* @__PURE__ */ jsxs(Text, { color: theme.info.label, children: [
320
+ stats.cpuPercent,
321
+ "%"
322
+ ] }),
323
+ /* @__PURE__ */ jsxs(Text, { color: theme.info.label, children: [
324
+ stats.memMB,
325
+ "MB"
326
+ ] }),
327
+ /* @__PURE__ */ jsxs(Text, { color: theme.info.label, children: [
328
+ "PID:",
329
+ stats.pid
330
+ ] })
331
+ ] });
332
+ }
333
+ function ExportModal({ format, path, rangeSummary, onChangePath, onSubmit }) {
334
+ const theme = useTheme();
335
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: theme.status.accent, paddingX: 1, children: [
336
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: theme.status.accent, bold: true, children: "Export messages" }) }),
337
+ /* @__PURE__ */ jsxs(Box, { children: [
338
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Range: " }),
339
+ /* @__PURE__ */ jsx(Text, { color: theme.info.value, children: rangeSummary })
340
+ ] }),
341
+ /* @__PURE__ */ jsxs(Box, { children: [
342
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Format: " }),
343
+ /* @__PURE__ */ jsx(
344
+ Text,
345
+ {
346
+ color: format === "markdown" ? theme.status.accent : theme.info.value,
347
+ bold: format === "markdown",
348
+ children: "[Markdown]"
349
+ }
350
+ ),
351
+ /* @__PURE__ */ jsx(Text, { children: " " }),
352
+ /* @__PURE__ */ jsx(
353
+ Text,
354
+ {
355
+ color: format === "csv" ? theme.status.accent : theme.info.value,
356
+ bold: format === "csv",
357
+ children: "[CSV]"
358
+ }
359
+ ),
360
+ /* @__PURE__ */ jsx(Text, { children: " " }),
361
+ /* @__PURE__ */ jsx(
362
+ Text,
363
+ {
364
+ color: format === "json" ? theme.status.accent : theme.info.value,
365
+ bold: format === "json",
366
+ children: "[JSON]"
367
+ }
368
+ ),
369
+ /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: " (Tab cycles)" })
370
+ ] }),
371
+ /* @__PURE__ */ jsxs(Box, { children: [
372
+ /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Path: " }),
373
+ /* @__PURE__ */ jsx(
374
+ TextInput,
375
+ {
376
+ defaultValue: path,
377
+ onChange: onChangePath,
378
+ onSubmit,
379
+ placeholder: "/absolute/path/to/file"
380
+ }
381
+ )
382
+ ] }),
383
+ /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "Enter: save Esc: cancel Tab: cycle format" }) })
384
+ ] });
385
+ }
386
+ const SIDEBAR_KEYS = [
387
+ ["j/k", "move"],
388
+ ["#j/k", "jump"],
389
+ ["gg/G", "top/btm"],
390
+ ["^d/u", "½page"],
391
+ ["y", "copy slug"],
392
+ ["Tab", "→msgs"],
393
+ ["/", "filter"],
394
+ ["d", "stats"],
395
+ ["r", "refresh"],
396
+ ["q", "quit"]
397
+ ];
398
+ const THREAD_KEYS = [
399
+ ["j/k", "move"],
400
+ ["#j/k", "jump"],
401
+ ["{/}", "grp jump"],
402
+ ["gg/G", "top/btm"],
403
+ ["^d/u", "½page"],
404
+ ["V", "select"],
405
+ [":", "date jump"],
406
+ ["Enter", "details"],
407
+ ["o", "open att"],
408
+ ["O", "open in Msgs"],
409
+ ["S", "send-via"],
410
+ ["c", "compose"],
411
+ ["d", "stats"],
412
+ ["Tab", "→list"]
413
+ ];
414
+ const SEND_VIA_KEYS = [
415
+ ["1-9", "pick app"],
416
+ ["Esc", "cancel"]
417
+ ];
418
+ const SELECT_KEYS = [
419
+ ["j/k", "extend"],
420
+ ["{/}", "grp"],
421
+ ["gg/G", "top/btm"],
422
+ ["^d/u", "½page"],
423
+ ["e", "export"],
424
+ ["y", "copy text"],
425
+ ["Esc", "exit select"]
426
+ ];
427
+ const EXPORT_KEYS = [
428
+ ["Tab", "fmt: md/csv/json"],
429
+ ["Enter", "save"],
430
+ ["Esc", "cancel"]
431
+ ];
432
+ const DATE_JUMP_KEYS = [
433
+ ["Tab", "picker↔text"],
434
+ ["←/→", "field"],
435
+ ["↑/↓", "adjust"],
436
+ ["Enter", "jump"],
437
+ ["Esc", "cancel"]
438
+ ];
439
+ const COMPOSE_KEYS = [
440
+ ["Enter", "send"],
441
+ ["Esc", "cancel"]
442
+ ];
443
+ const FILTER_KEYS = [["Enter/Esc", "exit filter"]];
444
+ const DRAWER_KEYS = [
445
+ ["j/k", "scroll"],
446
+ ["o", "open attachment"],
447
+ ["Esc/q", "close"]
448
+ ];
449
+ function HelpBar({ mode, focus }) {
450
+ const theme = useTheme();
451
+ let keys;
452
+ if (mode === "compose" || mode === "confirm") keys = COMPOSE_KEYS;
453
+ else if (mode === "filter") keys = FILTER_KEYS;
454
+ else if (mode === "drawer") keys = DRAWER_KEYS;
455
+ else if (mode === "select") keys = SELECT_KEYS;
456
+ else if (mode === "export") keys = EXPORT_KEYS;
457
+ else if (mode === "date-jump") keys = DATE_JUMP_KEYS;
458
+ else if (mode === "send-via") keys = SEND_VIA_KEYS;
459
+ else keys = focus === "thread" ? THREAD_KEYS : SIDEBAR_KEYS;
460
+ return /* @__PURE__ */ jsx(Box, { paddingX: 1, height: 1, gap: 1, children: keys.map(([key, desc]) => /* @__PURE__ */ jsxs(Box, { children: [
461
+ /* @__PURE__ */ jsx(Text, { color: theme.help.key, children: key }),
462
+ /* @__PURE__ */ jsxs(Text, { color: theme.help.desc, children: [
463
+ ":",
464
+ desc,
465
+ " "
466
+ ] })
467
+ ] }, key)) });
468
+ }
469
+ const SAFE = {
470
+ // Powerline arrows aren't actually safe — for the safe preset we
471
+ // substitute simple ASCII triangles. Components that draw arrow
472
+ // separators between segments fall back to vertical bar `│`.
473
+ arrowRight: "│",
474
+ arrowRightThin: "│",
475
+ arrowLeft: "│",
476
+ arrowLeftThin: "│",
477
+ // Geometric Shapes — fixed-width East-Asian range, every font has them.
478
+ sent: "▶",
479
+ // ▶
480
+ received: "◀",
481
+ // ◀
482
+ unreadDot: "●",
483
+ envelope: "✉",
484
+ iMessage: "💬",
485
+ sms: "📱",
486
+ paperclip: "📎",
487
+ group: "☰",
488
+ search: "⌕",
489
+ pencil: "✎",
490
+ refresh: "↻",
491
+ separator: "─"
492
+ };
493
+ const POWERLINE = {
494
+ // Powerline private-use range (E0B0..E0B3).
495
+ arrowRight: "",
496
+ arrowRightThin: "",
497
+ arrowLeft: "",
498
+ arrowLeftThin: "",
499
+ // Use the Powerline arrows as direction glyphs too — visually consistent.
500
+ sent: "",
501
+ received: "",
502
+ unreadDot: "●",
503
+ envelope: "",
504
+ // Nerd Font envelope (FontAwesome)
505
+ iMessage: "",
506
+ // Nerd Font speech bubble
507
+ sms: "",
508
+ // Nerd Font phone
509
+ paperclip: "",
510
+ // Nerd Font paperclip
511
+ group: "",
512
+ // Nerd Font group
513
+ search: "",
514
+ // Nerd Font magnifier
515
+ pencil: "",
516
+ // Nerd Font pencil
517
+ refresh: "",
518
+ // Nerd Font refresh
519
+ separator: "─"
520
+ };
521
+ const GLYPH_PRESETS = {
522
+ safe: SAFE,
523
+ powerline: POWERLINE
524
+ };
525
+ const TAPBACK_EMOJI = {
526
+ love: "❤️",
527
+ like: "👍",
528
+ dislike: "👎",
529
+ laugh: "😂",
530
+ emphasize: "‼️",
531
+ question: "❓"
532
+ };
533
+ const HEX6_RE = /^#[0-9a-fA-F]{6}$/;
534
+ function hexToRgb(hex) {
535
+ if (!HEX6_RE.test(hex)) {
536
+ throw new Error(`invalid hex color: ${hex} (expected #RRGGBB)`);
537
+ }
538
+ return {
539
+ r: Number.parseInt(hex.slice(1, 3), 16),
540
+ g: Number.parseInt(hex.slice(3, 5), 16),
541
+ b: Number.parseInt(hex.slice(5, 7), 16)
542
+ };
543
+ }
544
+ function rgbToHex(r, g, b) {
545
+ const c = (n) => Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
546
+ return `#${c(r)}${c(g)}${c(b)}`;
547
+ }
548
+ function hexToHsl(hex) {
549
+ const { r, g, b } = hexToRgb(hex);
550
+ const rn = r / 255;
551
+ const gn = g / 255;
552
+ const bn = b / 255;
553
+ const max = Math.max(rn, gn, bn);
554
+ const min = Math.min(rn, gn, bn);
555
+ const l = (max + min) / 2;
556
+ const d = max - min;
557
+ let h = 0;
558
+ let s = 0;
559
+ if (d !== 0) {
560
+ s = d / (1 - Math.abs(2 * l - 1));
561
+ if (max === rn) h = 60 * ((gn - bn) / d % 6);
562
+ else if (max === gn) h = 60 * ((bn - rn) / d + 2);
563
+ else h = 60 * ((rn - gn) / d + 4);
564
+ if (h < 0) h += 360;
565
+ }
566
+ return { h, s, l };
567
+ }
568
+ function hslToHex({ h, s, l }) {
569
+ const hh = (h % 360 + 360) % 360;
570
+ const ss = Math.max(0, Math.min(1, s));
571
+ const ll = Math.max(0, Math.min(1, l));
572
+ const c = (1 - Math.abs(2 * ll - 1)) * ss;
573
+ const x = c * (1 - Math.abs(hh / 60 % 2 - 1));
574
+ const m = ll - c / 2;
575
+ let r1 = 0;
576
+ let g1 = 0;
577
+ let b1 = 0;
578
+ if (hh < 60) [r1, g1, b1] = [c, x, 0];
579
+ else if (hh < 120) [r1, g1, b1] = [x, c, 0];
580
+ else if (hh < 180) [r1, g1, b1] = [0, c, x];
581
+ else if (hh < 240) [r1, g1, b1] = [0, x, c];
582
+ else if (hh < 300) [r1, g1, b1] = [x, 0, c];
583
+ else [r1, g1, b1] = [c, 0, x];
584
+ return rgbToHex((r1 + m) * 255, (g1 + m) * 255, (b1 + m) * 255);
585
+ }
586
+ function relativeLuminance(hex) {
587
+ const { r, g, b } = hexToRgb(hex);
588
+ const channel = (c) => {
589
+ const cs = c / 255;
590
+ return cs <= 0.03928 ? cs / 12.92 : ((cs + 0.055) / 1.055) ** 2.4;
591
+ };
592
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
593
+ }
594
+ function contrastRatio(a, b) {
595
+ const la = relativeLuminance(a);
596
+ const lb = relativeLuminance(b);
597
+ const [hi, lo] = la > lb ? [la, lb] : [lb, la];
598
+ return (hi + 0.05) / (lo + 0.05);
599
+ }
600
+ const DEFAULT_ACCENT = "#1982FC";
601
+ function derivePalette(accent) {
602
+ const { h } = hexToHsl(accent);
603
+ const acc = (l) => hslToHex({ h, s: 0.95, l });
604
+ const tint = (l, s = 0.06) => hslToHex({ h, s, l });
605
+ const dark = tint(0.05, 0.1);
606
+ const light = tint(0.97, 0.05);
607
+ const fgFor = (bg) => contrastRatio(dark, bg) > contrastRatio(light, bg) ? dark : light;
608
+ const sentBg = acc(0.55);
609
+ const selectedBg = acc(0.27);
610
+ return {
611
+ sent: {
612
+ bg: sentBg,
613
+ fg: fgFor(sentBg),
614
+ border: acc(0.42)
615
+ },
616
+ received: {
617
+ // Light grey bubble, very slight accent tint — preserves the iMessage
618
+ // "received" look. Stays the same shade across accents (it's mostly grey).
619
+ bg: tint(0.91, 0.05),
620
+ fg: tint(0.12, 0.1),
621
+ border: tint(0.75, 0.05)
622
+ },
623
+ pending: {
624
+ bg: tint(0.27, 0.06),
625
+ fg: tint(0.74, 0.06),
626
+ border: tint(0.35, 0.08)
627
+ },
628
+ sentText: acc(0.78),
629
+ // light accent — for sent text shown outside a bubble
630
+ receivedText: tint(0.85, 0.06),
631
+ // Sender names: a fixed teal that reads as "another person" in every
632
+ // accent. Keeping accent-derived would produce magenta sender names on
633
+ // a magenta accent, defeating the visual cue.
634
+ senderName: SEMANTIC.senderName,
635
+ replyContext: tint(0.62, 0.1),
636
+ attachment: SEMANTIC.attachment,
637
+ // fixed warm yellow
638
+ lineNum: tint(0.45, 0.05),
639
+ groupBg: {
640
+ sent: tint(0.16, 0.4),
641
+ // dark, clearly accent-tinted (sent rows)
642
+ received: hslToHex({ h: (h + 20) % 360, s: 0.18, l: 0.14 })
643
+ // close-but-not-equal
644
+ },
645
+ selectionBg: SEMANTIC.selection,
646
+ // muted warm yellow — universal "selected"
647
+ sidebar: {
648
+ selected: selectedBg,
649
+ selectedFg: fgFor(selectedBg),
650
+ unread: tint(0.97, 0.05),
651
+ read: tint(0.74, 0.06),
652
+ snippet: tint(0.51, 0.05),
653
+ slug: hslToHex({ h, s: 0.28, l: 0.55 }),
654
+ slugBg: tint(0.1, 0.15),
655
+ separator: tint(0.2, 0.05),
656
+ time: tint(0.51, 0.05)
657
+ },
658
+ border: tint(0.25, 0.05),
659
+ dot: acc(0.55),
660
+ header: {
661
+ focused: { bg: tint(0.2, 0.06), fg: tint(0.97, 0.05) },
662
+ dim: { bg: tint(0.13, 0.06), fg: tint(0.51, 0.05) }
663
+ },
664
+ info: { label: tint(0.62, 0.05), value: tint(0.85, 0.05) },
665
+ timestamp: tint(0.62, 0.1),
666
+ status: { bg: tint(0.13, 0.06), fg: tint(0.74, 0.06), accent: acc(0.55) },
667
+ help: { key: tint(0.74, 0.06), desc: tint(0.4, 0.05) },
668
+ sms: SEMANTIC.sms,
669
+ // green — universal "SMS not iMessage" cue
670
+ edited: SEMANTIC.edited,
671
+ // gold — universal "this was edited" cue
672
+ compose: {
673
+ bg: tint(0.18, 0.06),
674
+ fg: tint(0.97, 0.05),
675
+ placeholder: tint(0.4, 0.05)
676
+ },
677
+ dateSep: tint(0.32, 0.05),
678
+ drawer: {
679
+ bg: tint(0.13, 0.06),
680
+ border: tint(0.25, 0.05),
681
+ label: tint(0.62, 0.05),
682
+ value: tint(0.85, 0.05)
683
+ },
684
+ rustEngine: SEMANTIC.rustEngine,
685
+ // warm orange — distinct from any accent
686
+ cpuHigh: SEMANTIC.cpuHigh
687
+ // red — universal "elevated"
688
+ };
689
+ }
690
+ const SEMANTIC = {
691
+ senderName: "#5AC8C8",
692
+ // teal
693
+ attachment: "#FFB347",
694
+ // orange (paperclip)
695
+ selection: "#3C3814",
696
+ // dim olive (visual select bg)
697
+ sms: "#5AC85A",
698
+ // green (SMS marker)
699
+ edited: "#96821E",
700
+ // gold (edited indicator)
701
+ rustEngine: "#FF6B35",
702
+ // warm orange (DevStats engine label)
703
+ cpuHigh: "#FF4444"
704
+ // red (DevStats elevated CPU)
705
+ };
706
+ function makeTheme({ preset = "safe", accent = DEFAULT_ACCENT } = {}) {
707
+ return {
708
+ ...derivePalette(accent),
709
+ glyphs: GLYPH_PRESETS[preset]
710
+ };
711
+ }
712
+ function formatFullDate(date) {
713
+ return date.toLocaleString("en-US", {
714
+ weekday: "short",
715
+ month: "short",
716
+ day: "numeric",
717
+ year: "numeric",
718
+ hour: "numeric",
719
+ minute: "2-digit",
720
+ second: "2-digit",
721
+ hour12: true
722
+ });
723
+ }
724
+ function Label({ children }) {
725
+ const theme = useTheme();
726
+ return /* @__PURE__ */ jsxs(Text, { color: theme.drawer.label, children: [
727
+ children,
728
+ ": "
729
+ ] });
730
+ }
731
+ function MessageDrawer({ message: m, width, height }) {
732
+ const theme = useTheme();
733
+ const hasAttachments = m.attachments && m.attachments.length > 0;
734
+ return /* @__PURE__ */ jsxs(
735
+ Box,
736
+ {
737
+ flexDirection: "column",
738
+ width,
739
+ height,
740
+ borderStyle: "single",
741
+ borderColor: theme.drawer.border,
742
+ overflow: "hidden",
743
+ children: [
744
+ /* @__PURE__ */ jsx(Box, { paddingX: 1, backgroundColor: theme.header.focused.bg, children: /* @__PURE__ */ jsx(Text, { color: theme.header.focused.fg, bold: true, children: "Message Details" }) }),
745
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 0, gap: 0, children: [
746
+ /* @__PURE__ */ jsxs(Box, { children: [
747
+ /* @__PURE__ */ jsx(Label, { children: "From" }),
748
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, children: m.isFromMe ? "Me" : m.displayName ?? m.handle })
749
+ ] }),
750
+ !m.isFromMe && m.displayName && /* @__PURE__ */ jsxs(Box, { children: [
751
+ /* @__PURE__ */ jsx(Label, { children: "Handle" }),
752
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, children: m.handle })
753
+ ] }),
754
+ /* @__PURE__ */ jsxs(Box, { children: [
755
+ /* @__PURE__ */ jsx(Label, { children: "Sent" }),
756
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, children: formatFullDate(m.date) })
757
+ ] }),
758
+ m.dateDelivered && /* @__PURE__ */ jsxs(Box, { children: [
759
+ /* @__PURE__ */ jsx(Label, { children: "Delivered" }),
760
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, children: formatFullDate(m.dateDelivered) })
761
+ ] }),
762
+ m.dateRead && /* @__PURE__ */ jsxs(Box, { children: [
763
+ /* @__PURE__ */ jsx(Label, { children: "Read" }),
764
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, children: formatFullDate(m.dateRead) })
765
+ ] }),
766
+ /* @__PURE__ */ jsxs(Box, { children: [
767
+ /* @__PURE__ */ jsx(Label, { children: "Service" }),
768
+ /* @__PURE__ */ jsx(Text, { color: m.service === "SMS" ? theme.sms : theme.info.label, children: m.service })
769
+ ] }),
770
+ /* @__PURE__ */ jsxs(Box, { children: [
771
+ /* @__PURE__ */ jsx(Label, { children: "Chat" }),
772
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, children: m.chatId })
773
+ ] }),
774
+ /* @__PURE__ */ jsxs(Box, { children: [
775
+ /* @__PURE__ */ jsx(Label, { children: "GUID" }),
776
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, wrap: "truncate", children: m.guid })
777
+ ] }),
778
+ m.isEdited && /* @__PURE__ */ jsxs(Box, { children: [
779
+ /* @__PURE__ */ jsx(Label, { children: "Status" }),
780
+ /* @__PURE__ */ jsx(Text, { color: theme.edited, children: "Edited" })
781
+ ] }),
782
+ m.reactions && m.reactions.length > 0 && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
783
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.label, children: "Reactions:" }),
784
+ m.reactions.filter((r) => !r.isRemoval).map((r) => /* @__PURE__ */ jsx(
785
+ Box,
786
+ {
787
+ paddingLeft: 1,
788
+ children: /* @__PURE__ */ jsxs(Text, { color: theme.drawer.value, children: [
789
+ r.emoji ?? TAPBACK_EMOJI[r.type] ?? r.type,
790
+ " ",
791
+ r.fromHandle ?? "unknown"
792
+ ] })
793
+ },
794
+ `${r.fromHandle}-${r.type}-${r.emoji ?? ""}-${r.targetMessageGuid}-${r.targetMessagePart}`
795
+ ))
796
+ ] }),
797
+ hasAttachments && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
798
+ /* @__PURE__ */ jsxs(Text, { color: theme.attachment, bold: true, children: [
799
+ "Attachments (",
800
+ m.attachments.length,
801
+ "):"
802
+ ] }),
803
+ m.attachments.map((att) => {
804
+ return /* @__PURE__ */ jsxs(
805
+ Box,
806
+ {
807
+ flexDirection: "column",
808
+ paddingLeft: 1,
809
+ children: [
810
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, wrap: "truncate", children: att.transferName ?? att.filename }),
811
+ /* @__PURE__ */ jsxs(Text, { color: theme.drawer.label, children: [
812
+ att.mimeType ?? "unknown",
813
+ " ·",
814
+ " ",
815
+ att.totalBytes > 0 ? formatBytes(att.totalBytes) : "?",
816
+ /* @__PURE__ */ jsx(Text, { color: theme.senderName, children: " (press o to preview)" })
817
+ ] })
818
+ ]
819
+ },
820
+ `${att.filename}-${att.transferName ?? ""}-${att.mimeType ?? ""}-${att.totalBytes}`
821
+ );
822
+ }),
823
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
824
+ /* @__PURE__ */ jsx(Text, { color: theme.help.key, children: "o" }),
825
+ /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: ": open attachment" })
826
+ ] })
827
+ ] }),
828
+ /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
829
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.label, children: "Full text:" }),
830
+ /* @__PURE__ */ jsx(Box, { borderStyle: "single", borderColor: theme.drawer.border, paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, wrap: "wrap", children: m.text ?? "(no text)" }) })
831
+ ] }),
832
+ m.isReply && m.replyTo && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
833
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.label, children: "Reply to:" }),
834
+ /* @__PURE__ */ jsx(Text, { color: theme.replyContext, italic: true, wrap: "wrap", children: m.replyTo.replyToText ?? "(unknown)" })
835
+ ] })
836
+ ] }),
837
+ /* @__PURE__ */ jsx(Box, { flexGrow: 1 }),
838
+ /* @__PURE__ */ jsx(Box, { paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "Esc/q to close" }) })
839
+ ]
840
+ }
841
+ );
842
+ }
843
+ function formatBytes(bytes) {
844
+ if (bytes < 1024) return `${bytes}B`;
845
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
846
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
847
+ }
848
+ function SendViaModal({ handle, apps }) {
849
+ const theme = useTheme();
850
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: theme.status.accent, paddingX: 1, children: [
851
+ /* @__PURE__ */ jsx(Text, { color: theme.status.accent, bold: true, children: "Send via external app" }),
852
+ /* @__PURE__ */ jsxs(Text, { color: theme.help.desc, children: [
853
+ "Handle: ",
854
+ handle
855
+ ] }),
856
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", marginTop: 1, children: apps.length === 0 ? /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "No compatible apps installed." }) : apps.map((a, i) => /* @__PURE__ */ jsxs(Box, { children: [
857
+ /* @__PURE__ */ jsx(Text, { color: theme.help.key, children: i + 1 }),
858
+ /* @__PURE__ */ jsxs(Text, { color: theme.help.desc, children: [
859
+ ": ",
860
+ a.name,
861
+ a.supportsBody ? "" : " (no body support)"
862
+ ] })
863
+ ] }, a.name)) }),
864
+ /* @__PURE__ */ jsx(Box, { marginTop: 1, children: /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "1-9: launch · Esc: cancel" }) })
865
+ ] });
866
+ }
867
+ function relativeDate$1(date) {
868
+ if (!date) return "";
869
+ const now = /* @__PURE__ */ new Date();
870
+ const time = date.toLocaleTimeString("en-US", {
871
+ hour: "numeric",
872
+ minute: "2-digit",
873
+ hour12: true
874
+ });
875
+ if (date.toDateString() === now.toDateString()) return time;
876
+ const y = new Date(now);
877
+ y.setDate(now.getDate() - 1);
878
+ if (date.toDateString() === y.toDateString()) return "Yest";
879
+ return `${date.getMonth() + 1}/${date.getDate()}`;
880
+ }
881
+ function nameBudget(width, hasUnread, unreadCount, isGroup) {
882
+ const lineNumW = 4;
883
+ const cursorW = 2;
884
+ const envelopeW = hasUnread ? 2 : 0;
885
+ const groupW = isGroup ? 2 : 0;
886
+ const countW = hasUnread ? ` (${unreadCount})`.length : 0;
887
+ const iconW = 2;
888
+ const timeW = 9;
889
+ const padding = 2;
890
+ return Math.max(
891
+ width - lineNumW - cursorW - envelopeW - groupW - countW - iconW - timeW - padding,
892
+ 8
893
+ );
894
+ }
895
+ function ConversationItem({
896
+ conversation: c,
897
+ selected,
898
+ width,
899
+ lineNum,
900
+ focused,
901
+ isLast
902
+ }) {
903
+ const theme = useTheme();
904
+ const hasUnread = c.unreadCount > 0;
905
+ const name = c.displayName ?? c.chatIdentifier;
906
+ const time = relativeDate$1(c.lastMessageDate);
907
+ const snippet = c.lastMessageSnippet ?? "";
908
+ const serviceIcon = c.serviceType === "SMS" ? theme.glyphs.sms : theme.glyphs.iMessage;
909
+ const nameW = nameBudget(width, hasUnread, c.unreadCount, c.isGroupChat);
910
+ const truncatedName = name.length > nameW ? `${name.slice(0, Math.max(nameW - 1, 1))}…` : name;
911
+ const snippetW = Math.max(width - 6, 8);
912
+ const truncatedSnippet = snippet.length > snippetW ? snippet.slice(0, snippetW) : snippet;
913
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
914
+ /* @__PURE__ */ jsxs(
915
+ Box,
916
+ {
917
+ flexDirection: "column",
918
+ width,
919
+ backgroundColor: selected ? theme.sidebar.selected : void 0,
920
+ children: [
921
+ /* @__PURE__ */ jsxs(Box, { width, justifyContent: "space-between", children: [
922
+ /* @__PURE__ */ jsxs(Box, { flexShrink: 1, children: [
923
+ lineNum !== void 0 && /* @__PURE__ */ jsxs(Text, { color: selected && focused ? theme.sent.bg : theme.lineNum, children: [
924
+ lineNum.padStart(3),
925
+ " "
926
+ ] }),
927
+ /* @__PURE__ */ jsx(Text, { color: selected && focused ? theme.sent.bg : void 0, children: selected && focused ? "▸" : " " }),
928
+ hasUnread && /* @__PURE__ */ jsxs(Text, { color: theme.dot, children: [
929
+ theme.glyphs.envelope,
930
+ " "
931
+ ] }),
932
+ c.isGroupChat && /* @__PURE__ */ jsxs(Text, { color: theme.info.label, children: [
933
+ theme.glyphs.group,
934
+ " "
935
+ ] }),
936
+ /* @__PURE__ */ jsx(
937
+ Text,
938
+ {
939
+ color: selected ? theme.sidebar.selectedFg : hasUnread ? theme.sidebar.unread : theme.sidebar.read,
940
+ bold: hasUnread,
941
+ wrap: "truncate",
942
+ children: truncatedName
943
+ }
944
+ )
945
+ ] }),
946
+ /* @__PURE__ */ jsxs(Box, { flexShrink: 0, children: [
947
+ hasUnread && /* @__PURE__ */ jsxs(Text, { color: theme.sidebar.unread, children: [
948
+ " (",
949
+ c.unreadCount,
950
+ ")"
951
+ ] }),
952
+ /* @__PURE__ */ jsxs(Text, { color: c.serviceType === "SMS" ? theme.sms : theme.info.label, children: [
953
+ " ",
954
+ serviceIcon
955
+ ] }),
956
+ /* @__PURE__ */ jsxs(Text, { color: theme.sidebar.time, children: [
957
+ " ",
958
+ time
959
+ ] })
960
+ ] })
961
+ ] }),
962
+ /* @__PURE__ */ jsx(Box, { width, paddingLeft: 5, children: /* @__PURE__ */ jsx(Text, { color: theme.sidebar.snippet, wrap: "truncate", children: truncatedSnippet }) }),
963
+ /* @__PURE__ */ jsx(
964
+ Box,
965
+ {
966
+ width,
967
+ justifyContent: "flex-end",
968
+ paddingRight: 1,
969
+ backgroundColor: theme.sidebar.slugBg,
970
+ children: /* @__PURE__ */ jsxs(Text, { color: theme.sidebar.slug, dimColor: true, italic: true, children: [
971
+ "~",
972
+ c.threadSlug
973
+ ] })
974
+ }
975
+ )
976
+ ]
977
+ }
978
+ ),
979
+ !isLast && /* @__PURE__ */ jsx(Box, { paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: theme.sidebar.separator, dimColor: true, children: theme.glyphs.separator.repeat(Math.max(width - 4, 1)) }) })
980
+ ] });
981
+ }
982
+ function Sidebar({
983
+ conversations,
984
+ selectedIdx,
985
+ scrollOffset,
986
+ filterQuery,
987
+ focused,
988
+ width,
989
+ height
990
+ }) {
991
+ const theme = useTheme();
992
+ const filtered = useMemo(() => {
993
+ if (!filterQuery) return conversations;
994
+ const q = filterQuery.toLowerCase();
995
+ return conversations.filter(
996
+ (c) => (c.displayName?.toLowerCase().includes(q) ?? false) || c.chatIdentifier.toLowerCase().includes(q) || c.threadSlug.toLowerCase().includes(q)
997
+ );
998
+ }, [conversations, filterQuery]);
999
+ const itemHeight = 4;
1000
+ const headerH = 1 + (filterQuery ? 1 : 0);
1001
+ const borderH = 2;
1002
+ const visibleCount = Math.floor((height - headerH - borderH) / itemHeight);
1003
+ const visible = filtered.slice(scrollOffset, scrollOffset + visibleCount);
1004
+ return /* @__PURE__ */ jsxs(
1005
+ Box,
1006
+ {
1007
+ flexDirection: "column",
1008
+ width,
1009
+ height,
1010
+ borderStyle: "single",
1011
+ borderColor: focused ? theme.header.focused.fg : theme.border,
1012
+ overflow: "hidden",
1013
+ children: [
1014
+ /* @__PURE__ */ jsx(Box, { paddingX: 1, backgroundColor: focused ? theme.header.focused.bg : theme.header.dim.bg, children: /* @__PURE__ */ jsxs(Text, { color: focused ? theme.header.focused.fg : theme.header.dim.fg, bold: focused, children: [
1015
+ "Conversations (",
1016
+ filtered.length,
1017
+ ")"
1018
+ ] }) }),
1019
+ filterQuery && /* @__PURE__ */ jsxs(Box, { paddingX: 1, children: [
1020
+ /* @__PURE__ */ jsx(Text, { color: theme.status.accent, children: "/ " }),
1021
+ /* @__PURE__ */ jsx(Text, { color: theme.compose.fg, children: filterQuery })
1022
+ ] }),
1023
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: visible.length === 0 ? /* @__PURE__ */ jsx(Box, { paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: theme.sidebar.snippet, children: "No conversations" }) }) : visible.map((conv, i) => {
1024
+ const realIdx = scrollOffset + i;
1025
+ const relNum = realIdx === selectedIdx ? `${realIdx}` : `${Math.abs(realIdx - selectedIdx)}`;
1026
+ return /* @__PURE__ */ jsx(
1027
+ ConversationItem,
1028
+ {
1029
+ conversation: conv,
1030
+ selected: realIdx === selectedIdx,
1031
+ width: width - 2,
1032
+ lineNum: relNum,
1033
+ focused,
1034
+ isLast: i === visible.length - 1
1035
+ },
1036
+ conv.threadSlug
1037
+ );
1038
+ }) })
1039
+ ]
1040
+ }
1041
+ );
1042
+ }
1043
+ function StatusBar({ totalUnread, selected, status, loading, children }) {
1044
+ const theme = useTheme();
1045
+ return /* @__PURE__ */ jsxs(Box, { backgroundColor: theme.status.bg, paddingX: 1, height: 1, justifyContent: "space-between", children: [
1046
+ /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
1047
+ totalUnread > 0 && /* @__PURE__ */ jsxs(Text, { color: theme.status.accent, bold: true, children: [
1048
+ "● ",
1049
+ totalUnread,
1050
+ " unread"
1051
+ ] }),
1052
+ selected && /* @__PURE__ */ jsx(Text, { color: theme.status.fg, children: selected.displayName ?? selected.chatIdentifier }),
1053
+ selected && /* @__PURE__ */ jsx(Text, { color: selected.serviceType === "SMS" ? theme.sms : theme.info.label, children: selected.serviceType })
1054
+ ] }),
1055
+ /* @__PURE__ */ jsxs(Box, { gap: 2, children: [
1056
+ children,
1057
+ /* @__PURE__ */ jsx(Text, { color: theme.status.fg, children: loading ? "loading..." : status })
1058
+ ] })
1059
+ ] });
1060
+ }
1061
+ function ComposeBar({ mode, recipientName, onChangeText, onSubmit }) {
1062
+ const theme = useTheme();
1063
+ if (mode === "confirm") {
1064
+ return /* @__PURE__ */ jsxs(Box, { backgroundColor: theme.compose.bg, paddingX: 1, height: 1, children: [
1065
+ /* @__PURE__ */ jsxs(Text, { color: theme.status.accent, bold: true, children: [
1066
+ "Send to ",
1067
+ recipientName,
1068
+ "?",
1069
+ " "
1070
+ ] }),
1071
+ /* @__PURE__ */ jsx(Text, { color: theme.compose.fg, children: "Enter: send Esc: cancel" })
1072
+ ] });
1073
+ }
1074
+ if (mode === "compose") {
1075
+ return /* @__PURE__ */ jsxs(Box, { backgroundColor: theme.compose.bg, paddingX: 1, height: 1, children: [
1076
+ /* @__PURE__ */ jsx(Text, { color: theme.compose.fg, children: "> " }),
1077
+ /* @__PURE__ */ jsx(TextInput, { onChange: onChangeText, onSubmit, placeholder: "Type a message..." })
1078
+ ] });
1079
+ }
1080
+ return null;
1081
+ }
1082
+ function relativeDate(date) {
1083
+ const now = /* @__PURE__ */ new Date();
1084
+ const time = date.toLocaleTimeString("en-US", {
1085
+ hour: "numeric",
1086
+ minute: "2-digit",
1087
+ hour12: true
1088
+ });
1089
+ if (date.toDateString() === now.toDateString()) return time;
1090
+ const y = new Date(now);
1091
+ y.setDate(now.getDate() - 1);
1092
+ if (date.toDateString() === y.toDateString()) return `Yest ${time}`;
1093
+ return `${date.getMonth() + 1}/${date.getDate()} ${time}`;
1094
+ }
1095
+ function formatReactions(reactions) {
1096
+ const counts = /* @__PURE__ */ new Map();
1097
+ for (const r of reactions) {
1098
+ if (r.isRemoval) continue;
1099
+ const emoji = r.emoji ?? TAPBACK_EMOJI[r.type] ?? r.type;
1100
+ counts.set(emoji, (counts.get(emoji) ?? 0) + 1);
1101
+ }
1102
+ if (counts.size === 0) return "";
1103
+ return [...counts.entries()].map(([e, c]) => c > 1 ? `${e}${c}` : e).join("");
1104
+ }
1105
+ function isDifferentDay(a, b) {
1106
+ return a.toDateString() !== b.toDateString();
1107
+ }
1108
+ function dateSeparator(date) {
1109
+ const now = /* @__PURE__ */ new Date();
1110
+ if (date.toDateString() === now.toDateString()) return "Today";
1111
+ const y = new Date(now);
1112
+ y.setDate(now.getDate() - 1);
1113
+ if (date.toDateString() === y.toDateString()) return "Yesterday";
1114
+ return date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" });
1115
+ }
1116
+ function isGroupStart(msg, prev) {
1117
+ if (!prev) return true;
1118
+ return msg.isFromMe !== prev.isFromMe || msg.handle !== prev.handle;
1119
+ }
1120
+ function isGroupEnd(msg, next) {
1121
+ if (!next) return true;
1122
+ return msg.isFromMe !== next.isFromMe || msg.handle !== next.handle;
1123
+ }
1124
+ function MessageBubble({
1125
+ message: m,
1126
+ maxWidth,
1127
+ showSender,
1128
+ senderName,
1129
+ selected,
1130
+ lineNum,
1131
+ isFirstInGroup,
1132
+ isLastInGroup,
1133
+ bgTint,
1134
+ lookupReplyText,
1135
+ inSelection
1136
+ }) {
1137
+ const theme = useTheme();
1138
+ const isSent = m.isFromMe;
1139
+ const text = m.text ?? "(attachment)";
1140
+ const timestamp = relativeDate(m.date);
1141
+ const reactions = m.reactions ? formatReactions(m.reactions) : "";
1142
+ const hasAttachments = m.hasAttachments;
1143
+ const cursor = selected ? "▸" : " ";
1144
+ const cursorColor = selected ? theme.sent.bg : void 0;
1145
+ const sender = isFirstInGroup ? showSender && !isSent && senderName ? senderName : isSent ? "Me" : void 0 : void 0;
1146
+ const groupSepChar = isFirstInGroup ? "┌" : isLastInGroup ? "└" : "│";
1147
+ const groupColor = isSent ? theme.sent.border : theme.received.border;
1148
+ return /* @__PURE__ */ jsxs(
1149
+ Box,
1150
+ {
1151
+ flexDirection: "column",
1152
+ backgroundColor: selected ? theme.sidebar.selected : inSelection ? theme.selectionBg : bgTint,
1153
+ children: [
1154
+ /* @__PURE__ */ jsxs(Box, { children: [
1155
+ lineNum !== void 0 && /* @__PURE__ */ jsxs(Text, { color: selected ? theme.sent.bg : theme.lineNum, children: [
1156
+ lineNum.padStart(3),
1157
+ " "
1158
+ ] }),
1159
+ /* @__PURE__ */ jsx(Text, { color: cursorColor, children: cursor }),
1160
+ /* @__PURE__ */ jsxs(Text, { color: groupColor, children: [
1161
+ groupSepChar,
1162
+ " "
1163
+ ] }),
1164
+ isFirstInGroup ? /* @__PURE__ */ jsx(Text, { color: theme.timestamp, children: timestamp.padEnd(13) }) : /* @__PURE__ */ jsx(Text, { color: theme.timestamp, children: " " }),
1165
+ isFirstInGroup ? /* @__PURE__ */ jsxs(Fragment, { children: [
1166
+ isSent ? /* @__PURE__ */ jsxs(Text, { color: theme.sent.bg, bold: true, children: [
1167
+ theme.glyphs.sent,
1168
+ " "
1169
+ ] }) : /* @__PURE__ */ jsxs(Text, { color: theme.received.border, bold: true, children: [
1170
+ theme.glyphs.received,
1171
+ " "
1172
+ ] }),
1173
+ sender && /* @__PURE__ */ jsxs(Text, { color: isSent ? theme.sent.bg : theme.senderName, bold: true, children: [
1174
+ sender.length > 12 ? `${sender.slice(0, 11)}…` : sender,
1175
+ ": "
1176
+ ] })
1177
+ ] }) : (
1178
+ // Continuation: indent to align with first message text
1179
+ /* @__PURE__ */ jsxs(Text, { children: [
1180
+ " ",
1181
+ sender ? " " : ""
1182
+ ] })
1183
+ ),
1184
+ /* @__PURE__ */ jsx(Text, { color: isSent ? theme.sentText : theme.receivedText, wrap: "truncate", children: text }),
1185
+ reactions && /* @__PURE__ */ jsxs(Text, { children: [
1186
+ " ",
1187
+ reactions
1188
+ ] }),
1189
+ hasAttachments && /* @__PURE__ */ jsx(Text, { color: theme.attachment, children: " 📎" }),
1190
+ m.isEdited && /* @__PURE__ */ jsx(Text, { color: theme.edited, children: " ✎" })
1191
+ ] }),
1192
+ m.isReply && (() => {
1193
+ let replyText = m.replyTo?.replyToText ?? null;
1194
+ if (!replyText && m.replyTo?.replyToGuid && lookupReplyText) {
1195
+ replyText = lookupReplyText(m.replyTo.replyToGuid);
1196
+ }
1197
+ const display = replyText ? replyText.slice(0, maxWidth - 12) : "(replied to earlier message)";
1198
+ return /* @__PURE__ */ jsxs(Box, { children: [
1199
+ lineNum !== void 0 && /* @__PURE__ */ jsx(Text, { children: " " }),
1200
+ /* @__PURE__ */ jsx(Text, { children: " " }),
1201
+ /* @__PURE__ */ jsxs(Text, { color: theme.replyContext, italic: true, children: [
1202
+ " ↩ ",
1203
+ display
1204
+ ] })
1205
+ ] });
1206
+ })()
1207
+ ]
1208
+ }
1209
+ );
1210
+ }
1211
+ function PendingBubble({ text, status }) {
1212
+ const theme = useTheme();
1213
+ const indicator = status === "sending" ? "⏳" : status === "failed" ? "⚠" : "✓";
1214
+ const color = status === "failed" ? theme.edited : theme.pending.fg;
1215
+ return /* @__PURE__ */ jsxs(Box, { backgroundColor: theme.groupBg.sent, children: [
1216
+ /* @__PURE__ */ jsx(Text, { children: " " }),
1217
+ /* @__PURE__ */ jsxs(Text, { color, children: [
1218
+ indicator,
1219
+ " "
1220
+ ] }),
1221
+ /* @__PURE__ */ jsx(Text, { color: theme.timestamp, children: "now".padEnd(13) }),
1222
+ /* @__PURE__ */ jsx(Text, { color: theme.sent.bg, bold: true, children: `${theme.glyphs.sent} Me: ` }),
1223
+ /* @__PURE__ */ jsx(Text, { color: theme.pending.fg, wrap: "truncate", children: text })
1224
+ ] });
1225
+ }
1226
+ function ThreadPane({
1227
+ conversation,
1228
+ messages,
1229
+ pending,
1230
+ resolvedNames: _resolvedNames,
1231
+ scrollOffset: _scrollOffset,
1232
+ selectedMsgIdx,
1233
+ selectionAnchor,
1234
+ gapMarkers,
1235
+ focused,
1236
+ width,
1237
+ height,
1238
+ mode,
1239
+ onChangeCompose,
1240
+ onSubmitCompose
1241
+ }) {
1242
+ const theme = useTheme();
1243
+ const isGroup = conversation?.isGroupChat ?? false;
1244
+ const maxBubbleW = Math.max(width - 8, 20);
1245
+ const composing = mode === "compose" || mode === "confirm";
1246
+ const headerH = 1;
1247
+ const composeH = composing ? 1 : 0;
1248
+ const borderH = 2;
1249
+ const msgAreaHeight = Math.max(height - headerH - composeH - borderH, 3);
1250
+ const { visibleStart, visibleEnd } = useMemo(() => {
1251
+ const total = messages.length + pending.length;
1252
+ if (total === 0) return { visibleStart: 0, visibleEnd: 0 };
1253
+ let cursorIdx = selectedMsgIdx >= 0 ? selectedMsgIdx : total - 1;
1254
+ cursorIdx = Math.max(0, Math.min(cursorIdx, total - 1));
1255
+ const NEAR_END = 2;
1256
+ if (cursorIdx >= messages.length - NEAR_END && pending.length === 0) {
1257
+ const end2 = messages.length - 1;
1258
+ let start2 = end2;
1259
+ let totalLines2 = lineHeight(messages, end2, maxBubbleW);
1260
+ while (start2 > 0 && totalLines2 + lineHeight(messages, start2 - 1, maxBubbleW) <= msgAreaHeight) {
1261
+ start2--;
1262
+ totalLines2 += lineHeight(messages, start2, maxBubbleW);
1263
+ }
1264
+ return { visibleStart: start2, visibleEnd: end2 + 1 };
1265
+ }
1266
+ let start = cursorIdx;
1267
+ let linesAbove = 0;
1268
+ const targetAbove = Math.floor(msgAreaHeight * 0.4);
1269
+ while (start > 0 && linesAbove < targetAbove) {
1270
+ start--;
1271
+ linesAbove += lineHeight(messages, start, maxBubbleW);
1272
+ }
1273
+ let end = cursorIdx;
1274
+ let totalLines = linesAbove + lineHeight(messages, cursorIdx, maxBubbleW);
1275
+ while (end < total - 1 && totalLines < msgAreaHeight) {
1276
+ end++;
1277
+ if (end < messages.length) {
1278
+ totalLines += lineHeight(messages, end, maxBubbleW);
1279
+ } else {
1280
+ totalLines += 1;
1281
+ }
1282
+ }
1283
+ while (start > 0 && totalLines < msgAreaHeight) {
1284
+ start--;
1285
+ totalLines += lineHeight(messages, start, maxBubbleW);
1286
+ }
1287
+ return { visibleStart: start, visibleEnd: end + 1 };
1288
+ }, [messages, pending.length, selectedMsgIdx, msgAreaHeight, maxBubbleW]);
1289
+ const visibleMessages = messages.slice(visibleStart, Math.min(visibleEnd, messages.length));
1290
+ const messagesByGuid = useMemo(() => {
1291
+ const map = /* @__PURE__ */ new Map();
1292
+ for (const m of messages) {
1293
+ if (m.guid && m.text) map.set(m.guid, m.text);
1294
+ }
1295
+ return map;
1296
+ }, [messages]);
1297
+ const lookupReplyText = (guid) => messagesByGuid.get(guid) ?? null;
1298
+ const selRange = selectionAnchor != null && selectedMsgIdx >= 0 ? [Math.min(selectionAnchor, selectedMsgIdx), Math.max(selectionAnchor, selectedMsgIdx)] : null;
1299
+ return /* @__PURE__ */ jsxs(
1300
+ Box,
1301
+ {
1302
+ flexDirection: "column",
1303
+ width,
1304
+ height,
1305
+ borderStyle: "single",
1306
+ borderColor: focused ? theme.header.focused.fg : theme.border,
1307
+ overflow: "hidden",
1308
+ children: [
1309
+ /* @__PURE__ */ jsxs(
1310
+ Box,
1311
+ {
1312
+ paddingX: 1,
1313
+ backgroundColor: focused ? theme.header.focused.bg : theme.header.dim.bg,
1314
+ justifyContent: "space-between",
1315
+ children: [
1316
+ /* @__PURE__ */ jsxs(Box, { children: [
1317
+ /* @__PURE__ */ jsx(Text, { color: focused ? theme.header.focused.fg : theme.header.dim.fg, bold: focused, children: conversation?.displayName ?? conversation?.chatIdentifier ?? "Thread" }),
1318
+ conversation && /* @__PURE__ */ jsxs(Text, { color: theme.info.label, children: [
1319
+ " (",
1320
+ messages.length,
1321
+ " msgs)"
1322
+ ] })
1323
+ ] }),
1324
+ conversation && /* @__PURE__ */ jsxs(Box, { gap: 1, children: [
1325
+ conversation.displayName && /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: conversation.rawIdentifier }),
1326
+ /* @__PURE__ */ jsx(Text, { color: conversation.serviceType === "SMS" ? theme.sms : theme.info.label, children: conversation.serviceType }),
1327
+ conversation.isGroupChat && /* @__PURE__ */ jsx(Text, { color: theme.info.label, children: "Group" })
1328
+ ] })
1329
+ ]
1330
+ }
1331
+ ),
1332
+ /* @__PURE__ */ jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: messages.length === 0 && pending.length === 0 ? /* @__PURE__ */ jsx(Box, { paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: theme.sidebar.snippet, children: "No messages" }) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
1333
+ visibleStart > 0 && /* @__PURE__ */ jsx(Box, { justifyContent: "center", children: /* @__PURE__ */ jsxs(Text, { color: theme.dateSep, children: [
1334
+ "── ↑ ",
1335
+ visibleStart,
1336
+ " more ──"
1337
+ ] }) }),
1338
+ visibleMessages.map((msg, i) => {
1339
+ const realIdx = visibleStart + i;
1340
+ const prevMsg = realIdx > 0 ? messages[realIdx - 1] : void 0;
1341
+ const nextMsg = realIdx < messages.length - 1 ? messages[realIdx + 1] : void 0;
1342
+ const showDateSep = !prevMsg || isDifferentDay(prevMsg.date, msg.date);
1343
+ const firstInGroup = isGroupStart(msg, prevMsg);
1344
+ const lastInGroup = isGroupEnd(msg, nextMsg);
1345
+ const bgTint = msg.isFromMe ? theme.groupBg.sent : theme.groupBg.received;
1346
+ const relNum = selectedMsgIdx >= 0 ? realIdx === selectedMsgIdx ? `${realIdx}` : `${Math.abs(realIdx - selectedMsgIdx)}` : `${realIdx}`;
1347
+ return /* @__PURE__ */ jsxs(React.Fragment, { children: [
1348
+ (() => {
1349
+ const gap = gapMarkers.find((g) => g.atIdx === realIdx);
1350
+ if (!gap) return null;
1351
+ return /* @__PURE__ */ jsx(Box, { justifyContent: "center", marginTop: 1, marginBottom: 1, children: /* @__PURE__ */ jsxs(Text, { color: theme.edited, children: [
1352
+ "─── ",
1353
+ gap.count.toLocaleString(),
1354
+ " older messages evicted (scroll back to reload) ───"
1355
+ ] }) });
1356
+ })(),
1357
+ showDateSep && // Always 1 row of breathing room above date separators so the
1358
+ // visual rhythm is consistent — without this, separators that
1359
+ // appear after a same-sender continuation feel cramped while
1360
+ // ones after a different-sender row feel fine.
1361
+ /* @__PURE__ */ jsx(Box, { justifyContent: "center", marginTop: realIdx === 0 ? 0 : 1, children: /* @__PURE__ */ jsxs(Text, { color: theme.dateSep, children: [
1362
+ "─── ",
1363
+ dateSeparator(msg.date),
1364
+ " ───"
1365
+ ] }) }),
1366
+ /* @__PURE__ */ jsx(
1367
+ MessageBubble,
1368
+ {
1369
+ message: msg,
1370
+ maxWidth: maxBubbleW,
1371
+ showSender: isGroup,
1372
+ senderName: msg.displayName ?? msg.handle,
1373
+ selected: realIdx === selectedMsgIdx && focused,
1374
+ lineNum: relNum,
1375
+ isFirstInGroup: firstInGroup || showDateSep,
1376
+ isLastInGroup: lastInGroup,
1377
+ bgTint,
1378
+ lookupReplyText,
1379
+ inSelection: selRange != null && realIdx >= selRange[0] && realIdx <= selRange[1]
1380
+ }
1381
+ ),
1382
+ lastInGroup && nextMsg && !isDifferentDay(msg.date, nextMsg.date) && /* @__PURE__ */ jsx(Box, { height: 0 })
1383
+ ] }, msg.id);
1384
+ }),
1385
+ pending.map((pm) => /* @__PURE__ */ jsx(
1386
+ PendingBubble,
1387
+ {
1388
+ text: pm.text,
1389
+ status: pm.status,
1390
+ maxWidth: maxBubbleW
1391
+ },
1392
+ pm.text
1393
+ )),
1394
+ visibleEnd < messages.length && /* @__PURE__ */ jsx(Box, { justifyContent: "center", children: /* @__PURE__ */ jsxs(Text, { color: theme.dateSep, children: [
1395
+ "── ↓ ",
1396
+ messages.length - visibleEnd,
1397
+ " more ──"
1398
+ ] }) })
1399
+ ] }) }),
1400
+ composing && /* @__PURE__ */ jsx(
1401
+ ComposeBar,
1402
+ {
1403
+ mode,
1404
+ recipientName: conversation?.displayName ?? conversation?.chatIdentifier ?? "",
1405
+ onChangeText: onChangeCompose,
1406
+ onSubmit: onSubmitCompose
1407
+ }
1408
+ )
1409
+ ]
1410
+ }
1411
+ );
1412
+ }
1413
+ function lineHeight(messages, i, bubbleWidth) {
1414
+ if (i < 0 || i >= messages.length) return 1;
1415
+ const msg = messages[i];
1416
+ const innerW = Math.max(20, bubbleWidth - 4);
1417
+ const text = msg.text ?? "";
1418
+ let h = Math.max(1, Math.ceil(text.length / innerW));
1419
+ if (msg.isReply) {
1420
+ const replyLen = msg.replyTo?.replyToText?.length ?? 0;
1421
+ h += Math.max(1, Math.ceil(replyLen / Math.max(20, innerW - 4)));
1422
+ }
1423
+ if (i === 0) {
1424
+ h += 1;
1425
+ } else if (isDifferentDay(messages[i - 1].date, msg.date)) {
1426
+ h += 2;
1427
+ }
1428
+ if (msg.attachments && msg.attachments.length > 0) h += 1;
1429
+ return h;
1430
+ }
1431
+ function nextGroupBoundary(messages, fromIdx) {
1432
+ if (fromIdx >= messages.length - 1) return messages.length - 1;
1433
+ const current = messages[fromIdx];
1434
+ let i = fromIdx + 1;
1435
+ while (i < messages.length) {
1436
+ const m = messages[i];
1437
+ if (m.isFromMe !== current.isFromMe || m.handle !== current.handle) {
1438
+ return i;
1439
+ }
1440
+ i++;
1441
+ }
1442
+ return messages.length - 1;
1443
+ }
1444
+ function prevGroupBoundary(messages, fromIdx) {
1445
+ if (fromIdx <= 0) return 0;
1446
+ const current = messages[fromIdx];
1447
+ const prev = messages[fromIdx - 1];
1448
+ if (prev.isFromMe !== current.isFromMe || prev.handle !== current.handle) {
1449
+ let i2 = fromIdx - 1;
1450
+ while (i2 > 0) {
1451
+ const m = messages[i2 - 1];
1452
+ if (m.isFromMe !== prev.isFromMe || m.handle !== prev.handle) {
1453
+ return i2;
1454
+ }
1455
+ i2--;
1456
+ }
1457
+ return 0;
1458
+ }
1459
+ let i = fromIdx - 1;
1460
+ while (i > 0) {
1461
+ const m = messages[i - 1];
1462
+ if (m.isFromMe !== current.isFromMe || m.handle !== current.handle) {
1463
+ return i;
1464
+ }
1465
+ i--;
1466
+ }
1467
+ return 0;
1468
+ }
1469
+ function formatUptime(seconds) {
1470
+ if (seconds < 60) return `${Math.round(seconds)}s`;
1471
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
1472
+ const h = Math.floor(seconds / 3600);
1473
+ const m = Math.floor(seconds % 3600 / 60);
1474
+ return `${h}h${m}m`;
1475
+ }
1476
+ function formatAgo(ms) {
1477
+ if (ms < 1e3) return "now";
1478
+ const sec = Math.floor(ms / 1e3);
1479
+ if (sec < 60) return `${sec}s`;
1480
+ if (sec < 3600) return `${Math.floor(sec / 60)}m`;
1481
+ return `${Math.floor(sec / 3600)}h`;
1482
+ }
1483
+ function engineLabel() {
1484
+ return hasNativeModule() ? "Rust parser + TS DB" : "TS";
1485
+ }
1486
+ function useDevStats(visible) {
1487
+ const [stats, setStats] = useState({
1488
+ engine: engineLabel(),
1489
+ cpuPercent: 0,
1490
+ memMB: 0,
1491
+ pid: process.pid,
1492
+ uptime: "0s",
1493
+ lastQueryMs: null,
1494
+ eventLoopP99Ms: 0,
1495
+ lastActivityAgo: "now"
1496
+ });
1497
+ const lastCpuRef = useRef(process.cpuUsage());
1498
+ const lastTimeRef = useRef(Date.now());
1499
+ const lastQueryMsRef = useRef(null);
1500
+ useEffect(() => {
1501
+ if (!visible) return;
1502
+ const timer = setInterval(() => {
1503
+ const now = Date.now();
1504
+ const elapsed = now - lastTimeRef.current;
1505
+ if (elapsed === 0) return;
1506
+ const cpuNow = process.cpuUsage(lastCpuRef.current);
1507
+ const totalCpuUs = cpuNow.user + cpuNow.system;
1508
+ const cpuPercent = totalCpuUs / 1e3 / elapsed * 100;
1509
+ lastCpuRef.current = process.cpuUsage();
1510
+ lastTimeRef.current = now;
1511
+ const { rss } = process.memoryUsage();
1512
+ const memMB = Math.round(rss / 1024 / 1024 * 10) / 10;
1513
+ const wd = readWatchdogState();
1514
+ setStats({
1515
+ engine: engineLabel(),
1516
+ cpuPercent: Math.round(cpuPercent * 10) / 10,
1517
+ memMB,
1518
+ pid: process.pid,
1519
+ uptime: formatUptime(process.uptime()),
1520
+ lastQueryMs: lastQueryMsRef.current,
1521
+ eventLoopP99Ms: Math.round(wd.eventLoopP99Ms * 10) / 10,
1522
+ lastActivityAgo: formatAgo(Date.now() - wd.lastActivityTs)
1523
+ });
1524
+ }, 2e3);
1525
+ timer.unref();
1526
+ return () => clearInterval(timer);
1527
+ }, [visible]);
1528
+ const recordQueryTime = useCallback((ms) => {
1529
+ lastQueryMsRef.current = Math.round(ms * 10) / 10;
1530
+ }, []);
1531
+ return { stats, recordQueryTime };
1532
+ }
1533
+ function envNum(name, fallback) {
1534
+ const raw = process.env[name];
1535
+ if (!raw) return fallback;
1536
+ const parsed = Number.parseInt(raw, 10);
1537
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
1538
+ }
1539
+ const TTL_MS = envNum("IMSG_TUI_CACHE_TTL_MS", 6e5);
1540
+ const STALE_MS = envNum("IMSG_TUI_CACHE_STALE_MS", 3e4);
1541
+ const MEMORY_PRESSURE_MB = envNum("IMSG_TUI_CACHE_MEM_PRESSURE_MB", 200);
1542
+ const cache = /* @__PURE__ */ new Map();
1543
+ let sweepTimer = null;
1544
+ let unsubMemSample = null;
1545
+ function estimateBytes(messages) {
1546
+ let bytes = 0;
1547
+ for (const m of messages) {
1548
+ bytes += (m.text?.length ?? 0) * 2;
1549
+ bytes += (m.handle?.length ?? 0) * 2;
1550
+ bytes += 80;
1551
+ }
1552
+ return bytes;
1553
+ }
1554
+ function getCached(chatIdentifier) {
1555
+ const entry = cache.get(chatIdentifier);
1556
+ if (entry) entry.lastAccess = Date.now();
1557
+ return entry;
1558
+ }
1559
+ function isFresh(entry, now = Date.now()) {
1560
+ return now - entry.loadedAt < STALE_MS;
1561
+ }
1562
+ function setCached(chatIdentifier, messages, oldestId) {
1563
+ const now = Date.now();
1564
+ cache.set(chatIdentifier, {
1565
+ messages,
1566
+ oldestId,
1567
+ loadedAt: now,
1568
+ lastAccess: now,
1569
+ bytesEstimate: estimateBytes(messages)
1570
+ });
1571
+ }
1572
+ function prependCached(chatIdentifier, olderMessages) {
1573
+ const entry = cache.get(chatIdentifier);
1574
+ if (!entry) return;
1575
+ const existingIds = new Set(entry.messages.map((m) => m.id));
1576
+ const fresh = olderMessages.filter((m) => !existingIds.has(m.id));
1577
+ if (fresh.length === 0) return;
1578
+ const merged = [...fresh, ...entry.messages].sort((a, b) => a.date.getTime() - b.date.getTime());
1579
+ entry.messages = merged;
1580
+ entry.oldestId = Math.min(entry.oldestId, ...fresh.map((m) => m.id));
1581
+ entry.lastAccess = Date.now();
1582
+ entry.bytesEstimate = estimateBytes(merged);
1583
+ }
1584
+ function clearCache() {
1585
+ cache.clear();
1586
+ }
1587
+ function ttlSweep(now = Date.now()) {
1588
+ let dropped = 0;
1589
+ for (const [k, v] of cache) {
1590
+ if (now - v.loadedAt > TTL_MS) {
1591
+ cache.delete(k);
1592
+ dropped++;
1593
+ }
1594
+ }
1595
+ return dropped;
1596
+ }
1597
+ function evictUnderPressure(heapMb) {
1598
+ if (heapMb < MEMORY_PRESSURE_MB) return 0;
1599
+ const sorted = [...cache.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess);
1600
+ const half = Math.ceil(sorted.length / 2);
1601
+ for (let i = 0; i < half; i++) {
1602
+ cache.delete(sorted[i][0]);
1603
+ }
1604
+ return half;
1605
+ }
1606
+ function installCacheSweepers() {
1607
+ if (sweepTimer) return;
1608
+ sweepTimer = setInterval(() => {
1609
+ ttlSweep();
1610
+ }, 6e4);
1611
+ sweepTimer.unref();
1612
+ unsubMemSample = onMemorySample((_rss, heapMb) => {
1613
+ evictUnderPressure(heapMb);
1614
+ });
1615
+ }
1616
+ function stopCacheSweepers() {
1617
+ if (sweepTimer) {
1618
+ clearInterval(sweepTimer);
1619
+ sweepTimer = null;
1620
+ }
1621
+ if (unsubMemSample) {
1622
+ unsubMemSample();
1623
+ unsubMemSample = null;
1624
+ }
1625
+ }
1626
+ function useImsg() {
1627
+ const dbRef = useRef(null);
1628
+ const getDb = useCallback(() => {
1629
+ if (!dbRef.current) {
1630
+ dbRef.current = new IMessageDB(getImsgDbPath(), getContactsDbPaths(), getSlugsDbPath());
1631
+ }
1632
+ return dbRef.current;
1633
+ }, []);
1634
+ const loadConversations = useCallback(
1635
+ async (limit = 200) => {
1636
+ return getDb().listConversations(limit);
1637
+ },
1638
+ [getDb]
1639
+ );
1640
+ const loadMessages = useCallback(
1641
+ async (chatIdentifier) => {
1642
+ const cached = getCached(chatIdentifier);
1643
+ if (cached && isFresh(cached)) return cached.messages;
1644
+ const messages = await getDb().getMessagesForChat(chatIdentifier, 200, {
1645
+ includeReactionDetails: true
1646
+ });
1647
+ const oldestId = messages.length > 0 ? Math.min(...messages.map((m) => m.id)) : 0;
1648
+ setCached(chatIdentifier, messages, oldestId);
1649
+ return messages;
1650
+ },
1651
+ [getDb]
1652
+ );
1653
+ const loadOlderMessages = useCallback(
1654
+ async (chatIdentifier, beforeMessageId) => {
1655
+ const older = await getDb().getMessagesForChat(chatIdentifier, 100, {
1656
+ includeReactionDetails: true,
1657
+ beforeMessageId
1658
+ });
1659
+ if (older.length > 0) prependCached(chatIdentifier, older);
1660
+ return older;
1661
+ },
1662
+ [getDb]
1663
+ );
1664
+ const resolveNames = useCallback(
1665
+ (handles) => {
1666
+ return getDb().resolveParticipantNames(handles);
1667
+ },
1668
+ [getDb]
1669
+ );
1670
+ const send = useCallback(
1671
+ async (threadSlug, text) => {
1672
+ const db = getDb();
1673
+ const slugRecord = db.getSlugRecord(threadSlug);
1674
+ if (!slugRecord) return { success: false, error: `Unknown slug: ${threadSlug}` };
1675
+ if (slugRecord.isGroup) {
1676
+ return slugRecord.displayName && !slugRecord.displayName.startsWith("chat") ? sendToChat(slugRecord.displayName, text) : sendToChatId(slugRecord.chatGuid, text);
1677
+ }
1678
+ return sendMessageAlt(slugRecord.chatIdentifier, text);
1679
+ },
1680
+ [getDb]
1681
+ );
1682
+ const refresh = useCallback(() => {
1683
+ getDb().scheduleBackgroundRefresh();
1684
+ }, [getDb]);
1685
+ const close = useCallback(async () => {
1686
+ if (dbRef.current) {
1687
+ await dbRef.current.close();
1688
+ dbRef.current = null;
1689
+ }
1690
+ }, []);
1691
+ return { loadConversations, loadMessages, loadOlderMessages, resolveNames, send, refresh, close };
1692
+ }
1693
+ function useMouse(onEvent) {
1694
+ const { stdin, setRawMode } = useStdin();
1695
+ useEffect(() => {
1696
+ process.stdout.write("\x1B[?1000h\x1B[?1006h");
1697
+ setRawMode(true);
1698
+ const handler = (data) => {
1699
+ const str = data.toString();
1700
+ const re = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
1701
+ let match = re.exec(str);
1702
+ while (match !== null) {
1703
+ const btn = Number.parseInt(match[1], 10);
1704
+ const x = Number.parseInt(match[2], 10);
1705
+ const y = Number.parseInt(match[3], 10);
1706
+ const isPress = match[4] === "M";
1707
+ if (btn === 64 && isPress) {
1708
+ onEvent({ type: "scroll-up", x, y, button: btn });
1709
+ } else if (btn === 65 && isPress) {
1710
+ onEvent({ type: "scroll-down", x, y, button: btn });
1711
+ } else if (btn === 0 && isPress) {
1712
+ onEvent({ type: "click", x, y, button: btn });
1713
+ }
1714
+ match = re.exec(str);
1715
+ }
1716
+ };
1717
+ stdin.on("data", handler);
1718
+ return () => {
1719
+ stdin.off("data", handler);
1720
+ process.stdout.write("\x1B[?1000l\x1B[?1006l");
1721
+ };
1722
+ }, [stdin, setRawMode, onEvent]);
1723
+ }
1724
+ const initialState = {
1725
+ conversations: [],
1726
+ messages: [],
1727
+ selectedIdx: 0,
1728
+ selectedMsgIdx: -1,
1729
+ focus: "sidebar",
1730
+ mode: "browse",
1731
+ sidebarScroll: 0,
1732
+ threadScroll: 0,
1733
+ composeText: "",
1734
+ filterQuery: "",
1735
+ pending: [],
1736
+ loading: true,
1737
+ status: "Loading...",
1738
+ numBuffer: "",
1739
+ showDevStats: false,
1740
+ conversationLoadedCount: 0,
1741
+ conversationLoadingMore: false,
1742
+ messageOldestLoadedId: null,
1743
+ messageLoadingOlder: false,
1744
+ gapMarkers: [],
1745
+ selectionAnchor: null,
1746
+ exportFormat: "markdown",
1747
+ exportPath: "",
1748
+ exportStatus: "",
1749
+ dateJumpInput: "",
1750
+ dateJumpError: ""
1751
+ };
1752
+ function clampMsg(state, idx) {
1753
+ const total = state.messages.length;
1754
+ if (total === 0) return { selectedMsgIdx: -1 };
1755
+ const clamped = Math.max(0, Math.min(idx, total - 1));
1756
+ return { selectedMsgIdx: clamped };
1757
+ }
1758
+ const MESSAGES_HARD_CAP = Number.parseInt(process.env.IMSG_TUI_MSG_HARD_CAP ?? "5000", 10);
1759
+ const ANCHOR_KEEP = 200;
1760
+ const WINDOW_BUFFER = 300;
1761
+ function boundMessagesIfNeeded(messages, selectedMsgIdx, existingGaps) {
1762
+ if (messages.length <= MESSAGES_HARD_CAP) {
1763
+ return { messages, selectedMsgIdx, gapMarkers: existingGaps };
1764
+ }
1765
+ const total = messages.length;
1766
+ const anchorStart = Math.max(total - ANCHOR_KEEP, 0);
1767
+ const cursorLo = Math.max(0, selectedMsgIdx - WINDOW_BUFFER);
1768
+ const cursorHi = Math.min(total - 1, selectedMsgIdx + WINDOW_BUFFER);
1769
+ const ranges = [
1770
+ [cursorLo, cursorHi],
1771
+ [anchorStart, total - 1]
1772
+ ];
1773
+ ranges.sort((a, b) => a[0] - b[0]);
1774
+ const merged = [];
1775
+ for (const r of ranges) {
1776
+ if (merged.length === 0) {
1777
+ merged.push(r);
1778
+ continue;
1779
+ }
1780
+ const last = merged[merged.length - 1];
1781
+ if (r[0] <= last[1] + 1) {
1782
+ last[1] = Math.max(last[1], r[1]);
1783
+ } else {
1784
+ merged.push(r);
1785
+ }
1786
+ }
1787
+ const kept = [];
1788
+ const gapMarkers = [];
1789
+ for (let i = 0; i < merged.length; i++) {
1790
+ const [start, end] = merged[i];
1791
+ if (i > 0) {
1792
+ const prevEnd = merged[i - 1][1];
1793
+ const gapStart = prevEnd + 1;
1794
+ const gapEnd = start - 1;
1795
+ if (gapEnd >= gapStart) {
1796
+ gapMarkers.push({
1797
+ atIdx: kept.length,
1798
+ oldestId: messages[gapStart].id,
1799
+ newestId: messages[gapEnd].id,
1800
+ count: gapEnd - gapStart + 1
1801
+ });
1802
+ }
1803
+ }
1804
+ for (let j = start; j <= end; j++) kept.push(messages[j]);
1805
+ }
1806
+ let newCursor = -1;
1807
+ if (selectedMsgIdx >= 0) {
1808
+ let collapsedIdx = 0;
1809
+ for (const [start, end] of merged) {
1810
+ if (selectedMsgIdx >= start && selectedMsgIdx <= end) {
1811
+ newCursor = collapsedIdx + (selectedMsgIdx - start);
1812
+ break;
1813
+ }
1814
+ collapsedIdx += end - start + 1;
1815
+ }
1816
+ if (newCursor === -1) newCursor = 0;
1817
+ }
1818
+ return { messages: kept, selectedMsgIdx: newCursor, gapMarkers };
1819
+ }
1820
+ function ensureVisibleScroll(selectedIdx, currentScroll, visibleCount, totalCount) {
1821
+ if (visibleCount <= 0 || totalCount <= 0) return 0;
1822
+ const buffer = Math.min(2, Math.floor(visibleCount / 4));
1823
+ if (selectedIdx < currentScroll + buffer) {
1824
+ return Math.max(0, selectedIdx - buffer);
1825
+ }
1826
+ const lastVisible = currentScroll + visibleCount - 1;
1827
+ if (selectedIdx > lastVisible - buffer) {
1828
+ return Math.min(
1829
+ Math.max(0, totalCount - visibleCount),
1830
+ selectedIdx - visibleCount + 1 + buffer
1831
+ );
1832
+ }
1833
+ return Math.max(0, Math.min(currentScroll, Math.max(0, totalCount - visibleCount)));
1834
+ }
1835
+ function reducer(state, action) {
1836
+ switch (action.type) {
1837
+ case "SET_CONVERSATIONS":
1838
+ return {
1839
+ ...state,
1840
+ conversations: action.data,
1841
+ conversationLoadedCount: action.data.length,
1842
+ conversationLoadingMore: false
1843
+ };
1844
+ case "APPEND_CONVERSATIONS": {
1845
+ const existing = new Set(state.conversations.map((c) => c.threadSlug));
1846
+ const fresh = action.data.filter((c) => !existing.has(c.threadSlug));
1847
+ return {
1848
+ ...state,
1849
+ conversations: [...state.conversations, ...fresh],
1850
+ conversationLoadedCount: action.loadedCount,
1851
+ conversationLoadingMore: false
1852
+ };
1853
+ }
1854
+ case "SET_MESSAGES": {
1855
+ const msgs = action.data;
1856
+ const lastIdx = Math.max(0, msgs.length - 1);
1857
+ const scrollToEnd = Math.max(0, msgs.length);
1858
+ const oldestId = msgs.length > 0 ? Math.min(...msgs.map((m) => m.id)) : null;
1859
+ return {
1860
+ ...state,
1861
+ messages: msgs,
1862
+ pending: [],
1863
+ selectedMsgIdx: lastIdx,
1864
+ threadScroll: scrollToEnd,
1865
+ messageOldestLoadedId: oldestId,
1866
+ messageLoadingOlder: false,
1867
+ gapMarkers: []
1868
+ };
1869
+ }
1870
+ case "PREPEND_MESSAGES": {
1871
+ const existingIds = new Set(state.messages.map((m) => m.id));
1872
+ const fresh = action.data.filter((m) => !existingIds.has(m.id));
1873
+ const merged = [...fresh, ...state.messages].sort(
1874
+ (a, b) => a.date.getTime() - b.date.getTime()
1875
+ );
1876
+ const shift = fresh.length;
1877
+ const shiftedCursor = state.selectedMsgIdx >= 0 ? state.selectedMsgIdx + shift : state.selectedMsgIdx;
1878
+ const bounded = boundMessagesIfNeeded(merged, shiftedCursor, state.gapMarkers);
1879
+ return {
1880
+ ...state,
1881
+ messages: bounded.messages,
1882
+ selectedMsgIdx: bounded.selectedMsgIdx,
1883
+ gapMarkers: bounded.gapMarkers,
1884
+ messageOldestLoadedId: action.oldestId,
1885
+ messageLoadingOlder: false
1886
+ };
1887
+ }
1888
+ case "SET_LOADING_OLDER":
1889
+ return { ...state, messageLoadingOlder: action.loading };
1890
+ case "ENTER_SELECT_MODE":
1891
+ return { ...state, mode: "select", selectionAnchor: state.selectedMsgIdx };
1892
+ case "EXIT_SELECT_MODE":
1893
+ return { ...state, mode: "browse", selectionAnchor: null };
1894
+ case "ENTER_EXPORT_MODE":
1895
+ return { ...state, mode: "export", exportPath: action.defaultPath };
1896
+ case "EXIT_EXPORT_MODE":
1897
+ return { ...state, mode: state.selectionAnchor != null ? "select" : "browse" };
1898
+ case "SET_EXPORT_FORMAT":
1899
+ return { ...state, exportFormat: action.format };
1900
+ case "SET_EXPORT_PATH":
1901
+ return { ...state, exportPath: action.path };
1902
+ case "SET_EXPORT_STATUS":
1903
+ return { ...state, exportStatus: action.status };
1904
+ case "ENTER_DATE_JUMP":
1905
+ return { ...state, mode: "date-jump", dateJumpInput: "", dateJumpError: "" };
1906
+ case "EXIT_DATE_JUMP":
1907
+ return { ...state, mode: "browse", dateJumpInput: "", dateJumpError: "" };
1908
+ case "ENTER_SEND_VIA":
1909
+ return { ...state, mode: "send-via" };
1910
+ case "EXIT_SEND_VIA":
1911
+ return { ...state, mode: "browse" };
1912
+ case "SET_DATE_JUMP_INPUT":
1913
+ return { ...state, dateJumpInput: action.value, dateJumpError: "" };
1914
+ case "SET_DATE_JUMP_ERROR":
1915
+ return { ...state, dateJumpError: action.error };
1916
+ case "SELECT": {
1917
+ const idx = Math.max(0, Math.min(action.index, Math.max(0, state.conversations.length - 1)));
1918
+ const sidebarScroll = action.visibleCount ? ensureVisibleScroll(
1919
+ idx,
1920
+ state.sidebarScroll,
1921
+ action.visibleCount,
1922
+ state.conversations.length
1923
+ ) : state.sidebarScroll;
1924
+ return { ...state, selectedIdx: idx, sidebarScroll };
1925
+ }
1926
+ case "SELECT_MSG": {
1927
+ const c = clampMsg(state, action.index);
1928
+ return { ...state, ...c };
1929
+ }
1930
+ case "MOVE_MSG": {
1931
+ const c = clampMsg(state, state.selectedMsgIdx + action.delta);
1932
+ return { ...state, ...c };
1933
+ }
1934
+ case "FOCUS":
1935
+ return { ...state, focus: action.pane, numBuffer: "" };
1936
+ case "SCROLL_SIDEBAR":
1937
+ return { ...state, sidebarScroll: Math.max(0, state.sidebarScroll + action.delta) };
1938
+ case "SCROLL_THREAD":
1939
+ return { ...state, threadScroll: Math.max(0, state.threadScroll + action.delta) };
1940
+ case "SCROLL_THREAD_TO":
1941
+ return { ...state, threadScroll: Math.max(0, action.position) };
1942
+ case "ENTER_COMPOSE":
1943
+ return { ...state, mode: "compose", composeText: "", focus: "thread" };
1944
+ case "UPDATE_COMPOSE":
1945
+ return { ...state, composeText: action.text };
1946
+ case "CONFIRM_SEND":
1947
+ return { ...state, mode: "confirm" };
1948
+ case "CANCEL_COMPOSE":
1949
+ return { ...state, mode: "browse", composeText: "" };
1950
+ case "ADD_PENDING":
1951
+ return { ...state, mode: "browse", composeText: "", pending: [...state.pending, action.msg] };
1952
+ case "RESOLVE_PENDING":
1953
+ return { ...state, pending: state.pending.filter((p) => p.text !== action.text) };
1954
+ case "FAIL_PENDING":
1955
+ return {
1956
+ ...state,
1957
+ pending: state.pending.map(
1958
+ (p) => p.text === action.text ? { ...p, status: "failed" } : p
1959
+ )
1960
+ };
1961
+ case "SET_LOADING":
1962
+ return { ...state, loading: action.loading, status: action.status ?? state.status };
1963
+ case "SET_STATUS":
1964
+ return { ...state, status: action.status };
1965
+ case "ENTER_FILTER":
1966
+ return { ...state, mode: "filter", filterQuery: "", focus: "sidebar" };
1967
+ case "UPDATE_FILTER":
1968
+ return { ...state, filterQuery: action.query };
1969
+ case "EXIT_FILTER":
1970
+ return { ...state, mode: "browse", filterQuery: "" };
1971
+ case "OPEN_DRAWER":
1972
+ return { ...state, mode: "drawer" };
1973
+ case "CLOSE_DRAWER":
1974
+ return { ...state, mode: "browse" };
1975
+ case "SET_NUM_BUFFER":
1976
+ return { ...state, numBuffer: action.value };
1977
+ case "TOGGLE_DEV_STATS":
1978
+ return { ...state, showDevStats: !state.showDevStats };
1979
+ default:
1980
+ return state;
1981
+ }
1982
+ }
1983
+ function App() {
1984
+ const [state, dispatch] = useReducer(reducer, initialState);
1985
+ const { exit } = useApp();
1986
+ const { width: columns, height: rows } = useScreenSize();
1987
+ const imsg = useImsg();
1988
+ const { stats: devStats, recordQueryTime } = useDevStats(state.showDevStats);
1989
+ const pollTimerRef = useRef(null);
1990
+ const moveDebounceRef = useRef(null);
1991
+ const ggPendingRef = useRef(false);
1992
+ const ggTimerRef = useRef(null);
1993
+ const drawerWidth = state.mode === "drawer" ? Math.min(Math.floor(columns * 0.35), 50) : 0;
1994
+ const devStatsWidth = state.showDevStats ? 20 : 0;
1995
+ const sidebarWidth = Math.max(Math.floor((columns - drawerWidth - devStatsWidth) * 0.32), 28);
1996
+ const threadWidth = Math.max(columns - sidebarWidth - drawerWidth - devStatsWidth, 20);
1997
+ const bodyHeight = rows - 2;
1998
+ const SIDEBAR_ITEM_HEIGHT = 4;
1999
+ const sidebarVisibleCount = Math.max(
2000
+ Math.floor((bodyHeight - 1 - (state.filterQuery ? 1 : 0) - 2) / SIDEBAR_ITEM_HEIGHT),
2001
+ 1
2002
+ );
2003
+ const selected = state.conversations[state.selectedIdx];
2004
+ const totalUnread = state.conversations.reduce((s, c) => s + c.unreadCount, 0);
2005
+ const resolvedNames = selected ? imsg.resolveNames(selected.participants) : [];
2006
+ const selectedMsg = state.selectedMsgIdx >= 0 ? state.messages[state.selectedMsgIdx] : void 0;
2007
+ const loadMessages = useCallback(
2008
+ async (idx) => {
2009
+ const conv = state.conversations[idx];
2010
+ if (!conv) return;
2011
+ dispatch({
2012
+ type: "SET_LOADING",
2013
+ loading: true,
2014
+ status: `Loading ${conv.displayName ?? conv.chatIdentifier}...`
2015
+ });
2016
+ const t0 = performance.now();
2017
+ const msgs = await imsg.loadMessages(conv.chatIdentifier);
2018
+ recordQueryTime(performance.now() - t0);
2019
+ dispatch({ type: "SET_MESSAGES", data: msgs });
2020
+ dispatch({ type: "SET_LOADING", loading: false, status: "" });
2021
+ },
2022
+ [state.conversations, imsg, recordQueryTime]
2023
+ );
2024
+ const refreshAll = useCallback(async () => {
2025
+ dispatch({ type: "SET_LOADING", loading: true, status: "Refreshing..." });
2026
+ const convs = await imsg.loadConversations();
2027
+ dispatch({ type: "SET_CONVERSATIONS", data: convs });
2028
+ if (convs.length > 0) {
2029
+ const prevSlug = selected?.threadSlug;
2030
+ if (prevSlug) {
2031
+ const idx = convs.findIndex((c) => c.threadSlug === prevSlug);
2032
+ if (idx >= 0) dispatch({ type: "SELECT", index: idx, visibleCount: sidebarVisibleCount });
2033
+ }
2034
+ await loadMessages(state.selectedIdx);
2035
+ }
2036
+ imsg.refresh();
2037
+ dispatch({ type: "SET_LOADING", loading: false, status: "" });
2038
+ }, [imsg, loadMessages, selected?.threadSlug, state.selectedIdx, sidebarVisibleCount]);
2039
+ const NEAR_END_THRESHOLD = 20;
2040
+ const CONV_BATCH_SIZE = 100;
2041
+ const loadMoreConversations = useCallback(async () => {
2042
+ if (state.conversationLoadingMore) return;
2043
+ const targetCount = state.conversationLoadedCount + CONV_BATCH_SIZE;
2044
+ dispatch({ type: "SET_STATUS", status: `Loading more conversations (${targetCount})...` });
2045
+ const convs = await imsg.loadConversations(targetCount);
2046
+ dispatch({ type: "APPEND_CONVERSATIONS", data: convs, loadedCount: targetCount });
2047
+ dispatch({ type: "SET_STATUS", status: "" });
2048
+ }, [imsg, state.conversationLoadingMore, state.conversationLoadedCount]);
2049
+ const NEAR_TOP_THRESHOLD = 10;
2050
+ const loadOlderMessages = useCallback(async () => {
2051
+ if (!selected) return;
2052
+ if (state.messageLoadingOlder) return;
2053
+ if (state.messageOldestLoadedId == null) return;
2054
+ dispatch({ type: "SET_LOADING_OLDER", loading: true });
2055
+ const olderMsgs = await imsg.loadOlderMessages(
2056
+ selected.chatIdentifier,
2057
+ state.messageOldestLoadedId
2058
+ );
2059
+ if (olderMsgs.length === 0) {
2060
+ dispatch({ type: "PREPEND_MESSAGES", data: [], oldestId: -1 });
2061
+ return;
2062
+ }
2063
+ const newOldestId = Math.min(...olderMsgs.map((m) => m.id));
2064
+ dispatch({ type: "PREPEND_MESSAGES", data: olderMsgs, oldestId: newOldestId });
2065
+ }, [imsg, selected, state.messageLoadingOlder, state.messageOldestLoadedId]);
2066
+ useEffect(() => {
2067
+ if (state.loading || state.messageLoadingOlder) return;
2068
+ if (state.messages.length === 0) return;
2069
+ if (state.messageOldestLoadedId === -1) return;
2070
+ if (state.selectedMsgIdx >= 0 && state.selectedMsgIdx < NEAR_TOP_THRESHOLD) {
2071
+ loadOlderMessages();
2072
+ }
2073
+ }, [
2074
+ state.selectedMsgIdx,
2075
+ state.messages.length,
2076
+ state.messageOldestLoadedId,
2077
+ state.messageLoadingOlder,
2078
+ state.loading,
2079
+ loadOlderMessages
2080
+ ]);
2081
+ useEffect(() => {
2082
+ if (state.loading || state.conversationLoadingMore) return;
2083
+ if (state.conversations.length === 0) return;
2084
+ const cursorNearEnd = state.selectedIdx >= state.conversations.length - NEAR_END_THRESHOLD;
2085
+ const scrollNearEnd = state.sidebarScroll + sidebarVisibleCount >= state.conversations.length - NEAR_END_THRESHOLD;
2086
+ if (cursorNearEnd || scrollNearEnd) {
2087
+ if (state.conversationLoadedCount === state.conversations.length) {
2088
+ loadMoreConversations();
2089
+ }
2090
+ }
2091
+ }, [
2092
+ state.selectedIdx,
2093
+ state.sidebarScroll,
2094
+ state.conversations.length,
2095
+ state.conversationLoadedCount,
2096
+ state.conversationLoadingMore,
2097
+ state.loading,
2098
+ sidebarVisibleCount,
2099
+ loadMoreConversations
2100
+ ]);
2101
+ useEffect(() => {
2102
+ registerCleanup(() => imsg.close());
2103
+ refreshAll();
2104
+ }, [imsg.close]);
2105
+ const sendMessage = useCallback(async () => {
2106
+ if (!selected || !state.composeText.trim()) return;
2107
+ const text = state.composeText.trim();
2108
+ dispatch({ type: "ADD_PENDING", msg: { text, sentAt: /* @__PURE__ */ new Date(), status: "sending" } });
2109
+ const result = await imsg.send(selected.threadSlug, text);
2110
+ if (!result.success) {
2111
+ dispatch({ type: "FAIL_PENDING", text });
2112
+ return;
2113
+ }
2114
+ let attempt = 0;
2115
+ const poll = async () => {
2116
+ if (!pollTimerRef.current) return;
2117
+ attempt++;
2118
+ const msgs = await imsg.loadMessages(selected.chatIdentifier);
2119
+ const found = msgs.some((m) => m.isFromMe && m.text?.includes(text));
2120
+ if (found) {
2121
+ dispatch({ type: "SET_MESSAGES", data: msgs });
2122
+ dispatch({ type: "RESOLVE_PENDING", text });
2123
+ } else if (attempt < 7) {
2124
+ pollTimerRef.current = setTimeout(poll, 1500);
2125
+ }
2126
+ };
2127
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
2128
+ pollTimerRef.current = setTimeout(poll, 1500);
2129
+ }, [selected, state.composeText, imsg]);
2130
+ const getCount = useCallback(() => {
2131
+ const n = state.numBuffer ? Number.parseInt(state.numBuffer, 10) : 1;
2132
+ dispatch({ type: "SET_NUM_BUFFER", value: "" });
2133
+ return Math.max(1, Math.min(n, 999));
2134
+ }, [state.numBuffer]);
2135
+ useInput(async (input, key) => {
2136
+ if (key.ctrl && input === "c") {
2137
+ await imsg.close();
2138
+ exit();
2139
+ return;
2140
+ }
2141
+ if (state.mode === "filter") {
2142
+ if (key.escape || key.return) {
2143
+ dispatch({ type: "EXIT_FILTER" });
2144
+ } else if (key.backspace || key.delete) {
2145
+ dispatch({ type: "UPDATE_FILTER", query: state.filterQuery.slice(0, -1) });
2146
+ } else if (input && !key.ctrl && !key.meta) {
2147
+ dispatch({ type: "UPDATE_FILTER", query: state.filterQuery + input });
2148
+ }
2149
+ return;
2150
+ }
2151
+ if (state.mode === "compose") {
2152
+ if (key.escape) {
2153
+ dispatch({ type: "CANCEL_COMPOSE" });
2154
+ } else if (key.return && state.composeText.trim()) {
2155
+ dispatch({ type: "CONFIRM_SEND" });
2156
+ }
2157
+ return;
2158
+ }
2159
+ if (state.mode === "confirm") {
2160
+ if (key.return) {
2161
+ await sendMessage();
2162
+ } else {
2163
+ dispatch({ type: "CANCEL_COMPOSE" });
2164
+ }
2165
+ return;
2166
+ }
2167
+ if (state.mode === "drawer") {
2168
+ if (key.escape || input === "q") {
2169
+ dispatch({ type: "CLOSE_DRAWER" });
2170
+ } else if (input === "o" && selectedMsg) {
2171
+ openAttachment(selectedMsg);
2172
+ }
2173
+ return;
2174
+ }
2175
+ if (state.mode === "date-jump") {
2176
+ if (key.escape) {
2177
+ dispatch({ type: "EXIT_DATE_JUMP" });
2178
+ }
2179
+ return;
2180
+ }
2181
+ if (state.mode === "send-via") {
2182
+ if (key.escape) {
2183
+ dispatch({ type: "EXIT_SEND_VIA" });
2184
+ return;
2185
+ }
2186
+ if (input && /^[1-9]$/.test(input) && selected) {
2187
+ const apps = getInstalledChatApps();
2188
+ const idx = Number.parseInt(input, 10) - 1;
2189
+ const app = apps[idx];
2190
+ if (app) {
2191
+ const lastMsgText = state.messages.length ? state.messages[state.messages.length - 1]?.text ?? void 0 : void 0;
2192
+ const built = app.buildUri(selected.chatIdentifier, lastMsgText);
2193
+ if (built) {
2194
+ (await import("node:child_process")).spawn("open", [built], { detached: true, stdio: "ignore" }).unref();
2195
+ dispatch({ type: "SET_STATUS", status: `Launched ${app.name}` });
2196
+ } else {
2197
+ dispatch({
2198
+ type: "SET_STATUS",
2199
+ status: `${app.name}: handle not compatible with this scheme`
2200
+ });
2201
+ }
2202
+ }
2203
+ dispatch({ type: "EXIT_SEND_VIA" });
2204
+ setTimeout(() => dispatch({ type: "SET_STATUS", status: "" }), 2500);
2205
+ }
2206
+ return;
2207
+ }
2208
+ if (state.mode === "export") {
2209
+ if (key.escape) {
2210
+ dispatch({ type: "EXIT_EXPORT_MODE" });
2211
+ } else if (key.tab) {
2212
+ const order = ["markdown", "csv", "json"];
2213
+ const next = order[(order.indexOf(state.exportFormat) + 1) % order.length];
2214
+ dispatch({ type: "SET_EXPORT_FORMAT", format: next });
2215
+ const stripped = state.exportPath.replace(/\.(md|csv|json)$/, "");
2216
+ dispatch({ type: "SET_EXPORT_PATH", path: `${stripped}.${extensionFor(next)}` });
2217
+ }
2218
+ return;
2219
+ }
2220
+ if (state.mode === "select") {
2221
+ if (key.escape) {
2222
+ dispatch({ type: "EXIT_SELECT_MODE" });
2223
+ return;
2224
+ }
2225
+ if (input === "e") {
2226
+ const slug = selected?.threadSlug ?? "messages";
2227
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
2228
+ const defaultPath = join(homedir(), `imsg-export-${slug}-${stamp}.md`);
2229
+ dispatch({ type: "ENTER_EXPORT_MODE", defaultPath });
2230
+ return;
2231
+ }
2232
+ if (input === "y" && state.selectionAnchor != null) {
2233
+ const [lo, hi] = [
2234
+ Math.min(state.selectionAnchor, state.selectedMsgIdx),
2235
+ Math.max(state.selectionAnchor, state.selectedMsgIdx)
2236
+ ];
2237
+ const text = state.messages.slice(lo, hi + 1).map(
2238
+ (m) => `[${m.date.toISOString()}] ${m.isFromMe ? "Me" : m.displayName ?? m.handle}: ${m.text ?? "(no text)"}`
2239
+ ).join("\n");
2240
+ try {
2241
+ execSync("pbcopy", { input: text });
2242
+ dispatch({ type: "SET_STATUS", status: `Copied ${hi - lo + 1} msgs` });
2243
+ } catch {
2244
+ dispatch({ type: "SET_STATUS", status: "Copy failed" });
2245
+ }
2246
+ setTimeout(() => dispatch({ type: "SET_STATUS", status: "" }), 2e3);
2247
+ return;
2248
+ }
2249
+ }
2250
+ if (input === "d" && !key.ctrl && !key.meta && state.mode === "browse") {
2251
+ dispatch({ type: "TOGGLE_DEV_STATS" });
2252
+ return;
2253
+ }
2254
+ if (input === "V" && state.focus === "thread" && state.selectedMsgIdx >= 0) {
2255
+ dispatch({ type: "ENTER_SELECT_MODE" });
2256
+ return;
2257
+ }
2258
+ if (input === ":" && state.focus === "thread" && state.mode === "browse") {
2259
+ dispatch({ type: "ENTER_DATE_JUMP" });
2260
+ return;
2261
+ }
2262
+ if (input === "O" && !key.ctrl && !key.meta && state.focus === "thread" && state.mode === "browse" && selected) {
2263
+ const handle = selected.chatIdentifier;
2264
+ const uri = `imessage://${encodeURIComponent(handle)}`;
2265
+ (await import("node:child_process")).spawn("open", [uri], { detached: true, stdio: "ignore" }).unref();
2266
+ dispatch({ type: "SET_STATUS", status: `Opened ${handle} in Messages.app` });
2267
+ setTimeout(() => dispatch({ type: "SET_STATUS", status: "" }), 2500);
2268
+ return;
2269
+ }
2270
+ if (input === "S" && !key.ctrl && !key.meta && state.focus === "thread" && state.mode === "browse" && selected) {
2271
+ dispatch({ type: "ENTER_SEND_VIA" });
2272
+ return;
2273
+ }
2274
+ if (input === "q") {
2275
+ await imsg.close();
2276
+ exit();
2277
+ return;
2278
+ }
2279
+ if (input === "r") {
2280
+ await refreshAll();
2281
+ return;
2282
+ }
2283
+ if (input === "c" || key.return && state.focus === "thread" && state.mode === "browse") {
2284
+ if (state.focus === "thread" && key.return && state.selectedMsgIdx >= 0) {
2285
+ dispatch({ type: "OPEN_DRAWER" });
2286
+ return;
2287
+ }
2288
+ dispatch({ type: "ENTER_COMPOSE" });
2289
+ return;
2290
+ }
2291
+ if (input === "/" && state.mode === "browse") {
2292
+ dispatch({ type: "ENTER_FILTER" });
2293
+ return;
2294
+ }
2295
+ if (key.tab) {
2296
+ dispatch({ type: "FOCUS", pane: state.focus === "sidebar" ? "thread" : "sidebar" });
2297
+ return;
2298
+ }
2299
+ if (state.loading) return;
2300
+ if (input && input >= "0" && input <= "9" && !key.ctrl && !key.meta) {
2301
+ if (input === "0" && !state.numBuffer) {
2302
+ if (state.focus === "sidebar") {
2303
+ dispatch({ type: "SELECT", index: 0, visibleCount: sidebarVisibleCount });
2304
+ loadMessages(0);
2305
+ } else {
2306
+ dispatch({ type: "SELECT_MSG", index: 0 });
2307
+ }
2308
+ return;
2309
+ }
2310
+ dispatch({ type: "SET_NUM_BUFFER", value: state.numBuffer + input });
2311
+ return;
2312
+ }
2313
+ if (state.focus === "sidebar") {
2314
+ if (input === "y" && selected) {
2315
+ try {
2316
+ execSync("pbcopy", { input: `~${selected.threadSlug}` });
2317
+ dispatch({ type: "SET_STATUS", status: `Copied ~${selected.threadSlug}` });
2318
+ setTimeout(() => dispatch({ type: "SET_STATUS", status: "" }), 2e3);
2319
+ } catch {
2320
+ dispatch({ type: "SET_STATUS", status: "Failed to copy" });
2321
+ }
2322
+ return;
2323
+ }
2324
+ const count = getCount();
2325
+ let next = null;
2326
+ if (input === "j" || key.downArrow) {
2327
+ next = Math.min(state.selectedIdx + count, state.conversations.length - 1);
2328
+ } else if (input === "k" || key.upArrow) {
2329
+ next = Math.max(state.selectedIdx - count, 0);
2330
+ } else if (input === "G") {
2331
+ next = state.conversations.length - 1;
2332
+ } else if (input === "g") {
2333
+ if (ggPendingRef.current) {
2334
+ ggPendingRef.current = false;
2335
+ if (ggTimerRef.current) clearTimeout(ggTimerRef.current);
2336
+ next = 0;
2337
+ } else {
2338
+ ggPendingRef.current = true;
2339
+ ggTimerRef.current = setTimeout(() => {
2340
+ ggPendingRef.current = false;
2341
+ }, 500);
2342
+ return;
2343
+ }
2344
+ } else if (key.ctrl && input === "d") {
2345
+ next = Math.min(
2346
+ state.selectedIdx + Math.floor(bodyHeight / 2),
2347
+ state.conversations.length - 1
2348
+ );
2349
+ } else if (key.ctrl && input === "u") {
2350
+ next = Math.max(state.selectedIdx - Math.floor(bodyHeight / 2), 0);
2351
+ } else if (key.ctrl && input === "f" || key.pageDown) {
2352
+ next = Math.min(state.selectedIdx + bodyHeight, state.conversations.length - 1);
2353
+ } else if (key.ctrl && input === "b" || key.pageUp) {
2354
+ next = Math.max(state.selectedIdx - bodyHeight, 0);
2355
+ } else if (input === "H") {
2356
+ next = state.sidebarScroll;
2357
+ } else if (input === "M") {
2358
+ next = Math.min(
2359
+ state.sidebarScroll + Math.floor(bodyHeight / 2),
2360
+ state.conversations.length - 1
2361
+ );
2362
+ } else if (input === "L") {
2363
+ next = Math.min(state.sidebarScroll + bodyHeight - 1, state.conversations.length - 1);
2364
+ }
2365
+ if (next !== null && next !== state.selectedIdx) {
2366
+ dispatch({ type: "SELECT", index: next, visibleCount: sidebarVisibleCount });
2367
+ if (moveDebounceRef.current) clearTimeout(moveDebounceRef.current);
2368
+ const target = next;
2369
+ moveDebounceRef.current = setTimeout(() => {
2370
+ moveDebounceRef.current = null;
2371
+ loadMessages(target);
2372
+ }, 80);
2373
+ }
2374
+ } else {
2375
+ const count = getCount();
2376
+ if (input === "j" || key.downArrow) {
2377
+ dispatch({ type: "MOVE_MSG", delta: count });
2378
+ } else if (input === "k" || key.upArrow) {
2379
+ dispatch({ type: "MOVE_MSG", delta: -count });
2380
+ } else if (input === "G") {
2381
+ dispatch({ type: "SELECT_MSG", index: state.messages.length - 1 });
2382
+ } else if (input === "g") {
2383
+ if (ggPendingRef.current) {
2384
+ ggPendingRef.current = false;
2385
+ if (ggTimerRef.current) clearTimeout(ggTimerRef.current);
2386
+ dispatch({ type: "SELECT_MSG", index: 0 });
2387
+ } else {
2388
+ ggPendingRef.current = true;
2389
+ ggTimerRef.current = setTimeout(() => {
2390
+ ggPendingRef.current = false;
2391
+ }, 500);
2392
+ }
2393
+ } else if (key.ctrl && input === "d") {
2394
+ dispatch({ type: "MOVE_MSG", delta: Math.floor(bodyHeight / 2) });
2395
+ } else if (key.ctrl && input === "u") {
2396
+ dispatch({ type: "MOVE_MSG", delta: -Math.floor(bodyHeight / 2) });
2397
+ } else if (key.ctrl && input === "f" || key.pageDown) {
2398
+ dispatch({ type: "MOVE_MSG", delta: bodyHeight });
2399
+ } else if (key.ctrl && input === "b" || key.pageUp) {
2400
+ dispatch({ type: "MOVE_MSG", delta: -bodyHeight });
2401
+ } else if (input === "H") {
2402
+ const visibleTop = Math.max(0, state.selectedMsgIdx - Math.floor(bodyHeight * 0.7));
2403
+ dispatch({ type: "SELECT_MSG", index: visibleTop });
2404
+ } else if (input === "L") {
2405
+ const visibleBottom = Math.min(
2406
+ state.messages.length - 1,
2407
+ state.selectedMsgIdx + Math.floor(bodyHeight * 0.3)
2408
+ );
2409
+ dispatch({ type: "SELECT_MSG", index: visibleBottom });
2410
+ } else if (input === "M") ;
2411
+ else if (input === "}" || input === "]") {
2412
+ const next = nextGroupBoundary(state.messages, state.selectedMsgIdx);
2413
+ dispatch({ type: "SELECT_MSG", index: next });
2414
+ } else if (input === "{" || input === "[") {
2415
+ const prev = prevGroupBoundary(state.messages, state.selectedMsgIdx);
2416
+ dispatch({ type: "SELECT_MSG", index: prev });
2417
+ } else if (input === "o" && state.selectedMsgIdx >= 0) {
2418
+ openAttachment(state.messages[state.selectedMsgIdx]);
2419
+ }
2420
+ }
2421
+ });
2422
+ const MAX_JUMP_BATCHES = 100;
2423
+ const doDateJump = useCallback(
2424
+ async (input) => {
2425
+ const target = parseUserDate(input);
2426
+ if (!target) {
2427
+ dispatch({
2428
+ type: "SET_DATE_JUMP_ERROR",
2429
+ error: `Could not parse "${input}". Try YYYY-MM-DD or "1 week ago".`
2430
+ });
2431
+ return;
2432
+ }
2433
+ if (!selected) {
2434
+ dispatch({ type: "EXIT_DATE_JUMP" });
2435
+ return;
2436
+ }
2437
+ let batches = 0;
2438
+ while (batches < MAX_JUMP_BATCHES) {
2439
+ const oldest = state.messages.length > 0 ? state.messages[0].date : /* @__PURE__ */ new Date();
2440
+ if (oldest <= target) break;
2441
+ if (state.messageOldestLoadedId == null || state.messageOldestLoadedId === -1) break;
2442
+ const older = await imsg.loadOlderMessages(
2443
+ selected.chatIdentifier,
2444
+ state.messageOldestLoadedId
2445
+ );
2446
+ if (older.length === 0) break;
2447
+ const newOldestId = Math.min(...older.map((m) => m.id));
2448
+ dispatch({ type: "PREPEND_MESSAGES", data: older, oldestId: newOldestId });
2449
+ batches++;
2450
+ await new Promise((r) => setTimeout(r, 10));
2451
+ }
2452
+ const idx = state.messages.findIndex((m) => m.date >= target);
2453
+ if (idx >= 0) {
2454
+ dispatch({ type: "SELECT_MSG", index: idx });
2455
+ }
2456
+ dispatch({ type: "EXIT_DATE_JUMP" });
2457
+ dispatch({
2458
+ type: "SET_STATUS",
2459
+ status: batches >= MAX_JUMP_BATCHES ? `Jumped (capped) — load more manually for older history` : `Jumped to ${formatJumpTarget(target)}`
2460
+ });
2461
+ setTimeout(() => dispatch({ type: "SET_STATUS", status: "" }), 4e3);
2462
+ },
2463
+ [imsg, selected, state.messages, state.messageOldestLoadedId]
2464
+ );
2465
+ const doExport = useCallback(() => {
2466
+ const messagesToExport = state.selectionAnchor != null ? state.messages.slice(
2467
+ Math.min(state.selectionAnchor, state.selectedMsgIdx),
2468
+ Math.max(state.selectionAnchor, state.selectedMsgIdx) + 1
2469
+ ) : state.messages;
2470
+ if (messagesToExport.length === 0) {
2471
+ dispatch({ type: "SET_STATUS", status: "Nothing to export" });
2472
+ return;
2473
+ }
2474
+ const expandedPath = state.exportPath.replace(/^~/, homedir());
2475
+ try {
2476
+ let content;
2477
+ const header = {
2478
+ thread: selected?.displayName ?? selected?.chatIdentifier ?? "thread",
2479
+ participants: selected?.participants ?? [],
2480
+ serviceType: selected?.serviceType
2481
+ };
2482
+ switch (state.exportFormat) {
2483
+ case "markdown":
2484
+ content = toMarkdown(messagesToExport, header);
2485
+ break;
2486
+ case "csv":
2487
+ content = toCSV(messagesToExport);
2488
+ break;
2489
+ case "json":
2490
+ content = toJSON(messagesToExport, header);
2491
+ break;
2492
+ }
2493
+ writeFileSync(expandedPath, content, "utf8");
2494
+ dispatch({ type: "EXIT_EXPORT_MODE" });
2495
+ dispatch({ type: "EXIT_SELECT_MODE" });
2496
+ dispatch({
2497
+ type: "SET_STATUS",
2498
+ status: `Exported ${messagesToExport.length} msgs to ${expandedPath}`
2499
+ });
2500
+ setTimeout(() => dispatch({ type: "SET_STATUS", status: "" }), 4e3);
2501
+ } catch (err) {
2502
+ dispatch({
2503
+ type: "SET_STATUS",
2504
+ status: `Export failed: ${err instanceof Error ? err.message : String(err)}`
2505
+ });
2506
+ }
2507
+ }, [
2508
+ state.exportFormat,
2509
+ state.exportPath,
2510
+ state.messages,
2511
+ state.selectedMsgIdx,
2512
+ state.selectionAnchor,
2513
+ selected
2514
+ ]);
2515
+ function openAttachment(msg) {
2516
+ if (!msg?.attachments?.length) return;
2517
+ const att = msg.attachments[0];
2518
+ if (!att.filename) return;
2519
+ const filepath = att.filename.replace(/^~/, process.env.HOME ?? "~");
2520
+ const mime = att.mimeType ?? "";
2521
+ import("node:child_process").then(({ spawn }) => {
2522
+ const spawnQuickLook = () => spawn("qlmanage", ["-p", filepath], { detached: true, stdio: "ignore" }).unref();
2523
+ if (mime.startsWith("video/")) {
2524
+ const child = spawn("mpv", [filepath], { detached: true, stdio: "ignore" });
2525
+ child.on("error", spawnQuickLook);
2526
+ child.unref();
2527
+ } else {
2528
+ spawnQuickLook();
2529
+ }
2530
+ });
2531
+ }
2532
+ const handleMouse = useCallback(
2533
+ (event) => {
2534
+ if (event.type === "click") {
2535
+ if (event.x <= sidebarWidth) {
2536
+ dispatch({ type: "FOCUS", pane: "sidebar" });
2537
+ const convIdx = Math.floor((event.y - 2 + state.sidebarScroll * 3) / 3);
2538
+ if (convIdx >= 0 && convIdx < state.conversations.length) {
2539
+ dispatch({ type: "SELECT", index: convIdx, visibleCount: sidebarVisibleCount });
2540
+ loadMessages(convIdx);
2541
+ }
2542
+ } else {
2543
+ dispatch({ type: "FOCUS", pane: "thread" });
2544
+ }
2545
+ } else if (event.type === "scroll-up") {
2546
+ if (event.x <= sidebarWidth) {
2547
+ dispatch({ type: "SCROLL_SIDEBAR", delta: -1 });
2548
+ } else {
2549
+ dispatch({ type: "MOVE_MSG", delta: -1 });
2550
+ }
2551
+ } else if (event.type === "scroll-down") {
2552
+ if (event.x <= sidebarWidth) {
2553
+ dispatch({ type: "SCROLL_SIDEBAR", delta: 1 });
2554
+ } else {
2555
+ dispatch({ type: "MOVE_MSG", delta: 1 });
2556
+ }
2557
+ }
2558
+ },
2559
+ [
2560
+ sidebarWidth,
2561
+ state.sidebarScroll,
2562
+ state.conversations.length,
2563
+ loadMessages,
2564
+ sidebarVisibleCount
2565
+ ]
2566
+ );
2567
+ useMouse(handleMouse);
2568
+ useEffect(() => {
2569
+ return () => {
2570
+ if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
2571
+ if (moveDebounceRef.current) clearTimeout(moveDebounceRef.current);
2572
+ if (ggTimerRef.current) clearTimeout(ggTimerRef.current);
2573
+ };
2574
+ }, []);
2575
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", height: rows, width: columns, children: [
2576
+ /* @__PURE__ */ jsxs(Box, { flexGrow: 1, height: bodyHeight, children: [
2577
+ /* @__PURE__ */ jsx(
2578
+ Sidebar,
2579
+ {
2580
+ conversations: state.conversations,
2581
+ selectedIdx: state.selectedIdx,
2582
+ scrollOffset: state.sidebarScroll,
2583
+ filterQuery: state.filterQuery,
2584
+ focused: state.focus === "sidebar",
2585
+ width: sidebarWidth,
2586
+ height: bodyHeight
2587
+ }
2588
+ ),
2589
+ /* @__PURE__ */ jsx(
2590
+ ThreadPane,
2591
+ {
2592
+ conversation: selected,
2593
+ messages: state.messages,
2594
+ pending: state.pending,
2595
+ resolvedNames,
2596
+ scrollOffset: state.threadScroll,
2597
+ selectedMsgIdx: state.selectedMsgIdx,
2598
+ selectionAnchor: state.selectionAnchor,
2599
+ gapMarkers: state.gapMarkers,
2600
+ focused: state.focus === "thread",
2601
+ width: threadWidth,
2602
+ height: bodyHeight,
2603
+ mode: state.mode,
2604
+ onChangeCompose: (text) => dispatch({ type: "UPDATE_COMPOSE", text }),
2605
+ onSubmitCompose: (text) => text.trim() && dispatch({ type: "CONFIRM_SEND" })
2606
+ }
2607
+ ),
2608
+ state.mode === "drawer" && selectedMsg && /* @__PURE__ */ jsx(MessageDrawer, { message: selectedMsg, width: drawerWidth, height: bodyHeight }),
2609
+ state.showDevStats && /* @__PURE__ */ jsx(DevStats, { stats: devStats, width: devStatsWidth })
2610
+ ] }),
2611
+ state.mode === "date-jump" && /* @__PURE__ */ jsx(
2612
+ DateJumpModal,
2613
+ {
2614
+ value: state.dateJumpInput,
2615
+ error: state.dateJumpError,
2616
+ onChange: (v) => dispatch({ type: "SET_DATE_JUMP_INPUT", value: v }),
2617
+ onSubmit: (v) => doDateJump(v)
2618
+ }
2619
+ ),
2620
+ state.mode === "send-via" && selected && /* @__PURE__ */ jsx(SendViaModal, { handle: selected.chatIdentifier, apps: getInstalledChatApps() }),
2621
+ state.mode === "export" && /* @__PURE__ */ jsx(
2622
+ ExportModal,
2623
+ {
2624
+ format: state.exportFormat,
2625
+ path: state.exportPath,
2626
+ rangeSummary: (() => {
2627
+ if (state.selectionAnchor != null) {
2628
+ const lo = Math.min(state.selectionAnchor, state.selectedMsgIdx);
2629
+ const hi = Math.max(state.selectionAnchor, state.selectedMsgIdx);
2630
+ return `${hi - lo + 1} selected messages`;
2631
+ }
2632
+ return `entire loaded thread (${state.messages.length} messages)`;
2633
+ })(),
2634
+ onChangePath: (p) => dispatch({ type: "SET_EXPORT_PATH", path: p }),
2635
+ onSubmit: doExport
2636
+ }
2637
+ ),
2638
+ /* @__PURE__ */ jsx(
2639
+ StatusBar,
2640
+ {
2641
+ totalUnread,
2642
+ selected,
2643
+ status: state.status,
2644
+ loading: state.loading,
2645
+ children: !state.showDevStats && /* @__PURE__ */ jsx(CompactStats, { stats: devStats })
2646
+ }
2647
+ ),
2648
+ /* @__PURE__ */ jsx(HelpBar, { mode: state.mode, focus: state.focus })
2649
+ ] });
2650
+ }
2651
+ function parseTuiCliArgs() {
2652
+ try {
2653
+ const { values } = parseArgs({
2654
+ args: process.argv.slice(2),
2655
+ options: {
2656
+ theme: { type: "string" },
2657
+ accent: { type: "string" }
2658
+ },
2659
+ strict: false,
2660
+ allowPositionals: true
2661
+ });
2662
+ return {
2663
+ theme: typeof values.theme === "string" ? values.theme : void 0,
2664
+ accent: typeof values.accent === "string" ? values.accent : void 0
2665
+ };
2666
+ } catch {
2667
+ return {};
2668
+ }
2669
+ }
2670
+ async function runTui() {
2671
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
2672
+ throw new Error("The TUI requires an interactive terminal (TTY).");
2673
+ }
2674
+ const report = await checkLocalAccess();
2675
+ if (!report.ok) {
2676
+ console.log(formatAccessReport(report));
2677
+ process.exit(1);
2678
+ }
2679
+ const cli = parseTuiCliArgs();
2680
+ const cfg = resolveTuiConfig({ cliTheme: cli.theme, cliAccent: cli.accent });
2681
+ for (const w of cfg.warnings) console.error(`warn: ${w}`);
2682
+ const theme = makeTheme({ preset: cfg.theme, accent: cfg.accentColor });
2683
+ installShutdownHandlers();
2684
+ enableOrphanWatchdog();
2685
+ installWatchdog();
2686
+ installCacheSweepers();
2687
+ registerCleanup(() => {
2688
+ stopCacheSweepers();
2689
+ clearCache();
2690
+ });
2691
+ const screen = withFullScreen(
2692
+ /* @__PURE__ */ jsx(ThemeProvider, { value: theme, children: /* @__PURE__ */ jsx(App, {}) })
2693
+ );
2694
+ registerCleanup(() => {
2695
+ try {
2696
+ screen.instance.unmount();
2697
+ } catch {
2698
+ }
2699
+ });
2700
+ await screen.start();
2701
+ await screen.waitUntilExit();
2702
+ }
2703
+ export {
2704
+ runTui
2705
+ };
2706
+ //# sourceMappingURL=tui.js.map