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