@tenyokjs/devdevian-cli 0.3.1-alpha.1 → 0.4.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/README.md CHANGED
@@ -18,7 +18,7 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
18
18
  - **/** focuses search and commands: `/feed`, `/inbox`, `/search query`, `/open 142`, `/ask`, `/review`, `/drafts`, `/communities`, `/devices`, `/help`, `/logout`, `/quit`.
19
19
  - **←/→** switch conversation tabs; **Space** reads the selected reply/remark in full. **[ / ]** paginate; **Page Up/Down** scroll.
20
20
  - **N** creates a conversation (a community on the Communities screen); **A** asks a question; **R** replies inside a conversation; **Shift+R** refreshes.
21
- - **C** comments on snippet lines; **E/D** edit/delete your own ordinary replies or code feedback; **S** resolves/reopens a code remark as the publication author; **!** reports selected code feedback.
21
+ - **C** comments on snippet lines; **E/D** edit/delete your own ordinary replies or code feedback; **S** resolves/reopens a code remark or accepts/removes a reply as the solution (question/review author only); **!** reports selected code feedback.
22
22
  - In forms, **Tab/Shift+Tab** change fields, arrows choose options or move the text cursor, **Enter** adds a line, **Ctrl+S** opens the preview. Publication requires a separate confirmation; Escape asks before discarding a draft. Drafts auto-save locally after a short pause and can be restored from **Local drafts** or `/drafts`.
23
23
  - **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
24
24
 
@@ -28,6 +28,20 @@ Pages have a bounded 30-second in-memory cache. Refresh explicitly with Shift+R.
28
28
 
29
29
  Ordinary reply deletion replaces your text with a placeholder and preserves nested replies. Its attachments become unavailable. Deleting an accepted answer reopens the question. D/d works on a selected reply or while reading it in full; confirmation is required.
30
30
 
31
+ ## Code and discussion, together
32
+
33
+ Press **V** in a review to open the code workspace. Wide terminals show the snippet alongside its discussion; narrow terminals show one pane at a time. **W** switches focus. In the code pane, arrows move between lines (left/right scroll horizontally), **Space** anchors a range and **C** opens a prefilled comment form. Remarks may cover at most 50 lines. **Enter** selects a remark covering the current line, if it is on the current page. Markers describe the current page only; **F** cycles all/open/resolved remarks and **[ / ]** page through them.
34
+
35
+ Selecting a remark highlights its range. A remark on an earlier snippet shows its **saved quote**, not unrelated current lines. Switch to current code with **W** before adding a new remark. Git patch line numbers still include patch headers.
36
+
37
+ **X** or **?** opens a keyboard action menu for the selected item. It shows edits/deletion for your own content, resolve/reopen for review authors and accepted solutions for question/review authors. Deletion and accepting/removing a solution require confirmation. Feedback reports go to the existing moderation queue. The server checks permissions independently.
38
+
39
+ ## Inbox and live updates
40
+
41
+ The header shows unread notifications. Live reads run every **45 seconds**, slowing to **3 minutes** after two minutes without input; failures back off up to five minutes. An expired/revoked session pauses updates. Pause/resume from **X → Pause/Resume live updates**, or start with `devdevian tui --no-live`.
42
+
43
+ Inbox and open discussion views refresh without moving a selected item that still exists. While you type, use a picker, read full text or confirm an action, background reads update only the unread count; they do not replace the view or draft. Polling never marks notifications as read. **X → Mark received notifications as read** affects only notifications through the last loaded boundary; newer arrivals remain unread. Shift+R refreshes manually.
44
+
31
45
  ## Project files and Git changes
32
46
 
33
47
  Start inside your project (`cd PROJECT1 && devdevian tui`) or use `devdevian tui --project /path/to/PROJECT1`. `devdevian open --project /path/to/PROJECT1` carries the same project into a new terminal window. The project root stays fixed for the session.
package/bin/devdevian.mjs CHANGED
@@ -39,6 +39,7 @@ const options = {
39
39
  json: { type: "boolean" },
40
40
  editor: { type: "boolean" },
41
41
  "no-open": { type: "boolean" },
42
+ "no-live": { type: "boolean" },
42
43
  help: { type: "boolean", short: "h" },
43
44
  version: { type: "boolean", short: "v" },
44
45
  };
@@ -95,6 +96,7 @@ const help = `Devdevian CLI ${version} · alpha
95
96
  devdevian review reopen POST_ID REVIEW_ID
96
97
  devdevian communities [--page 2]
97
98
 
99
+ --no-live Disable background reads in the live workspace
98
100
  --project PATH Root directory for the live file/diff picker (default: current directory)
99
101
  --community ID Publish to an approved private community
100
102
  --editor Write text in $VISUAL / $EDITOR
@@ -477,6 +479,7 @@ try {
477
479
  for (const key of ["host", "name", "project"])
478
480
  if (values[key]) args.push("--" + key, values[key]);
479
481
  if (values["no-open"]) args.push("--no-open");
482
+ if (values["no-live"]) args.push("--no-live");
480
483
  await (
481
484
  await import("../lib/window.mjs")
482
485
  ).openWindow({ entry: fileURLToPath(import.meta.url), args });
@@ -0,0 +1,53 @@
1
+ // Visibility is a convenience; the API remains the permission authority.
2
+ export function contextActions({ route, data, selected, user, live }) {
3
+ const actions = [],
4
+ post = data?.post;
5
+ const add = (id, label) => actions.push({ id, label });
6
+ const feedback =
7
+ route.view === "feedback" ||
8
+ (route.view === "post" && route.tab === "feedback");
9
+ const reply =
10
+ route.view === "thread" ||
11
+ (route.view === "post" && route.tab === "replies");
12
+ if (selected) add("read", "Read selected item");
13
+ if (post && !post.archived_at && !post.locked_at) {
14
+ add("reply", "Write a reply");
15
+ if (post.code) add("comment", "Comment on selected code lines");
16
+ }
17
+ if (
18
+ (reply || feedback) &&
19
+ selected &&
20
+ user &&
21
+ selected.user_id === user.id &&
22
+ !selected.deleted_at
23
+ ) {
24
+ add("edit", "Edit your reply / remark");
25
+ add("delete", "Delete your reply / remark…");
26
+ }
27
+ if (post && user && post.user_id === user.id) {
28
+ const note =
29
+ route.view === "feedback" ? data.note : feedback ? selected : null;
30
+ if (note && !note.deleted_at)
31
+ add("resolve", note.resolved ? "Reopen remark" : "Resolve remark");
32
+ if (
33
+ reply &&
34
+ selected &&
35
+ !selected.deleted_at &&
36
+ ["question", "review"].includes(post.kind)
37
+ )
38
+ add(
39
+ "solution",
40
+ post.accepted_reply_id === selected.id
41
+ ? "Remove accepted solution…"
42
+ : "Accept as solution…",
43
+ );
44
+ }
45
+ if (feedback && selected && !selected.deleted_at)
46
+ add("report", "Report selected feedback…");
47
+ if (post) add("browser", "Open conversation in browser");
48
+ if (route.view === "inbox" && data?.unread_count > 0)
49
+ add("read-all", "Mark received notifications as read");
50
+ add("sync", "Refresh this view");
51
+ add("live", live ? "Pause live updates" : "Resume live updates");
52
+ return actions;
53
+ }
package/lib/live.mjs ADDED
@@ -0,0 +1,52 @@
1
+ // Serial, cancellable polling: network failures never retry a mutation.
2
+ export function liveLoop(
3
+ work,
4
+ {
5
+ schedule = setTimeout,
6
+ cancel = clearTimeout,
7
+ now = Date.now,
8
+ activity = () => now(),
9
+ active = 45000,
10
+ idle = 180000,
11
+ onError = () => {},
12
+ } = {},
13
+ ) {
14
+ const controller = new AbortController();
15
+ let timer,
16
+ failures = 0,
17
+ stopped = false;
18
+ async function run() {
19
+ try {
20
+ await work(controller.signal);
21
+ failures = 0;
22
+ } catch (error) {
23
+ if (stopped) return;
24
+ failures++;
25
+ onError(error);
26
+ if (error.status === 401 || error.status === 403) {
27
+ stop();
28
+ return;
29
+ }
30
+ }
31
+ if (stopped) return;
32
+ const base = now() - activity() > 120000 ? idle : active;
33
+ timer = schedule(run, Math.min(300000, base * 2 ** Math.min(failures, 3)));
34
+ }
35
+ function stop() {
36
+ stopped = true;
37
+ cancel(timer);
38
+ controller.abort();
39
+ }
40
+ timer = schedule(run, 0);
41
+ return stop;
42
+ }
43
+ export function anchoredIndex(previous, next, index) {
44
+ const row = previous?.[index];
45
+ if (row) {
46
+ const found = next.findIndex(
47
+ (item) => item.id === row.id && item.entryType === row.entryType,
48
+ );
49
+ if (found >= 0) return found;
50
+ }
51
+ return Math.max(0, Math.min(index, next.length - 1));
52
+ }
package/lib/picker.mjs CHANGED
@@ -9,17 +9,9 @@ import {
9
9
  readDiff,
10
10
  selectLines,
11
11
  } from "./workspace.mjs";
12
+ import { colors } from "./theme.mjs";
12
13
  const h = React.createElement,
13
- c = {
14
- text: "#e0e7ff",
15
- muted: "#8190af",
16
- blue: "#79b8ff",
17
- purple: "#b29aff",
18
- green: "#70d6b0",
19
- red: "#ff9baf",
20
- bg: "#12192b",
21
- selected: "#29345b",
22
- };
14
+ c = { ...colors, bg: colors.panel };
23
15
  export function WorkspacePicker({
24
16
  root,
25
17
  width,
@@ -0,0 +1,157 @@
1
+ import React from "react";
2
+ import { Box, Text } from "ink";
3
+ import { clip, graphemes } from "./tui-model.mjs";
4
+ import { colors as c } from "./theme.mjs";
5
+ const h = React.createElement;
6
+ export function codeRange(line, anchor = null) {
7
+ return {
8
+ start: Math.min(line, anchor ?? line) + 1,
9
+ end: Math.max(line, anchor ?? line) + 1,
10
+ };
11
+ }
12
+ export function reviewExcerpt(post, note) {
13
+ // Never attach an old remark to unrelated lines in the new snippet.
14
+ if (note?.outdated)
15
+ return {
16
+ text: note.quote || "Earlier snippet unavailable",
17
+ first: note.start_line || 1,
18
+ old: true,
19
+ };
20
+ return { text: post?.code || "", first: 1, old: false };
21
+ }
22
+ export function ReviewWorkspace({
23
+ post,
24
+ note,
25
+ items,
26
+ index,
27
+ width,
28
+ height,
29
+ focus,
30
+ line,
31
+ anchor,
32
+ horizontal = 0,
33
+ rows,
34
+ }) {
35
+ const split = width >= 78,
36
+ codeWidth = split ? Math.floor(width * 0.55) : width;
37
+ const selectedNote = note || items[index];
38
+ const excerpt = reviewExcerpt(post, focus === "code" ? null : selectedNote),
39
+ lines = excerpt.text.split("\n");
40
+ const range = codeRange(line, anchor);
41
+ const target = excerpt.old
42
+ ? 0
43
+ : focus === "code"
44
+ ? line
45
+ : Math.max(0, (selectedNote?.start_line || 1) - 1);
46
+ const header = height >= 4 ? 2 : 1,
47
+ count = Math.max(1, height - header),
48
+ top = Math.max(
49
+ 0,
50
+ Math.min(lines.length - count, target - Math.floor(count / 3)),
51
+ );
52
+ const code = h(
53
+ Box,
54
+ { width: codeWidth, height, flexDirection: "column", overflow: "hidden" },
55
+ h(
56
+ Text,
57
+ { color: focus === "code" ? c.blue : c.purple, bold: true },
58
+ clip(
59
+ excerpt.old
60
+ ? "Earlier snippet · saved quote"
61
+ : "Code · W focus · C comment",
62
+ codeWidth,
63
+ ),
64
+ ),
65
+ header === 2
66
+ ? h(
67
+ Text,
68
+ { color: c.muted },
69
+ clip(
70
+ focus === "code"
71
+ ? `Lines ${range.start}–${range.end} · Space select`
72
+ : "Markers: remarks on this page",
73
+ codeWidth,
74
+ ),
75
+ )
76
+ : null,
77
+ ...Array.from({ length: count }, (_, i) => {
78
+ const n = top + i,
79
+ number = excerpt.first + n;
80
+ if (n >= lines.length) return h(Text, { key: i }, " ");
81
+ const marked =
82
+ !excerpt.old &&
83
+ items.some(
84
+ (item) =>
85
+ !item.outdated &&
86
+ !item.deleted_at &&
87
+ number >= item.start_line &&
88
+ number <= item.end_line,
89
+ );
90
+ const highlighted =
91
+ excerpt.old ||
92
+ (focus === "code"
93
+ ? number >= range.start && number <= range.end
94
+ : selectedNote &&
95
+ !selectedNote.outdated &&
96
+ number >= selectedNote.start_line &&
97
+ number <= selectedNote.end_line);
98
+ const text = lines[n];
99
+ return h(
100
+ Text,
101
+ {
102
+ key: i,
103
+ backgroundColor: highlighted ? c.selected : undefined,
104
+ color: text.startsWith("+")
105
+ ? c.green
106
+ : text.startsWith("-")
107
+ ? c.red
108
+ : c.text,
109
+ },
110
+ clip(
111
+ `${marked ? "●" : " "}${String(number).padStart(4)} ${graphemes(text.replaceAll("\t", " ")).slice(horizontal).join("")}`,
112
+ codeWidth,
113
+ ),
114
+ );
115
+ }),
116
+ );
117
+ const notesWidth = split ? width - codeWidth - 2 : width;
118
+ const visible = Math.max(1, Math.floor((height - 2) / 4)),
119
+ first = Math.max(0, index - visible + 1);
120
+ const notes = h(
121
+ Box,
122
+ { width: notesWidth, height, flexDirection: "column", overflow: "hidden" },
123
+ h(
124
+ Text,
125
+ { color: focus === "notes" ? c.blue : c.purple, bold: true },
126
+ "Discussion · W focus",
127
+ ),
128
+ header === 2
129
+ ? h(
130
+ Text,
131
+ { color: c.muted },
132
+ clip("Enter thread · Space read · X actions", notesWidth),
133
+ )
134
+ : null,
135
+ ...(items.length
136
+ ? rows(notesWidth)
137
+ .slice(first * 4, first * 4 + height - header)
138
+ .map((row, i) =>
139
+ h(
140
+ Text,
141
+ {
142
+ key: i,
143
+ color: row.color || c.text,
144
+ bold: row.bold,
145
+ backgroundColor: row.selected ? c.selected : undefined,
146
+ },
147
+ clip(typeof row === "string" ? row : row.text, notesWidth),
148
+ ),
149
+ )
150
+ : [h(Text, { key: "empty", color: c.muted }, "No remarks. C adds one.")]),
151
+ );
152
+ return h(
153
+ Box,
154
+ { height, width, gap: split ? 2 : 0, overflow: "hidden" },
155
+ ...(split ? [code, notes] : [focus === "code" ? code : notes]),
156
+ );
157
+ }
package/lib/theme.mjs ADDED
@@ -0,0 +1,13 @@
1
+ // One palette for the workspace, file picker and review panes.
2
+ export const colors = Object.freeze({
3
+ bg: "#0b1020",
4
+ panel: "#11182b",
5
+ line: "#303d60",
6
+ muted: "#8e9ab8",
7
+ text: "#e5eaff",
8
+ blue: "#86baff",
9
+ purple: "#b5a0ff",
10
+ green: "#7bdcbd",
11
+ red: "#ffa0b4",
12
+ selected: "#29365b",
13
+ });
package/lib/tui-data.mjs CHANGED
@@ -1,3 +1,14 @@
1
+ import { createHash } from "node:crypto";
2
+ export function assertSnippet(code, hash) {
3
+ if (
4
+ createHash("sha256")
5
+ .update(code || "")
6
+ .digest("hex") !== hash
7
+ )
8
+ throw Error(
9
+ "Code changed while loading. Press Shift+R to refresh before reviewing.",
10
+ );
11
+ }
1
12
  export const navigation = [
2
13
  ["inbox", "Inbox", "Replies & mentions"],
3
14
  ["feed", "Public feed", "Developer commons"],
@@ -77,15 +88,23 @@ export async function loadPage(api, route, signal) {
77
88
  const post = await get("/posts/" + route.id + "?reply_page=" + page);
78
89
  let items = post.replies,
79
90
  pagination = post.reply_pagination;
91
+ let snippet_hash;
80
92
  if (route.tab === "feedback") {
81
93
  const feedback = await get(
82
- "/posts/" + route.id + "/code-reviews?page=" + page,
94
+ "/posts/" +
95
+ route.id +
96
+ "/code-reviews?page=" +
97
+ page +
98
+ (route.status ? "&status=" + route.status : ""),
83
99
  );
100
+ assertSnippet(post.code, feedback.snippet_hash);
101
+ snippet_hash = feedback.snippet_hash;
84
102
  items = feedback.items;
85
103
  pagination = feedback.pagination;
86
104
  }
87
105
  return {
88
106
  post,
107
+ snippet_hash,
89
108
  items,
90
109
  pagination,
91
110
  title: post.title,
@@ -128,6 +147,7 @@ export async function loadPage(api, route, signal) {
128
147
  get("/posts/" + route.postId),
129
148
  get(`/posts/${route.postId}/code-reviews?target_id=${route.id}`),
130
149
  ]);
150
+ assertSnippet(post.code, notes.snippet_hash);
131
151
  const note = notes.items.find((item) => item.id === Number(route.id));
132
152
  if (!note) throw Error("This feedback is no longer available.");
133
153
  const replies = await get(
@@ -136,6 +156,7 @@ export async function loadPage(api, route, signal) {
136
156
  return {
137
157
  post,
138
158
  note,
159
+ snippet_hash: notes.snippet_hash,
139
160
  items: [
140
161
  { ...note, entryType: "note" },
141
162
  ...replies.items.map((item) => ({
@@ -163,6 +184,11 @@ export function contentLines(route, data) {
163
184
  "Page Up / Down Scroll a conversation or list",
164
185
  "N / A New conversation / ask a question",
165
186
  "R Reply in a conversation; refresh a list",
187
+ "V / W Open review workspace / switch code and discussion",
188
+ "Space / C Select a line range / comment on selected code",
189
+ "F Filter remarks: all, open, resolved",
190
+ "X / ? Context actions (edit, delete, solution, live updates)",
191
+ "Live updates 45s active / 3min idle; pause in X actions or --no-live",
166
192
  "C Add feedback on code lines",
167
193
  "S Resolve / reopen selected code feedback",
168
194
  "E / D Edit / delete your own feedback or reply",
package/lib/tui.mjs CHANGED
@@ -24,20 +24,18 @@ 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
+ } from "./tui-data.mjs";
33
+ import { basename } from "node:path";
34
+ import { colors } from "./theme.mjs";
35
+ import { liveLoop, anchoredIndex } from "./live.mjs";
36
+ import { contextActions } from "./context-actions.mjs";
37
+ import { ReviewWorkspace, codeRange } from "./review-workspace.mjs";
28
38
  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
39
  const itemsOf = (value) => (Array.isArray(value) ? value : value?.items || []);
42
40
  const stamp = (value) => {
43
41
  const date = new Date(
@@ -335,7 +333,10 @@ function listRows(route, data, index, width) {
335
333
  ? " · original remark"
336
334
  : item.entryType === "parent"
337
335
  ? " · parent reply"
338
- : " · reply #" + item.id);
336
+ : " · reply #" + item.id) +
337
+ (data.post?.accepted_reply_id === item.id
338
+ ? " · accepted solution"
339
+ : "");
339
340
  meta =
340
341
  stamp(item.created) +
341
342
  ((item.edited_at || item.updated_at) && !item.deleted_at
@@ -355,7 +356,7 @@ function listRows(route, data, index, width) {
355
356
  const selected = i === index;
356
357
  return [
357
358
  {
358
- text: (selected ? "› " : " ") + clip(title, width - 3),
359
+ text: (selected ? "▸ " : " ") + clip(title, width - 3),
359
360
  color: selected ? colors.blue : colors.text,
360
361
  bold: selected,
361
362
  selected,
@@ -366,7 +367,7 @@ function listRows(route, data, index, width) {
366
367
  color: selected ? colors.text : colors.muted,
367
368
  selected,
368
369
  },
369
- "",
370
+ { text: " " + "─".repeat(Math.max(0, width - 4)), color: colors.line },
370
371
  ];
371
372
  });
372
373
  }
@@ -402,7 +403,18 @@ function App({ initialConfig, flags, root }) {
402
403
  [tick, setTick] = useState(0),
403
404
  [reading, setReading] = useState(null),
404
405
  [picker, setPicker] = useState(null),
405
- [draftSaved, setDraftSaved] = useState(false);
406
+ [draftSaved, setDraftSaved] = useState(false),
407
+ [actionIndex, setActionIndex] = useState(null),
408
+ [reviewFocus, setReviewFocus] = useState("notes"),
409
+ [reviewLine, setReviewLine] = useState(0),
410
+ [reviewAnchor, setReviewAnchor] = useState(null),
411
+ [reviewHorizontal, setReviewHorizontal] = useState(0),
412
+ [live, setLive] = useState(!flags["no-live"]),
413
+ [unread, setUnread] = useState(0),
414
+ [liveStatus, setLiveStatus] = useState("");
415
+ const activity = useRef(Date.now()),
416
+ liveView = useRef(null),
417
+ notificationGeneration = useRef(0);
406
418
  const cache = useRef(new PageCache()),
407
419
  history = useRef([]),
408
420
  busy = useRef(false),
@@ -432,6 +444,88 @@ function App({ initialConfig, flags, root }) {
432
444
  const key = JSON.stringify(route),
433
445
  current = data?.key === key ? data : null;
434
446
  const selected = current?.items?.[index];
447
+ const reviewing =
448
+ !!current?.post?.code &&
449
+ (route.view === "feedback" ||
450
+ (route.view === "post" && route.tab === "feedback"));
451
+ const actions = contextActions({
452
+ route,
453
+ data: current,
454
+ selected,
455
+ user,
456
+ live,
457
+ });
458
+ liveView.current = {
459
+ route,
460
+ key,
461
+ current,
462
+ index,
463
+ blocked: !!(
464
+ form ||
465
+ preview ||
466
+ picker ||
467
+ dialog ||
468
+ reading ||
469
+ actionIndex !== null ||
470
+ loading ||
471
+ pending ||
472
+ focus === "prompt"
473
+ ),
474
+ };
475
+ useEffect(() => {
476
+ if (phase !== "ready" || !api || !live) return;
477
+ return liveLoop(
478
+ async (signal) => {
479
+ const generation = notificationGeneration.current;
480
+ const count = await api("/notifications?limit=1", { signal });
481
+ if (signal.aborted || generation !== notificationGeneration.current)
482
+ return;
483
+ setUnread(count.unread_count || 0);
484
+ const before = liveView.current;
485
+ if (
486
+ before.blocked ||
487
+ !["inbox", "post", "feedback", "thread"].includes(before.route.view)
488
+ ) {
489
+ setLiveStatus("");
490
+ return;
491
+ }
492
+ const next = await loadPage(api, before.route, signal);
493
+ const after = liveView.current;
494
+ if (
495
+ signal.aborted ||
496
+ after.key !== before.key ||
497
+ after.current !== before.current ||
498
+ after.blocked
499
+ )
500
+ return;
501
+ cache.current.set(before.key, next);
502
+ setIndex(
503
+ anchoredIndex(after.current?.items, next.items || [], after.index),
504
+ );
505
+ setData({ ...next, key: before.key });
506
+ setLiveStatus("");
507
+ },
508
+ {
509
+ activity: () => activity.current,
510
+ onError: (e) => {
511
+ setLiveStatus(
512
+ e.status === 401 || e.status === 403
513
+ ? "Live updates paused: sign in again. Local draft preserved."
514
+ : "Live updates delayed · automatic retry",
515
+ );
516
+ if (e.status === 401 || e.status === 403) setLive(false);
517
+ },
518
+ },
519
+ );
520
+ }, [api, phase, live]);
521
+ useEffect(() => {
522
+ if (current?.unread_count !== undefined) setUnread(current.unread_count);
523
+ }, [current]);
524
+ useEffect(() => {
525
+ setReviewLine(0);
526
+ setReviewAnchor(null);
527
+ setReviewHorizontal(0);
528
+ }, [current?.post?.id, current?.post?.code]);
435
529
  const resetAccount = () => {
436
530
  cache.current.clear();
437
531
  history.current = [];
@@ -459,6 +553,7 @@ function App({ initialConfig, flags, root }) {
459
553
  if (!replace) history.current.push({ route, index, scroll });
460
554
  if (history.current.length > 30) history.current.shift();
461
555
  setReading(null);
556
+ if (next.view === "feedback") setReviewFocus("notes");
462
557
  setRoute(next);
463
558
  setIndex(0);
464
559
  setScroll(0);
@@ -805,8 +900,25 @@ function App({ initialConfig, flags, root }) {
805
900
  setError("Replies are paused.");
806
901
  return;
807
902
  }
903
+ const note = route.view === "feedback" ? current.note : selected;
904
+ const range = reviewing
905
+ ? reviewFocus === "notes" && note?.start_line
906
+ ? { start: note.start_line, end: note.end_line }
907
+ : codeRange(reviewLine, reviewAnchor)
908
+ : { start: 1, end: 1 };
909
+ if (reviewing && reviewFocus === "notes" && note?.outdated) {
910
+ setError(
911
+ "This remark belongs to an earlier snippet. W switches to current code before adding a new remark.",
912
+ );
913
+ return;
914
+ }
915
+ if (range.end - range.start >= 50) {
916
+ setError("Select at most 50 code lines for a remark.");
917
+ return;
918
+ }
808
919
  await task(async () => {
809
920
  const notes = await api(`/posts/${p.id}/code-reviews`);
921
+ assertSnippet(p.code, notes.snippet_hash);
810
922
  openForm({
811
923
  kind: "line",
812
924
  title: "Comment on code lines",
@@ -815,7 +927,11 @@ function App({ initialConfig, flags, root }) {
815
927
  postId: p.id,
816
928
  snippetHash: notes.snippet_hash,
817
929
  lineCount: p.code.split("\n").length,
818
- values: { start_line: "1", end_line: "1", body: "" },
930
+ values: {
931
+ start_line: String(range.start),
932
+ end_line: String(range.end),
933
+ body: "",
934
+ },
819
935
  });
820
936
  });
821
937
  }
@@ -1006,11 +1122,14 @@ function App({ initialConfig, flags, root }) {
1006
1122
  }
1007
1123
  if (route.view === "inbox") {
1008
1124
  await task(async () => {
1009
- if (!item.read_at)
1125
+ if (!item.read_at) {
1126
+ notificationGeneration.current++;
1010
1127
  await api("/notifications/" + item.id + "/read", {
1011
1128
  method: "PATCH",
1012
1129
  body: {},
1013
1130
  });
1131
+ setUnread((v) => Math.max(0, v - 1));
1132
+ }
1014
1133
  cache.current.clear();
1015
1134
  if (item.event_id) {
1016
1135
  openBrowser(
@@ -1176,6 +1295,81 @@ function App({ initialConfig, flags, root }) {
1176
1295
  invalidate(note.resolved ? "Feedback reopened." : "Feedback resolved.");
1177
1296
  });
1178
1297
  }
1298
+ function acceptSolution() {
1299
+ const post = current?.post;
1300
+ if (
1301
+ !selected ||
1302
+ selected.deleted_at ||
1303
+ !post ||
1304
+ post.user_id !== user.id ||
1305
+ !["question", "review"].includes(post.kind) ||
1306
+ !(route.view === "thread" || route.tab === "replies")
1307
+ )
1308
+ return;
1309
+ const accepted = post.accepted_reply_id === selected.id;
1310
+ askConfirm(
1311
+ accepted
1312
+ ? "Remove accepted solution?"
1313
+ : "Accept this reply as the solution?",
1314
+ "This changes the solution shown to everyone in the conversation.",
1315
+ () =>
1316
+ task(async () => {
1317
+ await api(`/posts/${post.id}/solution`, {
1318
+ method: "PATCH",
1319
+ body: { reply_id: accepted ? null : selected.id },
1320
+ });
1321
+ invalidate(accepted ? "Solution removed." : "Solution accepted.");
1322
+ }),
1323
+ );
1324
+ }
1325
+ function reportSelected() {
1326
+ const target = editable();
1327
+ if (!target?.feedback || target.row.deleted_at) {
1328
+ setError("Select an existing code remark or feedback reply to report.");
1329
+ return;
1330
+ }
1331
+ const noteId = route.view === "feedback" ? route.id : target.row.id;
1332
+ openForm({
1333
+ kind: "report",
1334
+ feedback: true,
1335
+ title: "Report selected feedback",
1336
+ url: `/posts/${current.post.id}/code-reviews/${noteId}/report`,
1337
+ replyId: target.row.entryType === "feedbackReply" ? target.row.id : 0,
1338
+ audience: "Assigned moderation team",
1339
+ values: { reason: "" },
1340
+ });
1341
+ }
1342
+ function runAction(id) {
1343
+ setActionIndex(null);
1344
+ if (id === "read") {
1345
+ if (current?.post) {
1346
+ setReading(selected);
1347
+ setScroll(0);
1348
+ } else openSelected();
1349
+ } else if (id === "reply") reply();
1350
+ else if (id === "comment") lineFeedback();
1351
+ else if (id === "edit") editSelected();
1352
+ else if (id === "delete") deleteSelected();
1353
+ else if (id === "resolve") resolveSelected();
1354
+ else if (id === "solution") acceptSolution();
1355
+ else if (id === "report") reportSelected();
1356
+ else if (id === "browser")
1357
+ openBrowser(config.origin + "/app#post/" + current.post.id);
1358
+ else if (id === "sync") invalidate("Updated.");
1359
+ else if (id === "live") {
1360
+ setLive((v) => !v);
1361
+ setLiveStatus("");
1362
+ } else if (id === "read-all")
1363
+ task(async () => {
1364
+ notificationGeneration.current++;
1365
+ await api("/notifications/read-all", {
1366
+ method: "PATCH",
1367
+ body: { through_id: current.latest_id },
1368
+ });
1369
+ setUnread(0);
1370
+ invalidate("Received notifications marked as read.");
1371
+ });
1372
+ }
1179
1373
  async function runCommand() {
1180
1374
  const value = command.trim();
1181
1375
  if (!value) return;
@@ -1249,6 +1443,7 @@ function App({ initialConfig, flags, root }) {
1249
1443
  );
1250
1444
  }
1251
1445
  useInput((input, keyEvent) => {
1446
+ activity.current = Date.now();
1252
1447
  if (picker) return;
1253
1448
  if (keyEvent.ctrl && input === "c") {
1254
1449
  if (dialog) {
@@ -1291,6 +1486,15 @@ function App({ initialConfig, flags, root }) {
1291
1486
  return;
1292
1487
  }
1293
1488
  if (busy.current) return;
1489
+ if (actionIndex !== null) {
1490
+ if (keyEvent.escape || input === "x" || input === "X")
1491
+ setActionIndex(null);
1492
+ else if (keyEvent.upArrow) setActionIndex((v) => Math.max(0, v - 1));
1493
+ else if (keyEvent.downArrow)
1494
+ setActionIndex((v) => Math.min(actions.length - 1, v + 1));
1495
+ else if (keyEvent.return) runAction(actions[actionIndex]?.id);
1496
+ return;
1497
+ }
1294
1498
  if (reading) {
1295
1499
  const lines = wrap(
1296
1500
  reading.deleted_at
@@ -1478,6 +1682,80 @@ function App({ initialConfig, flags, root }) {
1478
1682
  else if (keyEvent.rightArrow) setFocus("content");
1479
1683
  return;
1480
1684
  }
1685
+ if (input === "x" || input === "X" || input === "?") {
1686
+ setActionIndex(0);
1687
+ return;
1688
+ }
1689
+ if ((input === "v" || input === "V") && current?.post?.code) {
1690
+ navigate({ view: "post", id: current.post.id, tab: "feedback", page: 1 });
1691
+ return;
1692
+ }
1693
+ if (reviewing && (input === "w" || input === "W")) {
1694
+ setReviewFocus((v) => (v === "code" ? "notes" : "code"));
1695
+ return;
1696
+ }
1697
+ if (
1698
+ reviewing &&
1699
+ (input === "f" || input === "F") &&
1700
+ route.view === "post"
1701
+ ) {
1702
+ const filters = [undefined, "open", "resolved"];
1703
+ navigate(
1704
+ {
1705
+ ...route,
1706
+ status: filters[(filters.indexOf(route.status) + 1) % filters.length],
1707
+ page: 1,
1708
+ },
1709
+ { replace: true },
1710
+ );
1711
+ return;
1712
+ }
1713
+ if (reviewing && reviewFocus === "code") {
1714
+ if (
1715
+ keyEvent.upArrow ||
1716
+ keyEvent.downArrow ||
1717
+ keyEvent.pageUp ||
1718
+ keyEvent.pageDown
1719
+ ) {
1720
+ const delta =
1721
+ (keyEvent.upArrow || keyEvent.pageUp ? -1 : 1) *
1722
+ (keyEvent.pageUp || keyEvent.pageDown
1723
+ ? Math.max(1, contentHeight - 2)
1724
+ : 1);
1725
+ setReviewLine((v) =>
1726
+ Math.max(
1727
+ 0,
1728
+ Math.min(current.post.code.split("\n").length - 1, v + delta),
1729
+ ),
1730
+ );
1731
+ return;
1732
+ }
1733
+ if (keyEvent.leftArrow || keyEvent.rightArrow) {
1734
+ setReviewHorizontal((v) =>
1735
+ Math.max(0, v + (keyEvent.leftArrow ? -8 : 8)),
1736
+ );
1737
+ return;
1738
+ }
1739
+ if (input === " ") {
1740
+ setReviewAnchor((v) => (v === null ? reviewLine : null));
1741
+ return;
1742
+ }
1743
+ if (keyEvent.return) {
1744
+ const i = current.items.findIndex(
1745
+ (n) =>
1746
+ !n.outdated &&
1747
+ !n.deleted_at &&
1748
+ reviewLine + 1 >= n.start_line &&
1749
+ reviewLine + 1 <= n.end_line,
1750
+ );
1751
+ if (i >= 0) {
1752
+ setIndex(i);
1753
+ setReviewFocus("notes");
1754
+ } else
1755
+ setNotice("No remark on this line on the current page. C adds one.");
1756
+ return;
1757
+ }
1758
+ }
1481
1759
  const prose =
1482
1760
  route.view === "help" ||
1483
1761
  (route.view === "post" && (!route.tab || route.tab === "overview")) ||
@@ -1572,15 +1850,16 @@ function App({ initialConfig, flags, root }) {
1572
1850
  invalidate("Updated.");
1573
1851
  return;
1574
1852
  }
1575
- if (input === "c") {
1853
+ if (input === "c" || input === "C") {
1576
1854
  lineFeedback();
1577
1855
  return;
1578
1856
  }
1579
- if (input === "s") {
1580
- resolveSelected();
1857
+ if (input === "s" || input === "S") {
1858
+ if (route.view === "thread" || route.tab === "replies") acceptSolution();
1859
+ else resolveSelected();
1581
1860
  return;
1582
1861
  }
1583
- if (input === "e") {
1862
+ if (input === "e" || input === "E") {
1584
1863
  editSelected();
1585
1864
  return;
1586
1865
  }
@@ -1609,25 +1888,12 @@ function App({ initialConfig, flags, root }) {
1609
1888
  return;
1610
1889
  }
1611
1890
  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
- });
1891
+ reportSelected();
1627
1892
  }
1628
1893
  });
1629
1894
  const status =
1630
1895
  error ||
1896
+ liveStatus ||
1631
1897
  notice ||
1632
1898
  (pending
1633
1899
  ? "Saving…"
@@ -1826,6 +2092,61 @@ function App({ initialConfig, flags, root }) {
1826
2092
  { color: colors.muted },
1827
2093
  "Opening " + labelOf(route).toLowerCase() + " " + spin,
1828
2094
  );
2095
+ else if (actionIndex !== null) {
2096
+ const first = Math.max(
2097
+ 0,
2098
+ actionIndex - Math.max(1, contentHeight - 2) + 1,
2099
+ );
2100
+ body = h(
2101
+ Box,
2102
+ { height: contentHeight, flexDirection: "column", overflow: "hidden" },
2103
+ h(
2104
+ Text,
2105
+ { bold: true, color: colors.blue },
2106
+ "Actions · ↑↓ choose · Enter run · Esc close",
2107
+ ),
2108
+ contentHeight >= 4
2109
+ ? h(
2110
+ Text,
2111
+ { color: colors.muted },
2112
+ "Available for this selection and your role",
2113
+ )
2114
+ : null,
2115
+ ...actions
2116
+ .slice(
2117
+ first,
2118
+ first + Math.max(1, contentHeight - (contentHeight >= 4 ? 2 : 1)),
2119
+ )
2120
+ .map((action, i) =>
2121
+ h(
2122
+ Text,
2123
+ {
2124
+ key: action.id,
2125
+ color: i + first === actionIndex ? colors.blue : colors.text,
2126
+ backgroundColor:
2127
+ i + first === actionIndex ? colors.selected : undefined,
2128
+ },
2129
+ clip(
2130
+ (i + first === actionIndex ? "› " : " ") + action.label,
2131
+ contentWidth,
2132
+ ),
2133
+ ),
2134
+ ),
2135
+ );
2136
+ } else if (reviewing)
2137
+ body = h(ReviewWorkspace, {
2138
+ post: current.post,
2139
+ note: route.view === "feedback" ? current.note : null,
2140
+ items: current.items || [],
2141
+ index,
2142
+ width: contentWidth,
2143
+ height: contentHeight,
2144
+ focus: reviewFocus,
2145
+ line: reviewLine,
2146
+ anchor: reviewAnchor,
2147
+ horizontal: reviewHorizontal,
2148
+ rows: (w) => listRows(route, current, index, w),
2149
+ });
1829
2150
  else {
1830
2151
  const lines = contentLines(route, current || {}).flatMap((line) =>
1831
2152
  wrap(line, contentWidth),
@@ -1941,11 +2262,19 @@ function App({ initialConfig, flags, root }) {
1941
2262
  ? colors.blue
1942
2263
  : colors.muted,
1943
2264
  bold: (route.tab || "overview") === id,
2265
+ backgroundColor:
2266
+ (route.tab || "overview") === id
2267
+ ? colors.selected
2268
+ : undefined,
1944
2269
  },
1945
2270
  " " + label + " ",
1946
2271
  ),
1947
2272
  ),
1948
- h(Text, { color: colors.muted }, " ← →"),
2273
+ h(
2274
+ Text,
2275
+ { color: colors.muted },
2276
+ reviewing ? " W focus · F " + (route.status || "all") : " ← →",
2277
+ ),
1949
2278
  )
1950
2279
  : h(TextLine, {
1951
2280
  text: form
@@ -1981,7 +2310,7 @@ function App({ initialConfig, flags, root }) {
1981
2310
  paddingX: 1,
1982
2311
  overflow: "hidden",
1983
2312
  },
1984
- h(Text, { color: colors.muted }, "WORKSPACE"),
2313
+ h(Text, { color: colors.purple, bold: true }, "YOUR SPACE"),
1985
2314
  h(Text, null, " "),
1986
2315
  ...navigation
1987
2316
  .slice(
@@ -2005,9 +2334,16 @@ function App({ initialConfig, flags, root }) {
2005
2334
  backgroundColor: selected ? colors.selected : undefined,
2006
2335
  wrap: "truncate",
2007
2336
  },
2008
- (selected ? "› " : " ") + label,
2337
+ (selected ? "› " : route.view === id ? "● " : " ") +
2338
+ label +
2339
+ (id === "inbox" && unread ? " · " + unread : ""),
2009
2340
  );
2010
2341
  }),
2342
+ h(
2343
+ Text,
2344
+ { color: colors.muted, dimColor: true },
2345
+ " Tab sections · / find",
2346
+ ),
2011
2347
  )
2012
2348
  : null;
2013
2349
  center = h(
@@ -2017,21 +2353,26 @@ function App({ initialConfig, flags, root }) {
2017
2353
  compact && focus === "nav" ? null : pane,
2018
2354
  );
2019
2355
  }
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";
2356
+ const hint =
2357
+ actionIndex !== null
2358
+ ? "Actions are scoped to the selected item · Esc closes"
2359
+ : reviewing && !form && !preview && !reading
2360
+ ? "W code / discussion · Space select · C comment · X actions · F filter"
2361
+ : picker
2362
+ ? "Project files stay local until publication is confirmed"
2363
+ : reading
2364
+ ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2365
+ : preview
2366
+ ? "← → choose · Enter confirm · Esc edit"
2367
+ : form
2368
+ ? "Tab field · Ctrl+O files · Ctrl+G diff · Ctrl+S preview · Esc discard"
2369
+ : phase !== "ready"
2370
+ ? "Enter connect / open browser · Ctrl+C exit"
2371
+ : route.view === "feedback"
2372
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2373
+ : route.view === "post"
2374
+ ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2375
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2035
2376
  return h(
2036
2377
  Box,
2037
2378
  {
@@ -2054,12 +2395,22 @@ function App({ initialConfig, flags, root }) {
2054
2395
  h(
2055
2396
  Text,
2056
2397
  { color: colors.purple, bold: true },
2057
- clip("◈ DEVDEVIAN / TERMINAL WORKSPACE", Math.max(10, width - 28)),
2398
+ clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2058
2399
  ),
2059
2400
  h(
2060
2401
  Text,
2061
2402
  { color: colors.blue },
2062
- clip(user ? "@" + user.username : "CLI · ALPHA", 24),
2403
+ clip(
2404
+ user
2405
+ ? "@" +
2406
+ user.username +
2407
+ " · " +
2408
+ unread +
2409
+ " unread · " +
2410
+ (live ? "● Live" : "○ Paused")
2411
+ : "CLI · ALPHA",
2412
+ 32,
2413
+ ),
2063
2414
  ),
2064
2415
  ),
2065
2416
  center,
@@ -2087,7 +2438,10 @@ function App({ initialConfig, flags, root }) {
2087
2438
  : h(
2088
2439
  Text,
2089
2440
  { color: colors.muted },
2090
- clip("Search or /command · Press / to type", width - 6),
2441
+ clip(
2442
+ "Search or /command · Press / to type · X actions",
2443
+ width - 6,
2444
+ ),
2091
2445
  ),
2092
2446
  ),
2093
2447
  h(TextLine, { text: hint, width, color: colors.muted }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenyokjs/devdevian-cli",
3
- "version": "0.3.1-alpha.1",
3
+ "version": "0.4.0-alpha.1",
4
4
  "description": "Devdevian in your terminal: discussions, questions and human code reviews.",
5
5
  "type": "module",
6
6
  "bin": {