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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/tui.mjs CHANGED
@@ -24,20 +24,22 @@ import {
24
24
  parseCommand,
25
25
  PageCache,
26
26
  } from "./tui-model.mjs";
27
- import { navigation, loadPage, contentLines } from "./tui-data.mjs";
27
+ import {
28
+ navigation,
29
+ loadPage,
30
+ contentLines,
31
+ assertSnippet,
32
+ notificationRoute,
33
+ } from "./tui-data.mjs";
34
+ import { basename } from "node:path";
35
+ import { colors } from "./theme.mjs";
36
+ import { liveLoop, anchoredIndex } from "./live.mjs";
37
+ import { contextActions } from "./context-actions.mjs";
38
+ import { editExternal } from "./external-editor.mjs";
39
+ import { markdownRows } from "./rich-text.mjs";
40
+ import { problemMessage } from "./problems.mjs";
41
+ import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
28
42
  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
43
  const itemsOf = (value) => (Array.isArray(value) ? value : value?.items || []);
42
44
  const stamp = (value) => {
43
45
  const date = new Date(
@@ -196,13 +198,30 @@ function FrameRows({ rows, width, height, offset = 0 }) {
196
198
  Box,
197
199
  { flexDirection: "column", height, overflow: "hidden" },
198
200
  ...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
- }),
201
+ row.spans
202
+ ? h(
203
+ Text,
204
+ {
205
+ key: i,
206
+ color: row.color || colors.text,
207
+ bold: row.bold,
208
+ wrap: "truncate",
209
+ },
210
+ ...row.spans.map((span, n) =>
211
+ h(
212
+ Text,
213
+ { key: n, color: span.color, bold: span.bold || row.bold },
214
+ span.text,
215
+ ),
216
+ ),
217
+ )
218
+ : h(TextLine, {
219
+ key: i,
220
+ text: typeof row === "string" ? row : row.text,
221
+ width,
222
+ color: row.color || colors.text,
223
+ bold: row.bold || false,
224
+ }),
206
225
  ),
207
226
  );
208
227
  }
@@ -335,7 +354,10 @@ function listRows(route, data, index, width) {
335
354
  ? " · original remark"
336
355
  : item.entryType === "parent"
337
356
  ? " · parent reply"
338
- : " · reply #" + item.id);
357
+ : " · reply #" + item.id) +
358
+ (data.post?.accepted_reply_id === item.id
359
+ ? " · accepted solution"
360
+ : "");
339
361
  meta =
340
362
  stamp(item.created) +
341
363
  ((item.edited_at || item.updated_at) && !item.deleted_at
@@ -355,7 +377,7 @@ function listRows(route, data, index, width) {
355
377
  const selected = i === index;
356
378
  return [
357
379
  {
358
- text: (selected ? "› " : " ") + clip(title, width - 3),
380
+ text: (selected ? "▸ " : " ") + clip(title, width - 3),
359
381
  color: selected ? colors.blue : colors.text,
360
382
  bold: selected,
361
383
  selected,
@@ -366,12 +388,51 @@ function listRows(route, data, index, width) {
366
388
  color: selected ? colors.text : colors.muted,
367
389
  selected,
368
390
  },
369
- "",
391
+ { text: " " + "─".repeat(Math.max(0, width - 4)), color: colors.line },
370
392
  ];
371
393
  });
372
394
  }
395
+ function documentRows(route, data, width) {
396
+ if (route.view !== "post" || (route.tab && route.tab !== "overview"))
397
+ return contentLines(route, data).flatMap((line) => wrap(line, width));
398
+ const post = data?.post;
399
+ if (!post) return [];
400
+ const rows = markdownRows(post.body || "", width);
401
+ if (post.review_focus)
402
+ rows.push(
403
+ { text: "" },
404
+ { text: "REVIEW FOCUS", color: colors.purple, bold: true },
405
+ ...markdownRows(post.review_focus, width),
406
+ );
407
+ if (post.code) {
408
+ // Pick a fence longer than any backtick run in the snippet.
409
+ const fence = "`".repeat(
410
+ Math.max(
411
+ 3,
412
+ ...(post.code.match(/`+/g) || []).map((run) => run.length + 1),
413
+ ),
414
+ );
415
+ rows.push(
416
+ { text: "" },
417
+ ...markdownRows(fence + "\n" + post.code + "\n" + fence, width),
418
+ );
419
+ }
420
+ if (post.links?.length)
421
+ rows.push(
422
+ { text: "LINKS", bold: true },
423
+ ...post.links.flatMap((link) =>
424
+ wrap(link.label + " · " + link.url, width),
425
+ ),
426
+ );
427
+ if (post.archived_at || post.locked_at)
428
+ rows.push({
429
+ text: "Replies are paused for this conversation.",
430
+ color: colors.muted,
431
+ });
432
+ return rows;
433
+ }
373
434
  function App({ initialConfig, flags, root }) {
374
- const { exit } = useApp(),
435
+ const { exit, suspendTerminal } = useApp(),
375
436
  { columns, rows } = useWindowSize();
376
437
  const [config, setConfig] = useState(initialConfig),
377
438
  [phase, setPhase] = useState(
@@ -402,9 +463,22 @@ function App({ initialConfig, flags, root }) {
402
463
  [tick, setTick] = useState(0),
403
464
  [reading, setReading] = useState(null),
404
465
  [picker, setPicker] = useState(null),
405
- [draftSaved, setDraftSaved] = useState(false);
466
+ [draftSaved, setDraftSaved] = useState(false),
467
+ [actionIndex, setActionIndex] = useState(null),
468
+ [reviewFocus, setReviewFocus] = useState("notes"),
469
+ [reviewLine, setReviewLine] = useState(0),
470
+ [reviewAnchor, setReviewAnchor] = useState(null),
471
+ [reviewHorizontal, setReviewHorizontal] = useState(0),
472
+ [live, setLive] = useState(!flags["no-live"]),
473
+ [unread, setUnread] = useState(0),
474
+ [liveStatus, setLiveStatus] = useState("");
475
+ const activity = useRef(Date.now()),
476
+ liveView = useRef(null),
477
+ notificationGeneration = useRef(0);
406
478
  const cache = useRef(new PageCache()),
407
479
  history = useRef([]),
480
+ restoreSelection = useRef(null),
481
+ seenSnippet = useRef(null),
408
482
  busy = useRef(false),
409
483
  alive = useRef(true),
410
484
  authController = useRef(null),
@@ -432,6 +506,97 @@ function App({ initialConfig, flags, root }) {
432
506
  const key = JSON.stringify(route),
433
507
  current = data?.key === key ? data : null;
434
508
  const selected = current?.items?.[index];
509
+ const reviewing =
510
+ !!current?.post?.code &&
511
+ (route.view === "feedback" ||
512
+ (route.view === "post" && route.tab === "feedback"));
513
+ const actions = contextActions({
514
+ route,
515
+ data: current,
516
+ selected,
517
+ user,
518
+ live,
519
+ });
520
+ liveView.current = {
521
+ route,
522
+ key,
523
+ current,
524
+ index,
525
+ blocked: !!(
526
+ form ||
527
+ preview ||
528
+ picker ||
529
+ dialog ||
530
+ reading ||
531
+ actionIndex !== null ||
532
+ loading ||
533
+ pending ||
534
+ focus === "prompt"
535
+ ),
536
+ };
537
+ useEffect(() => {
538
+ if (phase !== "ready" || !api || !live) return;
539
+ return liveLoop(
540
+ async (signal) => {
541
+ const generation = notificationGeneration.current;
542
+ const count = await api("/notifications?limit=1", { signal });
543
+ if (signal.aborted || generation !== notificationGeneration.current)
544
+ return;
545
+ setUnread(count.unread_count || 0);
546
+ const before = liveView.current;
547
+ if (
548
+ before.blocked ||
549
+ !["inbox", "post", "feedback", "thread"].includes(before.route.view)
550
+ ) {
551
+ setLiveStatus("");
552
+ return;
553
+ }
554
+ const next = await loadPage(api, before.route, signal);
555
+ const after = liveView.current;
556
+ if (
557
+ signal.aborted ||
558
+ after.key !== before.key ||
559
+ after.current !== before.current ||
560
+ after.blocked
561
+ )
562
+ return;
563
+ cache.current.set(before.key, next);
564
+ setIndex(
565
+ anchoredIndex(after.current?.items, next.items || [], after.index),
566
+ );
567
+ setData({ ...next, key: before.key });
568
+ setLiveStatus("");
569
+ },
570
+ {
571
+ activity: () => activity.current,
572
+ onError: (e) => {
573
+ setLiveStatus(
574
+ e.status === 401 || e.status === 403
575
+ ? "Live updates paused: sign in again. Local draft preserved."
576
+ : "Live updates delayed · automatic retry",
577
+ );
578
+ if (e.status === 401 || e.status === 403) setLive(false);
579
+ },
580
+ },
581
+ );
582
+ }, [api, phase, live]);
583
+ useEffect(() => {
584
+ if (current?.unread_count !== undefined) setUnread(current.unread_count);
585
+ }, [current]);
586
+ useEffect(() => {
587
+ const post = current?.post;
588
+ if (!post?.code) return;
589
+ if (
590
+ seenSnippet.current?.id === post.id &&
591
+ seenSnippet.current.code !== post.code
592
+ ) {
593
+ setReviewLine(0);
594
+ setReviewAnchor(null);
595
+ setReviewHorizontal(0);
596
+ } else
597
+ setReviewLine((line) => Math.min(line, post.code.split("\n").length - 1));
598
+ seenSnippet.current = { id: post.id, code: post.code };
599
+ }, [current?.post?.id, current?.post?.code]);
435
600
  const resetAccount = () => {
436
601
  cache.current.clear();
437
602
  history.current = [];
@@ -441,24 +606,56 @@ function App({ initialConfig, flags, root }) {
441
606
  setPreview(null);
442
607
  setDevice(null);
443
608
  setUser(null);
609
+ setReading(null);
610
+ setActionIndex(null);
611
+ setUnread(0);
612
+ setLiveStatus("");
613
+ notificationGeneration.current++;
444
614
  setRoute({ view: "feed", page: 1 });
445
615
  setError("");
446
616
  };
447
617
  function fail(e) {
448
618
  if (!alive.current) return;
449
- if (e.status === 401) {
619
+ if (e.status === 401 && form) {
620
+ setLive(false);
621
+ setError(problemMessage(e));
622
+ } else if (e.status === 401) {
450
623
  resetAccount();
451
624
  setPhase("auth");
452
625
  setConfig(null);
453
626
  setError(
454
627
  "Your session expired or was revoked. Press Enter to reconnect.",
455
628
  );
456
- } else setError(clean(e.message));
629
+ } else setError(problemMessage(e));
457
630
  }
458
631
  function navigate(next, { replace = false } = {}) {
459
- if (!replace) history.current.push({ route, index, scroll });
632
+ if (!replace)
633
+ history.current.push({
634
+ route,
635
+ index,
636
+ scroll,
637
+ selectedId: selected?.id,
638
+ entryType: selected?.entryType,
639
+ reviewFocus,
640
+ reviewLine,
641
+ reviewAnchor,
642
+ reviewHorizontal,
643
+ });
644
+ restoreSelection.current = {
645
+ key: JSON.stringify(next),
646
+ selectedId: next.targetReply,
647
+ entryType:
648
+ next.view === "feedback" && next.targetReply
649
+ ? "feedbackReply"
650
+ : undefined,
651
+ index: 0,
652
+ };
460
653
  if (history.current.length > 30) history.current.shift();
461
654
  setReading(null);
655
+ setReviewLine(0);
656
+ setReviewAnchor(null);
657
+ setReviewHorizontal(0);
658
+ if (next.view === "feedback") setReviewFocus("notes");
462
659
  setRoute(next);
463
660
  setIndex(0);
464
661
  setScroll(0);
@@ -469,6 +666,15 @@ function App({ initialConfig, flags, root }) {
469
666
  function back() {
470
667
  const previous = history.current.pop();
471
668
  if (previous) {
669
+ restoreSelection.current = {
670
+ ...previous,
671
+ key: JSON.stringify(previous.route),
672
+ };
673
+ setReading(null);
674
+ setReviewFocus(previous.reviewFocus);
675
+ setReviewLine(previous.reviewLine);
676
+ setReviewAnchor(previous.reviewAnchor);
677
+ setReviewHorizontal(previous.reviewHorizontal);
472
678
  setRoute(previous.route);
473
679
  setIndex(previous.index);
474
680
  setScroll(previous.scroll);
@@ -609,6 +815,23 @@ function App({ initialConfig, flags, root }) {
609
815
  cached = cache.current.get(key);
610
816
  setData(cached ? { ...cached, key } : null);
611
817
  setLoading(!cached);
818
+ if (cached && restoreSelection.current?.key === key) {
819
+ const saved = restoreSelection.current;
820
+ const found = cached.items?.findIndex(
821
+ (item) =>
822
+ item.id === saved.selectedId &&
823
+ (saved.entryType === undefined || item.entryType === saved.entryType),
824
+ );
825
+ setIndex(
826
+ found >= 0
827
+ ? found
828
+ : Math.min(
829
+ saved.index || 0,
830
+ Math.max(0, (cached.items?.length || 0) - 1),
831
+ ),
832
+ );
833
+ restoreSelection.current = null;
834
+ }
612
835
  setError("");
613
836
  if (!cached)
614
837
  (route.view === "drafts"
@@ -624,9 +847,27 @@ function App({ initialConfig, flags, root }) {
624
847
  if (!active) return;
625
848
  cache.current.set(key, result);
626
849
  setData({ ...result, key });
627
- setIndex((i) =>
628
- Math.min(i, Math.max(0, (result.items?.length || 0) - 1)),
629
- );
850
+ const saved = restoreSelection.current;
851
+ if (saved?.key === key) {
852
+ const found = result.items?.findIndex(
853
+ (item) =>
854
+ item.id === saved.selectedId &&
855
+ (saved.entryType === undefined ||
856
+ item.entryType === saved.entryType),
857
+ );
858
+ setIndex(
859
+ found >= 0
860
+ ? found
861
+ : Math.min(
862
+ saved.index || 0,
863
+ Math.max(0, (result.items?.length || 0) - 1),
864
+ ),
865
+ );
866
+ restoreSelection.current = null;
867
+ } else
868
+ setIndex((i) =>
869
+ Math.min(i, Math.max(0, (result.items?.length || 0) - 1)),
870
+ );
630
871
  })
631
872
  .catch((e) => {
632
873
  if (active) fail(e);
@@ -795,6 +1036,46 @@ function App({ initialConfig, flags, root }) {
795
1036
  values: { body: "" },
796
1037
  });
797
1038
  }
1039
+ async function externalEdit() {
1040
+ const descriptor = fieldsFor(form)[field];
1041
+ if (!descriptor?.multiline) {
1042
+ setNotice("Ctrl+E opens the editor for a multiline text field.");
1043
+ return;
1044
+ }
1045
+ const original = form;
1046
+ await task(async () => {
1047
+ clearTimeout(draftTimer.current);
1048
+ await queueDraft(() => drafts.save(original));
1049
+ let value;
1050
+ await suspendTerminal(async () => {
1051
+ value = await editExternal(original.values[descriptor.key] || "", {
1052
+ max: descriptor.max,
1053
+ });
1054
+ });
1055
+ const updated = {
1056
+ ...original,
1057
+ values: { ...original.values, [descriptor.key]: value },
1058
+ };
1059
+ setForm(updated);
1060
+ setCursor(graphemes(value).length);
1061
+ await queueDraft(() => drafts.save(updated));
1062
+ setDraftSaved(true);
1063
+ setNotice("Text returned from editor. Preview before publishing.");
1064
+ });
1065
+ }
1066
+ async function reconnect() {
1067
+ await task(async () => {
1068
+ clearTimeout(draftTimer.current);
1069
+ draftGeneration.current++;
1070
+ if (form && drafts) await queueDraft(() => drafts.save(form));
1071
+ resetAccount();
1072
+ setConfig(null);
1073
+ setPhase("auth");
1074
+ setNotice(
1075
+ "Draft saved locally. Enter reconnects; restore it from Local drafts after signing in.",
1076
+ );
1077
+ });
1078
+ }
798
1079
  async function lineFeedback() {
799
1080
  const p = current?.post;
800
1081
  if (!p?.code) {
@@ -805,8 +1086,25 @@ function App({ initialConfig, flags, root }) {
805
1086
  setError("Replies are paused.");
806
1087
  return;
807
1088
  }
1089
+ const note = route.view === "feedback" ? current.note : selected;
1090
+ const range = reviewing
1091
+ ? reviewFocus === "notes" && note?.start_line
1092
+ ? { start: note.start_line, end: note.end_line }
1093
+ : codeRange(reviewLine, reviewAnchor)
1094
+ : { start: 1, end: 1 };
1095
+ if (reviewing && reviewFocus === "notes" && note?.outdated) {
1096
+ setError(
1097
+ "This remark belongs to an earlier snippet. W switches to current code before adding a new remark.",
1098
+ );
1099
+ return;
1100
+ }
1101
+ if (range.end - range.start >= 50) {
1102
+ setError("Select at most 50 code lines for a remark.");
1103
+ return;
1104
+ }
808
1105
  await task(async () => {
809
1106
  const notes = await api(`/posts/${p.id}/code-reviews`);
1107
+ assertSnippet(p.code, notes.snippet_hash);
810
1108
  openForm({
811
1109
  kind: "line",
812
1110
  title: "Comment on code lines",
@@ -815,7 +1113,11 @@ function App({ initialConfig, flags, root }) {
815
1113
  postId: p.id,
816
1114
  snippetHash: notes.snippet_hash,
817
1115
  lineCount: p.code.split("\n").length,
818
- values: { start_line: "1", end_line: "1", body: "" },
1116
+ values: {
1117
+ start_line: String(range.start),
1118
+ end_line: String(range.end),
1119
+ body: "",
1120
+ },
819
1121
  });
820
1122
  });
821
1123
  }
@@ -1006,11 +1308,14 @@ function App({ initialConfig, flags, root }) {
1006
1308
  }
1007
1309
  if (route.view === "inbox") {
1008
1310
  await task(async () => {
1009
- if (!item.read_at)
1311
+ if (!item.read_at) {
1312
+ notificationGeneration.current++;
1010
1313
  await api("/notifications/" + item.id + "/read", {
1011
1314
  method: "PATCH",
1012
1315
  body: {},
1013
1316
  });
1317
+ setUnread((v) => Math.max(0, v - 1));
1318
+ }
1014
1319
  cache.current.clear();
1015
1320
  if (item.event_id) {
1016
1321
  openBrowser(
@@ -1019,21 +1324,7 @@ function App({ initialConfig, flags, root }) {
1019
1324
  setNotice("Event opened in your browser.");
1020
1325
  return;
1021
1326
  }
1022
- if (item.code_review_id)
1023
- navigate({
1024
- view: "feedback",
1025
- postId: item.post_id,
1026
- id: item.code_review_id,
1027
- page: 1,
1028
- targetReply: item.feedback_reply_id || null,
1029
- });
1030
- else
1031
- navigate({
1032
- view: "post",
1033
- id: item.post_id,
1034
- tab: item.reply_id ? "replies" : "overview",
1035
- page: 1,
1036
- });
1327
+ navigate(await notificationRoute(api, item));
1037
1328
  });
1038
1329
  return;
1039
1330
  }
@@ -1176,6 +1467,81 @@ function App({ initialConfig, flags, root }) {
1176
1467
  invalidate(note.resolved ? "Feedback reopened." : "Feedback resolved.");
1177
1468
  });
1178
1469
  }
1470
+ function acceptSolution() {
1471
+ const post = current?.post;
1472
+ if (
1473
+ !selected ||
1474
+ selected.deleted_at ||
1475
+ !post ||
1476
+ post.user_id !== user.id ||
1477
+ !["question", "review"].includes(post.kind) ||
1478
+ !(route.view === "thread" || route.tab === "replies")
1479
+ )
1480
+ return;
1481
+ const accepted = post.accepted_reply_id === selected.id;
1482
+ askConfirm(
1483
+ accepted
1484
+ ? "Remove accepted solution?"
1485
+ : "Accept this reply as the solution?",
1486
+ "This changes the solution shown to everyone in the conversation.",
1487
+ () =>
1488
+ task(async () => {
1489
+ await api(`/posts/${post.id}/solution`, {
1490
+ method: "PATCH",
1491
+ body: { reply_id: accepted ? null : selected.id },
1492
+ });
1493
+ invalidate(accepted ? "Solution removed." : "Solution accepted.");
1494
+ }),
1495
+ );
1496
+ }
1497
+ function reportSelected() {
1498
+ const target = editable();
1499
+ if (!target?.feedback || target.row.deleted_at) {
1500
+ setError("Select an existing code remark or feedback reply to report.");
1501
+ return;
1502
+ }
1503
+ const noteId = route.view === "feedback" ? route.id : target.row.id;
1504
+ openForm({
1505
+ kind: "report",
1506
+ feedback: true,
1507
+ title: "Report selected feedback",
1508
+ url: `/posts/${current.post.id}/code-reviews/${noteId}/report`,
1509
+ replyId: target.row.entryType === "feedbackReply" ? target.row.id : 0,
1510
+ audience: "Assigned moderation team",
1511
+ values: { reason: "" },
1512
+ });
1513
+ }
1514
+ function runAction(id) {
1515
+ setActionIndex(null);
1516
+ if (id === "read") {
1517
+ if (current?.post) {
1518
+ setReading(selected);
1519
+ setScroll(0);
1520
+ } else openSelected();
1521
+ } else if (id === "reply") reply();
1522
+ else if (id === "comment") lineFeedback();
1523
+ else if (id === "edit") editSelected();
1524
+ else if (id === "delete") deleteSelected();
1525
+ else if (id === "resolve") resolveSelected();
1526
+ else if (id === "solution") acceptSolution();
1527
+ else if (id === "report") reportSelected();
1528
+ else if (id === "browser")
1529
+ openBrowser(config.origin + "/app#post/" + current.post.id);
1530
+ else if (id === "sync") invalidate("Updated.");
1531
+ else if (id === "live") {
1532
+ setLive((v) => !v);
1533
+ setLiveStatus("");
1534
+ } else if (id === "read-all")
1535
+ task(async () => {
1536
+ notificationGeneration.current++;
1537
+ await api("/notifications/read-all", {
1538
+ method: "PATCH",
1539
+ body: { through_id: current.latest_id },
1540
+ });
1541
+ setUnread(0);
1542
+ invalidate("Received notifications marked as read.");
1543
+ });
1544
+ }
1179
1545
  async function runCommand() {
1180
1546
  const value = command.trim();
1181
1547
  if (!value) return;
@@ -1249,6 +1615,7 @@ function App({ initialConfig, flags, root }) {
1249
1615
  );
1250
1616
  }
1251
1617
  useInput((input, keyEvent) => {
1618
+ activity.current = Date.now();
1252
1619
  if (picker) return;
1253
1620
  if (keyEvent.ctrl && input === "c") {
1254
1621
  if (dialog) {
@@ -1291,8 +1658,21 @@ function App({ initialConfig, flags, root }) {
1291
1658
  return;
1292
1659
  }
1293
1660
  if (busy.current) return;
1661
+ if (keyEvent.ctrl && input === "l") {
1662
+ reconnect();
1663
+ return;
1664
+ }
1665
+ if (actionIndex !== null) {
1666
+ if (keyEvent.escape || input === "x" || input === "X")
1667
+ setActionIndex(null);
1668
+ else if (keyEvent.upArrow) setActionIndex((v) => Math.max(0, v - 1));
1669
+ else if (keyEvent.downArrow)
1670
+ setActionIndex((v) => Math.min(actions.length - 1, v + 1));
1671
+ else if (keyEvent.return) runAction(actions[actionIndex]?.id);
1672
+ return;
1673
+ }
1294
1674
  if (reading) {
1295
- const lines = wrap(
1675
+ const lines = markdownRows(
1296
1676
  reading.deleted_at
1297
1677
  ? "[deleted — discussion preserved]"
1298
1678
  : reading.body || "",
@@ -1345,6 +1725,10 @@ function App({ initialConfig, flags, root }) {
1345
1725
  return;
1346
1726
  }
1347
1727
  if (form) {
1728
+ if (keyEvent.ctrl && input === "e") {
1729
+ externalEdit();
1730
+ return;
1731
+ }
1348
1732
  if (
1349
1733
  keyEvent.ctrl &&
1350
1734
  ["o", "g"].includes(input) &&
@@ -1478,6 +1862,80 @@ function App({ initialConfig, flags, root }) {
1478
1862
  else if (keyEvent.rightArrow) setFocus("content");
1479
1863
  return;
1480
1864
  }
1865
+ if (input === "x" || input === "X" || input === "?") {
1866
+ setActionIndex(0);
1867
+ return;
1868
+ }
1869
+ if ((input === "v" || input === "V") && current?.post?.code) {
1870
+ navigate({ view: "post", id: current.post.id, tab: "feedback", page: 1 });
1871
+ return;
1872
+ }
1873
+ if (reviewing && (input === "w" || input === "W")) {
1874
+ setReviewFocus((v) => (v === "code" ? "notes" : "code"));
1875
+ return;
1876
+ }
1877
+ if (
1878
+ reviewing &&
1879
+ (input === "f" || input === "F") &&
1880
+ route.view === "post"
1881
+ ) {
1882
+ const filters = [undefined, "open", "resolved"];
1883
+ navigate(
1884
+ {
1885
+ ...route,
1886
+ status: filters[(filters.indexOf(route.status) + 1) % filters.length],
1887
+ page: 1,
1888
+ },
1889
+ { replace: true },
1890
+ );
1891
+ return;
1892
+ }
1893
+ if (reviewing && reviewFocus === "code") {
1894
+ if (
1895
+ keyEvent.upArrow ||
1896
+ keyEvent.downArrow ||
1897
+ keyEvent.pageUp ||
1898
+ keyEvent.pageDown
1899
+ ) {
1900
+ const delta =
1901
+ (keyEvent.upArrow || keyEvent.pageUp ? -1 : 1) *
1902
+ (keyEvent.pageUp || keyEvent.pageDown
1903
+ ? Math.max(1, contentHeight - 2)
1904
+ : 1);
1905
+ setReviewLine((v) =>
1906
+ Math.max(
1907
+ 0,
1908
+ Math.min(current.post.code.split("\n").length - 1, v + delta),
1909
+ ),
1910
+ );
1911
+ return;
1912
+ }
1913
+ if (keyEvent.leftArrow || keyEvent.rightArrow) {
1914
+ setReviewHorizontal((v) =>
1915
+ Math.max(0, v + (keyEvent.leftArrow ? -8 : 8)),
1916
+ );
1917
+ return;
1918
+ }
1919
+ if (input === " ") {
1920
+ setReviewAnchor((v) => (v === null ? reviewLine : null));
1921
+ return;
1922
+ }
1923
+ if (keyEvent.return) {
1924
+ const i = current.items.findIndex(
1925
+ (n) =>
1926
+ !n.outdated &&
1927
+ !n.deleted_at &&
1928
+ reviewLine + 1 >= n.start_line &&
1929
+ reviewLine + 1 <= n.end_line,
1930
+ );
1931
+ if (i >= 0) {
1932
+ setIndex(i);
1933
+ setReviewFocus("notes");
1934
+ } else
1935
+ setNotice("No remark on this line on the current page. C adds one.");
1936
+ return;
1937
+ }
1938
+ }
1481
1939
  const prose =
1482
1940
  route.view === "help" ||
1483
1941
  (route.view === "post" && (!route.tab || route.tab === "overview")) ||
@@ -1496,9 +1954,8 @@ function App({ initialConfig, flags, root }) {
1496
1954
  if (prose) {
1497
1955
  const maximum = Math.max(
1498
1956
  0,
1499
- contentLines(route, current || {}).flatMap((line) =>
1500
- wrap(line, contentWidth),
1501
- ).length - contentHeight,
1957
+ documentRows(route, current || {}, contentWidth).length -
1958
+ contentHeight,
1502
1959
  );
1503
1960
  const amount =
1504
1961
  keyEvent.pageUp || keyEvent.pageDown
@@ -1572,15 +2029,16 @@ function App({ initialConfig, flags, root }) {
1572
2029
  invalidate("Updated.");
1573
2030
  return;
1574
2031
  }
1575
- if (input === "c") {
2032
+ if (input === "c" || input === "C") {
1576
2033
  lineFeedback();
1577
2034
  return;
1578
2035
  }
1579
- if (input === "s") {
1580
- resolveSelected();
2036
+ if (input === "s" || input === "S") {
2037
+ if (route.view === "thread" || route.tab === "replies") acceptSolution();
2038
+ else resolveSelected();
1581
2039
  return;
1582
2040
  }
1583
- if (input === "e") {
2041
+ if (input === "e" || input === "E") {
1584
2042
  editSelected();
1585
2043
  return;
1586
2044
  }
@@ -1609,25 +2067,12 @@ function App({ initialConfig, flags, root }) {
1609
2067
  return;
1610
2068
  }
1611
2069
  if (input === "!" && ["feedback", "post"].includes(route.view)) {
1612
- const target = editable();
1613
- if (!target?.feedback || target.row.deleted_at) {
1614
- setError("Select an existing code remark or feedback reply to report.");
1615
- return;
1616
- }
1617
- const noteId = route.view === "feedback" ? route.id : target.row.id;
1618
- openForm({
1619
- kind: "report",
1620
- feedback: true,
1621
- title: "Report selected feedback",
1622
- url: `/posts/${current.post.id}/code-reviews/${noteId}/report`,
1623
- replyId: target.row.entryType === "feedbackReply" ? target.row.id : 0,
1624
- audience: "Assigned moderation team",
1625
- values: { reason: "" },
1626
- });
2070
+ reportSelected();
1627
2071
  }
1628
2072
  });
1629
2073
  const status =
1630
2074
  error ||
2075
+ liveStatus ||
1631
2076
  notice ||
1632
2077
  (pending
1633
2078
  ? "Saving…"
@@ -1752,7 +2197,7 @@ function App({ initialConfig, flags, root }) {
1752
2197
  } else {
1753
2198
  let body;
1754
2199
  if (reading) {
1755
- const lines = wrap(
2200
+ const lines = markdownRows(
1756
2201
  reading.deleted_at
1757
2202
  ? "[deleted — discussion preserved]"
1758
2203
  : reading.body || "",
@@ -1826,10 +2271,78 @@ function App({ initialConfig, flags, root }) {
1826
2271
  { color: colors.muted },
1827
2272
  "Opening " + labelOf(route).toLowerCase() + " " + spin,
1828
2273
  );
1829
- else {
1830
- const lines = contentLines(route, current || {}).flatMap((line) =>
1831
- wrap(line, contentWidth),
2274
+ else if (actionIndex !== null) {
2275
+ const first = Math.max(
2276
+ 0,
2277
+ actionIndex - Math.max(1, contentHeight - 2) + 1,
1832
2278
  );
2279
+ body = h(
2280
+ Box,
2281
+ { height: contentHeight, flexDirection: "column", overflow: "hidden" },
2282
+ h(
2283
+ Text,
2284
+ { bold: true, color: colors.blue },
2285
+ "Actions · ↑↓ choose · Enter run · Esc close",
2286
+ ),
2287
+ contentHeight >= 4
2288
+ ? h(
2289
+ Text,
2290
+ { color: colors.muted },
2291
+ "Available for this selection and your role",
2292
+ )
2293
+ : null,
2294
+ ...actions
2295
+ .slice(
2296
+ first,
2297
+ first + Math.max(1, contentHeight - (contentHeight >= 4 ? 2 : 1)),
2298
+ )
2299
+ .map((action, i) =>
2300
+ h(
2301
+ Text,
2302
+ {
2303
+ key: action.id,
2304
+ color: i + first === actionIndex ? colors.blue : colors.text,
2305
+ backgroundColor:
2306
+ i + first === actionIndex ? colors.selected : undefined,
2307
+ },
2308
+ clip(
2309
+ (i + first === actionIndex ? "› " : " ") + action.label,
2310
+ contentWidth,
2311
+ ),
2312
+ ),
2313
+ ),
2314
+ );
2315
+ } else if (reviewing)
2316
+ body = h(ReviewWorkspace, {
2317
+ post: current.post,
2318
+ note: route.view === "feedback" ? current.note : null,
2319
+ items: current.items || [],
2320
+ index,
2321
+ width: contentWidth,
2322
+ height: contentHeight,
2323
+ focus: reviewFocus,
2324
+ line: reviewLine,
2325
+ anchor: reviewAnchor,
2326
+ horizontal: reviewHorizontal,
2327
+ rows: (w) => listRows(route, current, index, w),
2328
+ });
2329
+ else if (error && !current)
2330
+ body = h(FrameRows, {
2331
+ rows: [
2332
+ { text: "Could not open this view", bold: true, color: colors.red },
2333
+ "",
2334
+ ...wrap(error, contentWidth),
2335
+ "",
2336
+ ...wrap(
2337
+ "Shift+R retries this view · Esc goes back · Ctrl+L reconnects",
2338
+ contentWidth,
2339
+ ),
2340
+ ],
2341
+ width: contentWidth,
2342
+ height: contentHeight,
2343
+ });
2344
+ else {
2345
+ const lines = documentRows(route, current || {}, contentWidth);
1833
2346
  if (lines.length)
1834
2347
  body = h(FrameRows, {
1835
2348
  rows: lines,
@@ -1941,15 +2454,23 @@ function App({ initialConfig, flags, root }) {
1941
2454
  ? colors.blue
1942
2455
  : colors.muted,
1943
2456
  bold: (route.tab || "overview") === id,
2457
+ backgroundColor:
2458
+ (route.tab || "overview") === id
2459
+ ? colors.selected
2460
+ : undefined,
1944
2461
  },
1945
2462
  " " + label + " ",
1946
2463
  ),
1947
2464
  ),
1948
- h(Text, { color: colors.muted }, " ← →"),
2465
+ h(
2466
+ Text,
2467
+ { color: colors.muted },
2468
+ reviewing ? " W focus · F " + (route.status || "all") : " ← →",
2469
+ ),
1949
2470
  )
1950
2471
  : h(TextLine, {
1951
2472
  text: form
1952
- ? "Tab next field · Ctrl+S preview" +
2473
+ ? "Tab next field · Ctrl+E editor · Ctrl+S preview" +
1953
2474
  (draftSaved ? " · Draft saved locally" : "")
1954
2475
  : preview
1955
2476
  ? "Scroll to review · ← → choose · Enter confirms"
@@ -1981,7 +2502,7 @@ function App({ initialConfig, flags, root }) {
1981
2502
  paddingX: 1,
1982
2503
  overflow: "hidden",
1983
2504
  },
1984
- h(Text, { color: colors.muted }, "WORKSPACE"),
2505
+ h(Text, { color: colors.purple, bold: true }, "YOUR SPACE"),
1985
2506
  h(Text, null, " "),
1986
2507
  ...navigation
1987
2508
  .slice(
@@ -2005,9 +2526,16 @@ function App({ initialConfig, flags, root }) {
2005
2526
  backgroundColor: selected ? colors.selected : undefined,
2006
2527
  wrap: "truncate",
2007
2528
  },
2008
- (selected ? "› " : " ") + label,
2529
+ (selected ? "› " : route.view === id ? "● " : " ") +
2530
+ label +
2531
+ (id === "inbox" && unread ? " · " + unread : ""),
2009
2532
  );
2010
2533
  }),
2534
+ h(
2535
+ Text,
2536
+ { color: colors.muted, dimColor: true },
2537
+ " Tab sections · / find",
2538
+ ),
2011
2539
  )
2012
2540
  : null;
2013
2541
  center = h(
@@ -2017,21 +2545,26 @@ function App({ initialConfig, flags, root }) {
2017
2545
  compact && focus === "nav" ? null : pane,
2018
2546
  );
2019
2547
  }
2020
- const hint = picker
2021
- ? "Project files stay local until publication is confirmed"
2022
- : reading
2023
- ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2024
- : preview
2025
- ? "← → choose · Enter confirm · Esc edit"
2026
- : form
2027
- ? "Tab field · Ctrl+O files · Ctrl+G diff · Ctrl+S preview · Esc discard"
2028
- : phase !== "ready"
2029
- ? "Enter connect / open browser · Ctrl+C exit"
2030
- : route.view === "feedback"
2031
- ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2032
- : route.view === "post"
2033
- ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2034
- : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2548
+ const hint =
2549
+ actionIndex !== null
2550
+ ? "Actions are scoped to the selected item · Esc closes"
2551
+ : reviewing && !form && !preview && !reading
2552
+ ? "W code / discussion · Space select · C comment · X actions · F filter"
2553
+ : picker
2554
+ ? "Project files stay local until publication is confirmed"
2555
+ : reading
2556
+ ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2557
+ : preview
2558
+ ? "← → choose · Enter confirm · Esc edit"
2559
+ : form
2560
+ ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
2561
+ : phase !== "ready"
2562
+ ? "Enter connect / open browser · Ctrl+C exit"
2563
+ : route.view === "feedback"
2564
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2565
+ : route.view === "post"
2566
+ ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2567
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2035
2568
  return h(
2036
2569
  Box,
2037
2570
  {
@@ -2054,12 +2587,22 @@ function App({ initialConfig, flags, root }) {
2054
2587
  h(
2055
2588
  Text,
2056
2589
  { color: colors.purple, bold: true },
2057
- clip("◈ DEVDEVIAN / TERMINAL WORKSPACE", Math.max(10, width - 28)),
2590
+ clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2058
2591
  ),
2059
2592
  h(
2060
2593
  Text,
2061
2594
  { color: colors.blue },
2062
- clip(user ? "@" + user.username : "CLI · ALPHA", 24),
2595
+ clip(
2596
+ user
2597
+ ? "@" +
2598
+ user.username +
2599
+ " · " +
2600
+ unread +
2601
+ " unread · " +
2602
+ (live ? "● Live" : "○ Paused")
2603
+ : "CLI · ALPHA",
2604
+ 32,
2605
+ ),
2063
2606
  ),
2064
2607
  ),
2065
2608
  center,
@@ -2087,7 +2630,10 @@ function App({ initialConfig, flags, root }) {
2087
2630
  : h(
2088
2631
  Text,
2089
2632
  { color: colors.muted },
2090
- clip("Search or /command · Press / to type", width - 6),
2633
+ clip(
2634
+ "Search or /command · Press / to type · X actions",
2635
+ width - 6,
2636
+ ),
2091
2637
  ),
2092
2638
  ),
2093
2639
  h(TextLine, { text: hint, width, color: colors.muted }),