@tenyokjs/devdevian-cli 0.2.0-alpha.1 → 0.3.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/README.md CHANGED
@@ -15,17 +15,43 @@ Login opens a browser. Compare the code shown in the terminal, sign in to Devdev
15
15
  The default `devdevian` command and `devdevian tui` open an alternate terminal screen. Navigation, conversations, inline forms, previews and search update in that screen; exiting restores your normal terminal. The blue and purple layout adapts to the terminal size, with compact navigation below 90 columns. Minimum size: 60 columns × 18 rows.
16
16
 
17
17
  - **Tab** switches between navigation, content and the command bar. **↑/↓** select; **Enter** opens; **Esc** goes back.
18
- - **/** focuses search and commands: `/feed`, `/inbox`, `/search query`, `/open 142`, `/ask`, `/review`, `/communities`, `/devices`, `/help`, `/logout`, `/quit`.
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.
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 are kept in memory only.
23
- - **Ctrl+C** exits. With an unsent draft, confirm leaving or press Ctrl+C again.
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.
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
+ - **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
24
24
 
25
25
  `devdevian open` launches Terminal on macOS, Windows Terminal on Windows, or GNOME Terminal/Konsole/xterm on a Linux desktop. A graphical desktop and an installed supported terminal are required; over SSH and inside an editor, use `devdevian tui`. Window launchers still need platform-specific user testing; terminal rendering and input are covered by macOS/Linux pseudo-terminal checks.
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
+ ## Project files and Git changes
32
+
33
+ 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.
34
+
35
+ In a **Code review** form:
36
+
37
+ 1. Press **Ctrl+O** to browse project files, or **Ctrl+G** to browse Git changes. These shortcuts work from any review field. Enter on an empty Code file field also opens the explorer.
38
+ 2. **↑/↓** select entries; **Enter** opens a directory or previews a file; **←/Backspace** goes to the parent directory. **/** filters names in the current directory, **H** shows hidden files and **G** switches Files/Git changes. Wide terminals show the file list next to the preview; compact terminals use the available width for code.
39
+ 3. In a file preview, use **↑/↓** or **Page Up/Down** to move. **Space** starts a line selection; move to extend it, or press Space again to reset. **←/→** scroll long lines horizontally. **Enter** uses the file/range; **Esc** returns to the list.
40
+ 4. In Git changes, **T** switches **unstaged/staged**. The preview initially includes all hunks for one file; **[ / ]** choose the whole patch or an individual hunk. **Enter** uses that selection. Untracked files are selected through Files; binary diffs/submodules are excluded.
41
+ 5. Fill in title, focus and context. **Ctrl+S** shows the exact code and audience to publish. Publication requires a separate confirmation.
42
+
43
+ The explorer excludes `.git`, dependency/build directories, symlinks, common environment/credential/key filenames and (inside Git repositories) ignored paths. No content scanner can identify every secret: inspect the publication preview before confirming. Git is required for changes and `.gitignore` detection; ordinary file selection also works without Git. Git reads disable external diff/textconv/fsmonitor helpers and never stage, commit, reset or execute project code.
44
+
45
+ File previews accept UTF-8 text up to **512 KiB**; a published snippet/patch is **10–20,000 characters**. Large files can be narrowed to a range and large patches to one hunk. Git patches appear as ordinary review snippets on the website; review line numbers count the **published patch including headers**, not the original source file. Source paths are relative to the project. If the selected patch changes before the publication preview, selection must be repeated. Publication uses the confirmed preview snapshot.
46
+
47
+ ## Local drafts
48
+
49
+ Publication, reply, feedback, edit and community forms save locally after a **450 ms** pause. Explicit draft discard removes that draft; successful publication removes it too. `/drafts` lists saved drafts for the current **server, account and project directory**. Nothing is uploaded by autosave. Switching accounts or projects does not expose another scope's drafts.
50
+
51
+ Draft files live under the CLI credentials directory in `drafts/<scope>/`, with Unix file permissions `0600` and new directories `0700`. They contain your text and file/diff selection descriptors, **not copies of selected source files or patches**. They are local JSON files, not encrypted; protect your OS account and do not commit/share this directory. The history retains up to **20 drafts per scope for 30 days**. An abrupt termination may lose the most recent input before autosave; these drafts are not a backup service.
52
+
53
+ Restoring a review rereads the local source at the publication preview. Changed Git selections require reselection. A private audience that is no longer accessible is preserved as unavailable until you explicitly choose an available audience; it never silently becomes public.
54
+
29
55
  ## Everyday commands
30
56
 
31
57
  ```sh
package/bin/devdevian.mjs CHANGED
@@ -34,6 +34,7 @@ const options = {
34
34
  revision: { type: "string" },
35
35
  "reply-id": { type: "string" },
36
36
  name: { type: "string" },
37
+ project: { type: "string" },
37
38
  yes: { type: "boolean" },
38
39
  json: { type: "boolean" },
39
40
  editor: { type: "boolean" },
@@ -94,6 +95,7 @@ const help = `Devdevian CLI ${version} · alpha
94
95
  devdevian review reopen POST_ID REVIEW_ID
95
96
  devdevian communities [--page 2]
96
97
 
98
+ --project PATH Root directory for the live file/diff picker (default: current directory)
97
99
  --community ID Publish to an approved private community
98
100
  --editor Write text in $VISUAL / $EDITOR
99
101
  --json Machine-readable output
@@ -472,7 +474,7 @@ try {
472
474
  await (await import("../lib/tui.mjs")).startTui(values);
473
475
  else if (positionals[0] === "open") {
474
476
  const args = [];
475
- for (const key of ["host", "name"])
477
+ for (const key of ["host", "name", "project"])
476
478
  if (values[key]) args.push("--" + key, values[key]);
477
479
  if (values["no-open"]) args.push("--no-open");
478
480
  await (
package/lib/drafts.mjs ADDED
@@ -0,0 +1,119 @@
1
+ import {
2
+ mkdir,
3
+ lstat,
4
+ readdir,
5
+ readFile,
6
+ writeFile,
7
+ rename,
8
+ unlink,
9
+ } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+ import { createHash, randomBytes } from "node:crypto";
12
+ import { configFile } from "./client.mjs";
13
+ const digest = (value) =>
14
+ createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
+ const MAX = 128 * 1024,
16
+ TTL = 30 * 86400000;
17
+ export function draftIdentity(form) {
18
+ return /^[a-f0-9]{64}$/.test(form.draftId || "")
19
+ ? form.draftId
20
+ : digest([
21
+ form.kind,
22
+ form.postId || "",
23
+ form.url || "",
24
+ form.parentId || "",
25
+ ]);
26
+ }
27
+ export class DraftStore {
28
+ constructor({ origin, userId, root, env = process.env }) {
29
+ this.scope = digest([origin, userId, root]);
30
+ this.directory = join(dirname(configFile(env)), "drafts", this.scope);
31
+ this.root = root;
32
+ }
33
+ async ensure() {
34
+ const base = dirname(dirname(this.directory));
35
+ await mkdir(base, { recursive: true, mode: 0o700 });
36
+ for (const directory of [base, dirname(this.directory), this.directory]) {
37
+ await mkdir(directory, { recursive: true, mode: 0o700 });
38
+ const info = await lstat(directory);
39
+ if (!info.isDirectory() || info.isSymbolicLink())
40
+ throw Error("The draft directory must be a real directory.");
41
+ }
42
+ }
43
+ async read(id) {
44
+ if (!/^[a-f0-9]{64}$/.test(id)) throw Error("Invalid draft ID.");
45
+ const file = join(this.directory, id + ".json");
46
+ const info = await lstat(file);
47
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX)
48
+ throw Error("Invalid draft file.");
49
+ if (process.platform !== "win32" && info.mode & 0o077)
50
+ throw Error("Draft file permissions must be 600.");
51
+ const record = JSON.parse(await readFile(file, "utf8"));
52
+ if (
53
+ !Number.isFinite(record.savedAt) ||
54
+ record.version !== 1 ||
55
+ record.scope !== this.scope ||
56
+ record.id !== id ||
57
+ Date.now() - record.savedAt > TTL ||
58
+ record.savedAt > Date.now() + 60000 ||
59
+ !["publication", "reply", "line", "edit", "community"].includes(
60
+ record.form?.kind,
61
+ ) ||
62
+ !record.form?.values ||
63
+ typeof record.form.values !== "object" ||
64
+ Array.isArray(record.form.values)
65
+ )
66
+ throw Error("Draft expired or is invalid.");
67
+ return record;
68
+ }
69
+ async list() {
70
+ await this.ensure();
71
+ const names = await readdir(this.directory),
72
+ rows = [];
73
+ for (const name of names) {
74
+ if (!/^[a-f0-9]{64}\.json$/.test(name)) continue;
75
+ try {
76
+ rows.push(await this.read(name.slice(0, -5)));
77
+ } catch {
78
+ /* A corrupt or expired local draft never prevents opening the workspace. */
79
+ }
80
+ }
81
+ return rows.sort((a, b) => b.savedAt - a.savedAt).slice(0, 20);
82
+ }
83
+ async save(form) {
84
+ if (
85
+ ["open", "report"].includes(form.kind) ||
86
+ !["title", "body", "name", "description", "file", "review_focus"].some(
87
+ (key) => String(form.values[key] || "").trim(),
88
+ )
89
+ )
90
+ return;
91
+ await this.ensure();
92
+ const id = draftIdentity(form),
93
+ record = { version: 1, id, scope: this.scope, savedAt: Date.now(), form };
94
+ const body = JSON.stringify(record);
95
+ if (Buffer.byteLength(body) > MAX)
96
+ throw Error("This draft is too large to save locally.");
97
+ const temp = join(this.directory, "." + randomBytes(12).toString("hex"));
98
+ try {
99
+ await writeFile(temp, body, { mode: 0o600, flag: "wx" });
100
+ await rename(temp, join(this.directory, id + ".json"));
101
+ } finally {
102
+ await unlink(temp).catch((e) => {
103
+ if (e.code !== "ENOENT") throw e;
104
+ });
105
+ }
106
+ const rows = await this.list();
107
+ const keep = new Set(rows.map((row) => row.id));
108
+ for (const file of await readdir(this.directory))
109
+ if (/^[a-f0-9]{64}\.json$/.test(file) && !keep.has(file.slice(0, -5)))
110
+ await this.remove(file.slice(0, -5));
111
+ return id;
112
+ }
113
+ async remove(id) {
114
+ if (!/^[a-f0-9]{64}$/.test(id)) throw Error("Invalid draft ID.");
115
+ await unlink(join(this.directory, id + ".json")).catch((e) => {
116
+ if (e.code !== "ENOENT") throw e;
117
+ });
118
+ }
119
+ }
package/lib/picker.mjs ADDED
@@ -0,0 +1,421 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { Box, Text, useInput } from "ink";
3
+ import { basename, dirname } from "node:path";
4
+ import { clip, editText, graphemes } from "./tui-model.mjs";
5
+ import {
6
+ listFiles,
7
+ listChanges,
8
+ readCodeFile,
9
+ readDiff,
10
+ selectLines,
11
+ } from "./workspace.mjs";
12
+ 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
+ };
23
+ export function WorkspacePicker({
24
+ root,
25
+ width,
26
+ height,
27
+ initial = "files",
28
+ onSelect,
29
+ onCancel,
30
+ }) {
31
+ const [mode, setMode] = useState(initial),
32
+ [folder, setFolder] = useState("."),
33
+ [hidden, setHidden] = useState(false),
34
+ [staged, setStaged] = useState(false),
35
+ [query, setQuery] = useState(""),
36
+ [queryCursor, setQueryCursor] = useState(0),
37
+ [searching, setSearching] = useState(false),
38
+ [items, setItems] = useState([]),
39
+ [index, setIndex] = useState(0),
40
+ [loading, setLoading] = useState(false),
41
+ [error, setError] = useState(""),
42
+ [selected, setSelected] = useState(null),
43
+ [line, setLine] = useState(0),
44
+ [anchor, setAnchor] = useState(null),
45
+ [hunk, setHunk] = useState(-1),
46
+ [horizontal, setHorizontal] = useState(0);
47
+ const alive = useRef(true),
48
+ selectionController = useRef(null);
49
+ useEffect(
50
+ () => () => {
51
+ alive.current = false;
52
+ selectionController.current?.abort();
53
+ },
54
+ [],
55
+ );
56
+ useEffect(() => {
57
+ const controller = new AbortController();
58
+ setLoading(true);
59
+ setError("");
60
+ setItems([]);
61
+ setIndex(0);
62
+ const timer = setTimeout(
63
+ () => {
64
+ const load =
65
+ mode === "files"
66
+ ? listFiles(root, folder, {
67
+ hidden,
68
+ query,
69
+ signal: controller.signal,
70
+ })
71
+ : listChanges(root, { staged, query, signal: controller.signal });
72
+ load
73
+ .then((rows) => {
74
+ if (!controller.signal.aborted) setItems(rows);
75
+ })
76
+ .catch((e) => {
77
+ if (!controller.signal.aborted) setError(e.message);
78
+ })
79
+ .finally(() => {
80
+ if (!controller.signal.aborted) setLoading(false);
81
+ });
82
+ },
83
+ query ? 120 : 0,
84
+ );
85
+ return () => {
86
+ clearTimeout(timer);
87
+ controller.abort();
88
+ };
89
+ }, [root, folder, hidden, mode, staged, query]);
90
+ const content = selected
91
+ ? selected.kind === "diff"
92
+ ? hunk < 0
93
+ ? selected.patch
94
+ : selected.hunks[hunk].patch
95
+ : selected.code
96
+ : "",
97
+ lines = content.split("\n"),
98
+ panelHeight = Math.max(6, height - 2),
99
+ viewHeight = Math.max(1, panelHeight - 6),
100
+ split = !!selected && width >= 110,
101
+ listWidth = split ? 36 : width,
102
+ previewWidth = split ? width - listWidth - 1 : width;
103
+ function clearSearch() {
104
+ setQuery("");
105
+ setQueryCursor(0);
106
+ setSearching(false);
107
+ }
108
+ async function choose() {
109
+ const item = items[index];
110
+ if (!item || loading) return;
111
+ if (item.directory) {
112
+ setFolder(item.path);
113
+ clearSearch();
114
+ return;
115
+ }
116
+ setLoading(true);
117
+ setError("");
118
+ const controller = new AbortController();
119
+ selectionController.current = controller;
120
+ try {
121
+ const data =
122
+ mode === "files"
123
+ ? await readCodeFile(root, item.path)
124
+ : await readDiff(root, item.path, {
125
+ staged,
126
+ signal: controller.signal,
127
+ });
128
+ if (!alive.current) return;
129
+ setSelected({ ...data, kind: mode === "files" ? "file" : "diff" });
130
+ setLine(0);
131
+ setAnchor(null);
132
+ setHunk(-1);
133
+ setHorizontal(0);
134
+ setSearching(false);
135
+ } catch (e) {
136
+ if (alive.current) setError(e.message);
137
+ } finally {
138
+ if (alive.current) setLoading(false);
139
+ }
140
+ }
141
+ function accept() {
142
+ try {
143
+ if (selected.kind === "file") {
144
+ const start = anchor === null ? null : Math.min(anchor, line) + 1,
145
+ end = anchor === null ? null : Math.max(anchor, line) + 1;
146
+ selectLines(selected.code, start || 1, end || lines.length);
147
+ onSelect({ kind: "file", path: selected.path, start, end });
148
+ } else {
149
+ const part = hunk < 0 ? selected : selected.hunks[hunk];
150
+ selectLines(part.patch);
151
+ onSelect({
152
+ kind: "diff",
153
+ path: selected.path,
154
+ staged: selected.staged,
155
+ hunk: hunk < 0 ? null : hunk,
156
+ hash: part.hash,
157
+ });
158
+ }
159
+ } catch (e) {
160
+ setError(e.message);
161
+ }
162
+ }
163
+ useInput((input, key) => {
164
+ if (key.ctrl && input === "c") {
165
+ onCancel();
166
+ return;
167
+ }
168
+ if (searching) {
169
+ if (key.escape) {
170
+ setSearching(false);
171
+ return;
172
+ }
173
+ if (key.return) {
174
+ setSearching(false);
175
+ return;
176
+ }
177
+ const next = editText(query, queryCursor, input, key, { max: 160 });
178
+ setQuery(next.value);
179
+ setQueryCursor(next.cursor);
180
+ return;
181
+ }
182
+ if (selected) {
183
+ if (key.escape || key.backspace) {
184
+ setSelected(null);
185
+ setError("");
186
+ return;
187
+ }
188
+ if (key.upArrow || key.downArrow || key.pageUp || key.pageDown) {
189
+ const step = key.pageUp || key.pageDown ? viewHeight : 1,
190
+ delta = key.upArrow || key.pageUp ? -step : step;
191
+ setLine((v) => Math.max(0, Math.min(lines.length - 1, v + delta)));
192
+ return;
193
+ }
194
+ if (key.leftArrow || key.rightArrow) {
195
+ setHorizontal((v) => Math.max(0, v + (key.leftArrow ? -8 : 8)));
196
+ return;
197
+ }
198
+ if (input === " " && selected.kind === "file") {
199
+ setAnchor((v) => (v === null ? line : null));
200
+ return;
201
+ }
202
+ if ((input === "[" || input === "]") && selected.kind === "diff") {
203
+ setHunk((v) =>
204
+ Math.max(
205
+ -1,
206
+ Math.min(selected.hunks.length - 1, v + (input === "]" ? 1 : -1)),
207
+ ),
208
+ );
209
+ setLine(0);
210
+ return;
211
+ }
212
+ if (key.return) accept();
213
+ return;
214
+ }
215
+ if (key.escape) {
216
+ onCancel();
217
+ return;
218
+ }
219
+ if (loading) return;
220
+ if (key.upArrow || key.downArrow || key.pageUp || key.pageDown) {
221
+ const step = key.pageUp || key.pageDown ? viewHeight : 1,
222
+ delta = key.upArrow || key.pageUp ? -step : step;
223
+ setIndex((v) => Math.max(0, Math.min(items.length - 1, v + delta)));
224
+ return;
225
+ }
226
+ if (key.return) {
227
+ choose();
228
+ return;
229
+ }
230
+ if (key.leftArrow || key.backspace) {
231
+ setFolder(folder === "." ? "." : dirname(folder));
232
+ clearSearch();
233
+ return;
234
+ }
235
+ if (input === "/") {
236
+ setSearching(true);
237
+ return;
238
+ }
239
+ if (input === "g") {
240
+ setMode((v) => (v === "files" ? "changes" : "files"));
241
+ clearSearch();
242
+ return;
243
+ }
244
+ if (input === "t" && mode === "changes") {
245
+ setStaged((v) => !v);
246
+ clearSearch();
247
+ return;
248
+ }
249
+ if (input === "h" && mode === "files") setHidden((v) => !v);
250
+ });
251
+ function row(text, color = c.text, key, bg) {
252
+ return h(
253
+ Box,
254
+ { key, width: "100%", backgroundColor: bg },
255
+ h(
256
+ Text,
257
+ { color, wrap: "truncate" },
258
+ clip(
259
+ text,
260
+ (selected && (!split || key?.startsWith?.("code"))
261
+ ? previewWidth
262
+ : listWidth) - 4,
263
+ ),
264
+ ),
265
+ );
266
+ }
267
+ const visible = viewHeight,
268
+ first = Math.max(0, index - visible + 1);
269
+ const browser = h(
270
+ Box,
271
+ {
272
+ width: listWidth,
273
+ height: panelHeight,
274
+ key: "browser",
275
+ flexDirection: "column",
276
+ borderStyle: "round",
277
+ borderColor: c.purple,
278
+ paddingX: 1,
279
+ overflow: "hidden",
280
+ },
281
+ row("PROJECT · " + basename(root), c.purple, "title"),
282
+ row(
283
+ mode === "files"
284
+ ? "Files · " + folder
285
+ : "Git changes · " + (staged ? "staged" : "unstaged"),
286
+ c.blue,
287
+ "mode",
288
+ ),
289
+ row(
290
+ searching ? "Find: " + query + "▏" : query ? "Filter: " + query : " ",
291
+ c.muted,
292
+ "query",
293
+ ),
294
+ h(
295
+ Box,
296
+ { height: visible, flexDirection: "column", overflow: "hidden" },
297
+ ...(items.length
298
+ ? items
299
+ .slice(first, first + visible)
300
+ .map((item, i) =>
301
+ row(
302
+ (first + i === index ? "› " : " ") +
303
+ (item.directory ? "▸ " : "") +
304
+ item.name +
305
+ (mode === "changes"
306
+ ? ` +${item.added} −${item.removed}`
307
+ : ""),
308
+ first + i === index ? c.blue : c.text,
309
+ "entry" + i,
310
+ first + i === index ? c.selected : undefined,
311
+ ),
312
+ )
313
+ : [
314
+ row(
315
+ loading ? "Reading project…" : "No matching files or changes.",
316
+ c.muted,
317
+ "empty",
318
+ ),
319
+ ]),
320
+ ),
321
+ row(
322
+ `${Math.min(index + 1, items.length)} / ${items.length}`,
323
+ c.muted,
324
+ "count",
325
+ ),
326
+ );
327
+ const start = Math.max(
328
+ 0,
329
+ Math.min(line - Math.floor(viewHeight / 2), lines.length - viewHeight),
330
+ ),
331
+ from = Math.min(anchor ?? line, line),
332
+ to = Math.max(anchor ?? line, line);
333
+ const preview = selected
334
+ ? h(
335
+ Box,
336
+ {
337
+ width: previewWidth,
338
+ height: panelHeight,
339
+ key: "preview",
340
+ flexDirection: "column",
341
+ borderStyle: "round",
342
+ borderColor: c.blue,
343
+ paddingX: 1,
344
+ overflow: "hidden",
345
+ },
346
+ row(selected.path, c.purple, "code-title"),
347
+ row(
348
+ selected.kind === "diff"
349
+ ? (staged ? "Staged" : "Unstaged") +
350
+ " · " +
351
+ (hunk < 0
352
+ ? "All hunks"
353
+ : `Hunk ${hunk + 1}/${selected.hunks.length}`)
354
+ : anchor === null
355
+ ? "Whole file · Space selects a range"
356
+ : `Selected lines ${from + 1}–${to + 1}`,
357
+ c.blue,
358
+ "code-mode",
359
+ ),
360
+ row(" ", c.muted, "code-blank"),
361
+ h(
362
+ Box,
363
+ { height: viewHeight, flexDirection: "column", overflow: "hidden" },
364
+ ...lines.slice(start, start + viewHeight).map((text, i) => {
365
+ const n = start + i,
366
+ highlight =
367
+ n === line || (anchor !== null && n >= from && n <= to),
368
+ color =
369
+ selected.kind === "diff"
370
+ ? text.startsWith("+")
371
+ ? c.green
372
+ : text.startsWith("-")
373
+ ? c.red
374
+ : c.text
375
+ : c.text;
376
+ return row(
377
+ (n === line ? "›" : " ") +
378
+ String(n + 1).padStart(4) +
379
+ " " +
380
+ graphemes(text).slice(horizontal).join(""),
381
+ color,
382
+ "code" + i,
383
+ highlight ? c.selected : undefined,
384
+ );
385
+ }),
386
+ ),
387
+ row(
388
+ `${line + 1}/${lines.length} · Enter uses selection`,
389
+ c.muted,
390
+ "code-footer",
391
+ ),
392
+ )
393
+ : null;
394
+ return h(
395
+ Box,
396
+ {
397
+ flexDirection: "column",
398
+ height,
399
+ overflow: "hidden",
400
+ backgroundColor: c.bg,
401
+ },
402
+ h(
403
+ Box,
404
+ { height: height - 2, flexDirection: "row", gap: split ? 1 : 0 },
405
+ selected ? (split ? [browser, preview] : preview) : browser,
406
+ ),
407
+ h(
408
+ Text,
409
+ { color: error ? c.red : c.muted, wrap: "truncate" },
410
+ clip(
411
+ error ||
412
+ (selected
413
+ ? selected.kind === "file"
414
+ ? "↑↓ move · Space start/reset range · Enter use · Esc files"
415
+ : "[ ] all / individual hunks · Enter use · Esc changes"
416
+ : "G files / git diff · T staged / unstaged · H hidden · / filter · Esc cancel"),
417
+ width,
418
+ ),
419
+ ),
420
+ );
421
+ }
package/lib/tui-data.mjs CHANGED
@@ -4,6 +4,7 @@ export const navigation = [
4
4
  ["open", "Open conversation", "Jump to a post"],
5
5
  ["ask", "Ask a question", "Find a way forward"],
6
6
  ["review", "Request code review", "Another pair of eyes"],
7
+ ["drafts", "Local drafts", "Resume your writing"],
7
8
  ["communities", "Communities", "Your private spaces"],
8
9
  ["search", "Search", "Find a conversation"],
9
10
  ["devices", "Connected terminals", "Manage CLI access"],
@@ -97,15 +98,29 @@ export async function loadPage(api, route, signal) {
97
98
  };
98
99
  }
99
100
  if (route.view === "thread") {
100
- const post = await get("/posts/" + route.postId),
101
- replies = await get(
102
- `/posts/${route.postId}/replies?parent_id=${route.parent.id}&page=${page}`,
101
+ const post = await get(
102
+ "/posts/" + route.postId + "?reply_id=" + route.parent.id,
103
+ );
104
+ const target = post.reply_path?.at(-1);
105
+ let parent = post.replies?.find((r) => r.id === route.parent.id);
106
+ if (!parent && target) {
107
+ const siblings = await get(
108
+ `/posts/${route.postId}/replies?parent_id=${target.parent_id || ""}&page=${target.page}`,
103
109
  );
110
+ parent = siblings.items.find((r) => r.id === route.parent.id);
111
+ }
112
+ const replies = await get(
113
+ `/posts/${route.postId}/replies?parent_id=${route.parent.id}&page=${page}`,
114
+ );
104
115
  return {
105
116
  post,
106
117
  ...replies,
118
+ items: [
119
+ ...(parent ? [{ ...parent, entryType: "parent" }] : []),
120
+ ...replies.items,
121
+ ],
107
122
  title: "Reply thread",
108
- subtitle: "Replying to " + route.parent.name,
123
+ subtitle: "Replying to " + (parent?.name || route.parent.name),
109
124
  };
110
125
  }
111
126
  if (route.view === "feedback") {
@@ -154,6 +169,8 @@ export function contentLines(route, data) {
154
169
  "O Open this conversation in your browser",
155
170
  "/ Focus the command / search bar",
156
171
  "Ctrl+S Preview the current form",
172
+ "Ctrl+O / Ctrl+G Select a code file / Git diff in a review form",
173
+ "/drafts Restore locally saved drafts for this account/project",
157
174
  "Ctrl+C Exit and restore the original terminal",
158
175
  "",
159
176
  "Commands: /feed /inbox /search words /open 142",
package/lib/tui-model.mjs CHANGED
@@ -128,6 +128,7 @@ export function parseCommand(text) {
128
128
  "review",
129
129
  "help",
130
130
  "devices",
131
+ "drafts",
131
132
  ].includes(cmd)
132
133
  )
133
134
  return { command: cmd, arg };
@@ -139,7 +140,7 @@ export function parseCommand(text) {
139
140
  return { command: cmd, arg: Number(arg) };
140
141
  if (["quit", "logout"].includes(cmd)) return { command: cmd };
141
142
  throw Error(
142
- "Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /help or /quit.",
143
+ "Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /drafts, /help or /quit.",
143
144
  );
144
145
  }
145
146
  export class PageCache {