@tenyokjs/devdevian-cli 0.4.1-alpha.1 → 0.5.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tui.mjs CHANGED
@@ -3,6 +3,7 @@ import { render, Box, Text, useApp, useInput, useWindowSize } from "ink";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { homedir, hostname } from "node:os";
5
5
  import { workspaceRoot, reviewSource } from "./workspace.mjs";
6
+ import { DeviPanel } from "./devi.mjs";
6
7
  import { WorkspacePicker } from "./picker.mjs";
7
8
  import { DraftStore, draftIdentity } from "./drafts.mjs";
8
9
  import { spawn } from "node:child_process";
@@ -32,7 +33,15 @@ import {
32
33
  notificationRoute,
33
34
  } from "./tui-data.mjs";
34
35
  import { basename } from "node:path";
35
- import { colors } from "./theme.mjs";
36
+ import { themeFor, ThemeContext, useTheme } from "./theme.mjs";
37
+ import {
38
+ appearanceFields,
39
+ defaultAppearance,
40
+ loadAppearance,
41
+ saveAppearance,
42
+ normalizeAppearance,
43
+ appearanceFlags,
44
+ } from "./appearance.mjs";
36
45
  import { liveLoop, anchoredIndex } from "./live.mjs";
37
46
  import { contextActions } from "./context-actions.mjs";
38
47
  import { editExternal } from "./external-editor.mjs";
@@ -83,6 +92,7 @@ function openBrowser(url) {
83
92
  child.unref();
84
93
  }
85
94
  function fieldsFor(form) {
95
+ if (form.kind === "appearance") return appearanceFields;
86
96
  if (form.kind === "publication")
87
97
  return [
88
98
  {
@@ -190,10 +200,16 @@ function fieldsFor(form) {
190
200
  },
191
201
  ];
192
202
  }
193
- function TextLine({ text, width, color = colors.text, bold = false }) {
194
- return h(Text, { color, bold, wrap: "truncate" }, clip(text, width));
203
+ function TextLine({ text, width, color, bold = false }) {
204
+ const colors = useTheme();
205
+ return h(
206
+ Text,
207
+ { color: color || colors.text, bold, wrap: "truncate" },
208
+ clip(text, width),
209
+ );
195
210
  }
196
211
  function FrameRows({ rows, width, height, offset = 0 }) {
212
+ const colors = useTheme();
197
213
  return h(
198
214
  Box,
199
215
  { flexDirection: "column", height, overflow: "hidden" },
@@ -226,6 +242,7 @@ function FrameRows({ rows, width, height, offset = 0 }) {
226
242
  );
227
243
  }
228
244
  function InputText({ value, cursor, width, height = 1 }) {
245
+ const colors = useTheme();
229
246
  const result = cursorLines(value, cursor, width),
230
247
  start = Math.max(0, result.line - height + 1);
231
248
  return h(
@@ -244,6 +261,7 @@ function InputText({ value, cursor, width, height = 1 }) {
244
261
  );
245
262
  }
246
263
  function FormView({ form, active, cursor, width, height }) {
264
+ const colors = useTheme();
247
265
  const fields = fieldsFor(form),
248
266
  rows = [];
249
267
  let selectedLine = 0;
@@ -295,14 +313,19 @@ function FormView({ form, active, cursor, width, height }) {
295
313
  rows.push({
296
314
  text:
297
315
  (active === fields.length ? "› " : " ") +
298
- "Preview publication [Ctrl+S]",
316
+ (form.kind === "appearance"
317
+ ? "Save appearance [Ctrl+S]"
318
+ : "Preview publication [Ctrl+S]"),
299
319
  color: colors.blue,
300
320
  bold: true,
301
321
  });
302
- const offset = Math.max(0, Math.min(selectedLine - 1, rows.length - height));
322
+ const offset = Math.max(
323
+ 0,
324
+ Math.min(selectedLine - (height >= 4 ? 1 : 0), rows.length - height),
325
+ );
303
326
  return h(FrameRows, { rows, width, height, offset });
304
327
  }
305
- function listRows(route, data, index, width) {
328
+ function listRows(route, data, index, width, colors, density = "comfortable") {
306
329
  const items = data.items || [];
307
330
  return items.flatMap((item, i) => {
308
331
  let title = item.title || item.name || "",
@@ -388,21 +411,28 @@ function listRows(route, data, index, width) {
388
411
  color: selected ? colors.text : colors.muted,
389
412
  selected,
390
413
  },
391
- { text: " " + "─".repeat(Math.max(0, width - 4)), color: colors.line },
414
+ ...(density === "compact"
415
+ ? []
416
+ : [
417
+ {
418
+ text: " " + "─".repeat(Math.max(0, width - 4)),
419
+ color: colors.line,
420
+ },
421
+ ]),
392
422
  ];
393
423
  });
394
424
  }
395
- function documentRows(route, data, width) {
425
+ function documentRows(route, data, width, colors) {
396
426
  if (route.view !== "post" || (route.tab && route.tab !== "overview"))
397
427
  return contentLines(route, data).flatMap((line) => wrap(line, width));
398
428
  const post = data?.post;
399
429
  if (!post) return [];
400
- const rows = markdownRows(post.body || "", width);
430
+ const rows = markdownRows(post.body || "", width, colors);
401
431
  if (post.review_focus)
402
432
  rows.push(
403
433
  { text: "" },
404
434
  { text: "REVIEW FOCUS", color: colors.purple, bold: true },
405
- ...markdownRows(post.review_focus, width),
435
+ ...markdownRows(post.review_focus, width, colors),
406
436
  );
407
437
  if (post.code) {
408
438
  // Pick a fence longer than any backtick run in the snippet.
@@ -414,7 +444,7 @@ function documentRows(route, data, width) {
414
444
  );
415
445
  rows.push(
416
446
  { text: "" },
417
- ...markdownRows(fence + "\n" + post.code + "\n" + fence, width),
447
+ ...markdownRows(fence + "\n" + post.code + "\n" + fence, width, colors),
418
448
  );
419
449
  }
420
450
  if (post.links?.length)
@@ -431,16 +461,71 @@ function documentRows(route, data, width) {
431
461
  });
432
462
  return rows;
433
463
  }
434
- function App({ initialConfig, flags, root }) {
464
+ function AppearanceView({ form, active, cursor, width, height }) {
465
+ const c = useTheme(),
466
+ wide = width >= 100;
467
+ return h(
468
+ Box,
469
+ { height, overflow: "hidden", gap: wide ? 2 : 0 },
470
+ h(
471
+ Box,
472
+ { width: wide ? width - 36 : width, flexDirection: "column" },
473
+ h(FormView, {
474
+ form,
475
+ active,
476
+ cursor,
477
+ width: wide ? width - 36 : width,
478
+ height,
479
+ }),
480
+ ),
481
+ wide
482
+ ? h(
483
+ Box,
484
+ {
485
+ width: 34,
486
+ height,
487
+ borderStyle: c.frame,
488
+ borderColor: c.line,
489
+ paddingX: 1,
490
+ flexDirection: "column",
491
+ overflow: "hidden",
492
+ },
493
+ h(Text, { color: c.purple, bold: true }, "LIVE PREVIEW"),
494
+ h(Text, { color: c.muted }, "Only this terminal changes."),
495
+ h(Text, null, " "),
496
+ h(
497
+ Text,
498
+ { color: c.text, bold: true },
499
+ "A good conversation starts here",
500
+ ),
501
+ h(
502
+ Text,
503
+ { color: c.blue, backgroundColor: c.selected },
504
+ "▸ Your selected conversation",
505
+ ),
506
+ h(Text, { color: c.muted }, "Ada · code review · just now"),
507
+ h(Text, { color: c.green }, "+ ship with confidence"),
508
+ h(Text, { color: c.red }, "− leave the guesswork behind"),
509
+ h(Text, null, " "),
510
+ h(Text, { color: c.purple }, "← → change · Tab next"),
511
+ h(Text, { color: c.text }, "Ctrl+S save · Esc cancel"),
512
+ h(Text, { color: c.muted }, "0 restores the default preview"),
513
+ )
514
+ : null,
515
+ );
516
+ }
517
+ function App({ initialConfig, flags, root, initialAppearance }) {
435
518
  const { exit, suspendTerminal } = useApp(),
436
519
  { columns, rows } = useWindowSize();
520
+ const [appearance, setAppearance] = useState(initialAppearance.settings);
437
521
  const [config, setConfig] = useState(initialConfig),
438
522
  [phase, setPhase] = useState(
439
523
  initialConfig && !flags.initialLogin ? "checking" : "auth",
440
524
  ),
441
525
  [user, setUser] = useState(null),
442
526
  [device, setDevice] = useState(null),
443
- [authVersion, setAuthVersion] = useState(0);
527
+ [authVersion, setAuthVersion] = useState(0),
528
+ [booting, setBooting] = useState(true);
444
529
  const [route, setRoute] = useState({ view: "feed", page: 1 }),
445
530
  [data, setData] = useState(null),
446
531
  [loading, setLoading] = useState(false),
@@ -452,7 +537,7 @@ function App({ initialConfig, flags, root }) {
452
537
  [command, setCommand] = useState(""),
453
538
  [commandCursor, setCommandCursor] = useState(0),
454
539
  [error, setError] = useState(""),
455
- [notice, setNotice] = useState("");
540
+ [notice, setNotice] = useState(initialAppearance.warning || "");
456
541
  const [form, setForm] = useState(null),
457
542
  [field, setField] = useState(0),
458
543
  [cursor, setCursor] = useState(0),
@@ -496,9 +581,13 @@ function App({ initialConfig, flags, root }) {
496
581
  () => (config ? client(config.origin, config.token) : null),
497
582
  [config?.origin, config?.token],
498
583
  );
584
+ const effectiveAppearance =
585
+ form?.kind === "appearance" ? normalizeAppearance(form.values) : appearance;
586
+ const colors = themeFor(effectiveAppearance),
587
+ stride = effectiveAppearance.density === "compact" ? 3 : 4;
499
588
  const width = Math.max(1, columns),
500
589
  height = Math.max(1, rows - 1),
501
- compact = width < 90,
590
+ compact = width < 90 || effectiveAppearance.sidebar === "hidden",
502
591
  sidebar = compact ? 0 : 25,
503
592
  bodyHeight = Math.max(1, height - 9),
504
593
  contentWidth = Math.max(1, width - sidebar - 6),
@@ -606,6 +695,7 @@ function App({ initialConfig, flags, root }) {
606
695
  setPreview(null);
607
696
  setDevice(null);
608
697
  setUser(null);
698
+ setBooting(true);
609
699
  setReading(null);
610
700
  setActionIndex(null);
611
701
  setUnread(0);
@@ -629,6 +719,7 @@ function App({ initialConfig, flags, root }) {
629
719
  } else setError(problemMessage(e));
630
720
  }
631
721
  function navigate(next, { replace = false } = {}) {
722
+ setBooting(false);
632
723
  if (!replace)
633
724
  history.current.push({
634
725
  route,
@@ -786,10 +877,14 @@ function App({ initialConfig, flags, root }) {
786
877
  };
787
878
  }, []);
788
879
  useEffect(() => {
789
- if (!loading && !pending && phase !== "pending") return;
880
+ if (
881
+ effectiveAppearance.motion === "reduced" ||
882
+ (!loading && !pending && phase !== "pending")
883
+ )
884
+ return;
790
885
  const timer = setInterval(() => setTick((v) => (v + 1) % 4), 180);
791
886
  return () => clearInterval(timer);
792
- }, [loading, pending, phase]);
887
+ }, [loading, pending, phase, effectiveAppearance.motion]);
793
888
  useEffect(() => {
794
889
  if (phase !== "checking" || !api) return;
795
890
  const controller = new AbortController();
@@ -807,7 +902,7 @@ function App({ initialConfig, flags, root }) {
807
902
  if (!controller.signal.aborted) fail(e);
808
903
  });
809
904
  return () => controller.abort();
810
- }, [phase, api]);
905
+ }, [phase, api, authVersion]);
811
906
  useEffect(() => {
812
907
  if (phase !== "ready" || !api || form || route.view === "help") return;
813
908
  let active = true;
@@ -873,7 +968,10 @@ function App({ initialConfig, flags, root }) {
873
968
  if (active) fail(e);
874
969
  })
875
970
  .finally(() => {
876
- if (active) setLoading(false);
971
+ if (active) {
972
+ setLoading(false);
973
+ setBooting(false);
974
+ }
877
975
  });
878
976
  return () => {
879
977
  active = false;
@@ -905,6 +1003,7 @@ function App({ initialConfig, flags, root }) {
905
1003
  if (controller.signal.aborted) return;
906
1004
  resetAccount();
907
1005
  setConfig(saved);
1006
+ setBooting(true);
908
1007
  setPhase("checking");
909
1008
  setNotice("Terminal connected.");
910
1009
  return;
@@ -944,6 +1043,7 @@ function App({ initialConfig, flags, root }) {
944
1043
  async function chooseNav(i) {
945
1044
  const name = navigation[i]?.[0];
946
1045
  setNavIndex(i);
1046
+ if (name === "appearance") return openAppearance();
947
1047
  if (name === "ask" || name === "review")
948
1048
  return composePublication(name === "review" ? "review" : "question");
949
1049
  if (name === "open")
@@ -959,7 +1059,18 @@ function App({ initialConfig, flags, root }) {
959
1059
  setCommandCursor(0);
960
1060
  }
961
1061
  }
1062
+ function openAppearance() {
1063
+ openForm({
1064
+ kind: "appearance",
1065
+ title: "Appearance",
1066
+ values: { ...appearance },
1067
+ });
1068
+ setNotice(
1069
+ "Live preview · ← → change · Ctrl+S save · Esc cancel · 0 defaults",
1070
+ );
1071
+ }
962
1072
  function openForm(next) {
1073
+ setBooting(false);
963
1074
  setForm({
964
1075
  ...next,
965
1076
  draftId: next.draftId || randomBytes(32).toString("hex"),
@@ -1123,6 +1234,18 @@ function App({ initialConfig, flags, root }) {
1123
1234
  }
1124
1235
  async function prepare() {
1125
1236
  if (!form) return;
1237
+ if (form.kind === "appearance") {
1238
+ await task(async () => {
1239
+ const saved = await saveAppearance(form.values);
1240
+ setAppearance(saved);
1241
+ setForm(null);
1242
+ setFocus("content");
1243
+ setNotice(
1244
+ "Appearance saved locally. Your account and other devices are unchanged.",
1245
+ );
1246
+ });
1247
+ return;
1248
+ }
1126
1249
  await task(async () => {
1127
1250
  if (
1128
1251
  form.unavailableAudience &&
@@ -1571,6 +1694,10 @@ function App({ initialConfig, flags, root }) {
1571
1694
  );
1572
1695
  return;
1573
1696
  }
1697
+ if (parsed.command === "appearance") {
1698
+ openAppearance();
1699
+ return;
1700
+ }
1574
1701
  if (parsed.command === "open") {
1575
1702
  navigate({ view: "post", id: parsed.arg, tab: "overview", page: 1 });
1576
1703
  return;
@@ -1591,6 +1718,10 @@ function App({ initialConfig, flags, root }) {
1591
1718
  }
1592
1719
  }
1593
1720
  function askQuit() {
1721
+ if (form?.kind === "appearance") {
1722
+ exit();
1723
+ return;
1724
+ }
1594
1725
  if (form) {
1595
1726
  askConfirm(
1596
1727
  "Leave this draft?",
@@ -1650,6 +1781,14 @@ function App({ initialConfig, flags, root }) {
1650
1781
  setDevice(null);
1651
1782
  return;
1652
1783
  }
1784
+ if (phase === "checking") {
1785
+ if (input === "q") exit();
1786
+ else if (error && (keyEvent.return || input === "r" || input === "R")) {
1787
+ setError("");
1788
+ setAuthVersion((v) => v + 1);
1789
+ }
1790
+ return;
1791
+ }
1653
1792
  if (input === "q") exit();
1654
1793
  else if (keyEvent.return && !busy.current) {
1655
1794
  if (phase === "pending" && device) openBrowser(device.verification_uri);
@@ -1677,6 +1816,7 @@ function App({ initialConfig, flags, root }) {
1677
1816
  ? "[deleted — discussion preserved]"
1678
1817
  : reading.body || "",
1679
1818
  contentWidth,
1819
+ colors,
1680
1820
  ),
1681
1821
  maximum = Math.max(0, lines.length - contentHeight);
1682
1822
  if (keyEvent.escape || keyEvent.return || input === " ") {
@@ -1725,6 +1865,17 @@ function App({ initialConfig, flags, root }) {
1725
1865
  return;
1726
1866
  }
1727
1867
  if (form) {
1868
+ if (form.kind === "appearance" && keyEvent.escape) {
1869
+ setForm(null);
1870
+ setFocus("content");
1871
+ setNotice("Appearance preview cancelled. Saved preferences kept.");
1872
+ return;
1873
+ }
1874
+ if (form.kind === "appearance" && input === "0") {
1875
+ setForm((old) => ({ ...old, values: { ...defaultAppearance } }));
1876
+ setNotice("Default preview restored. Ctrl+S saves; Esc cancels.");
1877
+ return;
1878
+ }
1728
1879
  if (keyEvent.ctrl && input === "e") {
1729
1880
  externalEdit();
1730
1881
  return;
@@ -1775,20 +1926,20 @@ function App({ initialConfig, flags, root }) {
1775
1926
  keyEvent.downArrow
1776
1927
  ) {
1777
1928
  const opts = active.options,
1778
- i = Math.max(
1929
+ step = keyEvent.leftArrow || keyEvent.upArrow ? -1 : 1;
1930
+ setForm((old) => {
1931
+ const i = Math.max(
1779
1932
  0,
1780
1933
  opts.findIndex(
1781
1934
  (item) =>
1782
- String(item[0]) === String(form.values[active.key] || ""),
1935
+ String(item[0]) === String(old.values[active.key] || ""),
1783
1936
  ),
1784
- ),
1785
- step = keyEvent.leftArrow || keyEvent.upArrow ? -1 : 1,
1786
- next = opts[(i + step + opts.length) % opts.length];
1787
- if (next)
1788
- setForm((old) => ({
1789
- ...old,
1790
- values: { ...old.values, [active.key]: next[0] },
1791
- }));
1937
+ );
1938
+ const next = opts[(i + step + opts.length) % opts.length];
1939
+ return next
1940
+ ? { ...old, values: { ...old.values, [active.key]: next[0] } }
1941
+ : old;
1942
+ });
1792
1943
  } else if (keyEvent.return) changeField(field + 1);
1793
1944
  return;
1794
1945
  }
@@ -1862,6 +2013,10 @@ function App({ initialConfig, flags, root }) {
1862
2013
  else if (keyEvent.rightArrow) setFocus("content");
1863
2014
  return;
1864
2015
  }
2016
+ if (input === ",") {
2017
+ openAppearance();
2018
+ return;
2019
+ }
1865
2020
  if (input === "x" || input === "X" || input === "?") {
1866
2021
  setActionIndex(0);
1867
2022
  return;
@@ -1948,13 +2103,13 @@ function App({ initialConfig, flags, root }) {
1948
2103
  ) {
1949
2104
  const step =
1950
2105
  keyEvent.pageUp || keyEvent.pageDown
1951
- ? Math.max(1, Math.floor(contentHeight / 4))
2106
+ ? Math.max(1, Math.floor(contentHeight / stride))
1952
2107
  : 1,
1953
2108
  delta = keyEvent.upArrow || keyEvent.pageUp ? -step : step;
1954
2109
  if (prose) {
1955
2110
  const maximum = Math.max(
1956
2111
  0,
1957
- documentRows(route, current || {}, contentWidth).length -
2112
+ documentRows(route, current || {}, contentWidth, colors).length -
1958
2113
  contentHeight,
1959
2114
  );
1960
2115
  const amount =
@@ -2079,7 +2234,8 @@ function App({ initialConfig, flags, root }) {
2079
2234
  : loading
2080
2235
  ? "Loading…"
2081
2236
  : "Shared account · shared conversations · same permissions");
2082
- const spin = ["◐", "◓", "◑", "◒"][tick];
2237
+ const spin =
2238
+ effectiveAppearance.motion === "reduced" ? "·" : ["◐", "◓", "◑", "◒"][tick];
2083
2239
  let center;
2084
2240
  if (width < 60 || rows < 18)
2085
2241
  center = h(
@@ -2118,51 +2274,89 @@ function App({ initialConfig, flags, root }) {
2118
2274
  },
2119
2275
  });
2120
2276
  else if (phase !== "ready") {
2121
- const lines = [
2122
- "A place for curious developers.",
2123
- "Your conversations, without leaving the terminal.",
2124
- "",
2125
- ...(phase === "pending" && device
2277
+ const statusLines =
2278
+ phase === "pending" && device
2126
2279
  ? [
2127
2280
  "Confirm this code: " + device.user_code,
2128
- "Waiting for your approval " + spin,
2129
2281
  device.verification_uri,
2130
- "Enter reopens the browser · Esc cancels",
2282
+ "Waiting for your approval...",
2131
2283
  ]
2132
2284
  : phase === "checking"
2133
- ? ["Opening your workspace " + spin]
2285
+ ? error
2286
+ ? ["Connection could not be completed.", error]
2287
+ : ["Connecting to Devdevian..."]
2134
2288
  : [
2135
2289
  "Sign in with your existing Devdevian account.",
2136
- "",
2137
- "Enter connects this terminal through your browser.",
2138
2290
  "Google, GitHub and email sign-in stay on the website.",
2139
- "",
2140
- "Q exits.",
2141
- ]),
2142
- ];
2291
+ ];
2292
+ const footer =
2293
+ phase === "pending"
2294
+ ? ["Enter reopens the browser · Esc cancels"]
2295
+ : phase === "checking"
2296
+ ? [error ? "R or Enter retries · Ctrl+C exits" : "Ctrl+C exits"]
2297
+ : [
2298
+ "Enter connects this terminal through your browser.",
2299
+ "Q or Ctrl+C exits.",
2300
+ ];
2143
2301
  center = h(
2144
2302
  Box,
2145
2303
  {
2146
- borderStyle: "round",
2304
+ borderStyle: colors.frame,
2147
2305
  borderColor: colors.purple,
2148
2306
  flexDirection: "column",
2149
- paddingX: 2,
2150
- paddingY: 1,
2307
+ paddingX: 1,
2308
+ height: bodyHeight,
2309
+ overflow: "hidden",
2310
+ },
2311
+ h(DeviPanel, {
2312
+ width: Math.max(1, width - 4),
2313
+ height: Math.max(1, bodyHeight - 2),
2314
+ message:
2315
+ effectiveAppearance.welcome === "off"
2316
+ ? ""
2317
+ : phase === "checking"
2318
+ ? "Devi missed you."
2319
+ : "Devi saved you a seat.",
2320
+ status: statusLines,
2321
+ footer,
2322
+ show: effectiveAppearance.welcome === "on",
2323
+ animate: phase !== "auth" && !error,
2324
+ motion: effectiveAppearance.motion,
2325
+ }),
2326
+ );
2327
+ } else if (
2328
+ booting &&
2329
+ !current &&
2330
+ effectiveAppearance.welcome === "on" &&
2331
+ !error &&
2332
+ !dialog &&
2333
+ !form &&
2334
+ !picker
2335
+ ) {
2336
+ center = h(
2337
+ Box,
2338
+ {
2339
+ borderStyle: colors.frame,
2340
+ borderColor: colors.purple,
2341
+ paddingX: 1,
2151
2342
  height: bodyHeight,
2152
2343
  overflow: "hidden",
2153
2344
  },
2154
- h(Text, { bold: true, color: colors.purple }, "YOUR DEVELOPER SPACE"),
2155
- h(FrameRows, {
2156
- rows: lines.flatMap((line) => wrap(line, width - 8)),
2157
- width: width - 8,
2158
- height: Math.max(1, bodyHeight - 5),
2345
+ h(DeviPanel, {
2346
+ width: Math.max(1, width - 4),
2347
+ height: Math.max(1, bodyHeight - 2),
2348
+ message: "Devi missed you.",
2349
+ status: ["Loading your " + labelOf(route).toLowerCase() + "..."],
2350
+ footer: ["Ctrl+C exits"],
2351
+ motion: effectiveAppearance.motion,
2352
+ animate: true,
2159
2353
  }),
2160
2354
  );
2161
2355
  } else if (dialog) {
2162
2356
  center = h(
2163
2357
  Box,
2164
2358
  {
2165
- borderStyle: "round",
2359
+ borderStyle: colors.frame,
2166
2360
  borderColor: colors.purple,
2167
2361
  flexDirection: "column",
2168
2362
  paddingX: 2,
@@ -2202,6 +2396,7 @@ function App({ initialConfig, flags, root }) {
2202
2396
  ? "[deleted — discussion preserved]"
2203
2397
  : reading.body || "",
2204
2398
  contentWidth,
2399
+ colors,
2205
2400
  );
2206
2401
  body = h(FrameRows, {
2207
2402
  rows: lines,
@@ -2258,14 +2453,14 @@ function App({ initialConfig, flags, root }) {
2258
2453
  ),
2259
2454
  );
2260
2455
  } else if (form)
2261
- body = h(FormView, {
2456
+ body = h(form.kind === "appearance" ? AppearanceView : FormView, {
2262
2457
  form,
2263
2458
  active: field,
2264
2459
  cursor,
2265
2460
  width: contentWidth,
2266
2461
  height: contentHeight,
2267
2462
  });
2268
- else if (loading && !current)
2463
+ else if ((loading || booting) && !current)
2269
2464
  body = h(
2270
2465
  Text,
2271
2466
  { color: colors.muted },
@@ -2324,7 +2519,17 @@ function App({ initialConfig, flags, root }) {
2324
2519
  line: reviewLine,
2325
2520
  anchor: reviewAnchor,
2326
2521
  horizontal: reviewHorizontal,
2327
- rows: (w) => listRows(route, current, index, w),
2522
+ layout: effectiveAppearance.reviewLayout,
2523
+ stride,
2524
+ rows: (w) =>
2525
+ listRows(
2526
+ route,
2527
+ current,
2528
+ index,
2529
+ w,
2530
+ colors,
2531
+ effectiveAppearance.density,
2532
+ ),
2328
2533
  });
2329
2534
  else if (error && !current)
2330
2535
  body = h(FrameRows, {
@@ -2342,7 +2547,7 @@ function App({ initialConfig, flags, root }) {
2342
2547
  height: contentHeight,
2343
2548
  });
2344
2549
  else {
2345
- const lines = documentRows(route, current || {}, contentWidth);
2550
+ const lines = documentRows(route, current || {}, contentWidth, colors);
2346
2551
  if (lines.length)
2347
2552
  body = h(FrameRows, {
2348
2553
  rows: lines,
@@ -2350,36 +2555,59 @@ function App({ initialConfig, flags, root }) {
2350
2555
  height: contentHeight,
2351
2556
  offset: Math.min(scroll, Math.max(0, lines.length - contentHeight)),
2352
2557
  });
2353
- else if (!current?.items?.length)
2354
- body = h(
2355
- Box,
2356
- { flexDirection: "column", paddingTop: 1 },
2357
- h(
2358
- Text,
2359
- { color: colors.text },
2360
- route.view === "search"
2361
- ? "Find your next useful conversation."
2362
- : error
2363
- ? "Could not open this view."
2364
- : "Nothing here yet.",
2365
- ),
2366
- h(
2367
- Text,
2368
- { color: colors.muted },
2369
- error
2370
- ? "Press Shift+R to retry."
2371
- : route.view === "feed"
2372
- ? "N starts a conversation. A asks a question."
2373
- : "Use / to search or Tab to explore another section.",
2374
- ),
2375
- );
2376
- else {
2377
- const visible = Math.max(1, Math.floor(contentHeight / 4)),
2558
+ else if (!current?.items?.length) {
2559
+ const messages = {
2560
+ inbox: [
2561
+ "You're all caught up.",
2562
+ "Replies, mentions and review updates will appear here.",
2563
+ ],
2564
+ drafts: [
2565
+ "No local drafts yet.",
2566
+ "Start writing to save a private draft on this device.",
2567
+ ],
2568
+ communities: [
2569
+ "Find your people.",
2570
+ "Use / to search or N to create a private community.",
2571
+ ],
2572
+ feed: [
2573
+ "Nothing here yet.",
2574
+ "N starts a conversation. A asks a question.",
2575
+ ],
2576
+ search: route.query
2577
+ ? [
2578
+ "No conversations matched your search.",
2579
+ "Try fewer words or a different phrase.",
2580
+ ]
2581
+ : [
2582
+ "Find your next useful conversation.",
2583
+ "Press / and type /search followed by your query.",
2584
+ ],
2585
+ };
2586
+ const message = messages[route.view] || [
2587
+ "Nothing here yet.",
2588
+ "Use / to search or Tab to explore another section.",
2589
+ ];
2590
+ body = h(DeviPanel, {
2591
+ width: contentWidth,
2592
+ height: contentHeight,
2593
+ message: message[0],
2594
+ status: [message[1]],
2595
+ show:
2596
+ effectiveAppearance.welcome === "on" &&
2597
+ Object.hasOwn(messages, route.view),
2598
+ motion: effectiveAppearance.motion,
2599
+ });
2600
+ } else {
2601
+ const visible = Math.max(1, Math.floor(contentHeight / stride)),
2378
2602
  first = Math.max(0, index - visible + 1),
2379
- rows = listRows(route, current, index, contentWidth).slice(
2380
- first * 4,
2381
- first * 4 + contentHeight,
2382
- );
2603
+ rows = listRows(
2604
+ route,
2605
+ current,
2606
+ index,
2607
+ contentWidth,
2608
+ colors,
2609
+ effectiveAppearance.density,
2610
+ ).slice(first * stride, first * stride + contentHeight);
2383
2611
  body = h(
2384
2612
  Box,
2385
2613
  {
@@ -2419,7 +2647,7 @@ function App({ initialConfig, flags, root }) {
2419
2647
  const pane = h(
2420
2648
  Box,
2421
2649
  {
2422
- borderStyle: "round",
2650
+ borderStyle: colors.frame,
2423
2651
  borderColor: focus === "nav" && !compact ? colors.line : colors.purple,
2424
2652
  backgroundColor: colors.panel,
2425
2653
  flexDirection: "column",
@@ -2470,7 +2698,9 @@ function App({ initialConfig, flags, root }) {
2470
2698
  )
2471
2699
  : h(TextLine, {
2472
2700
  text: form
2473
- ? "Tab next field · Ctrl+E editor · Ctrl+S preview" +
2701
+ ? (form.kind === "appearance"
2702
+ ? "Local preferences · Preview now · Ctrl+S save · Esc cancel"
2703
+ : "Tab next field · Ctrl+E editor · Ctrl+S preview") +
2474
2704
  (draftSaved ? " · Draft saved locally" : "")
2475
2705
  : preview
2476
2706
  ? "Scroll to review · ← → choose · Enter confirms"
@@ -2493,7 +2723,7 @@ function App({ initialConfig, flags, root }) {
2493
2723
  ? h(
2494
2724
  Box,
2495
2725
  {
2496
- borderStyle: "round",
2726
+ borderStyle: colors.frame,
2497
2727
  borderColor: focus === "nav" ? colors.blue : colors.line,
2498
2728
  backgroundColor: colors.panel,
2499
2729
  flexDirection: "column",
@@ -2546,97 +2776,103 @@ function App({ initialConfig, flags, root }) {
2546
2776
  );
2547
2777
  }
2548
2778
  const hint =
2549
- actionIndex !== null
2550
- ? "Actions are scoped to the selected item · Esc closes"
2551
- : reviewing && !form && !preview && !reading
2552
- ? "W code / discussion · Space select · C comment · X actions · F filter"
2553
- : picker
2554
- ? "Project files stay local until publication is confirmed"
2555
- : reading
2556
- ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2557
- : preview
2558
- ? "← → choose · Enter confirm · Esc edit"
2559
- : form
2560
- ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
2561
- : phase !== "ready"
2562
- ? "Enter connect / open browser · Ctrl+C exit"
2563
- : route.view === "feedback"
2564
- ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2565
- : route.view === "post"
2566
- ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2567
- : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2779
+ form?.kind === "appearance"
2780
+ ? "← → choose · Tab next · Ctrl+S save · Esc cancel · 0 default preview"
2781
+ : actionIndex !== null
2782
+ ? "Actions are scoped to the selected item · Esc closes"
2783
+ : reviewing && !form && !preview && !reading
2784
+ ? "W code / discussion · Space select · C comment · X actions · F filter"
2785
+ : picker
2786
+ ? "Project files stay local until publication is confirmed"
2787
+ : reading
2788
+ ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2789
+ : preview
2790
+ ? "← → choose · Enter confirm · Esc edit"
2791
+ : form
2792
+ ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
2793
+ : phase !== "ready"
2794
+ ? "Enter connect / open browser · Ctrl+C exit"
2795
+ : route.view === "feedback"
2796
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2797
+ : route.view === "post"
2798
+ ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2799
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2568
2800
  return h(
2569
- Box,
2570
- {
2571
- flexDirection: "column",
2572
- width,
2573
- height,
2574
- overflow: "hidden",
2575
- backgroundColor: colors.bg,
2576
- },
2801
+ ThemeContext.Provider,
2802
+ { value: colors },
2577
2803
  h(
2578
2804
  Box,
2579
2805
  {
2580
- height: 3,
2581
- borderStyle: "round",
2582
- borderColor: colors.line,
2583
- paddingX: 1,
2584
- justifyContent: "space-between",
2806
+ flexDirection: "column",
2807
+ width,
2808
+ height,
2585
2809
  overflow: "hidden",
2810
+ backgroundColor: colors.bg,
2586
2811
  },
2587
2812
  h(
2588
- Text,
2589
- { color: colors.purple, bold: true },
2590
- clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2591
- ),
2592
- h(
2593
- Text,
2594
- { color: colors.blue },
2595
- clip(
2596
- user
2597
- ? "@" +
2598
- user.username +
2599
- " · " +
2600
- unread +
2601
- " unread · " +
2602
- (live ? "● Live" : "○ Paused")
2603
- : "CLI · ALPHA",
2604
- 32,
2813
+ Box,
2814
+ {
2815
+ height: 3,
2816
+ borderStyle: colors.frame,
2817
+ borderColor: colors.line,
2818
+ paddingX: 1,
2819
+ justifyContent: "space-between",
2820
+ overflow: "hidden",
2821
+ },
2822
+ h(
2823
+ Text,
2824
+ { color: colors.purple, bold: true },
2825
+ clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2826
+ ),
2827
+ h(
2828
+ Text,
2829
+ { color: colors.blue },
2830
+ clip(
2831
+ user
2832
+ ? "@" +
2833
+ user.username +
2834
+ " · " +
2835
+ unread +
2836
+ " unread · " +
2837
+ (live ? "● Live" : "○ Paused")
2838
+ : "CLI · ALPHA",
2839
+ 32,
2840
+ ),
2605
2841
  ),
2606
2842
  ),
2607
- ),
2608
- center,
2609
- h(TextLine, {
2610
- text: clip((pending || loading ? spin + " " : "") + status, width),
2611
- width,
2612
- color: error ? colors.red : notice ? colors.green : colors.muted,
2613
- }),
2614
- h(
2615
- Box,
2616
- {
2617
- height: 3,
2618
- borderStyle: "round",
2619
- borderColor: focus === "prompt" ? colors.blue : colors.line,
2620
- paddingX: 1,
2621
- overflow: "hidden",
2622
- },
2623
- h(Text, { color: colors.purple, bold: true }, "› "),
2624
- focus === "prompt"
2625
- ? h(InputText, {
2626
- value: command,
2627
- cursor: commandCursor,
2628
- width: Math.max(1, width - 6),
2629
- })
2630
- : h(
2631
- Text,
2632
- { color: colors.muted },
2633
- clip(
2634
- "Search or /command · Press / to type · X actions",
2635
- width - 6,
2843
+ center,
2844
+ h(TextLine, {
2845
+ text: clip((pending || loading ? spin + " " : "") + status, width),
2846
+ width,
2847
+ color: error ? colors.red : notice ? colors.green : colors.muted,
2848
+ }),
2849
+ h(
2850
+ Box,
2851
+ {
2852
+ height: 3,
2853
+ borderStyle: colors.frame,
2854
+ borderColor: focus === "prompt" ? colors.blue : colors.line,
2855
+ paddingX: 1,
2856
+ overflow: "hidden",
2857
+ },
2858
+ h(Text, { color: colors.purple, bold: true }, "› "),
2859
+ focus === "prompt"
2860
+ ? h(InputText, {
2861
+ value: command,
2862
+ cursor: commandCursor,
2863
+ width: Math.max(1, width - 6),
2864
+ })
2865
+ : h(
2866
+ Text,
2867
+ { color: colors.muted },
2868
+ clip(
2869
+ "Search or /command · Press / to type · X actions · , appearance",
2870
+ width - 6,
2871
+ ),
2636
2872
  ),
2637
- ),
2873
+ ),
2874
+ h(TextLine, { text: hint, width, color: colors.muted }),
2638
2875
  ),
2639
- h(TextLine, { text: hint, width, color: colors.muted }),
2640
2876
  );
2641
2877
  }
2642
2878
  export async function startTui(flags = {}) {
@@ -2648,13 +2884,24 @@ export async function startTui(flags = {}) {
2648
2884
  if (flags.host && config && originOf(flags.host) !== config.origin)
2649
2885
  config = null;
2650
2886
  const root = await workspaceRoot(flags.project || process.cwd());
2651
- const app = render(h(App, { initialConfig: config, flags, root }), {
2652
- alternateScreen: true,
2653
- exitOnCtrlC: false,
2654
- patchConsole: true,
2655
- maxFps: 20,
2656
- incrementalRendering: false,
2657
- });
2887
+ const loaded = await loadAppearance();
2888
+ const initialAppearance = {
2889
+ ...loaded,
2890
+ settings: normalizeAppearance({
2891
+ ...loaded.settings,
2892
+ ...appearanceFlags(flags),
2893
+ }),
2894
+ };
2895
+ const app = render(
2896
+ h(App, { initialConfig: config, flags, root, initialAppearance }),
2897
+ {
2898
+ alternateScreen: true,
2899
+ exitOnCtrlC: false,
2900
+ patchConsole: true,
2901
+ maxFps: 20,
2902
+ incrementalRendering: false,
2903
+ },
2904
+ );
2658
2905
  try {
2659
2906
  await app.waitUntilExit();
2660
2907
  } finally {