@tenyokjs/devdevian-cli 0.5.0-alpha.1 → 0.6.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
@@ -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";
@@ -47,6 +48,18 @@ import { editExternal } from "./external-editor.mjs";
47
48
  import { markdownRows } from "./rich-text.mjs";
48
49
  import { problemMessage } from "./problems.mjs";
49
50
  import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
51
+ import {
52
+ catalogFields,
53
+ initialCatalog,
54
+ catalogDocument,
55
+ prepareCatalog,
56
+ publishCatalog,
57
+ saveCalendar,
58
+ validateFilter,
59
+ localTime,
60
+ viewerZone,
61
+ webURL,
62
+ } from "./catalog.mjs";
50
63
  const h = React.createElement;
51
64
  const itemsOf = (value) => (Array.isArray(value) ? value : value?.items || []);
52
65
  const stamp = (value) => {
@@ -73,6 +86,8 @@ const labelOf = (route) =>
73
86
  community: "Community",
74
87
  feedback: "Code feedback",
75
88
  thread: "Reply thread",
89
+ project: "Project",
90
+ event: "Event",
76
91
  }[route.view] ||
77
92
  "Workspace";
78
93
  function openBrowser(url) {
@@ -91,6 +106,8 @@ function openBrowser(url) {
91
106
  child.unref();
92
107
  }
93
108
  function fieldsFor(form) {
109
+ if (["catalog", "catalogFilter", "calendar"].includes(form.kind))
110
+ return catalogFields(form);
94
111
  if (form.kind === "appearance") return appearanceFields;
95
112
  if (form.kind === "publication")
96
113
  return [
@@ -330,7 +347,15 @@ function listRows(route, data, index, width, colors, density = "comfortable") {
330
347
  let title = item.title || item.name || "",
331
348
  meta = "",
332
349
  body = "";
333
- if (route.view === "drafts") {
350
+ if (route.view === "projects") {
351
+ title = item.name;
352
+ meta = `${item.stage} · #${item.topic} · ${item.founder_name}`;
353
+ body = item.tagline || item.description;
354
+ } else if (route.view === "events") {
355
+ title = (item.cancelled_at ? "[Cancelled] " : "") + item.title;
356
+ meta = `${stamp(item.start_at)} · ${item.organizer_name}`;
357
+ body = item.description;
358
+ } else if (route.view === "drafts") {
334
359
  title = item.form.values.title || item.form.title;
335
360
  meta = "Local draft · " + stamp(item.savedAt);
336
361
  body =
@@ -422,6 +447,8 @@ function listRows(route, data, index, width, colors, density = "comfortable") {
422
447
  });
423
448
  }
424
449
  function documentRows(route, data, width, colors) {
450
+ if (route.view === "project" || route.view === "event")
451
+ return markdownRows(catalogDocument(route, data), width, colors);
425
452
  if (route.view !== "post" || (route.tab && route.tab !== "overview"))
426
453
  return contentLines(route, data).flatMap((line) => wrap(line, width));
427
454
  const post = data?.post;
@@ -523,7 +550,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
523
550
  ),
524
551
  [user, setUser] = useState(null),
525
552
  [device, setDevice] = useState(null),
526
- [authVersion, setAuthVersion] = useState(0);
553
+ [authVersion, setAuthVersion] = useState(0),
554
+ [booting, setBooting] = useState(true);
527
555
  const [route, setRoute] = useState({ view: "feed", page: 1 }),
528
556
  [data, setData] = useState(null),
529
557
  [loading, setLoading] = useState(false),
@@ -693,6 +721,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
693
721
  setPreview(null);
694
722
  setDevice(null);
695
723
  setUser(null);
724
+ setBooting(true);
696
725
  setReading(null);
697
726
  setActionIndex(null);
698
727
  setUnread(0);
@@ -716,6 +745,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
716
745
  } else setError(problemMessage(e));
717
746
  }
718
747
  function navigate(next, { replace = false } = {}) {
748
+ setBooting(false);
719
749
  if (!replace)
720
750
  history.current.push({
721
751
  route,
@@ -800,6 +830,20 @@ function App({ initialConfig, flags, root, initialAppearance }) {
800
830
  await queueDraft(() => drafts.remove(draftIdentity(value)));
801
831
  }
802
832
  async function restoreDraft(item) {
833
+ if (
834
+ item.form.kind === "catalog" &&
835
+ item.form.catalogProgress?.completedId
836
+ ) {
837
+ navigate({
838
+ view: item.form.entity,
839
+ id: item.form.catalogProgress.completedId,
840
+ page: 1,
841
+ });
842
+ setNotice(
843
+ "This draft was already published. Opened the existing publication.",
844
+ );
845
+ return;
846
+ }
803
847
  await task(async () => {
804
848
  const saved = await drafts.read(item.id),
805
849
  next = { ...saved.form, draftId: item.id };
@@ -838,6 +882,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
838
882
  }
839
883
  }
840
884
  }
885
+ if (next.kind === "catalog")
886
+ next.topics = itemsOf(await api("/topics")).map((t) => [t.id, t.name]);
841
887
  openForm(next);
842
888
  setNotice(
843
889
  "Restored local draft. Check the code selection before publishing.",
@@ -898,7 +944,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
898
944
  if (!controller.signal.aborted) fail(e);
899
945
  });
900
946
  return () => controller.abort();
901
- }, [phase, api]);
947
+ }, [phase, api, authVersion]);
902
948
  useEffect(() => {
903
949
  if (phase !== "ready" || !api || form || route.view === "help") return;
904
950
  let active = true;
@@ -964,7 +1010,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
964
1010
  if (active) fail(e);
965
1011
  })
966
1012
  .finally(() => {
967
- if (active) setLoading(false);
1013
+ if (active) {
1014
+ setLoading(false);
1015
+ setBooting(false);
1016
+ }
968
1017
  });
969
1018
  return () => {
970
1019
  active = false;
@@ -996,6 +1045,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
996
1045
  if (controller.signal.aborted) return;
997
1046
  resetAccount();
998
1047
  setConfig(saved);
1048
+ setBooting(true);
999
1049
  setPhase("checking");
1000
1050
  setNotice("Terminal connected.");
1001
1051
  return;
@@ -1051,6 +1101,128 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1051
1101
  setCommandCursor(0);
1052
1102
  }
1053
1103
  }
1104
+ async function composeCatalog(entity, editing = false) {
1105
+ await task(async () => {
1106
+ const row = editing ? await api(`/${entity}s/${route.id}`) : {};
1107
+ if (editing && row.user_id !== user.id)
1108
+ throw Error("Only the owner can edit this publication.");
1109
+ const topics = itemsOf(await api("/topics")).map((t) => [t.id, t.name]);
1110
+ const values = initialCatalog(entity, row);
1111
+ if (!topics.some(([id]) => id === values.topic))
1112
+ values.topic = topics[0]?.[0] || "web";
1113
+ openForm({
1114
+ kind: "catalog",
1115
+ entity,
1116
+ entityId: row.id,
1117
+ revision: row.revision,
1118
+ existing: row,
1119
+ title: (editing ? "Edit your " : "Create a ") + entity,
1120
+ topics,
1121
+ values,
1122
+ });
1123
+ });
1124
+ }
1125
+ async function catalogFilter() {
1126
+ await task(async () => {
1127
+ const entity = route.view === "projects" ? "project" : "event";
1128
+ const topics = itemsOf(await api("/topics")).map((t) => [t.id, t.name]);
1129
+ openForm({
1130
+ kind: "catalogFilter",
1131
+ entity,
1132
+ title: "Filter " + route.view,
1133
+ topics,
1134
+ values: {
1135
+ query: route.query || "",
1136
+ topic: route.topic || "",
1137
+ stage: route.stage || "",
1138
+ author: route.author || "",
1139
+ status: route.status || "",
1140
+ date: route.date || "",
1141
+ timezone: route.timezone || viewerZone(),
1142
+ mine: route.mine || "",
1143
+ order: route.order || (entity === "project" ? "newest" : "earliest"),
1144
+ },
1145
+ });
1146
+ });
1147
+ }
1148
+ function catalogAction(id) {
1149
+ const row = current?.project || current?.event;
1150
+ if (!row) return;
1151
+ if (id === "edit") {
1152
+ composeCatalog(route.view, true);
1153
+ return;
1154
+ }
1155
+ if (id === "conversation") {
1156
+ navigate({ view: "post", id: row.post_id, tab: "overview", page: 1 });
1157
+ return;
1158
+ }
1159
+ if (id === "calendar") {
1160
+ openForm({
1161
+ kind: "calendar",
1162
+ eventId: row.id,
1163
+ title: "Export calendar",
1164
+ values: { path: `devdevian-event-${row.id}.ics` },
1165
+ });
1166
+ return;
1167
+ }
1168
+ if (id === "attend") {
1169
+ const attending = !row.viewer_attending;
1170
+ askConfirm(
1171
+ attending ? "Attend this event?" : "Leave this event?",
1172
+ row.title,
1173
+ () =>
1174
+ task(async () => {
1175
+ await api(`/events/${row.id}/attendance`, {
1176
+ method: "PUT",
1177
+ body: { attending },
1178
+ });
1179
+ invalidate(
1180
+ attending
1181
+ ? "You're attending. Your browser account sees the same update."
1182
+ : "You left the event.",
1183
+ );
1184
+ }),
1185
+ );
1186
+ return;
1187
+ }
1188
+ if (id === "lifecycle") {
1189
+ if (row.user_id !== user.id) {
1190
+ setError("Only the organizer can manage this event.");
1191
+ return;
1192
+ }
1193
+ const action = row.cancelled_at ? "restore" : "cancel";
1194
+ askConfirm(
1195
+ action === "cancel" ? "Cancel your event?" : "Restore your event?",
1196
+ "Attendees will receive the event update.",
1197
+ () =>
1198
+ task(async () => {
1199
+ await api(`/events/${row.id}/lifecycle`, {
1200
+ method: "POST",
1201
+ body: { action, revision: row.revision },
1202
+ });
1203
+ invalidate("Event updated.");
1204
+ }),
1205
+ );
1206
+ return;
1207
+ }
1208
+ const target =
1209
+ id === "website"
1210
+ ? row.url
1211
+ : id === "source"
1212
+ ? row.repo
1213
+ : id === "meeting"
1214
+ ? row.url
1215
+ : config.origin +
1216
+ (route.view === "project"
1217
+ ? "/app#post/" + row.post_id
1218
+ : "/app#events/" + row.id);
1219
+ try {
1220
+ openBrowser(webURL(target));
1221
+ setNotice("Opened in your browser.");
1222
+ } catch (error) {
1223
+ setError(error.message);
1224
+ }
1225
+ }
1054
1226
  function openAppearance() {
1055
1227
  openForm({
1056
1228
  kind: "appearance",
@@ -1062,6 +1234,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1062
1234
  );
1063
1235
  }
1064
1236
  function openForm(next) {
1237
+ setBooting(false);
1065
1238
  setForm({
1066
1239
  ...next,
1067
1240
  draftId: next.draftId || randomBytes(32).toString("hex"),
@@ -1225,6 +1398,28 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1225
1398
  }
1226
1399
  async function prepare() {
1227
1400
  if (!form) return;
1401
+ if (form.kind === "catalogFilter") {
1402
+ await task(async () => {
1403
+ const values = validateFilter(form.values);
1404
+ setForm(null);
1405
+ navigate({ view: form.entity + "s", ...values, page: 1 });
1406
+ });
1407
+ return;
1408
+ }
1409
+ if (form.kind === "calendar") {
1410
+ await task(async () => {
1411
+ const path = await saveCalendar(
1412
+ api,
1413
+ root,
1414
+ form.eventId,
1415
+ form.values.path,
1416
+ );
1417
+ setForm(null);
1418
+ setFocus("content");
1419
+ setNotice("Calendar saved: " + clean(path));
1420
+ });
1421
+ return;
1422
+ }
1228
1423
  if (form.kind === "appearance") {
1229
1424
  await task(async () => {
1230
1425
  const saved = await saveAppearance(form.values);
@@ -1263,6 +1458,23 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1263
1458
  navigate({ view: "post", id, tab: "overview", page: 1 });
1264
1459
  return;
1265
1460
  }
1461
+ if (form.kind === "catalog") {
1462
+ if (form.catalogProgress?.uncertain)
1463
+ throw Error(
1464
+ "Publication outcome is unknown. Check your projects/events before creating another copy. Keep this draft for reference.",
1465
+ );
1466
+ const plan = await prepareCatalog(api, root, form);
1467
+ setPreview({
1468
+ catalog: plan,
1469
+ payload: plan.payload,
1470
+ title: form.title,
1471
+ method: form.entityId ? "PATCH" : "POST",
1472
+ url: `/${form.entity}s`,
1473
+ });
1474
+ setScroll(0);
1475
+ setYes(false);
1476
+ return;
1477
+ }
1266
1478
  let payload,
1267
1479
  method = "POST",
1268
1480
  url,
@@ -1354,10 +1566,22 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1354
1566
  async function publish() {
1355
1567
  if (!preview) return;
1356
1568
  await task(async () => {
1357
- const response = await api(preview.url, {
1358
- method: preview.method,
1359
- body: preview.payload,
1360
- });
1569
+ let working = form;
1570
+ const response = preview.catalog
1571
+ ? await publishCatalog(
1572
+ api,
1573
+ preview.catalog,
1574
+ form.catalogProgress,
1575
+ async (progress) => {
1576
+ working = { ...working, catalogProgress: progress };
1577
+ setForm(working);
1578
+ await queueDraft(() => drafts.save(working));
1579
+ },
1580
+ )
1581
+ : await api(preview.url, {
1582
+ method: preview.method,
1583
+ body: preview.payload,
1584
+ });
1361
1585
  cache.current.clear();
1362
1586
  let localWarning = "";
1363
1587
  try {
@@ -1369,7 +1593,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1369
1593
  setPreview(null);
1370
1594
  setForm(null);
1371
1595
  setScroll(0);
1372
- if (preview.url === "/posts")
1596
+ if (preview.catalog)
1597
+ navigate({ view: form.entity, id: response.id, page: 1 });
1598
+ else if (preview.url === "/posts")
1373
1599
  navigate({ view: "post", id: response.id, tab: "overview", page: 1 });
1374
1600
  else if (preview.url === "/communities")
1375
1601
  navigate({ view: "community", id: response.id, page: 1 });
@@ -1387,6 +1613,11 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1387
1613
  });
1388
1614
  }
1389
1615
  async function openSelected() {
1616
+ if (route.view === "projects" || route.view === "events") {
1617
+ if (selected)
1618
+ navigate({ view: route.view.slice(0, -1), id: selected.id, page: 1 });
1619
+ return;
1620
+ }
1390
1621
  if (route.view === "community" && !current?.community?.is_member) return;
1391
1622
  const item = selected;
1392
1623
  if (!item) return;
@@ -1627,6 +1858,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1627
1858
  }
1628
1859
  function runAction(id) {
1629
1860
  setActionIndex(null);
1861
+ if (id?.startsWith("catalog-")) {
1862
+ catalogAction(id.slice(8));
1863
+ return;
1864
+ }
1630
1865
  if (id === "read") {
1631
1866
  if (current?.post) {
1632
1867
  setReading(selected);
@@ -1685,6 +1920,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1685
1920
  );
1686
1921
  return;
1687
1922
  }
1923
+ if (parsed.command === "project" || parsed.command === "event") {
1924
+ navigate({ view: parsed.command, id: parsed.arg, page: 1 });
1925
+ return;
1926
+ }
1688
1927
  if (parsed.command === "appearance") {
1689
1928
  openAppearance();
1690
1929
  return;
@@ -1702,14 +1941,16 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1702
1941
  navigate({
1703
1942
  view: parsed.command,
1704
1943
  page: 1,
1705
- ...(parsed.command === "search" ? { query: parsed.arg } : {}),
1944
+ ...(["search", "projects", "events"].includes(parsed.command)
1945
+ ? { query: parsed.arg }
1946
+ : {}),
1706
1947
  });
1707
1948
  } catch (e) {
1708
1949
  setError(e.message);
1709
1950
  }
1710
1951
  }
1711
1952
  function askQuit() {
1712
- if (form?.kind === "appearance") {
1953
+ if (["appearance", "catalogFilter", "calendar"].includes(form?.kind)) {
1713
1954
  exit();
1714
1955
  return;
1715
1956
  }
@@ -1772,6 +2013,14 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1772
2013
  setDevice(null);
1773
2014
  return;
1774
2015
  }
2016
+ if (phase === "checking") {
2017
+ if (input === "q") exit();
2018
+ else if (error && (keyEvent.return || input === "r" || input === "R")) {
2019
+ setError("");
2020
+ setAuthVersion((v) => v + 1);
2021
+ }
2022
+ return;
2023
+ }
1775
2024
  if (input === "q") exit();
1776
2025
  else if (keyEvent.return && !busy.current) {
1777
2026
  if (phase === "pending" && device) openBrowser(device.verification_uri);
@@ -1848,6 +2097,22 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1848
2097
  return;
1849
2098
  }
1850
2099
  if (form) {
2100
+ if (keyEvent.ctrl && input === "o" && form.kind === "catalog") {
2101
+ const active = fieldsFor(form)[field];
2102
+ if (["logo", "banner", "screenshots"].includes(active?.key)) {
2103
+ setPicker({ mediaKey: active.key });
2104
+ return;
2105
+ }
2106
+ }
2107
+ if (
2108
+ ["catalogFilter", "calendar"].includes(form.kind) &&
2109
+ keyEvent.escape
2110
+ ) {
2111
+ setForm(null);
2112
+ setFocus("content");
2113
+ setError("");
2114
+ return;
2115
+ }
1851
2116
  if (form.kind === "appearance" && keyEvent.escape) {
1852
2117
  setForm(null);
1853
2118
  setFocus("content");
@@ -1927,6 +2192,14 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1927
2192
  return;
1928
2193
  }
1929
2194
  if (keyEvent.return && !active.multiline) {
2195
+ if (
2196
+ form.kind === "catalog" &&
2197
+ ["logo", "banner", "screenshots"].includes(active.key) &&
2198
+ !form.values[active.key]
2199
+ ) {
2200
+ setPicker({ mediaKey: active.key });
2201
+ return;
2202
+ }
1930
2203
  if (active.key === "file" && !form.values.file) {
1931
2204
  setPicker("files");
1932
2205
  return;
@@ -2012,6 +2285,17 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2012
2285
  setReviewFocus((v) => (v === "code" ? "notes" : "code"));
2013
2286
  return;
2014
2287
  }
2288
+ if (
2289
+ (input === "f" || input === "F") &&
2290
+ ["projects", "events"].includes(route.view)
2291
+ ) {
2292
+ catalogFilter();
2293
+ return;
2294
+ }
2295
+ if (input === "j" && route.view === "event") {
2296
+ catalogAction("attend");
2297
+ return;
2298
+ }
2015
2299
  if (
2016
2300
  reviewing &&
2017
2301
  (input === "f" || input === "F") &&
@@ -2077,7 +2361,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2077
2361
  const prose =
2078
2362
  route.view === "help" ||
2079
2363
  (route.view === "post" && (!route.tab || route.tab === "overview")) ||
2080
- (route.view === "community" && !current?.community?.is_member);
2364
+ (route.view === "community" && !current?.community?.is_member) ||
2365
+ ["project", "event"].includes(route.view);
2081
2366
  if (
2082
2367
  keyEvent.upArrow ||
2083
2368
  keyEvent.downArrow ||
@@ -2149,6 +2434,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2149
2434
  return;
2150
2435
  }
2151
2436
  if (input === "n") {
2437
+ if (["projects", "events"].includes(route.view)) {
2438
+ composeCatalog(route.view.slice(0, -1));
2439
+ return;
2440
+ }
2152
2441
  if (route.view === "communities")
2153
2442
  openForm({
2154
2443
  kind: "community",
@@ -2177,6 +2466,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2177
2466
  return;
2178
2467
  }
2179
2468
  if (input === "e" || input === "E") {
2469
+ if (["project", "event"].includes(route.view)) {
2470
+ composeCatalog(route.view, true);
2471
+ return;
2472
+ }
2180
2473
  editSelected();
2181
2474
  return;
2182
2475
  }
@@ -2233,9 +2526,33 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2233
2526
  root,
2234
2527
  width,
2235
2528
  height: bodyHeight,
2236
- initial: picker,
2529
+ initial: picker.mediaKey ? "files" : picker,
2530
+ media: !!picker.mediaKey,
2237
2531
  onCancel: () => setPicker(null),
2238
2532
  onSelect: (source) => {
2533
+ if (picker.mediaKey) {
2534
+ const key = picker.mediaKey;
2535
+ setForm((old) => ({
2536
+ ...old,
2537
+ values: {
2538
+ ...old.values,
2539
+ [key]:
2540
+ key === "screenshots"
2541
+ ? [
2542
+ old.values[key] === "-" ? "" : old.values[key],
2543
+ source.path,
2544
+ ]
2545
+ .filter(Boolean)
2546
+ .join("\n")
2547
+ : source.path,
2548
+ },
2549
+ }));
2550
+ setPicker(null);
2551
+ setNotice(
2552
+ "Image selected locally. It is uploaded only after confirmation.",
2553
+ );
2554
+ return;
2555
+ }
2239
2556
  setForm((old) => ({
2240
2557
  ...old,
2241
2558
  source,
@@ -2257,44 +2574,82 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2257
2574
  },
2258
2575
  });
2259
2576
  else if (phase !== "ready") {
2260
- const lines = [
2261
- "A place for curious developers.",
2262
- "Your conversations, without leaving the terminal.",
2263
- "",
2264
- ...(phase === "pending" && device
2577
+ const statusLines =
2578
+ phase === "pending" && device
2265
2579
  ? [
2266
2580
  "Confirm this code: " + device.user_code,
2267
- "Waiting for your approval " + spin,
2268
2581
  device.verification_uri,
2269
- "Enter reopens the browser · Esc cancels",
2582
+ "Waiting for your approval...",
2270
2583
  ]
2271
2584
  : phase === "checking"
2272
- ? ["Opening your workspace " + spin]
2585
+ ? error
2586
+ ? ["Connection could not be completed.", error]
2587
+ : ["Connecting to Devdevian..."]
2273
2588
  : [
2274
2589
  "Sign in with your existing Devdevian account.",
2275
- "",
2276
- "Enter connects this terminal through your browser.",
2277
2590
  "Google, GitHub and email sign-in stay on the website.",
2278
- "",
2279
- "Q exits.",
2280
- ]),
2281
- ];
2591
+ ];
2592
+ const footer =
2593
+ phase === "pending"
2594
+ ? ["Enter reopens the browser · Esc cancels"]
2595
+ : phase === "checking"
2596
+ ? [error ? "R or Enter retries · Ctrl+C exits" : "Ctrl+C exits"]
2597
+ : [
2598
+ "Enter connects this terminal through your browser.",
2599
+ "Q or Ctrl+C exits.",
2600
+ ];
2282
2601
  center = h(
2283
2602
  Box,
2284
2603
  {
2285
2604
  borderStyle: colors.frame,
2286
2605
  borderColor: colors.purple,
2287
2606
  flexDirection: "column",
2288
- paddingX: 2,
2289
- paddingY: 1,
2607
+ paddingX: 1,
2290
2608
  height: bodyHeight,
2291
2609
  overflow: "hidden",
2292
2610
  },
2293
- h(Text, { bold: true, color: colors.purple }, "YOUR DEVELOPER SPACE"),
2294
- h(FrameRows, {
2295
- rows: lines.flatMap((line) => wrap(line, width - 8)),
2296
- width: width - 8,
2297
- height: Math.max(1, bodyHeight - 5),
2611
+ h(DeviPanel, {
2612
+ width: Math.max(1, width - 4),
2613
+ height: Math.max(1, bodyHeight - 2),
2614
+ message:
2615
+ effectiveAppearance.welcome === "off"
2616
+ ? ""
2617
+ : phase === "checking"
2618
+ ? "Devi missed you."
2619
+ : "Devi saved you a seat.",
2620
+ status: statusLines,
2621
+ footer,
2622
+ show: effectiveAppearance.welcome === "on",
2623
+ animate: phase !== "auth" && !error,
2624
+ motion: effectiveAppearance.motion,
2625
+ }),
2626
+ );
2627
+ } else if (
2628
+ booting &&
2629
+ !current &&
2630
+ effectiveAppearance.welcome === "on" &&
2631
+ !error &&
2632
+ !dialog &&
2633
+ !form &&
2634
+ !picker
2635
+ ) {
2636
+ center = h(
2637
+ Box,
2638
+ {
2639
+ borderStyle: colors.frame,
2640
+ borderColor: colors.purple,
2641
+ paddingX: 1,
2642
+ height: bodyHeight,
2643
+ overflow: "hidden",
2644
+ },
2645
+ h(DeviPanel, {
2646
+ width: Math.max(1, width - 4),
2647
+ height: Math.max(1, bodyHeight - 2),
2648
+ message: "Devi missed you.",
2649
+ status: ["Loading your " + labelOf(route).toLowerCase() + "..."],
2650
+ footer: ["Ctrl+C exits"],
2651
+ motion: effectiveAppearance.motion,
2652
+ animate: true,
2298
2653
  }),
2299
2654
  );
2300
2655
  } else if (dialog) {
@@ -2361,8 +2716,29 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2361
2716
  const raw = [
2362
2717
  "AUDIENCE · " + audience,
2363
2718
  "",
2364
- ...(payload.title ? [payload.title, ""] : []),
2719
+ ...(payload.title || payload.name
2720
+ ? [payload.title || payload.name, ""]
2721
+ : []),
2365
2722
  payload.body || payload.reason || payload.description || "",
2723
+ ...(preview.catalog
2724
+ ? [
2725
+ "",
2726
+ ...Object.entries(payload)
2727
+ .filter(([key]) => !["description", "details"].includes(key))
2728
+ .map(([key, value]) => `${key}: ${value}`),
2729
+ "",
2730
+ "DETAILS",
2731
+ JSON.stringify(payload.details, null, 2),
2732
+ "",
2733
+ "IMAGES TO UPLOAD",
2734
+ ...(preview.catalog.files.length
2735
+ ? preview.catalog.files.map(
2736
+ (file) =>
2737
+ `${file.path} · ${file.mime} · ${file.size} bytes`,
2738
+ )
2739
+ : ["No new images"]),
2740
+ ]
2741
+ : []),
2366
2742
  ...(payload.code ? ["", "CODE TO PUBLISH", payload.code] : []),
2367
2743
  ...(payload.review_focus
2368
2744
  ? ["", "REVIEW FOCUS", payload.review_focus]
@@ -2405,7 +2781,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2405
2781
  width: contentWidth,
2406
2782
  height: contentHeight,
2407
2783
  });
2408
- else if (loading && !current)
2784
+ else if ((loading || booting) && !current)
2409
2785
  body = h(
2410
2786
  Text,
2411
2787
  { color: colors.muted },
@@ -2500,30 +2876,49 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2500
2876
  height: contentHeight,
2501
2877
  offset: Math.min(scroll, Math.max(0, lines.length - contentHeight)),
2502
2878
  });
2503
- else if (!current?.items?.length)
2504
- body = h(
2505
- Box,
2506
- { flexDirection: "column", paddingTop: 1 },
2507
- h(
2508
- Text,
2509
- { color: colors.text },
2510
- route.view === "search"
2511
- ? "Find your next useful conversation."
2512
- : error
2513
- ? "Could not open this view."
2514
- : "Nothing here yet.",
2515
- ),
2516
- h(
2517
- Text,
2518
- { color: colors.muted },
2519
- error
2520
- ? "Press Shift+R to retry."
2521
- : route.view === "feed"
2522
- ? "N starts a conversation. A asks a question."
2523
- : "Use / to search or Tab to explore another section.",
2524
- ),
2525
- );
2526
- else {
2879
+ else if (!current?.items?.length) {
2880
+ const messages = {
2881
+ inbox: [
2882
+ "You're all caught up.",
2883
+ "Replies, mentions and review updates will appear here.",
2884
+ ],
2885
+ drafts: [
2886
+ "No local drafts yet.",
2887
+ "Start writing to save a private draft on this device.",
2888
+ ],
2889
+ communities: [
2890
+ "Find your people.",
2891
+ "Use / to search or N to create a private community.",
2892
+ ],
2893
+ feed: [
2894
+ "Nothing here yet.",
2895
+ "N starts a conversation. A asks a question.",
2896
+ ],
2897
+ search: route.query
2898
+ ? [
2899
+ "No conversations matched your search.",
2900
+ "Try fewer words or a different phrase.",
2901
+ ]
2902
+ : [
2903
+ "Find your next useful conversation.",
2904
+ "Press / and type /search followed by your query.",
2905
+ ],
2906
+ };
2907
+ const message = messages[route.view] || [
2908
+ "Nothing here yet.",
2909
+ "Use / to search or Tab to explore another section.",
2910
+ ];
2911
+ body = h(DeviPanel, {
2912
+ width: contentWidth,
2913
+ height: contentHeight,
2914
+ message: message[0],
2915
+ status: [message[1]],
2916
+ show:
2917
+ effectiveAppearance.welcome === "on" &&
2918
+ Object.hasOwn(messages, route.view),
2919
+ motion: effectiveAppearance.motion,
2920
+ });
2921
+ } else {
2527
2922
  const visible = Math.max(1, Math.floor(contentHeight / stride)),
2528
2923
  first = Math.max(0, index - visible + 1),
2529
2924
  rows = listRows(
@@ -2702,27 +3097,31 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2702
3097
  );
2703
3098
  }
2704
3099
  const hint =
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";
3100
+ ["projects", "events"].includes(route.view) && !form && !preview && !picker
3101
+ ? "↑↓ select · Enter open · F filters · N new · [ ] pages · / search"
3102
+ : ["project", "event"].includes(route.view) && !form && !preview
3103
+ ? "↑↓ scroll · X actions · E edit your publication · Esc back"
3104
+ : form?.kind === "appearance"
3105
+ ? "← → choose · Tab next · Ctrl+S save · Esc cancel · 0 default preview"
3106
+ : actionIndex !== null
3107
+ ? "Actions are scoped to the selected item · Esc closes"
3108
+ : reviewing && !form && !preview && !reading
3109
+ ? "W code / discussion · Space select · C comment · X actions · F filter"
3110
+ : picker
3111
+ ? "Project files stay local until publication is confirmed"
3112
+ : reading
3113
+ ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
3114
+ : preview
3115
+ ? "← → choose · Enter confirm · Esc edit"
3116
+ : form
3117
+ ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
3118
+ : phase !== "ready"
3119
+ ? "Enter connect / open browser · Ctrl+C exit"
3120
+ : route.view === "feedback"
3121
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
3122
+ : route.view === "post"
3123
+ ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
3124
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2726
3125
  return h(
2727
3126
  ThemeContext.Provider,
2728
3127
  { value: colors },