@tenyokjs/devdevian-cli 0.3.0-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 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
 
@@ -26,6 +26,22 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
26
26
 
27
27
  Pages have a bounded 30-second in-memory cache. Refresh explicitly with Shift+R. Network errors stay inside the workspace; failed writes are never automatically retried. Existing web permissions and server validation apply to every operation.
28
28
 
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
+
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
+
29
45
  ## Project files and Git changes
30
46
 
31
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,
@@ -98,15 +117,29 @@ export async function loadPage(api, route, signal) {
98
117
  };
99
118
  }
100
119
  if (route.view === "thread") {
101
- const post = await get("/posts/" + route.postId),
102
- replies = await get(
103
- `/posts/${route.postId}/replies?parent_id=${route.parent.id}&page=${page}`,
120
+ const post = await get(
121
+ "/posts/" + route.postId + "?reply_id=" + route.parent.id,
122
+ );
123
+ const target = post.reply_path?.at(-1);
124
+ let parent = post.replies?.find((r) => r.id === route.parent.id);
125
+ if (!parent && target) {
126
+ const siblings = await get(
127
+ `/posts/${route.postId}/replies?parent_id=${target.parent_id || ""}&page=${target.page}`,
104
128
  );
129
+ parent = siblings.items.find((r) => r.id === route.parent.id);
130
+ }
131
+ const replies = await get(
132
+ `/posts/${route.postId}/replies?parent_id=${route.parent.id}&page=${page}`,
133
+ );
105
134
  return {
106
135
  post,
107
136
  ...replies,
137
+ items: [
138
+ ...(parent ? [{ ...parent, entryType: "parent" }] : []),
139
+ ...replies.items,
140
+ ],
108
141
  title: "Reply thread",
109
- subtitle: "Replying to " + route.parent.name,
142
+ subtitle: "Replying to " + (parent?.name || route.parent.name),
110
143
  };
111
144
  }
112
145
  if (route.view === "feedback") {
@@ -114,6 +147,7 @@ export async function loadPage(api, route, signal) {
114
147
  get("/posts/" + route.postId),
115
148
  get(`/posts/${route.postId}/code-reviews?target_id=${route.id}`),
116
149
  ]);
150
+ assertSnippet(post.code, notes.snippet_hash);
117
151
  const note = notes.items.find((item) => item.id === Number(route.id));
118
152
  if (!note) throw Error("This feedback is no longer available.");
119
153
  const replies = await get(
@@ -122,6 +156,7 @@ export async function loadPage(api, route, signal) {
122
156
  return {
123
157
  post,
124
158
  note,
159
+ snippet_hash: notes.snippet_hash,
125
160
  items: [
126
161
  { ...note, entryType: "note" },
127
162
  ...replies.items.map((item) => ({
@@ -149,6 +184,11 @@ export function contentLines(route, data) {
149
184
  "Page Up / Down Scroll a conversation or list",
150
185
  "N / A New conversation / ask a question",
151
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",
152
192
  "C Add feedback on code lines",
153
193
  "S Resolve / reopen selected code feedback",
154
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(
@@ -333,8 +331,17 @@ function listRows(route, data, index, width) {
333
331
  item.name +
334
332
  (item.entryType === "note"
335
333
  ? " · original remark"
336
- : " · reply #" + item.id);
337
- meta = stamp(item.created) + (item.edited_at ? " · edited" : "");
334
+ : item.entryType === "parent"
335
+ ? " · parent reply"
336
+ : " · reply #" + item.id) +
337
+ (data.post?.accepted_reply_id === item.id
338
+ ? " · accepted solution"
339
+ : "");
340
+ meta =
341
+ stamp(item.created) +
342
+ ((item.edited_at || item.updated_at) && !item.deleted_at
343
+ ? " · edited"
344
+ : "");
338
345
  body = item.deleted_at ? "[deleted — discussion preserved]" : item.body;
339
346
  } else {
340
347
  meta = [
@@ -349,7 +356,7 @@ function listRows(route, data, index, width) {
349
356
  const selected = i === index;
350
357
  return [
351
358
  {
352
- text: (selected ? "› " : " ") + clip(title, width - 3),
359
+ text: (selected ? "▸ " : " ") + clip(title, width - 3),
353
360
  color: selected ? colors.blue : colors.text,
354
361
  bold: selected,
355
362
  selected,
@@ -360,7 +367,7 @@ function listRows(route, data, index, width) {
360
367
  color: selected ? colors.text : colors.muted,
361
368
  selected,
362
369
  },
363
- "",
370
+ { text: " " + "─".repeat(Math.max(0, width - 4)), color: colors.line },
364
371
  ];
365
372
  });
366
373
  }
@@ -396,7 +403,18 @@ function App({ initialConfig, flags, root }) {
396
403
  [tick, setTick] = useState(0),
397
404
  [reading, setReading] = useState(null),
398
405
  [picker, setPicker] = useState(null),
399
- [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);
400
418
  const cache = useRef(new PageCache()),
401
419
  history = useRef([]),
402
420
  busy = useRef(false),
@@ -422,10 +440,92 @@ function App({ initialConfig, flags, root }) {
422
440
  sidebar = compact ? 0 : 25,
423
441
  bodyHeight = Math.max(1, height - 9),
424
442
  contentWidth = Math.max(1, width - sidebar - 6),
425
- contentHeight = Math.max(1, bodyHeight - 5);
443
+ contentHeight = Math.max(1, bodyHeight - 6);
426
444
  const key = JSON.stringify(route),
427
445
  current = data?.key === key ? data : null;
428
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]);
429
529
  const resetAccount = () => {
430
530
  cache.current.clear();
431
531
  history.current = [];
@@ -453,6 +553,7 @@ function App({ initialConfig, flags, root }) {
453
553
  if (!replace) history.current.push({ route, index, scroll });
454
554
  if (history.current.length > 30) history.current.shift();
455
555
  setReading(null);
556
+ if (next.view === "feedback") setReviewFocus("notes");
456
557
  setRoute(next);
457
558
  setIndex(0);
458
559
  setScroll(0);
@@ -799,8 +900,25 @@ function App({ initialConfig, flags, root }) {
799
900
  setError("Replies are paused.");
800
901
  return;
801
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
+ }
802
919
  await task(async () => {
803
920
  const notes = await api(`/posts/${p.id}/code-reviews`);
921
+ assertSnippet(p.code, notes.snippet_hash);
804
922
  openForm({
805
923
  kind: "line",
806
924
  title: "Comment on code lines",
@@ -809,7 +927,11 @@ function App({ initialConfig, flags, root }) {
809
927
  postId: p.id,
810
928
  snippetHash: notes.snippet_hash,
811
929
  lineCount: p.code.split("\n").length,
812
- 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
+ },
813
935
  });
814
936
  });
815
937
  }
@@ -1000,11 +1122,14 @@ function App({ initialConfig, flags, root }) {
1000
1122
  }
1001
1123
  if (route.view === "inbox") {
1002
1124
  await task(async () => {
1003
- if (!item.read_at)
1125
+ if (!item.read_at) {
1126
+ notificationGeneration.current++;
1004
1127
  await api("/notifications/" + item.id + "/read", {
1005
1128
  method: "PATCH",
1006
1129
  body: {},
1007
1130
  });
1131
+ setUnread((v) => Math.max(0, v - 1));
1132
+ }
1008
1133
  cache.current.clear();
1009
1134
  if (item.event_id) {
1010
1135
  openBrowser(
@@ -1035,6 +1160,11 @@ function App({ initialConfig, flags, root }) {
1035
1160
  navigate({ view: "feedback", postId: route.id, id: item.id, page: 1 });
1036
1161
  return;
1037
1162
  }
1163
+ if (route.view === "thread" && item.entryType === "parent") {
1164
+ setReading(item);
1165
+ setScroll(0);
1166
+ return;
1167
+ }
1038
1168
  if (
1039
1169
  (route.view === "post" && route.tab === "replies") ||
1040
1170
  route.view === "thread"
@@ -1116,24 +1246,30 @@ function App({ initialConfig, flags, root }) {
1116
1246
  return;
1117
1247
  }
1118
1248
  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.");
1249
+ if (!target || target.row.user_id !== user.id || target.row.deleted_at) {
1250
+ setError(
1251
+ "Select your own reply or code feedback in the Replies / Code feedback tab to delete.",
1252
+ );
1125
1253
  return;
1126
1254
  }
1127
1255
  askConfirm(
1128
- "Delete your feedback?",
1129
- "Your text is removed. Other members’ replies are preserved.",
1256
+ target.feedback ? "Delete your feedback?" : "Delete your reply?",
1257
+ "Your text is removed. Other members’ replies are preserved." +
1258
+ (!target.feedback && current.post?.accepted_reply_id === target.row.id
1259
+ ? " Its accepted-solution mark is cleared."
1260
+ : ""),
1130
1261
  () =>
1131
1262
  task(async () => {
1132
1263
  await api(target.url, {
1133
1264
  method: "DELETE",
1134
1265
  body: { revision: target.row.revision },
1135
1266
  });
1136
- invalidate("Feedback deleted; the discussion is preserved.");
1267
+ setReading(null);
1268
+ invalidate(
1269
+ target.feedback
1270
+ ? "Feedback deleted; the discussion is preserved."
1271
+ : "Reply deleted; other members’ replies are preserved.",
1272
+ );
1137
1273
  }),
1138
1274
  );
1139
1275
  }
@@ -1159,6 +1295,81 @@ function App({ initialConfig, flags, root }) {
1159
1295
  invalidate(note.resolved ? "Feedback reopened." : "Feedback resolved.");
1160
1296
  });
1161
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
+ }
1162
1373
  async function runCommand() {
1163
1374
  const value = command.trim();
1164
1375
  if (!value) return;
@@ -1232,6 +1443,7 @@ function App({ initialConfig, flags, root }) {
1232
1443
  );
1233
1444
  }
1234
1445
  useInput((input, keyEvent) => {
1446
+ activity.current = Date.now();
1235
1447
  if (picker) return;
1236
1448
  if (keyEvent.ctrl && input === "c") {
1237
1449
  if (dialog) {
@@ -1274,6 +1486,15 @@ function App({ initialConfig, flags, root }) {
1274
1486
  return;
1275
1487
  }
1276
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
+ }
1277
1498
  if (reading) {
1278
1499
  const lines = wrap(
1279
1500
  reading.deleted_at
@@ -1298,9 +1519,11 @@ function App({ initialConfig, flags, root }) {
1298
1519
  else if (input === "r") {
1299
1520
  setReading(null);
1300
1521
  reply();
1301
- } else if (input === "e") {
1522
+ } else if (input === "e" || input === "E") {
1302
1523
  setReading(null);
1303
1524
  editSelected();
1525
+ } else if (input === "d" || input === "D") {
1526
+ deleteSelected();
1304
1527
  }
1305
1528
  return;
1306
1529
  }
@@ -1459,6 +1682,80 @@ function App({ initialConfig, flags, root }) {
1459
1682
  else if (keyEvent.rightArrow) setFocus("content");
1460
1683
  return;
1461
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
+ }
1462
1759
  const prose =
1463
1760
  route.view === "help" ||
1464
1761
  (route.view === "post" && (!route.tab || route.tab === "overview")) ||
@@ -1553,19 +1850,20 @@ function App({ initialConfig, flags, root }) {
1553
1850
  invalidate("Updated.");
1554
1851
  return;
1555
1852
  }
1556
- if (input === "c") {
1853
+ if (input === "c" || input === "C") {
1557
1854
  lineFeedback();
1558
1855
  return;
1559
1856
  }
1560
- if (input === "s") {
1561
- resolveSelected();
1857
+ if (input === "s" || input === "S") {
1858
+ if (route.view === "thread" || route.tab === "replies") acceptSolution();
1859
+ else resolveSelected();
1562
1860
  return;
1563
1861
  }
1564
- if (input === "e") {
1862
+ if (input === "e" || input === "E") {
1565
1863
  editSelected();
1566
1864
  return;
1567
1865
  }
1568
- if (input === "d") {
1866
+ if (input === "d" || input === "D") {
1569
1867
  deleteSelected();
1570
1868
  return;
1571
1869
  }
@@ -1590,25 +1888,12 @@ function App({ initialConfig, flags, root }) {
1590
1888
  return;
1591
1889
  }
1592
1890
  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
- });
1891
+ reportSelected();
1608
1892
  }
1609
1893
  });
1610
1894
  const status =
1611
1895
  error ||
1896
+ liveStatus ||
1612
1897
  notice ||
1613
1898
  (pending
1614
1899
  ? "Saving…"
@@ -1807,6 +2092,61 @@ function App({ initialConfig, flags, root }) {
1807
2092
  { color: colors.muted },
1808
2093
  "Opening " + labelOf(route).toLowerCase() + " " + spin,
1809
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
+ });
1810
2150
  else {
1811
2151
  const lines = contentLines(route, current || {}).flatMap((line) =>
1812
2152
  wrap(line, contentWidth),
@@ -1922,11 +2262,19 @@ function App({ initialConfig, flags, root }) {
1922
2262
  ? colors.blue
1923
2263
  : colors.muted,
1924
2264
  bold: (route.tab || "overview") === id,
2265
+ backgroundColor:
2266
+ (route.tab || "overview") === id
2267
+ ? colors.selected
2268
+ : undefined,
1925
2269
  },
1926
2270
  " " + label + " ",
1927
2271
  ),
1928
2272
  ),
1929
- h(Text, { color: colors.muted }, " ← →"),
2273
+ h(
2274
+ Text,
2275
+ { color: colors.muted },
2276
+ reviewing ? " W focus · F " + (route.status || "all") : " ← →",
2277
+ ),
1930
2278
  )
1931
2279
  : h(TextLine, {
1932
2280
  text: form
@@ -1962,7 +2310,7 @@ function App({ initialConfig, flags, root }) {
1962
2310
  paddingX: 1,
1963
2311
  overflow: "hidden",
1964
2312
  },
1965
- h(Text, { color: colors.muted }, "WORKSPACE"),
2313
+ h(Text, { color: colors.purple, bold: true }, "YOUR SPACE"),
1966
2314
  h(Text, null, " "),
1967
2315
  ...navigation
1968
2316
  .slice(
@@ -1986,9 +2334,16 @@ function App({ initialConfig, flags, root }) {
1986
2334
  backgroundColor: selected ? colors.selected : undefined,
1987
2335
  wrap: "truncate",
1988
2336
  },
1989
- (selected ? "› " : " ") + label,
2337
+ (selected ? "› " : route.view === id ? "● " : " ") +
2338
+ label +
2339
+ (id === "inbox" && unread ? " · " + unread : ""),
1990
2340
  );
1991
2341
  }),
2342
+ h(
2343
+ Text,
2344
+ { color: colors.muted, dimColor: true },
2345
+ " Tab sections · / find",
2346
+ ),
1992
2347
  )
1993
2348
  : null;
1994
2349
  center = h(
@@ -1998,21 +2353,26 @@ function App({ initialConfig, flags, root }) {
1998
2353
  compact && focus === "nav" ? null : pane,
1999
2354
  );
2000
2355
  }
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";
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";
2016
2376
  return h(
2017
2377
  Box,
2018
2378
  {
@@ -2035,12 +2395,22 @@ function App({ initialConfig, flags, root }) {
2035
2395
  h(
2036
2396
  Text,
2037
2397
  { color: colors.purple, bold: true },
2038
- clip("◈ DEVDEVIAN / TERMINAL WORKSPACE", Math.max(10, width - 28)),
2398
+ clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2039
2399
  ),
2040
2400
  h(
2041
2401
  Text,
2042
2402
  { color: colors.blue },
2043
- 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
+ ),
2044
2414
  ),
2045
2415
  ),
2046
2416
  center,
@@ -2068,7 +2438,10 @@ function App({ initialConfig, flags, root }) {
2068
2438
  : h(
2069
2439
  Text,
2070
2440
  { color: colors.muted },
2071
- clip("Search or /command · Press / to type", width - 6),
2441
+ clip(
2442
+ "Search or /command · Press / to type · X actions",
2443
+ width - 6,
2444
+ ),
2072
2445
  ),
2073
2446
  ),
2074
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.0-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": {