@tenyokjs/devdevian-cli 0.1.0-alpha.1 → 0.2.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 ADDED
@@ -0,0 +1,1909 @@
1
+ import React, { useState, useEffect, useMemo, useRef } from "react";
2
+ import { render, Box, Text, useApp, useInput, useWindowSize } from "ink";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { homedir, hostname } from "node:os";
5
+ import { resolve } from "node:path";
6
+ import { spawn } from "node:child_process";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import {
9
+ client,
10
+ loadConfig,
11
+ saveConfig,
12
+ removeConfig,
13
+ originOf,
14
+ clean,
15
+ } from "./client.mjs";
16
+ import {
17
+ graphemes,
18
+ clip,
19
+ wrap,
20
+ editText,
21
+ cursorLines,
22
+ parseCommand,
23
+ PageCache,
24
+ } from "./tui-model.mjs";
25
+ import { navigation, loadPage, contentLines } from "./tui-data.mjs";
26
+ const h = React.createElement;
27
+ const colors = {
28
+ bg: "#0d1222",
29
+ panel: "#12192b",
30
+ line: "#354266",
31
+ muted: "#8190af",
32
+ text: "#e0e7ff",
33
+ blue: "#79b8ff",
34
+ purple: "#b29aff",
35
+ green: "#70d6b0",
36
+ red: "#ff9baf",
37
+ selected: "#29345b",
38
+ };
39
+ const itemsOf = (value) => (Array.isArray(value) ? value : value?.items || []);
40
+ const stamp = (value) => {
41
+ const date = new Date(
42
+ typeof value === "number"
43
+ ? value
44
+ : String(value || "").replace(" ", "T") +
45
+ (/Z$|[+-]\d\d:\d\d$/.test(value || "") ? "" : "Z"),
46
+ );
47
+ return Number.isNaN(date.getTime())
48
+ ? ""
49
+ : date.toLocaleString(undefined, {
50
+ month: "short",
51
+ day: "numeric",
52
+ hour: "2-digit",
53
+ minute: "2-digit",
54
+ hourCycle: "h23",
55
+ });
56
+ };
57
+ const labelOf = (route) =>
58
+ navigation.find((item) => item[0] === route.view)?.[1] ||
59
+ {
60
+ post: "Conversation",
61
+ community: "Community",
62
+ feedback: "Code feedback",
63
+ thread: "Reply thread",
64
+ }[route.view] ||
65
+ "Workspace";
66
+ function openBrowser(url) {
67
+ const [command, args] =
68
+ process.platform === "darwin"
69
+ ? ["open", [url]]
70
+ : process.platform === "win32"
71
+ ? ["rundll32", ["url.dll,FileProtocolHandler", url]]
72
+ : ["xdg-open", [url]];
73
+ const child = spawn(command, args, {
74
+ stdio: "ignore",
75
+ detached: true,
76
+ shell: false,
77
+ });
78
+ child.on("error", () => {});
79
+ child.unref();
80
+ }
81
+ function fieldsFor(form) {
82
+ if (form.kind === "publication")
83
+ return [
84
+ {
85
+ key: "kind",
86
+ label: "Publication type",
87
+ options: [
88
+ ["discussion", "Discussion"],
89
+ ["question", "Question"],
90
+ ["review", "Code review"],
91
+ ["showcase", "Show & tell"],
92
+ ],
93
+ },
94
+ { key: "title", label: "Title", min: 5, max: 160 },
95
+ { key: "topic", label: "Topic", options: form.topics },
96
+ { key: "community_id", label: "Audience", options: form.audiences },
97
+ ...(form.values.kind === "review"
98
+ ? [
99
+ {
100
+ key: "file",
101
+ label: "Code file",
102
+ min: 1,
103
+ max: 1500,
104
+ hint: "Explicit file path; no automatic project collection.",
105
+ },
106
+ {
107
+ key: "lines",
108
+ label: "File lines (optional)",
109
+ max: 30,
110
+ hint: "START:END; published snippet line numbers start at 1.",
111
+ },
112
+ {
113
+ key: "review_focus",
114
+ label: "Review focus",
115
+ min: 10,
116
+ max: 1500,
117
+ multiline: true,
118
+ },
119
+ ]
120
+ : []),
121
+ ...(form.values.kind === "showcase"
122
+ ? [{ key: "demo_url", label: "Demo URL", min: 8, max: 1000 }]
123
+ : []),
124
+ {
125
+ key: "body",
126
+ label: form.values.kind === "review" ? "Context" : "Message",
127
+ min: 10,
128
+ max: 15000,
129
+ multiline: true,
130
+ },
131
+ ];
132
+ if (form.kind === "open")
133
+ return [
134
+ {
135
+ key: "id",
136
+ label: "Conversation ID",
137
+ min: 1,
138
+ max: 16,
139
+ hint: "Find the ID next to a conversation in the feed.",
140
+ },
141
+ ];
142
+ if (form.kind === "community")
143
+ return [
144
+ { key: "name", label: "Community name", min: 3, max: 80 },
145
+ {
146
+ key: "description",
147
+ label: "Description",
148
+ min: 10,
149
+ max: 1500,
150
+ multiline: true,
151
+ },
152
+ ];
153
+ if (form.kind === "line")
154
+ return [
155
+ { key: "start_line", label: "From snippet line", min: 1, max: 8 },
156
+ { key: "end_line", label: "To snippet line", min: 1, max: 8 },
157
+ {
158
+ key: "body",
159
+ label: "What would you change?",
160
+ min: 2,
161
+ max: 5000,
162
+ multiline: true,
163
+ },
164
+ ];
165
+ if (form.kind === "report")
166
+ return [
167
+ {
168
+ key: "reason",
169
+ label: "Reason for reporting",
170
+ min: 5,
171
+ max: 1000,
172
+ multiline: true,
173
+ },
174
+ ];
175
+ return [
176
+ {
177
+ key: "body",
178
+ label: form.kind === "edit" ? "Edit your text" : "Your reply",
179
+ min: 2,
180
+ max: form.feedback ? 5000 : 10000,
181
+ multiline: true,
182
+ },
183
+ ];
184
+ }
185
+ function TextLine({ text, width, color = colors.text, bold = false }) {
186
+ return h(Text, { color, bold, wrap: "truncate" }, clip(text, width));
187
+ }
188
+ function FrameRows({ rows, width, height, offset = 0 }) {
189
+ return h(
190
+ Box,
191
+ { flexDirection: "column", height, overflow: "hidden" },
192
+ ...rows.slice(offset, offset + height).map((row, i) =>
193
+ h(TextLine, {
194
+ key: i,
195
+ text: typeof row === "string" ? row : row.text,
196
+ width,
197
+ color: row.color || colors.text,
198
+ bold: row.bold || false,
199
+ }),
200
+ ),
201
+ );
202
+ }
203
+ function InputText({ value, cursor, width, height = 1 }) {
204
+ const result = cursorLines(value, cursor, width),
205
+ start = Math.max(0, result.line - height + 1);
206
+ return h(
207
+ Box,
208
+ { flexDirection: "column", height, overflow: "hidden" },
209
+ ...result.lines.slice(start, start + height).map((line, index) => {
210
+ const [before, ...rest] = line.split("▏");
211
+ return h(
212
+ Text,
213
+ { key: index, color: colors.text, wrap: "truncate" },
214
+ before,
215
+ rest.length ? h(Text, { color: colors.purple, bold: true }, "▏") : null,
216
+ rest.join("▏"),
217
+ );
218
+ }),
219
+ );
220
+ }
221
+ function FormView({ form, active, cursor, width, height }) {
222
+ const fields = fieldsFor(form),
223
+ rows = [];
224
+ let selectedLine = 0;
225
+ fields.forEach((field, index) => {
226
+ if (index === active) selectedLine = rows.length;
227
+ rows.push({
228
+ text: (index === active ? "› " : " ") + field.label,
229
+ color: index === active ? colors.purple : colors.muted,
230
+ bold: index === active,
231
+ });
232
+ if (field.options) {
233
+ const option = field.options.find(
234
+ (item) => String(item[0]) === String(form.values[field.key] || ""),
235
+ );
236
+ rows.push({
237
+ text:
238
+ " ‹ " + (option?.[1] || form.values[field.key] || "Choose") + " ›",
239
+ color: index === active ? colors.blue : colors.text,
240
+ });
241
+ } else {
242
+ const value = form.values[field.key] || "";
243
+ const text =
244
+ index === active
245
+ ? cursorLines(value, cursor, width - 3).lines
246
+ : wrap(value || "…", width - 3);
247
+ const caretLine =
248
+ index === active
249
+ ? Math.max(
250
+ 0,
251
+ text.findIndex((line) => line.includes("▏")),
252
+ )
253
+ : 0;
254
+ const visible = field.multiline
255
+ ? Math.min(4, Math.max(2, text.length))
256
+ : 1,
257
+ start = index === active ? Math.max(0, caretLine - visible + 1) : 0;
258
+ rows.push(
259
+ ...text.slice(start, start + visible).map((line) => ({
260
+ text: " " + line,
261
+ color: index === active ? colors.text : colors.muted,
262
+ })),
263
+ );
264
+ }
265
+ if (field.hint && active === index)
266
+ rows.push({ text: " " + field.hint, color: colors.muted });
267
+ rows.push("");
268
+ });
269
+ if (active === fields.length) selectedLine = rows.length;
270
+ rows.push({
271
+ text:
272
+ (active === fields.length ? "› " : " ") +
273
+ "Preview publication [Ctrl+S]",
274
+ color: colors.blue,
275
+ bold: true,
276
+ });
277
+ const offset = Math.max(0, Math.min(selectedLine - 1, rows.length - height));
278
+ return h(FrameRows, { rows, width, height, offset });
279
+ }
280
+ function listRows(route, data, index, width) {
281
+ const items = data.items || [];
282
+ return items.flatMap((item, i) => {
283
+ let title = item.title || item.name || "",
284
+ meta = "",
285
+ body = "";
286
+ if (route.view === "inbox") {
287
+ title =
288
+ item.actor_name +
289
+ " · " +
290
+ ({
291
+ feedback_reply: "code feedback",
292
+ feedback_resolved: "resolved your feedback",
293
+ feedback_reopened: "reopened your feedback",
294
+ reply: "new reply",
295
+ mention: "mentioned you",
296
+ }[item.type] || item.type.replaceAll("_", " "));
297
+ meta = item.post_title || "Event update";
298
+ body = stamp(item.created) + (item.read_at ? "" : " · unread");
299
+ } else if (route.view === "communities") {
300
+ meta =
301
+ (item.is_member ? "Member" : "Private community") +
302
+ " · " +
303
+ item.members +
304
+ " members";
305
+ body = item.description;
306
+ } else if (route.view === "devices") {
307
+ meta = "Connected " + stamp(item.created);
308
+ body = "Expires " + stamp(item.expires);
309
+ } else if (route.view === "post" && route.tab === "feedback") {
310
+ title = `Lines ${item.start_line}–${item.end_line} · ${item.resolved ? "resolved" : "open"}${item.outdated ? " · earlier snippet" : ""}`;
311
+ meta = item.name;
312
+ body = item.deleted_at ? "[deleted feedback]" : item.body;
313
+ } else if (
314
+ route.view === "feedback" ||
315
+ route.view === "thread" ||
316
+ (route.view === "post" && route.tab === "replies")
317
+ ) {
318
+ title =
319
+ item.name +
320
+ (item.entryType === "note"
321
+ ? " · original remark"
322
+ : " · reply #" + item.id);
323
+ meta = stamp(item.created) + (item.edited_at ? " · edited" : "");
324
+ body = item.deleted_at ? "[deleted — discussion preserved]" : item.body;
325
+ } else {
326
+ meta = [
327
+ item.community_name || item.kind || item.type,
328
+ item.topic ? "#" + item.topic : "",
329
+ item.name || "",
330
+ ]
331
+ .filter(Boolean)
332
+ .join(" · ");
333
+ body = item.excerpt || item.body || "";
334
+ }
335
+ const selected = i === index;
336
+ return [
337
+ {
338
+ text: (selected ? "› " : " ") + clip(title, width - 3),
339
+ color: selected ? colors.blue : colors.text,
340
+ bold: selected,
341
+ selected,
342
+ },
343
+ { text: " " + clip(meta, width - 3), color: colors.muted, selected },
344
+ {
345
+ text: " " + clip(body || " ", width - 3),
346
+ color: selected ? colors.text : colors.muted,
347
+ selected,
348
+ },
349
+ "",
350
+ ];
351
+ });
352
+ }
353
+ function App({ initialConfig, flags }) {
354
+ const { exit } = useApp(),
355
+ { columns, rows } = useWindowSize();
356
+ const [config, setConfig] = useState(initialConfig),
357
+ [phase, setPhase] = useState(
358
+ initialConfig && !flags.initialLogin ? "checking" : "auth",
359
+ ),
360
+ [user, setUser] = useState(null),
361
+ [device, setDevice] = useState(null),
362
+ [authVersion, setAuthVersion] = useState(0);
363
+ const [route, setRoute] = useState({ view: "feed", page: 1 }),
364
+ [data, setData] = useState(null),
365
+ [loading, setLoading] = useState(false),
366
+ [refresh, setRefresh] = useState(0),
367
+ [index, setIndex] = useState(0),
368
+ [scroll, setScroll] = useState(0);
369
+ const [focus, setFocus] = useState("content"),
370
+ [navIndex, setNavIndex] = useState(1),
371
+ [command, setCommand] = useState(""),
372
+ [commandCursor, setCommandCursor] = useState(0),
373
+ [error, setError] = useState(""),
374
+ [notice, setNotice] = useState("");
375
+ const [form, setForm] = useState(null),
376
+ [field, setField] = useState(0),
377
+ [cursor, setCursor] = useState(0),
378
+ [preview, setPreview] = useState(null),
379
+ [dialog, setDialog] = useState(null),
380
+ [yes, setYes] = useState(false),
381
+ [pending, setPending] = useState(false),
382
+ [tick, setTick] = useState(0),
383
+ [reading, setReading] = useState(null);
384
+ const cache = useRef(new PageCache()),
385
+ history = useRef([]),
386
+ busy = useRef(false),
387
+ alive = useRef(true),
388
+ authController = useRef(null);
389
+ const api = useMemo(
390
+ () => (config ? client(config.origin, config.token) : null),
391
+ [config?.origin, config?.token],
392
+ );
393
+ const width = Math.max(1, columns),
394
+ height = Math.max(1, rows - 1),
395
+ compact = width < 90,
396
+ sidebar = compact ? 0 : 25,
397
+ bodyHeight = Math.max(1, height - 9),
398
+ contentWidth = Math.max(1, width - sidebar - 6),
399
+ contentHeight = Math.max(1, bodyHeight - 5);
400
+ const key = JSON.stringify(route),
401
+ current = data?.key === key ? data : null;
402
+ const selected = current?.items?.[index];
403
+ const resetAccount = () => {
404
+ cache.current.clear();
405
+ history.current = [];
406
+ setData(null);
407
+ setForm(null);
408
+ setPreview(null);
409
+ setDevice(null);
410
+ setUser(null);
411
+ setRoute({ view: "feed", page: 1 });
412
+ setError("");
413
+ };
414
+ function fail(e) {
415
+ if (!alive.current) return;
416
+ if (e.status === 401) {
417
+ resetAccount();
418
+ setPhase("auth");
419
+ setConfig(null);
420
+ setError(
421
+ "Your session expired or was revoked. Press Enter to reconnect.",
422
+ );
423
+ } else setError(clean(e.message));
424
+ }
425
+ function navigate(next, { replace = false } = {}) {
426
+ if (!replace) history.current.push({ route, index, scroll });
427
+ if (history.current.length > 30) history.current.shift();
428
+ setReading(null);
429
+ setRoute(next);
430
+ setIndex(0);
431
+ setScroll(0);
432
+ setFocus("content");
433
+ setError("");
434
+ setNotice("");
435
+ }
436
+ function back() {
437
+ const previous = history.current.pop();
438
+ if (previous) {
439
+ setRoute(previous.route);
440
+ setIndex(previous.index);
441
+ setScroll(previous.scroll);
442
+ setFocus("content");
443
+ setError("");
444
+ } else setFocus("nav");
445
+ }
446
+ async function task(work) {
447
+ if (busy.current) return;
448
+ busy.current = true;
449
+ setPending(true);
450
+ setError("");
451
+ try {
452
+ return await work();
453
+ } catch (e) {
454
+ fail(e);
455
+ } finally {
456
+ busy.current = false;
457
+ if (alive.current) setPending(false);
458
+ }
459
+ }
460
+ function invalidate(message) {
461
+ cache.current.clear();
462
+ setRefresh((n) => n + 1);
463
+ if (message) setNotice(message);
464
+ }
465
+ function askConfirm(title, description, action) {
466
+ setYes(false);
467
+ setDialog({ title, description, action });
468
+ }
469
+ useEffect(() => {
470
+ alive.current = true;
471
+ return () => {
472
+ alive.current = false;
473
+ authController.current?.abort();
474
+ };
475
+ }, []);
476
+ useEffect(() => {
477
+ if (!loading && !pending && phase !== "pending") return;
478
+ const timer = setInterval(() => setTick((v) => (v + 1) % 4), 180);
479
+ return () => clearInterval(timer);
480
+ }, [loading, pending, phase]);
481
+ useEffect(() => {
482
+ if (phase !== "checking" || !api) return;
483
+ const controller = new AbortController();
484
+ api("/me", { signal: controller.signal })
485
+ .then((result) => {
486
+ if (controller.signal.aborted) return;
487
+ if (!result.user) {
488
+ setPhase("auth");
489
+ return;
490
+ }
491
+ setUser(result.user);
492
+ setPhase("ready");
493
+ })
494
+ .catch((e) => {
495
+ if (!controller.signal.aborted) fail(e);
496
+ });
497
+ return () => controller.abort();
498
+ }, [phase, api]);
499
+ useEffect(() => {
500
+ if (phase !== "ready" || !api || form || route.view === "help") return;
501
+ let active = true;
502
+ const controller = new AbortController(),
503
+ cached = cache.current.get(key);
504
+ setData(cached ? { ...cached, key } : null);
505
+ setLoading(!cached);
506
+ setError("");
507
+ if (!cached)
508
+ loadPage(api, route, controller.signal)
509
+ .then((result) => {
510
+ if (!active) return;
511
+ cache.current.set(key, result);
512
+ setData({ ...result, key });
513
+ setIndex((i) =>
514
+ Math.min(i, Math.max(0, (result.items?.length || 0) - 1)),
515
+ );
516
+ })
517
+ .catch((e) => {
518
+ if (active) fail(e);
519
+ })
520
+ .finally(() => {
521
+ if (active) setLoading(false);
522
+ });
523
+ return () => {
524
+ active = false;
525
+ controller.abort();
526
+ };
527
+ }, [phase, api, key, refresh, !!form]);
528
+ useEffect(() => {
529
+ if (phase !== "pending" || !device) return;
530
+ const controller = new AbortController();
531
+ authController.current = controller;
532
+ (async () => {
533
+ try {
534
+ const anon = client(device.origin);
535
+ const until = Date.now() + 600000;
536
+ while (Date.now() < until && !controller.signal.aborted) {
537
+ await delay(5000, null, { signal: controller.signal });
538
+ const result = await anon("/cli/token", {
539
+ method: "POST",
540
+ body: { device_code: device.device_code },
541
+ signal: controller.signal,
542
+ });
543
+ if (result.pending) continue;
544
+ const saved = {
545
+ origin: device.origin,
546
+ token: result.access_token,
547
+ expires_at: result.expires_at,
548
+ };
549
+ await saveConfig(saved);
550
+ if (controller.signal.aborted) return;
551
+ resetAccount();
552
+ setConfig(saved);
553
+ setPhase("checking");
554
+ setNotice("Terminal connected.");
555
+ return;
556
+ }
557
+ if (!controller.signal.aborted)
558
+ throw Error(
559
+ "Connection request expired. Press Enter to start again.",
560
+ );
561
+ } catch (e) {
562
+ if (!controller.signal.aborted) {
563
+ setError(clean(e.message));
564
+ setPhase("auth");
565
+ setDevice(null);
566
+ }
567
+ }
568
+ })();
569
+ return () => controller.abort();
570
+ }, [phase, device, authVersion]);
571
+ async function connect() {
572
+ await task(async () => {
573
+ resetAccount();
574
+ const origin = originOf(
575
+ flags.host || initialConfig?.origin || "https://devdevian.vercel.app",
576
+ );
577
+ const result = await client(origin)("/cli/device", {
578
+ method: "POST",
579
+ body: { name: flags.name || "Devdevian TUI · " + hostname() },
580
+ });
581
+ if (new URL(result.verification_uri).origin !== origin)
582
+ throw Error("Unexpected sign-in address.");
583
+ setDevice({ ...result, origin });
584
+ setPhase("pending");
585
+ setAuthVersion((v) => v + 1);
586
+ if (!flags["no-open"]) openBrowser(result.verification_uri);
587
+ });
588
+ }
589
+ async function chooseNav(i) {
590
+ const name = navigation[i]?.[0];
591
+ setNavIndex(i);
592
+ if (name === "ask" || name === "review")
593
+ return composePublication(name === "review" ? "review" : "question");
594
+ if (name === "open")
595
+ return openForm({
596
+ kind: "open",
597
+ title: "Open a conversation",
598
+ values: { id: "" },
599
+ });
600
+ navigate({ view: name, page: 1 });
601
+ if (name === "search") {
602
+ setFocus("prompt");
603
+ setCommand("");
604
+ setCommandCursor(0);
605
+ }
606
+ }
607
+ function openForm(next) {
608
+ setForm(next);
609
+ setField(0);
610
+ setCursor(graphemes(next.values[fieldsFor(next)[0].key] || "").length);
611
+ setPreview(null);
612
+ setFocus("form");
613
+ setError("");
614
+ }
615
+ async function composePublication(kind = "discussion") {
616
+ await task(async () => {
617
+ const [topics, communities] = await Promise.all([
618
+ api("/topics"),
619
+ api("/communities?joined=1&limit=30"),
620
+ ]);
621
+ const audiences = [
622
+ ["", "Public feed · everyone"],
623
+ ...itemsOf(communities).map((item) => [
624
+ item.id,
625
+ "Private · " + item.name,
626
+ ]),
627
+ ];
628
+ const audience =
629
+ route.view === "community" && current?.community?.is_member
630
+ ? route.id
631
+ : "";
632
+ openForm({
633
+ kind: "publication",
634
+ title:
635
+ kind === "review" ? "Request a code review" : "Start a conversation",
636
+ topics: itemsOf(topics).map((item) => [item.id, item.name]),
637
+ audiences,
638
+ values: {
639
+ kind,
640
+ title: "",
641
+ topic: itemsOf(topics).some((item) => item.id === "web")
642
+ ? "web"
643
+ : itemsOf(topics)[0]?.id || "web",
644
+ community_id: audience,
645
+ body: "",
646
+ file: "",
647
+ lines: "",
648
+ review_focus: "",
649
+ demo_url: "",
650
+ },
651
+ });
652
+ });
653
+ }
654
+ function reply() {
655
+ const p = current?.post;
656
+ if (!p) return;
657
+ if (p.archived_at || p.locked_at) {
658
+ setError("Replies are paused for this conversation.");
659
+ return;
660
+ }
661
+ const feedback = route.view === "feedback",
662
+ thread = route.view === "thread";
663
+ openForm({
664
+ kind: "reply",
665
+ title: feedback
666
+ ? "Reply to code feedback"
667
+ : thread
668
+ ? "Reply in this thread"
669
+ : "Add your perspective",
670
+ feedback,
671
+ audience: p.community_id ? "Private community" : "Public feed",
672
+ postId: p.id,
673
+ url: feedback
674
+ ? `/posts/${p.id}/code-reviews/${route.id}/replies`
675
+ : `/posts/${p.id}/replies`,
676
+ parentId: thread ? route.parent.id : null,
677
+ values: { body: "" },
678
+ });
679
+ }
680
+ async function lineFeedback() {
681
+ const p = current?.post;
682
+ if (!p?.code) {
683
+ setError("This review needs a published code snippet.");
684
+ return;
685
+ }
686
+ if (p.archived_at || p.locked_at) {
687
+ setError("Replies are paused.");
688
+ return;
689
+ }
690
+ await task(async () => {
691
+ const notes = await api(`/posts/${p.id}/code-reviews`);
692
+ openForm({
693
+ kind: "line",
694
+ title: "Comment on code lines",
695
+ feedback: true,
696
+ audience: p.community_id ? "Private community" : "Public feed",
697
+ postId: p.id,
698
+ snippetHash: notes.snippet_hash,
699
+ lineCount: p.code.split("\n").length,
700
+ values: { start_line: "1", end_line: "1", body: "" },
701
+ });
702
+ });
703
+ }
704
+ async function prepare() {
705
+ if (!form) return;
706
+ await task(async () => {
707
+ const fields = fieldsFor(form);
708
+ for (let i = 0; i < fields.length; i++) {
709
+ const f = fields[i],
710
+ value = form.values[f.key] || "";
711
+ if (f.min && value.trim().length < f.min) {
712
+ setField(i);
713
+ setCursor(graphemes(value).length);
714
+ throw Error(f.label + " needs at least " + f.min + " characters.");
715
+ }
716
+ }
717
+ if (form.kind === "open") {
718
+ const id = Number(form.values.id);
719
+ if (!Number.isSafeInteger(id) || id < 1)
720
+ throw Error("Enter a positive conversation ID.");
721
+ setForm(null);
722
+ navigate({ view: "post", id, tab: "overview", page: 1 });
723
+ return;
724
+ }
725
+ let payload,
726
+ method = "POST",
727
+ url,
728
+ title = form.title;
729
+ if (form.kind === "publication") {
730
+ payload = {
731
+ kind: form.values.kind,
732
+ title: form.values.title.trim(),
733
+ topic: form.values.topic,
734
+ community_id: form.values.community_id || null,
735
+ body: form.values.body,
736
+ };
737
+ url = "/posts";
738
+ if (payload.kind === "review") {
739
+ const filename = resolve(
740
+ form.values.file.replace(/^~(?=\/)/, homedir()),
741
+ ),
742
+ info = await stat(filename);
743
+ if (!info.isFile() || info.size > 80000)
744
+ throw Error(
745
+ "Choose a UTF-8 code file of at most 20,000 characters.",
746
+ );
747
+ let code = await readFile(filename, "utf8");
748
+ if (
749
+ code.length > 20000 ||
750
+ code.includes("\u0000") ||
751
+ code.includes("\ufffd")
752
+ )
753
+ throw Error(
754
+ "Choose a UTF-8 code file of at most 20,000 characters.",
755
+ );
756
+ if (form.values.lines) {
757
+ const match = form.values.lines.match(/^([1-9]\d*):([1-9]\d*)$/);
758
+ if (!match) throw Error("Use START:END for file lines.");
759
+ const first = Number(match[1]),
760
+ last = Number(match[2]),
761
+ lines = code.split("\n");
762
+ if (last < first || last > lines.length)
763
+ throw Error("The range is outside the selected file.");
764
+ code = lines.slice(first - 1, last).join("\n");
765
+ }
766
+ if (code.trim().length < 10)
767
+ throw Error("The selected snippet needs at least 10 characters.");
768
+ payload.code = code;
769
+ payload.review_focus = form.values.review_focus;
770
+ }
771
+ if (payload.kind === "showcase")
772
+ payload.demo_url = form.values.demo_url;
773
+ } else if (form.kind === "community") {
774
+ payload = {
775
+ name: form.values.name,
776
+ description: form.values.description,
777
+ };
778
+ url = "/communities";
779
+ } else if (form.kind === "reply") {
780
+ payload = {
781
+ body: form.values.body,
782
+ ...(form.parentId ? { parent_id: form.parentId } : {}),
783
+ };
784
+ url = form.url;
785
+ } else if (form.kind === "line") {
786
+ const start = Number(form.values.start_line),
787
+ end = Number(form.values.end_line);
788
+ if (
789
+ !Number.isSafeInteger(start) ||
790
+ !Number.isSafeInteger(end) ||
791
+ start < 1 ||
792
+ end < start ||
793
+ end > form.lineCount ||
794
+ end - start >= 50
795
+ )
796
+ throw Error("Choose up to 50 existing snippet lines.");
797
+ payload = {
798
+ start_line: start,
799
+ end_line: end,
800
+ body: form.values.body,
801
+ snippet_hash: form.snippetHash,
802
+ };
803
+ url = `/posts/${form.postId}/code-reviews`;
804
+ } else if (form.kind === "edit") {
805
+ payload = { body: form.values.body, revision: form.revision };
806
+ url = form.url;
807
+ method = "PATCH";
808
+ } else if (form.kind === "report") {
809
+ payload = { reason: form.values.reason, reply_id: form.replyId || 0 };
810
+ url = form.url;
811
+ }
812
+ setPreview({ payload, method, url, title });
813
+ setScroll(0);
814
+ setYes(false);
815
+ setError("");
816
+ });
817
+ }
818
+ async function publish() {
819
+ if (!preview) return;
820
+ await task(async () => {
821
+ const response = await api(preview.url, {
822
+ method: preview.method,
823
+ body: preview.payload,
824
+ });
825
+ cache.current.clear();
826
+ setPreview(null);
827
+ setForm(null);
828
+ setScroll(0);
829
+ setNotice("Saved. Your web account sees the same update.");
830
+ if (preview.url === "/posts")
831
+ navigate({ view: "post", id: response.id, tab: "overview", page: 1 });
832
+ else if (preview.url === "/communities")
833
+ navigate({ view: "community", id: response.id, page: 1 });
834
+ else {
835
+ setRefresh((v) => v + 1);
836
+ if (form.kind === "line")
837
+ navigate({
838
+ view: "feedback",
839
+ postId: form.postId,
840
+ id: response.id,
841
+ page: 1,
842
+ });
843
+ }
844
+ });
845
+ }
846
+ async function openSelected() {
847
+ if (route.view === "community" && !current?.community?.is_member) return;
848
+ const item = selected;
849
+ if (!item) return;
850
+ if (route.view === "devices") {
851
+ askConfirm("Revoke this terminal?", item.name, () =>
852
+ task(async () => {
853
+ await api("/cli/sessions/" + encodeURIComponent(item.id), {
854
+ method: "DELETE",
855
+ body: {},
856
+ });
857
+ try {
858
+ await api("/me");
859
+ invalidate("Terminal access revoked.");
860
+ } catch (e) {
861
+ if (e.status === 401) {
862
+ await removeConfig();
863
+ resetAccount();
864
+ setConfig(null);
865
+ setPhase("auth");
866
+ } else throw e;
867
+ }
868
+ }),
869
+ );
870
+ return;
871
+ }
872
+ if (route.view === "communities" || item.type === "community") {
873
+ navigate({ view: "community", id: item.id, page: 1 });
874
+ return;
875
+ }
876
+ if (route.view === "inbox") {
877
+ await task(async () => {
878
+ if (!item.read_at)
879
+ await api("/notifications/" + item.id + "/read", {
880
+ method: "PATCH",
881
+ body: {},
882
+ });
883
+ cache.current.clear();
884
+ if (item.event_id) {
885
+ openBrowser(
886
+ config.origin + "/app#events/" + encodeURIComponent(item.event_id),
887
+ );
888
+ setNotice("Event opened in your browser.");
889
+ return;
890
+ }
891
+ if (item.code_review_id)
892
+ navigate({
893
+ view: "feedback",
894
+ postId: item.post_id,
895
+ id: item.code_review_id,
896
+ page: 1,
897
+ targetReply: item.feedback_reply_id || null,
898
+ });
899
+ else
900
+ navigate({
901
+ view: "post",
902
+ id: item.post_id,
903
+ tab: item.reply_id ? "replies" : "overview",
904
+ page: 1,
905
+ });
906
+ });
907
+ return;
908
+ }
909
+ if (route.view === "post" && route.tab === "feedback") {
910
+ navigate({ view: "feedback", postId: route.id, id: item.id, page: 1 });
911
+ return;
912
+ }
913
+ if (
914
+ (route.view === "post" && route.tab === "replies") ||
915
+ route.view === "thread"
916
+ ) {
917
+ navigate({
918
+ view: "thread",
919
+ postId: current.post.id,
920
+ parent: item,
921
+ page: 1,
922
+ });
923
+ return;
924
+ }
925
+ if (route.view === "feedback") {
926
+ setReading(item);
927
+ setScroll(0);
928
+ return;
929
+ }
930
+ navigate({
931
+ view: "post",
932
+ id: item.post_id || item.id,
933
+ tab: "overview",
934
+ page: 1,
935
+ });
936
+ }
937
+ function editable() {
938
+ if (!selected) return null;
939
+ if (route.view === "feedback")
940
+ return {
941
+ row: selected,
942
+ url:
943
+ `/posts/${route.postId}/code-reviews/${route.id}` +
944
+ (selected.entryType === "feedbackReply"
945
+ ? "/replies/" + selected.id
946
+ : ""),
947
+ feedback: true,
948
+ };
949
+ if (
950
+ route.view === "thread" ||
951
+ (route.view === "post" && route.tab === "replies")
952
+ )
953
+ return { row: selected, url: "/replies/" + selected.id, feedback: false };
954
+ if (route.view === "post" && route.tab === "feedback")
955
+ return {
956
+ row: selected,
957
+ url: `/posts/${route.id}/code-reviews/${selected.id}`,
958
+ feedback: true,
959
+ };
960
+ return null;
961
+ }
962
+ function editSelected() {
963
+ const target = editable();
964
+ if (!target || target.row.user_id !== user.id || target.row.deleted_at) {
965
+ setError("Select your own existing reply or code feedback to edit.");
966
+ return;
967
+ }
968
+ openForm({
969
+ kind: "edit",
970
+ feedback: target.feedback,
971
+ title: "Edit your feedback",
972
+ url: target.url,
973
+ revision: target.row.revision,
974
+ audience: current.post?.community_id
975
+ ? "Private community"
976
+ : "Public feed",
977
+ values: { body: target.row.body },
978
+ });
979
+ }
980
+ function deleteSelected() {
981
+ const target = editable();
982
+ if (
983
+ !target?.feedback ||
984
+ target.row.user_id !== user.id ||
985
+ target.row.deleted_at
986
+ ) {
987
+ setError("Select your own line feedback or feedback reply to delete.");
988
+ return;
989
+ }
990
+ askConfirm(
991
+ "Delete your feedback?",
992
+ "Your text is removed. Other members’ replies are preserved.",
993
+ () =>
994
+ task(async () => {
995
+ await api(target.url, {
996
+ method: "DELETE",
997
+ body: { revision: target.row.revision },
998
+ });
999
+ invalidate("Feedback deleted; the discussion is preserved.");
1000
+ }),
1001
+ );
1002
+ }
1003
+ async function resolveSelected() {
1004
+ const post = current?.post,
1005
+ note =
1006
+ route.view === "feedback"
1007
+ ? current.note
1008
+ : route.view === "post" && route.tab === "feedback"
1009
+ ? selected
1010
+ : null;
1011
+ if (!post || !note || post.user_id !== user.id || note.deleted_at) {
1012
+ setError(
1013
+ "Only the publication author can resolve existing line feedback.",
1014
+ );
1015
+ return;
1016
+ }
1017
+ await task(async () => {
1018
+ await api(`/posts/${post.id}/code-reviews/${note.id}`, {
1019
+ method: "PATCH",
1020
+ body: { revision: note.revision, resolved: !note.resolved },
1021
+ });
1022
+ invalidate(note.resolved ? "Feedback reopened." : "Feedback resolved.");
1023
+ });
1024
+ }
1025
+ async function runCommand() {
1026
+ const value = command.trim();
1027
+ if (!value) return;
1028
+ setCommand("");
1029
+ setCommandCursor(0);
1030
+ setFocus("content");
1031
+ try {
1032
+ const parsed = value.startsWith("/")
1033
+ ? parseCommand(value)
1034
+ : { command: "search", arg: value };
1035
+ if (parsed.command === "quit") {
1036
+ askQuit();
1037
+ return;
1038
+ }
1039
+ if (parsed.command === "logout") {
1040
+ askConfirm(
1041
+ "Disconnect this terminal?",
1042
+ "This revokes its access and removes the saved local token.",
1043
+ () =>
1044
+ task(async () => {
1045
+ await api("/cli/logout", { method: "POST", body: {} });
1046
+ await removeConfig();
1047
+ resetAccount();
1048
+ setConfig(null);
1049
+ setPhase("auth");
1050
+ }),
1051
+ );
1052
+ return;
1053
+ }
1054
+ if (parsed.command === "open") {
1055
+ navigate({ view: "post", id: parsed.arg, tab: "overview", page: 1 });
1056
+ return;
1057
+ }
1058
+ if (parsed.command === "ask" || parsed.command === "review") {
1059
+ await composePublication(
1060
+ parsed.command === "review" ? "review" : "question",
1061
+ );
1062
+ return;
1063
+ }
1064
+ navigate({
1065
+ view: parsed.command,
1066
+ page: 1,
1067
+ ...(parsed.command === "search" ? { query: parsed.arg } : {}),
1068
+ });
1069
+ } catch (e) {
1070
+ setError(e.message);
1071
+ }
1072
+ }
1073
+ function askQuit() {
1074
+ if (form) {
1075
+ askConfirm(
1076
+ "Leave this draft?",
1077
+ "The unsent draft lives only in this terminal session.",
1078
+ () => exit(),
1079
+ );
1080
+ } else exit();
1081
+ }
1082
+ function changeField(next) {
1083
+ const fields = fieldsFor(form),
1084
+ selected = (next + fields.length + 1) % (fields.length + 1);
1085
+ setField(selected);
1086
+ setCursor(
1087
+ selected < fields.length
1088
+ ? graphemes(form.values[fields[selected].key] || "").length
1089
+ : 0,
1090
+ );
1091
+ }
1092
+ useInput((input, keyEvent) => {
1093
+ if (keyEvent.ctrl && input === "c") {
1094
+ if (dialog) {
1095
+ exit();
1096
+ return;
1097
+ }
1098
+ askQuit();
1099
+ return;
1100
+ }
1101
+ if (width < 60 || rows < 18) {
1102
+ if (input === "q" || keyEvent.escape) exit();
1103
+ return;
1104
+ }
1105
+ if (dialog) {
1106
+ if (keyEvent.escape) {
1107
+ setDialog(null);
1108
+ return;
1109
+ }
1110
+ if (keyEvent.leftArrow || keyEvent.rightArrow || keyEvent.tab)
1111
+ setYes((v) => !v);
1112
+ else if (keyEvent.return) {
1113
+ const chosen = dialog;
1114
+ setDialog(null);
1115
+ if (yes) chosen.action();
1116
+ }
1117
+ return;
1118
+ }
1119
+ if (phase !== "ready") {
1120
+ if (keyEvent.escape && phase === "pending") {
1121
+ authController.current?.abort();
1122
+ setPhase("auth");
1123
+ setDevice(null);
1124
+ return;
1125
+ }
1126
+ if (input === "q") exit();
1127
+ else if (keyEvent.return && !busy.current) {
1128
+ if (phase === "pending" && device) openBrowser(device.verification_uri);
1129
+ else connect();
1130
+ }
1131
+ return;
1132
+ }
1133
+ if (busy.current) return;
1134
+ if (reading) {
1135
+ const lines = wrap(
1136
+ reading.deleted_at
1137
+ ? "[deleted — discussion preserved]"
1138
+ : reading.body || "",
1139
+ contentWidth,
1140
+ ),
1141
+ maximum = Math.max(0, lines.length - contentHeight);
1142
+ if (keyEvent.escape || keyEvent.return || input === " ") {
1143
+ setReading(null);
1144
+ setScroll(0);
1145
+ return;
1146
+ }
1147
+ if (keyEvent.upArrow || keyEvent.pageUp)
1148
+ setScroll((v) =>
1149
+ Math.max(0, v - (keyEvent.pageUp ? contentHeight : 1)),
1150
+ );
1151
+ else if (keyEvent.downArrow || keyEvent.pageDown)
1152
+ setScroll((v) =>
1153
+ Math.min(maximum, v + (keyEvent.pageDown ? contentHeight : 1)),
1154
+ );
1155
+ else if (input === "r") {
1156
+ setReading(null);
1157
+ reply();
1158
+ } else if (input === "e") {
1159
+ setReading(null);
1160
+ editSelected();
1161
+ }
1162
+ return;
1163
+ }
1164
+ if (preview) {
1165
+ if (keyEvent.escape) {
1166
+ setPreview(null);
1167
+ setScroll(0);
1168
+ return;
1169
+ }
1170
+ if (keyEvent.leftArrow || keyEvent.rightArrow || keyEvent.tab)
1171
+ setYes((v) => !v);
1172
+ else if (keyEvent.return) {
1173
+ if (yes) publish();
1174
+ else {
1175
+ setPreview(null);
1176
+ setScroll(0);
1177
+ }
1178
+ } else if (keyEvent.upArrow) setScroll((v) => Math.max(0, v - 1));
1179
+ else if (keyEvent.downArrow) setScroll((v) => v + 1);
1180
+ else if (keyEvent.pageDown) setScroll((v) => v + contentHeight);
1181
+ else if (keyEvent.pageUp)
1182
+ setScroll((v) => Math.max(0, v - contentHeight));
1183
+ return;
1184
+ }
1185
+ if (form) {
1186
+ if (keyEvent.escape) {
1187
+ askConfirm(
1188
+ "Discard this draft?",
1189
+ "Nothing has been published. Stay to keep editing.",
1190
+ () => {
1191
+ setForm(null);
1192
+ setFocus("content");
1193
+ setError("");
1194
+ },
1195
+ );
1196
+ return;
1197
+ }
1198
+ if (keyEvent.ctrl && input === "s") {
1199
+ prepare();
1200
+ return;
1201
+ }
1202
+ if (keyEvent.tab) {
1203
+ changeField(field + (keyEvent.shift ? -1 : 1));
1204
+ return;
1205
+ }
1206
+ const fields = fieldsFor(form),
1207
+ active = fields[field];
1208
+ if (!active) {
1209
+ if (keyEvent.return) prepare();
1210
+ else if (keyEvent.upArrow) changeField(field - 1);
1211
+ return;
1212
+ }
1213
+ if (active.options) {
1214
+ if (
1215
+ keyEvent.leftArrow ||
1216
+ keyEvent.rightArrow ||
1217
+ keyEvent.upArrow ||
1218
+ keyEvent.downArrow
1219
+ ) {
1220
+ const opts = active.options,
1221
+ i = Math.max(
1222
+ 0,
1223
+ opts.findIndex(
1224
+ (item) =>
1225
+ String(item[0]) === String(form.values[active.key] || ""),
1226
+ ),
1227
+ ),
1228
+ step = keyEvent.leftArrow || keyEvent.upArrow ? -1 : 1,
1229
+ next = opts[(i + step + opts.length) % opts.length];
1230
+ if (next)
1231
+ setForm((old) => ({
1232
+ ...old,
1233
+ values: { ...old.values, [active.key]: next[0] },
1234
+ }));
1235
+ } else if (keyEvent.return) changeField(field + 1);
1236
+ return;
1237
+ }
1238
+ if (keyEvent.return && !active.multiline) {
1239
+ changeField(field + 1);
1240
+ return;
1241
+ }
1242
+ const updated = editText(
1243
+ form.values[active.key] || "",
1244
+ cursor,
1245
+ input,
1246
+ keyEvent,
1247
+ { multiline: active.multiline, max: active.max },
1248
+ );
1249
+ setForm((old) => ({
1250
+ ...old,
1251
+ values: { ...old.values, [active.key]: updated.value },
1252
+ }));
1253
+ setCursor(updated.cursor);
1254
+ return;
1255
+ }
1256
+ if (focus === "prompt") {
1257
+ if (keyEvent.escape) {
1258
+ setFocus("content");
1259
+ return;
1260
+ }
1261
+ if (keyEvent.tab) {
1262
+ setFocus("nav");
1263
+ return;
1264
+ }
1265
+ if (keyEvent.return) {
1266
+ runCommand();
1267
+ return;
1268
+ }
1269
+ const updated = editText(command, commandCursor, input, keyEvent, {
1270
+ max: 500,
1271
+ });
1272
+ setCommand(updated.value);
1273
+ setCommandCursor(updated.cursor);
1274
+ return;
1275
+ }
1276
+ if (keyEvent.tab) {
1277
+ setFocus(focus === "nav" ? "content" : "prompt");
1278
+ return;
1279
+ }
1280
+ if (input === "/") {
1281
+ setFocus("prompt");
1282
+ setCommand("");
1283
+ setCommandCursor(0);
1284
+ return;
1285
+ }
1286
+ if (keyEvent.escape) {
1287
+ back();
1288
+ return;
1289
+ }
1290
+ if (input === "q") {
1291
+ askQuit();
1292
+ return;
1293
+ }
1294
+ if (focus === "nav") {
1295
+ if (keyEvent.upArrow)
1296
+ setNavIndex((v) => (v + navigation.length - 1) % navigation.length);
1297
+ else if (keyEvent.downArrow)
1298
+ setNavIndex((v) => (v + 1) % navigation.length);
1299
+ else if (keyEvent.return) chooseNav(navIndex);
1300
+ else if (keyEvent.rightArrow) setFocus("content");
1301
+ return;
1302
+ }
1303
+ const prose =
1304
+ route.view === "help" ||
1305
+ (route.view === "post" && (!route.tab || route.tab === "overview")) ||
1306
+ (route.view === "community" && !current?.community?.is_member);
1307
+ if (
1308
+ keyEvent.upArrow ||
1309
+ keyEvent.downArrow ||
1310
+ keyEvent.pageUp ||
1311
+ keyEvent.pageDown
1312
+ ) {
1313
+ const step =
1314
+ keyEvent.pageUp || keyEvent.pageDown
1315
+ ? Math.max(1, Math.floor(contentHeight / 4))
1316
+ : 1,
1317
+ delta = keyEvent.upArrow || keyEvent.pageUp ? -step : step;
1318
+ if (prose) {
1319
+ const maximum = Math.max(
1320
+ 0,
1321
+ contentLines(route, current || {}).flatMap((line) =>
1322
+ wrap(line, contentWidth),
1323
+ ).length - contentHeight,
1324
+ );
1325
+ const amount =
1326
+ keyEvent.pageUp || keyEvent.pageDown
1327
+ ? Math.sign(delta) * contentHeight
1328
+ : delta;
1329
+ setScroll((v) => Math.max(0, Math.min(maximum, v + amount)));
1330
+ } else
1331
+ setIndex((v) =>
1332
+ Math.max(0, Math.min((current?.items?.length || 1) - 1, v + delta)),
1333
+ );
1334
+ return;
1335
+ }
1336
+ if ((keyEvent.leftArrow || keyEvent.rightArrow) && route.view === "post") {
1337
+ const tabs =
1338
+ current?.post?.kind === "review"
1339
+ ? ["overview", "replies", "feedback"]
1340
+ : ["overview", "replies"],
1341
+ pos = tabs.indexOf(route.tab || "overview"),
1342
+ next =
1343
+ tabs[
1344
+ (pos + (keyEvent.rightArrow ? 1 : tabs.length - 1)) % tabs.length
1345
+ ];
1346
+ navigate({ ...route, tab: next, page: 1 }, { replace: true });
1347
+ return;
1348
+ }
1349
+ if (
1350
+ input === " " &&
1351
+ selected &&
1352
+ ["post", "thread", "feedback"].includes(route.view)
1353
+ ) {
1354
+ setReading(selected);
1355
+ setScroll(0);
1356
+ return;
1357
+ }
1358
+ if (keyEvent.return) {
1359
+ openSelected();
1360
+ return;
1361
+ }
1362
+ if (input === "]" || input === "[") {
1363
+ const meta = current?.pagination;
1364
+ if (meta) {
1365
+ const next = meta.page + (input === "]" ? 1 : -1);
1366
+ if (next >= 1 && next <= meta.pages)
1367
+ navigate(
1368
+ { ...route, page: next, targetReply: null },
1369
+ { replace: true },
1370
+ );
1371
+ }
1372
+ return;
1373
+ }
1374
+ if (input === "a") {
1375
+ composePublication("question");
1376
+ return;
1377
+ }
1378
+ if (input === "n") {
1379
+ if (route.view === "communities")
1380
+ openForm({
1381
+ kind: "community",
1382
+ title: "Create a private community",
1383
+ values: { name: "", description: "" },
1384
+ });
1385
+ else composePublication("discussion");
1386
+ return;
1387
+ }
1388
+ if (input === "r") {
1389
+ if (["post", "feedback", "thread"].includes(route.view)) reply();
1390
+ else invalidate("Updated.");
1391
+ return;
1392
+ }
1393
+ if (input === "R") {
1394
+ invalidate("Updated.");
1395
+ return;
1396
+ }
1397
+ if (input === "c") {
1398
+ lineFeedback();
1399
+ return;
1400
+ }
1401
+ if (input === "s") {
1402
+ resolveSelected();
1403
+ return;
1404
+ }
1405
+ if (input === "e") {
1406
+ editSelected();
1407
+ return;
1408
+ }
1409
+ if (input === "d") {
1410
+ deleteSelected();
1411
+ return;
1412
+ }
1413
+ if (input === "o" && current?.post) {
1414
+ openBrowser(config.origin + "/app#post/" + current.post.id);
1415
+ setNotice("Opened in your browser.");
1416
+ return;
1417
+ }
1418
+ if (
1419
+ input === "j" &&
1420
+ route.view === "community" &&
1421
+ !current?.community?.is_member &&
1422
+ !current?.community?.requested
1423
+ ) {
1424
+ task(async () => {
1425
+ await api("/communities/" + encodeURIComponent(route.id) + "/request", {
1426
+ method: "POST",
1427
+ body: {},
1428
+ });
1429
+ invalidate("Access requested.");
1430
+ });
1431
+ return;
1432
+ }
1433
+ if (input === "!" && ["feedback", "post"].includes(route.view)) {
1434
+ const target = editable();
1435
+ if (!target?.feedback || target.row.deleted_at) {
1436
+ setError("Select an existing code remark or feedback reply to report.");
1437
+ return;
1438
+ }
1439
+ const noteId = route.view === "feedback" ? route.id : target.row.id;
1440
+ openForm({
1441
+ kind: "report",
1442
+ feedback: true,
1443
+ title: "Report selected feedback",
1444
+ url: `/posts/${current.post.id}/code-reviews/${noteId}/report`,
1445
+ replyId: target.row.entryType === "feedbackReply" ? target.row.id : 0,
1446
+ audience: "Assigned moderation team",
1447
+ values: { reason: "" },
1448
+ });
1449
+ }
1450
+ });
1451
+ const status =
1452
+ error ||
1453
+ notice ||
1454
+ (pending
1455
+ ? "Saving…"
1456
+ : loading
1457
+ ? "Loading…"
1458
+ : "Shared account · shared conversations · same permissions");
1459
+ const spin = ["◐", "◓", "◑", "◒"][tick];
1460
+ let center;
1461
+ if (width < 60 || rows < 18)
1462
+ center = h(
1463
+ Box,
1464
+ { flexDirection: "column", padding: 1 },
1465
+ h(Text, { color: colors.purple }, "DEVDEVIAN"),
1466
+ h(Text, null, "Enlarge this terminal to at least 60 × 18."),
1467
+ h(Text, null, "Q or Ctrl+C exits."),
1468
+ );
1469
+ else if (phase !== "ready") {
1470
+ const lines = [
1471
+ "A place for curious developers.",
1472
+ "Your conversations, without leaving the terminal.",
1473
+ "",
1474
+ ...(phase === "pending" && device
1475
+ ? [
1476
+ "Confirm this code: " + device.user_code,
1477
+ "Waiting for your approval " + spin,
1478
+ device.verification_uri,
1479
+ "Enter reopens the browser · Esc cancels",
1480
+ ]
1481
+ : phase === "checking"
1482
+ ? ["Opening your workspace " + spin]
1483
+ : [
1484
+ "Sign in with your existing Devdevian account.",
1485
+ "",
1486
+ "Enter connects this terminal through your browser.",
1487
+ "Google, GitHub and email sign-in stay on the website.",
1488
+ "",
1489
+ "Q exits.",
1490
+ ]),
1491
+ ];
1492
+ center = h(
1493
+ Box,
1494
+ {
1495
+ borderStyle: "round",
1496
+ borderColor: colors.purple,
1497
+ flexDirection: "column",
1498
+ paddingX: 2,
1499
+ paddingY: 1,
1500
+ height: bodyHeight,
1501
+ overflow: "hidden",
1502
+ },
1503
+ h(Text, { bold: true, color: colors.purple }, "YOUR DEVELOPER SPACE"),
1504
+ h(FrameRows, {
1505
+ rows: lines.flatMap((line) => wrap(line, width - 8)),
1506
+ width: width - 8,
1507
+ height: Math.max(1, bodyHeight - 5),
1508
+ }),
1509
+ );
1510
+ } else if (dialog) {
1511
+ center = h(
1512
+ Box,
1513
+ {
1514
+ borderStyle: "round",
1515
+ borderColor: colors.purple,
1516
+ flexDirection: "column",
1517
+ paddingX: 2,
1518
+ paddingY: 1,
1519
+ height: bodyHeight,
1520
+ overflow: "hidden",
1521
+ },
1522
+ h(Text, { bold: true, color: colors.text }, dialog.title),
1523
+ h(Text, null, " "),
1524
+ h(Text, { color: colors.muted, wrap: "wrap" }, dialog.description),
1525
+ h(Text, null, " "),
1526
+ h(
1527
+ Text,
1528
+ null,
1529
+ h(
1530
+ Text,
1531
+ { color: !yes ? colors.blue : colors.muted, bold: !yes },
1532
+ " Stay / cancel ",
1533
+ ),
1534
+ h(
1535
+ Text,
1536
+ { color: yes ? colors.purple : colors.muted, bold: yes },
1537
+ " Confirm ",
1538
+ ),
1539
+ ),
1540
+ h(
1541
+ Text,
1542
+ { color: colors.muted },
1543
+ "← → choose · Enter confirm · Esc cancel",
1544
+ ),
1545
+ );
1546
+ } else {
1547
+ let body;
1548
+ if (reading) {
1549
+ const lines = wrap(
1550
+ reading.deleted_at
1551
+ ? "[deleted — discussion preserved]"
1552
+ : reading.body || "",
1553
+ contentWidth,
1554
+ );
1555
+ body = h(FrameRows, {
1556
+ rows: lines,
1557
+ width: contentWidth,
1558
+ height: contentHeight,
1559
+ offset: Math.min(scroll, Math.max(0, lines.length - contentHeight)),
1560
+ });
1561
+ } else if (preview) {
1562
+ const payload = preview.payload,
1563
+ audience =
1564
+ form.audience ||
1565
+ (payload.community_id
1566
+ ? form.audiences?.find(
1567
+ (item) => item[0] === payload.community_id,
1568
+ )?.[1] || "Private community"
1569
+ : "Public feed · everyone");
1570
+ const raw = [
1571
+ "AUDIENCE · " + audience,
1572
+ "",
1573
+ ...(payload.title ? [payload.title, ""] : []),
1574
+ payload.body || payload.reason || payload.description || "",
1575
+ ...(payload.code ? ["", "CODE TO PUBLISH", payload.code] : []),
1576
+ ...(payload.review_focus
1577
+ ? ["", "REVIEW FOCUS", payload.review_focus]
1578
+ : []),
1579
+ ];
1580
+ const lines = raw.flatMap((line) => wrap(line, contentWidth));
1581
+ body = h(
1582
+ Box,
1583
+ { flexDirection: "column" },
1584
+ h(FrameRows, {
1585
+ rows: lines,
1586
+ width: contentWidth,
1587
+ height: Math.max(1, contentHeight - 2),
1588
+ offset: Math.min(
1589
+ scroll,
1590
+ Math.max(0, lines.length - contentHeight + 2),
1591
+ ),
1592
+ }),
1593
+ h(Text, { color: colors.muted }, " "),
1594
+ h(
1595
+ Text,
1596
+ null,
1597
+ h(
1598
+ Text,
1599
+ { color: !yes ? colors.blue : colors.muted, bold: !yes },
1600
+ " ← Back to edit ",
1601
+ ),
1602
+ h(
1603
+ Text,
1604
+ { color: yes ? colors.purple : colors.muted, bold: yes },
1605
+ " Publish / save → ",
1606
+ ),
1607
+ ),
1608
+ );
1609
+ } else if (form)
1610
+ body = h(FormView, {
1611
+ form,
1612
+ active: field,
1613
+ cursor,
1614
+ width: contentWidth,
1615
+ height: contentHeight,
1616
+ });
1617
+ else if (loading && !current)
1618
+ body = h(
1619
+ Text,
1620
+ { color: colors.muted },
1621
+ "Opening " + labelOf(route).toLowerCase() + " " + spin,
1622
+ );
1623
+ else {
1624
+ const lines = contentLines(route, current || {}).flatMap((line) =>
1625
+ wrap(line, contentWidth),
1626
+ );
1627
+ if (lines.length)
1628
+ body = h(FrameRows, {
1629
+ rows: lines,
1630
+ width: contentWidth,
1631
+ height: contentHeight,
1632
+ offset: Math.min(scroll, Math.max(0, lines.length - contentHeight)),
1633
+ });
1634
+ else if (!current?.items?.length)
1635
+ body = h(
1636
+ Box,
1637
+ { flexDirection: "column", paddingTop: 1 },
1638
+ h(
1639
+ Text,
1640
+ { color: colors.text },
1641
+ route.view === "search"
1642
+ ? "Find your next useful conversation."
1643
+ : error
1644
+ ? "Could not open this view."
1645
+ : "Nothing here yet.",
1646
+ ),
1647
+ h(
1648
+ Text,
1649
+ { color: colors.muted },
1650
+ error
1651
+ ? "Press Shift+R to retry."
1652
+ : route.view === "feed"
1653
+ ? "N starts a conversation. A asks a question."
1654
+ : "Use / to search or Tab to explore another section.",
1655
+ ),
1656
+ );
1657
+ else {
1658
+ const visible = Math.max(1, Math.floor(contentHeight / 4)),
1659
+ first = Math.max(0, index - visible + 1),
1660
+ rows = listRows(route, current, index, contentWidth).slice(
1661
+ first * 4,
1662
+ first * 4 + contentHeight,
1663
+ );
1664
+ body = h(
1665
+ Box,
1666
+ {
1667
+ flexDirection: "column",
1668
+ height: contentHeight,
1669
+ overflow: "hidden",
1670
+ },
1671
+ ...rows.map((row, i) =>
1672
+ h(
1673
+ Box,
1674
+ {
1675
+ key: i,
1676
+ width: contentWidth,
1677
+ backgroundColor: row.selected ? colors.selected : undefined,
1678
+ },
1679
+ h(TextLine, {
1680
+ text: typeof row === "string" ? row : row.text,
1681
+ width: contentWidth,
1682
+ color: row.color || colors.text,
1683
+ bold: row.bold,
1684
+ }),
1685
+ ),
1686
+ ),
1687
+ );
1688
+ }
1689
+ }
1690
+ const tabs =
1691
+ route.view === "post" && !form
1692
+ ? [
1693
+ ["overview", "Overview"],
1694
+ ["replies", "Replies"],
1695
+ ...(current?.post?.kind === "review"
1696
+ ? [["feedback", "Code feedback"]]
1697
+ : []),
1698
+ ]
1699
+ : [];
1700
+ const pane = h(
1701
+ Box,
1702
+ {
1703
+ borderStyle: "round",
1704
+ borderColor: focus === "nav" && !compact ? colors.line : colors.purple,
1705
+ backgroundColor: colors.panel,
1706
+ flexDirection: "column",
1707
+ flexGrow: 1,
1708
+ paddingX: 1,
1709
+ height: bodyHeight,
1710
+ overflow: "hidden",
1711
+ },
1712
+ h(TextLine, {
1713
+ text: reading
1714
+ ? "Full text · " + (reading.name || "reply")
1715
+ : preview
1716
+ ? "Preview · " + preview.title
1717
+ : form
1718
+ ? form.title
1719
+ : current?.title || labelOf(route),
1720
+ width: contentWidth,
1721
+ color: colors.text,
1722
+ bold: true,
1723
+ }),
1724
+ tabs.length
1725
+ ? h(
1726
+ Text,
1727
+ { color: colors.muted, wrap: "truncate" },
1728
+ ...tabs.map(([id, label]) =>
1729
+ h(
1730
+ Text,
1731
+ {
1732
+ key: id,
1733
+ color:
1734
+ (route.tab || "overview") === id
1735
+ ? colors.blue
1736
+ : colors.muted,
1737
+ bold: (route.tab || "overview") === id,
1738
+ },
1739
+ " " + label + " ",
1740
+ ),
1741
+ ),
1742
+ h(Text, { color: colors.muted }, " ← →"),
1743
+ )
1744
+ : h(TextLine, {
1745
+ text: form
1746
+ ? "Tab next field · arrows choose · Ctrl+S preview"
1747
+ : preview
1748
+ ? "Scroll to review · ← → choose · Enter confirms"
1749
+ : current?.subtitle || " ",
1750
+ width: contentWidth,
1751
+ color: colors.muted,
1752
+ }),
1753
+ h(Text, null, " "),
1754
+ body,
1755
+ current?.pagination && !form
1756
+ ? h(TextLine, {
1757
+ text: `[ previous · Page ${current.pagination.page}/${current.pagination.pages || 1} · ${current.pagination.total} items · next ]`,
1758
+ width: contentWidth,
1759
+ color: colors.muted,
1760
+ })
1761
+ : null,
1762
+ );
1763
+ const side =
1764
+ !compact || focus === "nav"
1765
+ ? h(
1766
+ Box,
1767
+ {
1768
+ borderStyle: "round",
1769
+ borderColor: focus === "nav" ? colors.blue : colors.line,
1770
+ backgroundColor: colors.panel,
1771
+ flexDirection: "column",
1772
+ width: compact ? width : sidebar,
1773
+ height: bodyHeight,
1774
+ paddingX: 1,
1775
+ overflow: "hidden",
1776
+ },
1777
+ h(Text, { color: colors.muted }, "WORKSPACE"),
1778
+ h(Text, null, " "),
1779
+ ...navigation
1780
+ .slice(
1781
+ Math.max(0, navIndex - Math.max(1, bodyHeight - 5) + 1),
1782
+ Math.max(0, navIndex - Math.max(1, bodyHeight - 5) + 1) +
1783
+ Math.max(1, bodyHeight - 5),
1784
+ )
1785
+ .map(([id, label], i) => {
1786
+ const absolute = navigation.findIndex((item) => item[0] === id),
1787
+ selected = absolute === navIndex && focus === "nav";
1788
+ return h(
1789
+ Text,
1790
+ {
1791
+ key: id,
1792
+ color: selected
1793
+ ? colors.blue
1794
+ : route.view === id
1795
+ ? colors.purple
1796
+ : colors.muted,
1797
+ bold: selected || route.view === id,
1798
+ backgroundColor: selected ? colors.selected : undefined,
1799
+ wrap: "truncate",
1800
+ },
1801
+ (selected ? "› " : " ") + label,
1802
+ );
1803
+ }),
1804
+ )
1805
+ : null;
1806
+ center = h(
1807
+ Box,
1808
+ { height: bodyHeight, flexDirection: "row", gap: compact ? 0 : 1 },
1809
+ side,
1810
+ compact && focus === "nav" ? null : pane,
1811
+ );
1812
+ }
1813
+ const hint = reading
1814
+ ? "↑↓ scroll · R reply · E edit · Esc close"
1815
+ : preview
1816
+ ? "← → choose · Enter confirm · Esc edit"
1817
+ : form
1818
+ ? "Tab field · Enter newline · Ctrl+S preview · Esc back"
1819
+ : phase !== "ready"
1820
+ ? "Enter connect / open browser · Ctrl+C exit"
1821
+ : route.view === "feedback"
1822
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
1823
+ : route.view === "post"
1824
+ ? "←→ tabs · ↑↓ scroll/select · R reply · C code remark · Space full text · Tab navigate"
1825
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
1826
+ return h(
1827
+ Box,
1828
+ {
1829
+ flexDirection: "column",
1830
+ width,
1831
+ height,
1832
+ overflow: "hidden",
1833
+ backgroundColor: colors.bg,
1834
+ },
1835
+ h(
1836
+ Box,
1837
+ {
1838
+ height: 3,
1839
+ borderStyle: "round",
1840
+ borderColor: colors.line,
1841
+ paddingX: 1,
1842
+ justifyContent: "space-between",
1843
+ overflow: "hidden",
1844
+ },
1845
+ h(
1846
+ Text,
1847
+ { color: colors.purple, bold: true },
1848
+ clip("◈ DEVDEVIAN / TERMINAL WORKSPACE", Math.max(10, width - 28)),
1849
+ ),
1850
+ h(
1851
+ Text,
1852
+ { color: colors.blue },
1853
+ clip(user ? "@" + user.username : "CLI · ALPHA", 24),
1854
+ ),
1855
+ ),
1856
+ center,
1857
+ h(TextLine, {
1858
+ text: clip((pending || loading ? spin + " " : "") + status, width),
1859
+ width,
1860
+ color: error ? colors.red : notice ? colors.green : colors.muted,
1861
+ }),
1862
+ h(
1863
+ Box,
1864
+ {
1865
+ height: 3,
1866
+ borderStyle: "round",
1867
+ borderColor: focus === "prompt" ? colors.blue : colors.line,
1868
+ paddingX: 1,
1869
+ overflow: "hidden",
1870
+ },
1871
+ h(Text, { color: colors.purple, bold: true }, "› "),
1872
+ focus === "prompt"
1873
+ ? h(InputText, {
1874
+ value: command,
1875
+ cursor: commandCursor,
1876
+ width: Math.max(1, width - 6),
1877
+ })
1878
+ : h(
1879
+ Text,
1880
+ { color: colors.muted },
1881
+ clip("Search or /command · Press / to type", width - 6),
1882
+ ),
1883
+ ),
1884
+ h(TextLine, { text: hint, width, color: colors.muted }),
1885
+ );
1886
+ }
1887
+ export async function startTui(flags = {}) {
1888
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
1889
+ throw Error(
1890
+ "The live interface needs an interactive terminal. Use --help or a command with --json for scripts.",
1891
+ );
1892
+ let config = await loadConfig();
1893
+ if (flags.host && config && originOf(flags.host) !== config.origin)
1894
+ config = null;
1895
+ const app = render(h(App, { initialConfig: config, flags }), {
1896
+ alternateScreen: true,
1897
+ exitOnCtrlC: false,
1898
+ patchConsole: true,
1899
+ maxFps: 20,
1900
+ incrementalRendering: false,
1901
+ });
1902
+ try {
1903
+ await app.waitUntilExit();
1904
+ } finally {
1905
+ app.cleanup();
1906
+ process.stdin.pause();
1907
+ process.stdin.unref?.();
1908
+ }
1909
+ }