tui.jsx

  1// Interactive TUI email client demo โ€” matches real Matcha layout
  2const { useState, useEffect, useRef, useCallback, useMemo } = React;
  3
  4// ---------- Data: folders, accounts, messages ----------
  5const FOLDERS_LIST = [
  6  "INBOX",
  7  "Archive",
  8  "Deleted Messages",
  9  "Drafts",
 10  "Junk",
 11  "Notes",
 12  "Sent Messages",
 13  "Work",
 14  "Receipts",
 15];
 16
 17const ACCOUNTS_DEFAULT = [
 18  { id: "all", label: "ALL", isAll: true },
 19  { id: "home", label: "sam@proton.me" },
 20  { id: "work", label: "s.park@kestrel.works" },
 21  { id: "side", label: "hello@driftnotes.xyz" },
 22  { id: "news", label: "inbox@fastmail.sam" },
 23];
 24
 25const MSGS_DEFAULT = [
 26  {
 27    acct: "work",
 28    from: "GitHub",
 29    subject:
 30      "[kestrel/hedge] PR #184 ready for review โ€” refactor auth middleware",
 31    date: "2 min ago",
 32  },
 33  {
 34    acct: "home",
 35    from: "Linear",
 36    subject: 'You were assigned KST-219 ยท "Fix timezone drift in daily digest"',
 37    date: "14 min ago",
 38  },
 39  {
 40    acct: "home",
 41    from: "Margo Tran",
 42    subject: "re: pickup pottery on saturday?",
 43    date: "1 hour ago",
 44  },
 45  {
 46    acct: "side",
 47    from: "Stripe",
 48    subject: "Your payout of $642.11 is on its way",
 49    date: "3 hours ago",
 50  },
 51  {
 52    acct: "news",
 53    from: "Hacker News Digest",
 54    subject: 'Top 10: "Ask HN: what are you running on your homelab in 2026?"',
 55    date: "5 hours ago",
 56  },
 57  {
 58    acct: "home",
 59    from: "DMV",
 60    subject: "Appointment confirmed โ€” Tue May 12, 10:45 AM",
 61    date: "8 hours ago",
 62  },
 63  {
 64    acct: "work",
 65    from: "Figma",
 66    subject: 'Ines shared a file with you: "Hedge ยท onboarding v3"',
 67    date: "Yesterday",
 68  },
 69  {
 70    acct: "home",
 71    from: "Alaska Airlines",
 72    subject: "Check in for flight AS 1312 โ€” SEA โ†’ SFO",
 73    date: "Yesterday",
 74  },
 75  {
 76    acct: "news",
 77    from: "The Browser",
 78    subject: "Five long reads for a slow sunday",
 79    date: "Yesterday",
 80  },
 81  {
 82    acct: "work",
 83    from: "Vercel",
 84    subject: "Deploy failed โ€” hedge-app (main) ยท missing env DATABASE_URL",
 85    date: "21/04/2026 17:12",
 86  },
 87  {
 88    acct: "side",
 89    from: "Plausible Analytics",
 90    subject: "driftnotes.xyz ยท 1,204 visitors this week (+18%)",
 91    date: "21/04/2026 09:02",
 92  },
 93  {
 94    acct: "home",
 95    from: "Spotify",
 96    subject: "Your Discover Weekly is ready",
 97    date: "20/04/2026 08:00",
 98  },
 99  {
100    acct: "news",
101    from: "Metafilter",
102    subject: 'MeFi Digest โ€” "I finally fixed my kitchen sink (a story)"',
103    date: "19/04/2026 22:44",
104  },
105  {
106    acct: "work",
107    from: "Sentry",
108    subject:
109      "[hedge-prod] New issue: TimeoutError in worker.py:128 (ร—42 events)",
110    date: "18/04/2026 13:27",
111  },
112  {
113    acct: "home",
114    from: "Oliver Kim",
115    subject: "we are going to regret this tattoo idea (attached)",
116    date: "17/04/2026 23:10",
117  },
118  {
119    acct: "home",
120    from: "Patagonia",
121    subject: "Your Nano Puff has shipped โ€” arriving Thu Apr 25",
122    date: "16/04/2026 11:03",
123  },
124  {
125    acct: "side",
126    from: "Cloudflare",
127    subject: "Reminder: driftnotes.xyz renews in 9 days",
128    date: "15/04/2026 04:18",
129  },
130  {
131    acct: "work",
132    from: "Notion",
133    subject: 'Weekly digest: 4 pages edited in "Kestrel / Engineering"',
134    date: "14/04/2026 17:55",
135  },
136  {
137    acct: "news",
138    from: "arXiv daily",
139    subject: "cs.DC โ€” 6 new submissions (2026-04-13)",
140    date: "13/04/2026 06:00",
141  },
142  {
143    acct: "home",
144    from: "mom",
145    subject: "did the package get there ok??",
146    date: "12/04/2026 19:44",
147  },
148];
149
150const MSGS_DEV = [
151  {
152    acct: "work",
153    from: "GitHub",
154    subject: "[kestrel/hedge] PR #184 approved by ines-w",
155    date: "09:42",
156  },
157  {
158    acct: "work",
159    from: "Vercel",
160    subject: "Deploy succeeded โ€” hedge-app (main) ยท commit 9f2c31a",
161    date: "09:18",
162  },
163  {
164    acct: "work",
165    from: "Linear",
166    subject: "KST-219 moved to In Review ยท assigned to you",
167    date: "08:55",
168  },
169  {
170    acct: "work",
171    from: "Sentry",
172    subject: "[hedge-prod] Regression: 3 new issues in last 24h",
173    date: "Yesterday",
174  },
175  {
176    acct: "work",
177    from: "Cloudflare",
178    subject: "Worker deployed: hedge-edge@v4.1.0",
179    date: "Yesterday",
180  },
181  {
182    acct: "side",
183    from: "npm",
184    subject: "Your package 'haze-schedule' received 1,043 downloads",
185    date: "Mon",
186  },
187  {
188    acct: "news",
189    from: "Hacker News",
190    subject: "Show HN: A tiny CRDT you can read in one afternoon",
191    date: "Mon",
192  },
193  {
194    acct: "home",
195    from: "Stripe",
196    subject: "New payout: $428.00 USD to Mercury โ€ขโ€ข4411",
197    date: "Mon",
198  },
199  {
200    acct: "work",
201    from: "GitHub",
202    subject: "[kestrel/hedge] Issue #412: wrap long headers in digest",
203    date: "Sun",
204  },
205  {
206    acct: "work",
207    from: "AWS Billing",
208    subject: "April forecast: $387.22 (-12% vs Mar)",
209    date: "Sun",
210  },
211  {
212    acct: "side",
213    from: "Fly.io",
214    subject: "Machine restarted in ord: driftnotes-web (oom)",
215    date: "Sat",
216  },
217  {
218    acct: "news",
219    from: "arXiv daily",
220    subject: "cs.PL โ€” 4 new submissions (2026-04-20)",
221    date: "Sat",
222  },
223];
224
225const MSGS_PERSONAL = [
226  { acct: "home", from: "mom", subject: "did you eat?", date: "12:14" },
227  { acct: "home", from: "Margo", subject: "pottery saturday??", date: "09:33" },
228  {
229    acct: "home",
230    from: "REI",
231    subject: "Your order has shipped",
232    date: "10:01",
233  },
234  {
235    acct: "home",
236    from: "Strava",
237    subject: "Your week: 38km ยท 3 runs",
238    date: "Yesterday",
239  },
240  {
241    acct: "home",
242    from: "Ava (landlord)",
243    subject: "building โ€” water shutoff Sat 9-11am",
244    date: "Yesterday",
245  },
246  {
247    acct: "home",
248    from: "Goodreads",
249    subject: "Nadia finished 'The MANIAC'",
250    date: "Mon",
251  },
252  {
253    acct: "home",
254    from: "Calendly",
255    subject: "New booking: Coffee w/ Oliver โ€” Fri 3pm",
256    date: "Mon",
257  },
258  {
259    acct: "home",
260    from: "your past self",
261    subject: "rotate the sourdough starter today",
262    date: "Sun",
263  },
264  {
265    acct: "home",
266    from: "Spotify",
267    subject: "Your Spring Mix is ready",
268    date: "Sun",
269  },
270  {
271    acct: "news",
272    from: "The New Yorker",
273    subject: "The daily โ€” a long weekend read",
274    date: "Sat",
275  },
276];
277
278const DATASETS = {
279  default: {
280    accounts: ACCOUNTS_DEFAULT,
281    messages: MSGS_DEFAULT,
282    label: "inbox",
283  },
284  dev: { accounts: ACCOUNTS_DEFAULT, messages: MSGS_DEV, label: "work" },
285  personal: {
286    accounts: ACCOUNTS_DEFAULT,
287    messages: MSGS_PERSONAL,
288    label: "personal",
289  },
290};
291
292const EMAIL_BODIES = {
293  "re: pickup pottery on saturday?": `yes! saturday works โ€” 11am at the studio?
294they said the bowls finally came out of the kiln.
295
296also margo owes you coffee. i'm bringing cash.
297
298โ€” m`,
299  "Hello world": `Hello world!
300
301this is a simple greeting from nowhere in particular.
302just confirming the new inbox is working as expected.
303
304cheers,
305m`,
306};
307
308function bodyFor(msg) {
309  if (EMAIL_BODIES[msg.subject]) return EMAIL_BODIES[msg.subject];
310  return `[no plain-text part]
311
312This message has no rendered body in the demo.
313Press esc to return to the inbox.`;
314}
315
316// ---------- Watermark (original โ€” desert + running figure, NOT copyrighted) ----------
317function Watermark() {
318  return (
319    <svg
320      className="tui-watermark"
321      viewBox="0 0 1000 520"
322      aria-hidden="true"
323      preserveAspectRatio="xMidYMax meet"
324    >
325      {/* dust/star dots */}
326      {Array.from({ length: 60 }).map((_, i) => {
327        const x = (i * 173) % 1000;
328        const y = (i * 97 + 50) % 380;
329        const r = 0.8 + ((i * 13) % 5) * 0.3;
330        return (
331          <circle
332            key={i}
333            cx={x}
334            cy={y}
335            r={r}
336            fill="currentColor"
337            opacity={0.25 + ((i * 7) % 5) * 0.06}
338          />
339        );
340      })}
341      {/* ground */}
342      <path
343        d="M 0 470 L 260 470 Q 310 460 360 470 L 540 470 Q 580 482 620 470 L 1000 470"
344        stroke="currentColor"
345        strokeWidth="1.2"
346        fill="none"
347        opacity="0.35"
348      />
349      {/* original: saguaro cactus */}
350      <g transform="translate(660 300)" opacity="0.35" fill="currentColor">
351        <rect x="54" y="10" width="24" height="160" rx="4" />
352        <rect x="30" y="60" width="24" height="70" rx="4" />
353        <rect x="30" y="60" width="16" height="14" rx="3" />
354        <rect x="78" y="40" width="24" height="50" rx="4" />
355        <rect x="78" y="40" width="24" height="14" rx="3" />
356        <rect x="60" y="170" width="12" height="4" />
357      </g>
358      {/* original: little running figure (stick person) โ€” not a branded character */}
359      <g transform="translate(190 360)" fill="currentColor" opacity="0.32">
360        <circle cx="22" cy="14" r="11" />
361        <rect x="14" y="26" width="22" height="42" rx="4" />
362        <rect
363          x="4"
364          y="34"
365          width="14"
366          height="5"
367          rx="2"
368          transform="rotate(-18 11 36)"
369        />
370        <rect
371          x="30"
372          y="30"
373          width="14"
374          height="5"
375          rx="2"
376          transform="rotate(20 37 32)"
377        />
378        <rect
379          x="10"
380          y="68"
381          width="8"
382          height="24"
383          rx="3"
384          transform="rotate(-12 14 80)"
385        />
386        <rect
387          x="26"
388          y="68"
389          width="8"
390          height="24"
391          rx="3"
392          transform="rotate(14 30 80)"
393        />
394      </g>
395    </svg>
396  );
397}
398
399// ---------- Selection indicator char ----------
400function cursor(i, cur, visual, vStart) {
401  if (visual && vStart != null) {
402    const lo = Math.min(cur, vStart),
403      hi = Math.max(cur, vStart);
404    const inRange = i >= lo && i <= hi;
405    if (inRange && i === cur) return ">*";
406    if (inRange) return " *";
407    if (i === cur) return "> ";
408    return "  ";
409  }
410  return i === cur ? "> " : "  ";
411}
412
413// ---------- TUI component ----------
414function TUI({ datasetKey = "default", onKeyPressed }) {
415  const dataset = DATASETS[datasetKey] || DATASETS.default;
416  const [folderIdx, setFolderIdx] = useState(0);
417  const [acctIdx, setAcctIdx] = useState(0);
418  const [cur, setCur] = useState(0);
419  const [mode, setMode] = useState("list"); // "list" | "email" | "filter" | "visual"
420  const [filter, setFilter] = useState("");
421  const [visualStart, setVisualStart] = useState(null);
422  const [flash, setFlash] = useState("");
423  const [deleted, setDeleted] = useState(new Set()); // indices into dataset.messages
424  const containerRef = useRef(null);
425  const [focused, setFocused] = useState(false);
426
427  const messages = dataset.messages;
428  const accounts = dataset.accounts;
429  const activeAcct = accounts[acctIdx];
430
431  // Reset on dataset change
432  useEffect(() => {
433    setFolderIdx(0);
434    setAcctIdx(0);
435    setCur(0);
436    setMode("list");
437    setFilter("");
438    setVisualStart(null);
439    setDeleted(new Set());
440    setFlash(`loaded: ${dataset.label}`);
441    const t = setTimeout(() => setFlash(""), 1400);
442    return () => clearTimeout(t);
443  }, [datasetKey]);
444
445  // Visible messages
446  const visible = useMemo(() => {
447    let list = messages
448      .map((m, origIdx) => ({ ...m, origIdx }))
449      .filter((m) => !deleted.has(m.origIdx));
450    if (folderIdx === 0) {
451      if (!activeAcct.isAll)
452        list = list.filter((m) => m.acct === activeAcct.id);
453    } else {
454      // Other folders are empty in the demo
455      list = [];
456    }
457    if (filter.trim()) {
458      const q = filter.toLowerCase();
459      list = list.filter(
460        (m) =>
461          m.subject.toLowerCase().includes(q) ||
462          m.from.toLowerCase().includes(q),
463      );
464    }
465    return list;
466  }, [messages, deleted, folderIdx, activeAcct, filter]);
467
468  const selected = visible[cur] || null;
469
470  const focusMe = useCallback(() => {
471    if (containerRef.current) containerRef.current.focus();
472  }, []);
473
474  const flashFor = (msg, ms = 1200) => {
475    setFlash(msg);
476    setTimeout(() => setFlash((f) => (f === msg ? "" : f)), ms);
477  };
478
479  const doDelete = (indices) => {
480    setDeleted((prev) => {
481      const next = new Set(prev);
482      indices.forEach((i) => next.add(i));
483      return next;
484    });
485    flashFor(
486      `โœ“ deleted ${indices.length} message${indices.length === 1 ? "" : "s"}`,
487    );
488  };
489
490  // Key handling
491  const handleKey = (e) => {
492    if (onKeyPressed) onKeyPressed();
493    const k = e.key;
494
495    if (mode === "filter") {
496      if (k === "Enter" || k === "Escape") {
497        e.preventDefault();
498        setMode("list");
499        flashFor(filter ? `filter: "${filter}"` : "filter cleared");
500        return;
501      }
502      if (k === "Backspace") {
503        e.preventDefault();
504        setFilter((f) => f.slice(0, -1));
505        return;
506      }
507      if (k.length === 1) {
508        e.preventDefault();
509        setFilter((f) => f + k);
510        return;
511      }
512      return;
513    }
514
515    if (mode === "email") {
516      if (k === "Escape") {
517        e.preventDefault();
518        setMode("list");
519        return;
520      }
521      if (k === "r") {
522        e.preventDefault();
523        flashFor("โ‡ข reply (composer)");
524        return;
525      }
526      if (k === "f") {
527        e.preventDefault();
528        flashFor("โ‡ข forward");
529        return;
530      }
531      if (k === "d") {
532        e.preventDefault();
533        if (selected) {
534          doDelete([selected.origIdx]);
535          setMode("list");
536          setCur((c) => Math.max(0, Math.min(c, visible.length - 2)));
537        }
538        return;
539      }
540      if (k === "a") {
541        e.preventDefault();
542        flashFor("โ–ค archived");
543        setMode("list");
544        return;
545      }
546      if (k === "i") {
547        e.preventDefault();
548        flashFor("โ—ง images toggled");
549        return;
550      }
551      return;
552    }
553
554    if (mode === "visual") {
555      if (k === "v" || k === "Escape") {
556        e.preventDefault();
557        setMode("list");
558        setVisualStart(null);
559        return;
560      }
561      if (k === "j" || k === "ArrowDown") {
562        e.preventDefault();
563        setCur((c) => Math.min(c + 1, visible.length - 1));
564        return;
565      }
566      if (k === "k" || k === "ArrowUp") {
567        e.preventDefault();
568        setCur((c) => Math.max(c - 1, 0));
569        return;
570      }
571      if (k === "d") {
572        e.preventDefault();
573        const lo = Math.min(cur, visualStart),
574          hi = Math.max(cur, visualStart);
575        const indices = visible.slice(lo, hi + 1).map((m) => m.origIdx);
576        doDelete(indices);
577        setMode("list");
578        setVisualStart(null);
579        setCur((c) =>
580          Math.max(0, Math.min(lo, visible.length - indices.length - 1)),
581        );
582        return;
583      }
584      if (k === "a") {
585        e.preventDefault();
586        flashFor("โ–ค archived batch");
587        setMode("list");
588        setVisualStart(null);
589        return;
590      }
591      if (k === "m") {
592        e.preventDefault();
593        flashFor("โ‡ข move to folderโ€ฆ");
594        setMode("list");
595        setVisualStart(null);
596        return;
597      }
598      return;
599    }
600
601    // list mode
602    if (k === "Escape") {
603      e.preventDefault();
604      flashFor("โ† main menu");
605      return;
606    }
607    if (k === "/") {
608      e.preventDefault();
609      setMode("filter");
610      setFilter("");
611      return;
612    }
613    if (k === "j" || k === "ArrowDown") {
614      e.preventDefault();
615      setCur((c) => Math.min(c + 1, visible.length - 1));
616      return;
617    }
618    if (k === "k" || k === "ArrowUp") {
619      e.preventDefault();
620      setCur((c) => Math.max(c - 1, 0));
621      return;
622    }
623    if (k === "h" || k === "ArrowLeft") {
624      e.preventDefault();
625      setAcctIdx((i) => (i - 1 + accounts.length) % accounts.length);
626      setCur(0);
627      return;
628    }
629    if (k === "l" || k === "ArrowRight") {
630      e.preventDefault();
631      setAcctIdx((i) => (i + 1) % accounts.length);
632      setCur(0);
633      return;
634    }
635    if (k === "Tab" && !e.shiftKey) {
636      e.preventDefault();
637      setFolderIdx((i) => (i + 1) % FOLDERS_LIST.length);
638      setCur(0);
639      return;
640    }
641    if (k === "Tab" && e.shiftKey) {
642      e.preventDefault();
643      setFolderIdx((i) => (i - 1 + FOLDERS_LIST.length) % FOLDERS_LIST.length);
644      setCur(0);
645      return;
646    }
647    if (k === "Enter") {
648      e.preventDefault();
649      if (selected) setMode("email");
650      return;
651    }
652    if (k === "v") {
653      e.preventDefault();
654      if (visible.length) {
655        setMode("visual");
656        setVisualStart(cur);
657      }
658      return;
659    }
660    if (k === "d") {
661      e.preventDefault();
662      if (selected) {
663        doDelete([selected.origIdx]);
664        setCur((c) => Math.max(0, Math.min(c, visible.length - 2)));
665      }
666      return;
667    }
668    if (k === "a") {
669      e.preventDefault();
670      flashFor("โ–ค archived");
671      return;
672    }
673    if (k === "r") {
674      e.preventDefault();
675      flashFor("โ†ป inbox refreshed");
676      return;
677    }
678    if (k === "q") {
679      e.preventDefault();
680      flashFor("quit");
681      return;
682    }
683  };
684
685  const titleSuffix =
686    mode === "visual"
687      ? ` - VISUAL (${Math.abs(cur - (visualStart ?? cur)) + 1} selected)`
688      : "";
689  const folderLabel = FOLDERS_LIST[folderIdx];
690  const isInbox = folderIdx === 0;
691  const acctLabel = activeAcct.isAll ? "All Accounts" : activeAcct.label;
692
693  return (
694    <div
695      ref={containerRef}
696      className={"tui-root " + (focused ? "is-focused" : "")}
697      tabIndex={0}
698      onKeyDown={handleKey}
699      onFocus={() => setFocused(true)}
700      onBlur={() => setFocused(false)}
701      onClick={focusMe}
702    >
703      {/* Title bar */}
704      <div className="tui-titlebar">
705        <div className="tui-titlebar-left">
706          <span className="tui-traffic">
707            <span className="tl tl-r" />
708            <span className="tl tl-y" />
709            <span className="tl tl-g" />
710          </span>
711          <span className="tui-title-text">matcha โ€” kai@floatpane</span>
712        </div>
713        <div className="tui-titlebar-right">
714          <span className="sep">ยท</span>
715          <span>80ร—24</span>
716        </div>
717      </div>
718
719      {/* Body: sidebar + main pane */}
720      <div className="tui-shell">
721        {/* Sidebar */}
722        <div className="tui-sidebar">
723          <div className="tui-sidebar-head">Drew Smirnoff</div>
724          {FOLDERS_LIST.map((f, i) => (
725            <div
726              key={f}
727              className={"tui-folder " + (i === folderIdx ? "active" : "")}
728              onClick={() => {
729                setFolderIdx(i);
730                setCur(0);
731                focusMe();
732              }}
733            >
734              {f}
735            </div>
736          ))}
737        </div>
738
739        {/* Main */}
740        <div className="tui-main">
741          {mode !== "email" ? (
742            <>
743              {/* Account tabs */}
744              <div className="tui-acct-row">
745                {accounts.map((a, i) => (
746                  <div
747                    key={a.id}
748                    className={"tui-acct " + (i === acctIdx ? "active" : "")}
749                    onClick={() => {
750                      setAcctIdx(i);
751                      setCur(0);
752                      focusMe();
753                    }}
754                  >
755                    {a.label}
756                  </div>
757                ))}
758              </div>
759
760              {/* Header line */}
761              <div className="tui-heading">
762                {isInbox ? (
763                  <>
764                    INBOX โ€” {acctLabel}
765                    {titleSuffix}
766                  </>
767                ) : (
768                  <>
769                    {folderLabel}
770                    {titleSuffix}
771                  </>
772                )}
773              </div>
774              <div className="tui-subhead">
775                {isInbox
776                  ? `${visible.length} ${visible.length === 1 ? "email" : "emails"}`
777                  : "folder empty"}
778                {filter && <span className="tui-filter-chip"> /{filter}</span>}
779              </div>
780
781              {/* Message rows */}
782              <div className="tui-list">
783                {visible.length === 0 && (
784                  <div className="tui-row-empty">โ€” no messages โ€”</div>
785                )}
786                {visible.map((m, i) => {
787                  const acct = accounts.find((a) => a.id === m.acct);
788                  return (
789                    <div
790                      key={m.origIdx}
791                      className={
792                        "tui-row " +
793                        (i === cur ? "cur " : "") +
794                        (mode === "visual" ? "visual " : "")
795                      }
796                      onClick={() => {
797                        setCur(i);
798                        focusMe();
799                      }}
800                      onDoubleClick={() => {
801                        setCur(i);
802                        setMode("email");
803                        focusMe();
804                      }}
805                    >
806                      <span className="tui-row-cursor">
807                        {cursor(i, cur, mode === "visual", visualStart)}
808                      </span>
809                      <span className="tui-row-num">
810                        {String(i + 1).padStart(2, " ")}.
811                      </span>
812                      <span className="tui-row-acct">
813                        [{acct ? acct.label : m.acct}]
814                      </span>
815                      <span className="tui-row-sep">โ—†</span>
816                      <span className="tui-row-from">{m.from}</span>
817                      <span className="tui-row-sep">โ€ข</span>
818                      <span className="tui-row-subj">{m.subject}</span>
819                      <span className="tui-row-date">{m.date}</span>
820                    </div>
821                  );
822                })}
823                <div className="tui-row-dots">โ€ข โ€ข โ€ข โ€ข</div>
824              </div>
825
826              <Watermark />
827            </>
828          ) : (
829            <EmailView
830              msg={selected}
831              acct={accounts.find((a) => a.id === selected?.acct)}
832            />
833          )}
834        </div>
835      </div>
836
837      {/* Filter input */}
838      {mode === "filter" && (
839        <div className="tui-cmdline">
840          <span className="tui-cmdline-prompt">/</span>
841          <span className="tui-cmdline-buf">{filter}</span>
842          <span className="tui-caret">โ–ˆ</span>
843        </div>
844      )}
845
846      {/* Flash */}
847      {flash && <div className="tui-flash">{flash}</div>}
848
849      {/* Status line */}
850      <div className="tui-status">
851        {mode === "email" ? (
852          <>
853            <span className="seg">โ†‘โ†“ r: reply</span>
854            <span className="seg">โ†ต f: forward</span>
855            <span className="seg">โ— d: delete</span>
856            <span className="seg">โ–ก a: archive</span>
857            <span className="seg">โ‡ฅ tab: focus attachments</span>
858            <span className="seg">โŽ‹ esc: back to inbox</span>
859            <span className="seg">โšซ i: toggle images</span>
860          </>
861        ) : mode === "visual" ? (
862          <>
863            <span className="seg hi">-- VISUAL --</span>
864            <span className="seg">j/k expand</span>
865            <span className="seg">d delete</span>
866            <span className="seg">a archive</span>
867            <span className="seg">m move</span>
868            <span className="seg">v/esc exit</span>
869          </>
870        ) : mode === "filter" ? (
871          <>
872            <span className="seg hi">-- FILTER --</span>
873            <span className="seg">enter apply</span>
874            <span className="seg">esc cancel</span>
875          </>
876        ) : (
877          <>
878            <span className="seg">โ†‘/k up</span>
879            <span className="seg">โ†“/j down</span>
880            <span className="seg">/ filter</span>
881            <span className="seg">v visual mode</span>
882            <span className="seg">โ— d delete</span>
883            <span className="seg">โ–ก a archive</span>
884            <span className="seg">โ—‡ r refresh</span>
885            <span className="seg">โ†/h prev tab</span>
886            <span className="seg">โ†’/l next tab</span>
887            <span className="seg">tab next folder</span>
888            <span className="seg">shift+tab prev folder</span>
889            <span className="seg">m move</span>
890            <span className="seg">q quit</span>
891          </>
892        )}
893      </div>
894
895      {/* Focus hint */}
896      {!focused && (
897        <div className="tui-focus-hint">
898          click ยท try <kbd>j</kbd>
899          <kbd>k</kbd> <kbd>tab</kbd> <kbd>/</kbd> <kbd>โ†ต</kbd>
900        </div>
901      )}
902    </div>
903  );
904}
905
906function EmailView({ msg, acct }) {
907  if (!msg) return null;
908  const body = bodyFor(msg);
909  const from = acct ? `<${acct.label}>` : "";
910  return (
911    <div className="tui-email">
912      <div className="tui-email-head">
913        To: {acct ? acct.label : "you"} | From: {msg.from} {from} | Subject:{" "}
914        {msg.subject}
915      </div>
916      <div className="tui-email-body">
917        {body.split("\n").map((line, i) => {
918          // highlight 'this guy' style blue token if present
919          const parts = line.split(/(this guy)/);
920          return (
921            <div key={i} className="tui-email-line">
922              {parts.map((p, j) =>
923                p === "this guy" ? (
924                  <span key={j} className="tui-link">
925                    {p}
926                  </span>
927                ) : (
928                  <span key={j}>{p || "\u00a0"}</span>
929                ),
930              )}
931            </div>
932          );
933        })}
934      </div>
935      <Watermark />
936    </div>
937  );
938}
939
940window.MatchaTUI = TUI;