@tenyokjs/devdevian-cli 0.4.0-alpha.1 → 0.5.0-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
@@ -29,11 +29,23 @@ import {
29
29
  loadPage,
30
30
  contentLines,
31
31
  assertSnippet,
32
+ notificationRoute,
32
33
  } from "./tui-data.mjs";
33
34
  import { basename } from "node:path";
34
- import { colors } from "./theme.mjs";
35
+ import { themeFor, ThemeContext, useTheme } from "./theme.mjs";
36
+ import {
37
+ appearanceFields,
38
+ defaultAppearance,
39
+ loadAppearance,
40
+ saveAppearance,
41
+ normalizeAppearance,
42
+ appearanceFlags,
43
+ } from "./appearance.mjs";
35
44
  import { liveLoop, anchoredIndex } from "./live.mjs";
36
45
  import { contextActions } from "./context-actions.mjs";
46
+ import { editExternal } from "./external-editor.mjs";
47
+ import { markdownRows } from "./rich-text.mjs";
48
+ import { problemMessage } from "./problems.mjs";
37
49
  import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
38
50
  const h = React.createElement;
39
51
  const itemsOf = (value) => (Array.isArray(value) ? value : value?.items || []);
@@ -79,6 +91,7 @@ function openBrowser(url) {
79
91
  child.unref();
80
92
  }
81
93
  function fieldsFor(form) {
94
+ if (form.kind === "appearance") return appearanceFields;
82
95
  if (form.kind === "publication")
83
96
  return [
84
97
  {
@@ -186,25 +199,49 @@ function fieldsFor(form) {
186
199
  },
187
200
  ];
188
201
  }
189
- function TextLine({ text, width, color = colors.text, bold = false }) {
190
- return h(Text, { color, bold, wrap: "truncate" }, clip(text, width));
202
+ function TextLine({ text, width, color, bold = false }) {
203
+ const colors = useTheme();
204
+ return h(
205
+ Text,
206
+ { color: color || colors.text, bold, wrap: "truncate" },
207
+ clip(text, width),
208
+ );
191
209
  }
192
210
  function FrameRows({ rows, width, height, offset = 0 }) {
211
+ const colors = useTheme();
193
212
  return h(
194
213
  Box,
195
214
  { flexDirection: "column", height, overflow: "hidden" },
196
215
  ...rows.slice(offset, offset + height).map((row, i) =>
197
- h(TextLine, {
198
- key: i,
199
- text: typeof row === "string" ? row : row.text,
200
- width,
201
- color: row.color || colors.text,
202
- bold: row.bold || false,
203
- }),
216
+ row.spans
217
+ ? h(
218
+ Text,
219
+ {
220
+ key: i,
221
+ color: row.color || colors.text,
222
+ bold: row.bold,
223
+ wrap: "truncate",
224
+ },
225
+ ...row.spans.map((span, n) =>
226
+ h(
227
+ Text,
228
+ { key: n, color: span.color, bold: span.bold || row.bold },
229
+ span.text,
230
+ ),
231
+ ),
232
+ )
233
+ : h(TextLine, {
234
+ key: i,
235
+ text: typeof row === "string" ? row : row.text,
236
+ width,
237
+ color: row.color || colors.text,
238
+ bold: row.bold || false,
239
+ }),
204
240
  ),
205
241
  );
206
242
  }
207
243
  function InputText({ value, cursor, width, height = 1 }) {
244
+ const colors = useTheme();
208
245
  const result = cursorLines(value, cursor, width),
209
246
  start = Math.max(0, result.line - height + 1);
210
247
  return h(
@@ -223,6 +260,7 @@ function InputText({ value, cursor, width, height = 1 }) {
223
260
  );
224
261
  }
225
262
  function FormView({ form, active, cursor, width, height }) {
263
+ const colors = useTheme();
226
264
  const fields = fieldsFor(form),
227
265
  rows = [];
228
266
  let selectedLine = 0;
@@ -274,14 +312,19 @@ function FormView({ form, active, cursor, width, height }) {
274
312
  rows.push({
275
313
  text:
276
314
  (active === fields.length ? "› " : " ") +
277
- "Preview publication [Ctrl+S]",
315
+ (form.kind === "appearance"
316
+ ? "Save appearance [Ctrl+S]"
317
+ : "Preview publication [Ctrl+S]"),
278
318
  color: colors.blue,
279
319
  bold: true,
280
320
  });
281
- const offset = Math.max(0, Math.min(selectedLine - 1, rows.length - height));
321
+ const offset = Math.max(
322
+ 0,
323
+ Math.min(selectedLine - (height >= 4 ? 1 : 0), rows.length - height),
324
+ );
282
325
  return h(FrameRows, { rows, width, height, offset });
283
326
  }
284
- function listRows(route, data, index, width) {
327
+ function listRows(route, data, index, width, colors, density = "comfortable") {
285
328
  const items = data.items || [];
286
329
  return items.flatMap((item, i) => {
287
330
  let title = item.title || item.name || "",
@@ -367,13 +410,113 @@ function listRows(route, data, index, width) {
367
410
  color: selected ? colors.text : colors.muted,
368
411
  selected,
369
412
  },
370
- { text: " " + "─".repeat(Math.max(0, width - 4)), color: colors.line },
413
+ ...(density === "compact"
414
+ ? []
415
+ : [
416
+ {
417
+ text: " " + "─".repeat(Math.max(0, width - 4)),
418
+ color: colors.line,
419
+ },
420
+ ]),
371
421
  ];
372
422
  });
373
423
  }
374
- function App({ initialConfig, flags, root }) {
375
- const { exit } = useApp(),
424
+ function documentRows(route, data, width, colors) {
425
+ if (route.view !== "post" || (route.tab && route.tab !== "overview"))
426
+ return contentLines(route, data).flatMap((line) => wrap(line, width));
427
+ const post = data?.post;
428
+ if (!post) return [];
429
+ const rows = markdownRows(post.body || "", width, colors);
430
+ if (post.review_focus)
431
+ rows.push(
432
+ { text: "" },
433
+ { text: "REVIEW FOCUS", color: colors.purple, bold: true },
434
+ ...markdownRows(post.review_focus, width, colors),
435
+ );
436
+ if (post.code) {
437
+ // Pick a fence longer than any backtick run in the snippet.
438
+ const fence = "`".repeat(
439
+ Math.max(
440
+ 3,
441
+ ...(post.code.match(/`+/g) || []).map((run) => run.length + 1),
442
+ ),
443
+ );
444
+ rows.push(
445
+ { text: "" },
446
+ ...markdownRows(fence + "\n" + post.code + "\n" + fence, width, colors),
447
+ );
448
+ }
449
+ if (post.links?.length)
450
+ rows.push(
451
+ { text: "LINKS", bold: true },
452
+ ...post.links.flatMap((link) =>
453
+ wrap(link.label + " · " + link.url, width),
454
+ ),
455
+ );
456
+ if (post.archived_at || post.locked_at)
457
+ rows.push({
458
+ text: "Replies are paused for this conversation.",
459
+ color: colors.muted,
460
+ });
461
+ return rows;
462
+ }
463
+ function AppearanceView({ form, active, cursor, width, height }) {
464
+ const c = useTheme(),
465
+ wide = width >= 100;
466
+ return h(
467
+ Box,
468
+ { height, overflow: "hidden", gap: wide ? 2 : 0 },
469
+ h(
470
+ Box,
471
+ { width: wide ? width - 36 : width, flexDirection: "column" },
472
+ h(FormView, {
473
+ form,
474
+ active,
475
+ cursor,
476
+ width: wide ? width - 36 : width,
477
+ height,
478
+ }),
479
+ ),
480
+ wide
481
+ ? h(
482
+ Box,
483
+ {
484
+ width: 34,
485
+ height,
486
+ borderStyle: c.frame,
487
+ borderColor: c.line,
488
+ paddingX: 1,
489
+ flexDirection: "column",
490
+ overflow: "hidden",
491
+ },
492
+ h(Text, { color: c.purple, bold: true }, "LIVE PREVIEW"),
493
+ h(Text, { color: c.muted }, "Only this terminal changes."),
494
+ h(Text, null, " "),
495
+ h(
496
+ Text,
497
+ { color: c.text, bold: true },
498
+ "A good conversation starts here",
499
+ ),
500
+ h(
501
+ Text,
502
+ { color: c.blue, backgroundColor: c.selected },
503
+ "▸ Your selected conversation",
504
+ ),
505
+ h(Text, { color: c.muted }, "Ada · code review · just now"),
506
+ h(Text, { color: c.green }, "+ ship with confidence"),
507
+ h(Text, { color: c.red }, "− leave the guesswork behind"),
508
+ h(Text, null, " "),
509
+ h(Text, { color: c.purple }, "← → change · Tab next"),
510
+ h(Text, { color: c.text }, "Ctrl+S save · Esc cancel"),
511
+ h(Text, { color: c.muted }, "0 restores the default preview"),
512
+ )
513
+ : null,
514
+ );
515
+ }
516
+ function App({ initialConfig, flags, root, initialAppearance }) {
517
+ const { exit, suspendTerminal } = useApp(),
376
518
  { columns, rows } = useWindowSize();
519
+ const [appearance, setAppearance] = useState(initialAppearance.settings);
377
520
  const [config, setConfig] = useState(initialConfig),
378
521
  [phase, setPhase] = useState(
379
522
  initialConfig && !flags.initialLogin ? "checking" : "auth",
@@ -392,7 +535,7 @@ function App({ initialConfig, flags, root }) {
392
535
  [command, setCommand] = useState(""),
393
536
  [commandCursor, setCommandCursor] = useState(0),
394
537
  [error, setError] = useState(""),
395
- [notice, setNotice] = useState("");
538
+ [notice, setNotice] = useState(initialAppearance.warning || "");
396
539
  const [form, setForm] = useState(null),
397
540
  [field, setField] = useState(0),
398
541
  [cursor, setCursor] = useState(0),
@@ -417,6 +560,8 @@ function App({ initialConfig, flags, root }) {
417
560
  notificationGeneration = useRef(0);
418
561
  const cache = useRef(new PageCache()),
419
562
  history = useRef([]),
563
+ restoreSelection = useRef(null),
564
+ seenSnippet = useRef(null),
420
565
  busy = useRef(false),
421
566
  alive = useRef(true),
422
567
  authController = useRef(null),
@@ -434,9 +579,13 @@ function App({ initialConfig, flags, root }) {
434
579
  () => (config ? client(config.origin, config.token) : null),
435
580
  [config?.origin, config?.token],
436
581
  );
582
+ const effectiveAppearance =
583
+ form?.kind === "appearance" ? normalizeAppearance(form.values) : appearance;
584
+ const colors = themeFor(effectiveAppearance),
585
+ stride = effectiveAppearance.density === "compact" ? 3 : 4;
437
586
  const width = Math.max(1, columns),
438
587
  height = Math.max(1, rows - 1),
439
- compact = width < 90,
588
+ compact = width < 90 || effectiveAppearance.sidebar === "hidden",
440
589
  sidebar = compact ? 0 : 25,
441
590
  bodyHeight = Math.max(1, height - 9),
442
591
  contentWidth = Math.max(1, width - sidebar - 6),
@@ -522,9 +671,18 @@ function App({ initialConfig, flags, root }) {
522
671
  if (current?.unread_count !== undefined) setUnread(current.unread_count);
523
672
  }, [current]);
524
673
  useEffect(() => {
525
- setReviewLine(0);
526
- setReviewAnchor(null);
527
- setReviewHorizontal(0);
674
+ const post = current?.post;
675
+ if (!post?.code) return;
676
+ if (
677
+ seenSnippet.current?.id === post.id &&
678
+ seenSnippet.current.code !== post.code
679
+ ) {
680
+ setReviewLine(0);
681
+ setReviewAnchor(null);
682
+ setReviewHorizontal(0);
683
+ } else
684
+ setReviewLine((line) => Math.min(line, post.code.split("\n").length - 1));
685
+ seenSnippet.current = { id: post.id, code: post.code };
528
686
  }, [current?.post?.id, current?.post?.code]);
529
687
  const resetAccount = () => {
530
688
  cache.current.clear();
@@ -535,24 +693,55 @@ function App({ initialConfig, flags, root }) {
535
693
  setPreview(null);
536
694
  setDevice(null);
537
695
  setUser(null);
696
+ setReading(null);
697
+ setActionIndex(null);
698
+ setUnread(0);
699
+ setLiveStatus("");
700
+ notificationGeneration.current++;
538
701
  setRoute({ view: "feed", page: 1 });
539
702
  setError("");
540
703
  };
541
704
  function fail(e) {
542
705
  if (!alive.current) return;
543
- if (e.status === 401) {
706
+ if (e.status === 401 && form) {
707
+ setLive(false);
708
+ setError(problemMessage(e));
709
+ } else if (e.status === 401) {
544
710
  resetAccount();
545
711
  setPhase("auth");
546
712
  setConfig(null);
547
713
  setError(
548
714
  "Your session expired or was revoked. Press Enter to reconnect.",
549
715
  );
550
- } else setError(clean(e.message));
716
+ } else setError(problemMessage(e));
551
717
  }
552
718
  function navigate(next, { replace = false } = {}) {
553
- if (!replace) history.current.push({ route, index, scroll });
719
+ if (!replace)
720
+ history.current.push({
721
+ route,
722
+ index,
723
+ scroll,
724
+ selectedId: selected?.id,
725
+ entryType: selected?.entryType,
726
+ reviewFocus,
727
+ reviewLine,
728
+ reviewAnchor,
729
+ reviewHorizontal,
730
+ });
731
+ restoreSelection.current = {
732
+ key: JSON.stringify(next),
733
+ selectedId: next.targetReply,
734
+ entryType:
735
+ next.view === "feedback" && next.targetReply
736
+ ? "feedbackReply"
737
+ : undefined,
738
+ index: 0,
739
+ };
554
740
  if (history.current.length > 30) history.current.shift();
555
741
  setReading(null);
742
+ setReviewLine(0);
743
+ setReviewAnchor(null);
744
+ setReviewHorizontal(0);
556
745
  if (next.view === "feedback") setReviewFocus("notes");
557
746
  setRoute(next);
558
747
  setIndex(0);
@@ -564,6 +753,15 @@ function App({ initialConfig, flags, root }) {
564
753
  function back() {
565
754
  const previous = history.current.pop();
566
755
  if (previous) {
756
+ restoreSelection.current = {
757
+ ...previous,
758
+ key: JSON.stringify(previous.route),
759
+ };
760
+ setReading(null);
761
+ setReviewFocus(previous.reviewFocus);
762
+ setReviewLine(previous.reviewLine);
763
+ setReviewAnchor(previous.reviewAnchor);
764
+ setReviewHorizontal(previous.reviewHorizontal);
567
765
  setRoute(previous.route);
568
766
  setIndex(previous.index);
569
767
  setScroll(previous.scroll);
@@ -675,10 +873,14 @@ function App({ initialConfig, flags, root }) {
675
873
  };
676
874
  }, []);
677
875
  useEffect(() => {
678
- if (!loading && !pending && phase !== "pending") return;
876
+ if (
877
+ effectiveAppearance.motion === "reduced" ||
878
+ (!loading && !pending && phase !== "pending")
879
+ )
880
+ return;
679
881
  const timer = setInterval(() => setTick((v) => (v + 1) % 4), 180);
680
882
  return () => clearInterval(timer);
681
- }, [loading, pending, phase]);
883
+ }, [loading, pending, phase, effectiveAppearance.motion]);
682
884
  useEffect(() => {
683
885
  if (phase !== "checking" || !api) return;
684
886
  const controller = new AbortController();
@@ -704,6 +906,23 @@ function App({ initialConfig, flags, root }) {
704
906
  cached = cache.current.get(key);
705
907
  setData(cached ? { ...cached, key } : null);
706
908
  setLoading(!cached);
909
+ if (cached && restoreSelection.current?.key === key) {
910
+ const saved = restoreSelection.current;
911
+ const found = cached.items?.findIndex(
912
+ (item) =>
913
+ item.id === saved.selectedId &&
914
+ (saved.entryType === undefined || item.entryType === saved.entryType),
915
+ );
916
+ setIndex(
917
+ found >= 0
918
+ ? found
919
+ : Math.min(
920
+ saved.index || 0,
921
+ Math.max(0, (cached.items?.length || 0) - 1),
922
+ ),
923
+ );
924
+ restoreSelection.current = null;
925
+ }
707
926
  setError("");
708
927
  if (!cached)
709
928
  (route.view === "drafts"
@@ -719,9 +938,27 @@ function App({ initialConfig, flags, root }) {
719
938
  if (!active) return;
720
939
  cache.current.set(key, result);
721
940
  setData({ ...result, key });
722
- setIndex((i) =>
723
- Math.min(i, Math.max(0, (result.items?.length || 0) - 1)),
724
- );
941
+ const saved = restoreSelection.current;
942
+ if (saved?.key === key) {
943
+ const found = result.items?.findIndex(
944
+ (item) =>
945
+ item.id === saved.selectedId &&
946
+ (saved.entryType === undefined ||
947
+ item.entryType === saved.entryType),
948
+ );
949
+ setIndex(
950
+ found >= 0
951
+ ? found
952
+ : Math.min(
953
+ saved.index || 0,
954
+ Math.max(0, (result.items?.length || 0) - 1),
955
+ ),
956
+ );
957
+ restoreSelection.current = null;
958
+ } else
959
+ setIndex((i) =>
960
+ Math.min(i, Math.max(0, (result.items?.length || 0) - 1)),
961
+ );
725
962
  })
726
963
  .catch((e) => {
727
964
  if (active) fail(e);
@@ -798,6 +1035,7 @@ function App({ initialConfig, flags, root }) {
798
1035
  async function chooseNav(i) {
799
1036
  const name = navigation[i]?.[0];
800
1037
  setNavIndex(i);
1038
+ if (name === "appearance") return openAppearance();
801
1039
  if (name === "ask" || name === "review")
802
1040
  return composePublication(name === "review" ? "review" : "question");
803
1041
  if (name === "open")
@@ -813,6 +1051,16 @@ function App({ initialConfig, flags, root }) {
813
1051
  setCommandCursor(0);
814
1052
  }
815
1053
  }
1054
+ function openAppearance() {
1055
+ openForm({
1056
+ kind: "appearance",
1057
+ title: "Appearance",
1058
+ values: { ...appearance },
1059
+ });
1060
+ setNotice(
1061
+ "Live preview · ← → change · Ctrl+S save · Esc cancel · 0 defaults",
1062
+ );
1063
+ }
816
1064
  function openForm(next) {
817
1065
  setForm({
818
1066
  ...next,
@@ -890,6 +1138,46 @@ function App({ initialConfig, flags, root }) {
890
1138
  values: { body: "" },
891
1139
  });
892
1140
  }
1141
+ async function externalEdit() {
1142
+ const descriptor = fieldsFor(form)[field];
1143
+ if (!descriptor?.multiline) {
1144
+ setNotice("Ctrl+E opens the editor for a multiline text field.");
1145
+ return;
1146
+ }
1147
+ const original = form;
1148
+ await task(async () => {
1149
+ clearTimeout(draftTimer.current);
1150
+ await queueDraft(() => drafts.save(original));
1151
+ let value;
1152
+ await suspendTerminal(async () => {
1153
+ value = await editExternal(original.values[descriptor.key] || "", {
1154
+ max: descriptor.max,
1155
+ });
1156
+ });
1157
+ const updated = {
1158
+ ...original,
1159
+ values: { ...original.values, [descriptor.key]: value },
1160
+ };
1161
+ setForm(updated);
1162
+ setCursor(graphemes(value).length);
1163
+ await queueDraft(() => drafts.save(updated));
1164
+ setDraftSaved(true);
1165
+ setNotice("Text returned from editor. Preview before publishing.");
1166
+ });
1167
+ }
1168
+ async function reconnect() {
1169
+ await task(async () => {
1170
+ clearTimeout(draftTimer.current);
1171
+ draftGeneration.current++;
1172
+ if (form && drafts) await queueDraft(() => drafts.save(form));
1173
+ resetAccount();
1174
+ setConfig(null);
1175
+ setPhase("auth");
1176
+ setNotice(
1177
+ "Draft saved locally. Enter reconnects; restore it from Local drafts after signing in.",
1178
+ );
1179
+ });
1180
+ }
893
1181
  async function lineFeedback() {
894
1182
  const p = current?.post;
895
1183
  if (!p?.code) {
@@ -937,6 +1225,18 @@ function App({ initialConfig, flags, root }) {
937
1225
  }
938
1226
  async function prepare() {
939
1227
  if (!form) return;
1228
+ if (form.kind === "appearance") {
1229
+ await task(async () => {
1230
+ const saved = await saveAppearance(form.values);
1231
+ setAppearance(saved);
1232
+ setForm(null);
1233
+ setFocus("content");
1234
+ setNotice(
1235
+ "Appearance saved locally. Your account and other devices are unchanged.",
1236
+ );
1237
+ });
1238
+ return;
1239
+ }
940
1240
  await task(async () => {
941
1241
  if (
942
1242
  form.unavailableAudience &&
@@ -1138,21 +1438,7 @@ function App({ initialConfig, flags, root }) {
1138
1438
  setNotice("Event opened in your browser.");
1139
1439
  return;
1140
1440
  }
1141
- if (item.code_review_id)
1142
- navigate({
1143
- view: "feedback",
1144
- postId: item.post_id,
1145
- id: item.code_review_id,
1146
- page: 1,
1147
- targetReply: item.feedback_reply_id || null,
1148
- });
1149
- else
1150
- navigate({
1151
- view: "post",
1152
- id: item.post_id,
1153
- tab: item.reply_id ? "replies" : "overview",
1154
- page: 1,
1155
- });
1441
+ navigate(await notificationRoute(api, item));
1156
1442
  });
1157
1443
  return;
1158
1444
  }
@@ -1399,6 +1685,10 @@ function App({ initialConfig, flags, root }) {
1399
1685
  );
1400
1686
  return;
1401
1687
  }
1688
+ if (parsed.command === "appearance") {
1689
+ openAppearance();
1690
+ return;
1691
+ }
1402
1692
  if (parsed.command === "open") {
1403
1693
  navigate({ view: "post", id: parsed.arg, tab: "overview", page: 1 });
1404
1694
  return;
@@ -1419,6 +1709,10 @@ function App({ initialConfig, flags, root }) {
1419
1709
  }
1420
1710
  }
1421
1711
  function askQuit() {
1712
+ if (form?.kind === "appearance") {
1713
+ exit();
1714
+ return;
1715
+ }
1422
1716
  if (form) {
1423
1717
  askConfirm(
1424
1718
  "Leave this draft?",
@@ -1486,6 +1780,10 @@ function App({ initialConfig, flags, root }) {
1486
1780
  return;
1487
1781
  }
1488
1782
  if (busy.current) return;
1783
+ if (keyEvent.ctrl && input === "l") {
1784
+ reconnect();
1785
+ return;
1786
+ }
1489
1787
  if (actionIndex !== null) {
1490
1788
  if (keyEvent.escape || input === "x" || input === "X")
1491
1789
  setActionIndex(null);
@@ -1496,11 +1794,12 @@ function App({ initialConfig, flags, root }) {
1496
1794
  return;
1497
1795
  }
1498
1796
  if (reading) {
1499
- const lines = wrap(
1797
+ const lines = markdownRows(
1500
1798
  reading.deleted_at
1501
1799
  ? "[deleted — discussion preserved]"
1502
1800
  : reading.body || "",
1503
1801
  contentWidth,
1802
+ colors,
1504
1803
  ),
1505
1804
  maximum = Math.max(0, lines.length - contentHeight);
1506
1805
  if (keyEvent.escape || keyEvent.return || input === " ") {
@@ -1549,6 +1848,21 @@ function App({ initialConfig, flags, root }) {
1549
1848
  return;
1550
1849
  }
1551
1850
  if (form) {
1851
+ if (form.kind === "appearance" && keyEvent.escape) {
1852
+ setForm(null);
1853
+ setFocus("content");
1854
+ setNotice("Appearance preview cancelled. Saved preferences kept.");
1855
+ return;
1856
+ }
1857
+ if (form.kind === "appearance" && input === "0") {
1858
+ setForm((old) => ({ ...old, values: { ...defaultAppearance } }));
1859
+ setNotice("Default preview restored. Ctrl+S saves; Esc cancels.");
1860
+ return;
1861
+ }
1862
+ if (keyEvent.ctrl && input === "e") {
1863
+ externalEdit();
1864
+ return;
1865
+ }
1552
1866
  if (
1553
1867
  keyEvent.ctrl &&
1554
1868
  ["o", "g"].includes(input) &&
@@ -1595,20 +1909,20 @@ function App({ initialConfig, flags, root }) {
1595
1909
  keyEvent.downArrow
1596
1910
  ) {
1597
1911
  const opts = active.options,
1598
- i = Math.max(
1912
+ step = keyEvent.leftArrow || keyEvent.upArrow ? -1 : 1;
1913
+ setForm((old) => {
1914
+ const i = Math.max(
1599
1915
  0,
1600
1916
  opts.findIndex(
1601
1917
  (item) =>
1602
- String(item[0]) === String(form.values[active.key] || ""),
1918
+ String(item[0]) === String(old.values[active.key] || ""),
1603
1919
  ),
1604
- ),
1605
- step = keyEvent.leftArrow || keyEvent.upArrow ? -1 : 1,
1606
- next = opts[(i + step + opts.length) % opts.length];
1607
- if (next)
1608
- setForm((old) => ({
1609
- ...old,
1610
- values: { ...old.values, [active.key]: next[0] },
1611
- }));
1920
+ );
1921
+ const next = opts[(i + step + opts.length) % opts.length];
1922
+ return next
1923
+ ? { ...old, values: { ...old.values, [active.key]: next[0] } }
1924
+ : old;
1925
+ });
1612
1926
  } else if (keyEvent.return) changeField(field + 1);
1613
1927
  return;
1614
1928
  }
@@ -1682,6 +1996,10 @@ function App({ initialConfig, flags, root }) {
1682
1996
  else if (keyEvent.rightArrow) setFocus("content");
1683
1997
  return;
1684
1998
  }
1999
+ if (input === ",") {
2000
+ openAppearance();
2001
+ return;
2002
+ }
1685
2003
  if (input === "x" || input === "X" || input === "?") {
1686
2004
  setActionIndex(0);
1687
2005
  return;
@@ -1768,15 +2086,14 @@ function App({ initialConfig, flags, root }) {
1768
2086
  ) {
1769
2087
  const step =
1770
2088
  keyEvent.pageUp || keyEvent.pageDown
1771
- ? Math.max(1, Math.floor(contentHeight / 4))
2089
+ ? Math.max(1, Math.floor(contentHeight / stride))
1772
2090
  : 1,
1773
2091
  delta = keyEvent.upArrow || keyEvent.pageUp ? -step : step;
1774
2092
  if (prose) {
1775
2093
  const maximum = Math.max(
1776
2094
  0,
1777
- contentLines(route, current || {}).flatMap((line) =>
1778
- wrap(line, contentWidth),
1779
- ).length - contentHeight,
2095
+ documentRows(route, current || {}, contentWidth, colors).length -
2096
+ contentHeight,
1780
2097
  );
1781
2098
  const amount =
1782
2099
  keyEvent.pageUp || keyEvent.pageDown
@@ -1900,7 +2217,8 @@ function App({ initialConfig, flags, root }) {
1900
2217
  : loading
1901
2218
  ? "Loading…"
1902
2219
  : "Shared account · shared conversations · same permissions");
1903
- const spin = ["◐", "◓", "◑", "◒"][tick];
2220
+ const spin =
2221
+ effectiveAppearance.motion === "reduced" ? "·" : ["◐", "◓", "◑", "◒"][tick];
1904
2222
  let center;
1905
2223
  if (width < 60 || rows < 18)
1906
2224
  center = h(
@@ -1964,7 +2282,7 @@ function App({ initialConfig, flags, root }) {
1964
2282
  center = h(
1965
2283
  Box,
1966
2284
  {
1967
- borderStyle: "round",
2285
+ borderStyle: colors.frame,
1968
2286
  borderColor: colors.purple,
1969
2287
  flexDirection: "column",
1970
2288
  paddingX: 2,
@@ -1983,7 +2301,7 @@ function App({ initialConfig, flags, root }) {
1983
2301
  center = h(
1984
2302
  Box,
1985
2303
  {
1986
- borderStyle: "round",
2304
+ borderStyle: colors.frame,
1987
2305
  borderColor: colors.purple,
1988
2306
  flexDirection: "column",
1989
2307
  paddingX: 2,
@@ -2018,11 +2336,12 @@ function App({ initialConfig, flags, root }) {
2018
2336
  } else {
2019
2337
  let body;
2020
2338
  if (reading) {
2021
- const lines = wrap(
2339
+ const lines = markdownRows(
2022
2340
  reading.deleted_at
2023
2341
  ? "[deleted — discussion preserved]"
2024
2342
  : reading.body || "",
2025
2343
  contentWidth,
2344
+ colors,
2026
2345
  );
2027
2346
  body = h(FrameRows, {
2028
2347
  rows: lines,
@@ -2079,7 +2398,7 @@ function App({ initialConfig, flags, root }) {
2079
2398
  ),
2080
2399
  );
2081
2400
  } else if (form)
2082
- body = h(FormView, {
2401
+ body = h(form.kind === "appearance" ? AppearanceView : FormView, {
2083
2402
  form,
2084
2403
  active: field,
2085
2404
  cursor,
@@ -2145,12 +2464,35 @@ function App({ initialConfig, flags, root }) {
2145
2464
  line: reviewLine,
2146
2465
  anchor: reviewAnchor,
2147
2466
  horizontal: reviewHorizontal,
2148
- rows: (w) => listRows(route, current, index, w),
2467
+ layout: effectiveAppearance.reviewLayout,
2468
+ stride,
2469
+ rows: (w) =>
2470
+ listRows(
2471
+ route,
2472
+ current,
2473
+ index,
2474
+ w,
2475
+ colors,
2476
+ effectiveAppearance.density,
2477
+ ),
2478
+ });
2479
+ else if (error && !current)
2480
+ body = h(FrameRows, {
2481
+ rows: [
2482
+ { text: "Could not open this view", bold: true, color: colors.red },
2483
+ "",
2484
+ ...wrap(error, contentWidth),
2485
+ "",
2486
+ ...wrap(
2487
+ "Shift+R retries this view · Esc goes back · Ctrl+L reconnects",
2488
+ contentWidth,
2489
+ ),
2490
+ ],
2491
+ width: contentWidth,
2492
+ height: contentHeight,
2149
2493
  });
2150
2494
  else {
2151
- const lines = contentLines(route, current || {}).flatMap((line) =>
2152
- wrap(line, contentWidth),
2153
- );
2495
+ const lines = documentRows(route, current || {}, contentWidth, colors);
2154
2496
  if (lines.length)
2155
2497
  body = h(FrameRows, {
2156
2498
  rows: lines,
@@ -2182,12 +2524,16 @@ function App({ initialConfig, flags, root }) {
2182
2524
  ),
2183
2525
  );
2184
2526
  else {
2185
- const visible = Math.max(1, Math.floor(contentHeight / 4)),
2527
+ const visible = Math.max(1, Math.floor(contentHeight / stride)),
2186
2528
  first = Math.max(0, index - visible + 1),
2187
- rows = listRows(route, current, index, contentWidth).slice(
2188
- first * 4,
2189
- first * 4 + contentHeight,
2190
- );
2529
+ rows = listRows(
2530
+ route,
2531
+ current,
2532
+ index,
2533
+ contentWidth,
2534
+ colors,
2535
+ effectiveAppearance.density,
2536
+ ).slice(first * stride, first * stride + contentHeight);
2191
2537
  body = h(
2192
2538
  Box,
2193
2539
  {
@@ -2227,7 +2573,7 @@ function App({ initialConfig, flags, root }) {
2227
2573
  const pane = h(
2228
2574
  Box,
2229
2575
  {
2230
- borderStyle: "round",
2576
+ borderStyle: colors.frame,
2231
2577
  borderColor: focus === "nav" && !compact ? colors.line : colors.purple,
2232
2578
  backgroundColor: colors.panel,
2233
2579
  flexDirection: "column",
@@ -2278,7 +2624,9 @@ function App({ initialConfig, flags, root }) {
2278
2624
  )
2279
2625
  : h(TextLine, {
2280
2626
  text: form
2281
- ? "Tab next field · Ctrl+S preview" +
2627
+ ? (form.kind === "appearance"
2628
+ ? "Local preferences · Preview now · Ctrl+S save · Esc cancel"
2629
+ : "Tab next field · Ctrl+E editor · Ctrl+S preview") +
2282
2630
  (draftSaved ? " · Draft saved locally" : "")
2283
2631
  : preview
2284
2632
  ? "Scroll to review · ← → choose · Enter confirms"
@@ -2301,7 +2649,7 @@ function App({ initialConfig, flags, root }) {
2301
2649
  ? h(
2302
2650
  Box,
2303
2651
  {
2304
- borderStyle: "round",
2652
+ borderStyle: colors.frame,
2305
2653
  borderColor: focus === "nav" ? colors.blue : colors.line,
2306
2654
  backgroundColor: colors.panel,
2307
2655
  flexDirection: "column",
@@ -2354,97 +2702,103 @@ function App({ initialConfig, flags, root }) {
2354
2702
  );
2355
2703
  }
2356
2704
  const hint =
2357
- actionIndex !== null
2358
- ? "Actions are scoped to the selected item · Esc closes"
2359
- : reviewing && !form && !preview && !reading
2360
- ? "W code / discussion · Space select · C comment · X actions · F filter"
2361
- : picker
2362
- ? "Project files stay local until publication is confirmed"
2363
- : reading
2364
- ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2365
- : preview
2366
- ? "← → choose · Enter confirm · Esc edit"
2367
- : form
2368
- ? "Tab field · Ctrl+O files · Ctrl+G diff · Ctrl+S preview · Esc discard"
2369
- : phase !== "ready"
2370
- ? "Enter connect / open browser · Ctrl+C exit"
2371
- : route.view === "feedback"
2372
- ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2373
- : route.view === "post"
2374
- ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2375
- : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2705
+ form?.kind === "appearance"
2706
+ ? "← → choose · Tab next · Ctrl+S save · Esc cancel · 0 default preview"
2707
+ : actionIndex !== null
2708
+ ? "Actions are scoped to the selected item · Esc closes"
2709
+ : reviewing && !form && !preview && !reading
2710
+ ? "W code / discussion · Space select · C comment · X actions · F filter"
2711
+ : picker
2712
+ ? "Project files stay local until publication is confirmed"
2713
+ : reading
2714
+ ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2715
+ : preview
2716
+ ? "← → choose · Enter confirm · Esc edit"
2717
+ : form
2718
+ ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
2719
+ : phase !== "ready"
2720
+ ? "Enter connect / open browser · Ctrl+C exit"
2721
+ : route.view === "feedback"
2722
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2723
+ : route.view === "post"
2724
+ ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2725
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2376
2726
  return h(
2377
- Box,
2378
- {
2379
- flexDirection: "column",
2380
- width,
2381
- height,
2382
- overflow: "hidden",
2383
- backgroundColor: colors.bg,
2384
- },
2727
+ ThemeContext.Provider,
2728
+ { value: colors },
2385
2729
  h(
2386
2730
  Box,
2387
2731
  {
2388
- height: 3,
2389
- borderStyle: "round",
2390
- borderColor: colors.line,
2391
- paddingX: 1,
2392
- justifyContent: "space-between",
2732
+ flexDirection: "column",
2733
+ width,
2734
+ height,
2393
2735
  overflow: "hidden",
2736
+ backgroundColor: colors.bg,
2394
2737
  },
2395
2738
  h(
2396
- Text,
2397
- { color: colors.purple, bold: true },
2398
- clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2399
- ),
2400
- h(
2401
- Text,
2402
- { color: colors.blue },
2403
- clip(
2404
- user
2405
- ? "@" +
2406
- user.username +
2407
- " · " +
2408
- unread +
2409
- " unread · " +
2410
- (live ? "● Live" : "○ Paused")
2411
- : "CLI · ALPHA",
2412
- 32,
2739
+ Box,
2740
+ {
2741
+ height: 3,
2742
+ borderStyle: colors.frame,
2743
+ borderColor: colors.line,
2744
+ paddingX: 1,
2745
+ justifyContent: "space-between",
2746
+ overflow: "hidden",
2747
+ },
2748
+ h(
2749
+ Text,
2750
+ { color: colors.purple, bold: true },
2751
+ clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2752
+ ),
2753
+ h(
2754
+ Text,
2755
+ { color: colors.blue },
2756
+ clip(
2757
+ user
2758
+ ? "@" +
2759
+ user.username +
2760
+ " · " +
2761
+ unread +
2762
+ " unread · " +
2763
+ (live ? "● Live" : "○ Paused")
2764
+ : "CLI · ALPHA",
2765
+ 32,
2766
+ ),
2413
2767
  ),
2414
2768
  ),
2415
- ),
2416
- center,
2417
- h(TextLine, {
2418
- text: clip((pending || loading ? spin + " " : "") + status, width),
2419
- width,
2420
- color: error ? colors.red : notice ? colors.green : colors.muted,
2421
- }),
2422
- h(
2423
- Box,
2424
- {
2425
- height: 3,
2426
- borderStyle: "round",
2427
- borderColor: focus === "prompt" ? colors.blue : colors.line,
2428
- paddingX: 1,
2429
- overflow: "hidden",
2430
- },
2431
- h(Text, { color: colors.purple, bold: true }, "› "),
2432
- focus === "prompt"
2433
- ? h(InputText, {
2434
- value: command,
2435
- cursor: commandCursor,
2436
- width: Math.max(1, width - 6),
2437
- })
2438
- : h(
2439
- Text,
2440
- { color: colors.muted },
2441
- clip(
2442
- "Search or /command · Press / to type · X actions",
2443
- width - 6,
2769
+ center,
2770
+ h(TextLine, {
2771
+ text: clip((pending || loading ? spin + " " : "") + status, width),
2772
+ width,
2773
+ color: error ? colors.red : notice ? colors.green : colors.muted,
2774
+ }),
2775
+ h(
2776
+ Box,
2777
+ {
2778
+ height: 3,
2779
+ borderStyle: colors.frame,
2780
+ borderColor: focus === "prompt" ? colors.blue : colors.line,
2781
+ paddingX: 1,
2782
+ overflow: "hidden",
2783
+ },
2784
+ h(Text, { color: colors.purple, bold: true }, "› "),
2785
+ focus === "prompt"
2786
+ ? h(InputText, {
2787
+ value: command,
2788
+ cursor: commandCursor,
2789
+ width: Math.max(1, width - 6),
2790
+ })
2791
+ : h(
2792
+ Text,
2793
+ { color: colors.muted },
2794
+ clip(
2795
+ "Search or /command · Press / to type · X actions · , appearance",
2796
+ width - 6,
2797
+ ),
2444
2798
  ),
2445
- ),
2799
+ ),
2800
+ h(TextLine, { text: hint, width, color: colors.muted }),
2446
2801
  ),
2447
- h(TextLine, { text: hint, width, color: colors.muted }),
2448
2802
  );
2449
2803
  }
2450
2804
  export async function startTui(flags = {}) {
@@ -2456,13 +2810,24 @@ export async function startTui(flags = {}) {
2456
2810
  if (flags.host && config && originOf(flags.host) !== config.origin)
2457
2811
  config = null;
2458
2812
  const root = await workspaceRoot(flags.project || process.cwd());
2459
- const app = render(h(App, { initialConfig: config, flags, root }), {
2460
- alternateScreen: true,
2461
- exitOnCtrlC: false,
2462
- patchConsole: true,
2463
- maxFps: 20,
2464
- incrementalRendering: false,
2465
- });
2813
+ const loaded = await loadAppearance();
2814
+ const initialAppearance = {
2815
+ ...loaded,
2816
+ settings: normalizeAppearance({
2817
+ ...loaded.settings,
2818
+ ...appearanceFlags(flags),
2819
+ }),
2820
+ };
2821
+ const app = render(
2822
+ h(App, { initialConfig: config, flags, root, initialAppearance }),
2823
+ {
2824
+ alternateScreen: true,
2825
+ exitOnCtrlC: false,
2826
+ patchConsole: true,
2827
+ maxFps: 20,
2828
+ incrementalRendering: false,
2829
+ },
2830
+ );
2466
2831
  try {
2467
2832
  await app.waitUntilExit();
2468
2833
  } finally {