@tenyokjs/devdevian-cli 0.6.0-alpha.1 → 0.7.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
@@ -12,6 +12,22 @@ Login opens a browser. Compare the code shown in the terminal, sign in to Devdev
12
12
 
13
13
  Version-by-version changes and the customization roadmap: [CLI version history](VERSIONS.md).
14
14
 
15
+ ## A look inside
16
+
17
+ Actual CLI captures from isolated demonstration accounts. Your terminal uses your real forum account and permissions.
18
+
19
+ ![Devdevian terminal feed](https://raw.githubusercontent.com/Tenyokj/devdevian/main/public/screenshots/cli-feed.png)
20
+
21
+ <details>
22
+ <summary>Code review and appearance</summary>
23
+
24
+ ![Side-by-side human code review](https://raw.githubusercontent.com/Tenyokj/devdevian/main/public/screenshots/cli-review.png)
25
+ ![Appearance controls](https://raw.githubusercontent.com/Tenyokj/devdevian/main/public/screenshots/cli-appearance.png)
26
+
27
+ </details>
28
+
29
+ Run `devdevian --help` for script commands and workspace shortcuts. Interactive help has a large wordmark and selective accents; `NO_COLOR=1` keeps it uncolored, narrow terminals use a compact heading, and redirected output stays plain.
30
+
15
31
  ## Live workspace
16
32
 
17
33
  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.
@@ -43,6 +59,14 @@ Calendar export from **X** writes a new `.ics` file inside the open workspace an
43
59
 
44
60
  Forms autosave locally. Successful upload IDs are retained if validation fails; edits require the latest server revision. If a publication response is lost, the CLI preserves the draft and asks you to check My projects / Hosting or attending before trying another copy. It does not automatically retry an uncertain write. Upload quotas and public visibility are the same as the website. These catalog operations are currently interactive TUI features, not separate script subcommands.
45
61
 
62
+ ## Quick actions and focused work
63
+
64
+ Press **Ctrl+K** outside a form or command input, or run **/actions**, to search actions, sections and creation flows. Recent views stay in memory for the current account/session; the palette offers up to eight. Type `project 7`, `event 9` or `open 142` for an explicit ID, or choose the forum-search result for a text query. Typing stays local; actions run only after Enter. Destructive actions retain their separate confirmation. **Esc** returns to the previous view without moving your selection.
65
+
66
+ Conversation, Project and Event forms show one step at a time. **Ctrl+N** validates the current step and moves forward; **Ctrl+P** returns to the previous step. **Tab** still moves through all fields and crosses steps. **Ctrl+S** checks every required field and opens the full publication preview; a missing value returns you to its step. Step changes preserve text, image paths, code selections and locally saved drafts. No upload or publication happens just by advancing a step.
67
+
68
+ In full-text reading (**Space** on a reply or remark), **/** opens local text search. **Enter** locates a matching rendered line; **n / Shift+N** cycles forward/backward. **Home / End** jumps to the start/end. **Esc** returns to the underlying view, and reopening the same unchanged item restores your place. This is bounded session memory, not cross-device or permanent reading history. In text fields and the command bar, **Ctrl+K** continues to delete to the end of the line.
69
+
46
70
  ## Meet Devi
47
71
 
48
72
  Devi is the Devdevian mascot: a small ghost with a laptop and an orbit. The terminal uses colored ASCII art, so it needs no image protocol or image downloads. A compact drawing is used when space is limited; authentication codes, addresses and controls take priority over decoration.
@@ -142,7 +166,7 @@ Commands are examples; replace IDs, file paths and topic IDs with your own. Run
142
166
 
143
167
  A selected file is read only when explicitly named. Before publication, the client previews the payload and audience and asks for confirmation. Use `--community ID` for an approved private community, `--json` for machine-readable API output and `--yes` to explicitly publish without a prompt. Noninteractive publishing requires the relevant text flags and `--yes`. No repository discovery or background file collection runs.
144
168
 
145
- `--editor` uses `$VISUAL`, then `$EDITOR`, then `vi` (`notepad` on Windows). The editor is launched without a shell. File, body and API limits also apply in the terminal. This client uses ordinary HTTPS API calls, not a direct database connection, and sends no background polling traffic when idle. Device login polls every five seconds for at most ten minutes.
169
+ `--editor` uses `$VISUAL`, then `$EDITOR`, then `vi` (`notepad` on Windows). The editor is launched without a shell. File, body and API limits also apply in the terminal. This client uses ordinary HTTPS API calls, not a direct database connection, and performs no background polling in script commands. The live workspace polls at bounded active/idle intervals unless paused or launched with `--no-live`. Device login polls every five seconds for at most ten minutes.
146
170
 
147
171
  ## Connected devices and credentials
148
172
 
package/VERSIONS.md CHANGED
@@ -6,7 +6,22 @@ Every published npm artifact has a unique immutable version. We record each vers
6
6
 
7
7
  ## Current milestone
8
8
 
9
- **0.6.0 brings Projects and Events into the terminal**, using the same hosted data and permissions as the browser.
9
+ **0.7.0 focuses on everyday terminal use**: discoverable quick actions, staged forms and resumable reading.
10
+
11
+ ## 0.7.0-alpha.1 — Everyday flow
12
+
13
+ - Added a searchable quick-action workspace via `Ctrl+K` or `/actions`: scoped actions, navigation, creation, up to eight recent views, explicit ID opening and a regular forum-search fallback. Typing performs no requests or mutations; existing server permissions and confirmation dialogs still apply.
14
+ - Conversation, Project and Event forms show one step at a time. `Ctrl+N` validates the current step and advances; `Ctrl+P` goes back. `Tab` continues to traverse fields across steps. `Ctrl+S` validates the complete form and opens the existing explicit publication preview. All draft values and file selections survive step changes.
15
+ - Full reply/remark reading remembers positions within the current terminal session, restores the underlying view on close, and offers literal rendered-line search (`/`, `n`/`N`) plus Home/End and line-position indicators. Thread headings identify the parent reply and conversation. Reading memory is bounded and cleared on account reset.
16
+ - Quick actions pause live reads while open. Text editing retains `Ctrl+K` for deleting to the end of the line. Compact terminal layout, themes, external editor and draft workflows remain supported.
17
+ - No API/schema change, production-data reset or new dependency. Real pseudo-terminal checks cover staged publication into an isolated API visible to another browser account, reader search/return, scoped actions, and 60×18 navigation.
18
+
19
+ ## 0.6.1-alpha.1 — A clearer first impression
20
+
21
+ - Added a large terminal wordmark and selective blue/violet accents to `--help`. Compact terminals, `NO_COLOR`, redirected output and JSON help remain readable without ANSI decoration.
22
+ - Help now points to the interactive Projects/Events commands and actions. CLI documentation includes real terminal captures and install/login/workspace guidance.
23
+ - The website introduces the CLI near the top, with keyboard-accessible screenshot tabs, copyable install/review commands and GitHub documentation links. A cached public-only activity counter updates while visible and respects reduced motion.
24
+ - No schema migration, account/credential change or production-data reset. The CLI package and web/API keep independent versions.
10
25
 
11
26
  ## 0.6.0-alpha.1 — Projects and Events
12
27
 
package/bin/devdevian.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  loadAppearance,
24
24
  saveAppearance,
25
25
  } from "../lib/appearance.mjs";
26
+ import { renderHelp } from "../lib/help.mjs";
26
27
  const version = packageInfo.version;
27
28
  const options = {
28
29
  host: { type: "string" },
@@ -113,6 +114,12 @@ const help = `Devdevian CLI ${version} · alpha
113
114
  devdevian review reopen POST_ID REVIEW_ID
114
115
  devdevian communities [--page 2]
115
116
 
117
+ Inside the workspace: /projects, /project ID, /events, /event ID
118
+ Ctrl+K or /actions opens quick actions; type to filter
119
+ Ctrl+N / Ctrl+P moves between form steps; Ctrl+S validates and previews
120
+ Full reply text: / find, n/N matches, Home/End, Esc remembers your place
121
+ N creates · F filters · E edits your publication · X opens context actions
122
+
116
123
  --theme NAME midnight | nebula | daylight | contrast
117
124
  --accent NAME blue | violet | cyan | rose
118
125
  --density NAME comfortable | compact
@@ -257,7 +264,15 @@ async function login(flags) {
257
264
  }
258
265
  async function execute(args, flags) {
259
266
  if (flags.help) {
260
- out(help);
267
+ process.stdout.write(
268
+ renderHelp(help, {
269
+ tty: !!process.stdout.isTTY,
270
+ columns: process.stdout.columns || 80,
271
+ noColor:
272
+ process.env.NO_COLOR !== undefined || process.env.TERM === "dumb",
273
+ json: !!flags.json,
274
+ }) + "\n",
275
+ );
261
276
  return;
262
277
  }
263
278
  if (flags.version) {
package/lib/help.mjs ADDED
@@ -0,0 +1,40 @@
1
+ // Static wordmark; no terminal probes, image protocols or account reads.
2
+ const font = {
3
+ D: ["████ ", "█ █", "█ █", "█ █", "████ "],
4
+ E: ["█████", "█ ", "████ ", "█ ", "█████"],
5
+ V: ["█ █", "█ █", "█ █", " █ █ ", " █ "],
6
+ I: ["█████", " █ ", " █ ", " █ ", "█████"],
7
+ A: [" ███ ", "█ █", "█████", "█ █", "█ █"],
8
+ N: ["█ █", "██ █", "█ █ █", "█ ██", "█ █"],
9
+ };
10
+ export function renderHelp(
11
+ text,
12
+ { tty = false, columns = 80, noColor = false, json = false } = {},
13
+ ) {
14
+ if (!tty || json) return text;
15
+ const paint = (value, color) =>
16
+ noColor ? value : `\x1b[38;5;${color}m${value}\x1b[0m`;
17
+ const banner =
18
+ columns >= 56
19
+ ? Array.from({ length: 5 }, (_, row) =>
20
+ paint(
21
+ [..."DEVDEVIAN"].map((c) => font[c][row]).join(" "),
22
+ [111, 105, 141, 177, 183][row],
23
+ ),
24
+ ).join("\n")
25
+ : paint("DEVDEVIAN", 141);
26
+ const content = text
27
+ .split("\n")
28
+ .map((line) => {
29
+ if (line.startsWith("Devdevian CLI")) return paint(line, 183);
30
+ return line.replace(
31
+ /^( devdevian)(\s+[a-z]+(?:\s+(?:open|create|list|comment|thread|reply|resolve|reopen))?)?|(--[a-z][a-z-]*)/g,
32
+ (match, command, rest) =>
33
+ command
34
+ ? paint(command, 111) + (rest ? paint(rest, 183) : "")
35
+ : paint(match, 147),
36
+ );
37
+ })
38
+ .join("\n");
39
+ return `\n${banner}\n\n${content}`;
40
+ }
@@ -0,0 +1,65 @@
1
+ import { navigation } from "./tui-data.mjs";
2
+ import { clean } from "./client.mjs";
3
+ export function paletteItems({
4
+ query = "",
5
+ actions = [],
6
+ history = [],
7
+ route = {},
8
+ } = {}) {
9
+ const items = actions.map((action) => ({
10
+ ...action,
11
+ id: "action:" + action.id,
12
+ group: "This view",
13
+ }));
14
+ navigation.forEach(([id, label, description], index) =>
15
+ items.push({ id: "nav:" + index, label, description, group: "Go to" }),
16
+ );
17
+ for (const [kind, label] of [
18
+ ["discussion", "Start a discussion"],
19
+ ["question", "Ask a question"],
20
+ ["review", "Request a code review"],
21
+ ["showcase", "Share what you built"],
22
+ ["project", "Create a project"],
23
+ ["event", "Create an event"],
24
+ ])
25
+ items.push({ id: "create:" + kind, label, group: "Create" });
26
+ const seen = new Set([JSON.stringify(route)]);
27
+ [...history].reverse().forEach((item) => {
28
+ const key = JSON.stringify(item.route);
29
+ if (!seen.has(key) && item.route.view !== "help") {
30
+ seen.add(key);
31
+ if (seen.size <= 9)
32
+ items.push({
33
+ id: "recent:" + key,
34
+ label: clean(item.label || item.route.view),
35
+ group: "Recent",
36
+ route: item.route,
37
+ });
38
+ }
39
+ });
40
+ const q = clean(query).trim().replace(/^\//, "");
41
+ const words = q.toLocaleLowerCase().split(/\s+/).filter(Boolean);
42
+ const result = items.filter((item) =>
43
+ words.every((word) =>
44
+ `${item.label} ${item.description || ""} ${item.group}`
45
+ .toLocaleLowerCase()
46
+ .includes(word),
47
+ ),
48
+ );
49
+ const direct = q.match(/^(?:open|project|event)\s+([1-9]\d*)$/i);
50
+ if (direct && Number.isSafeInteger(Number(direct[1])))
51
+ result.unshift({
52
+ id: "command",
53
+ command: "/" + q.toLowerCase(),
54
+ label: "Open " + q,
55
+ group: "Command",
56
+ });
57
+ if (q)
58
+ result.push({
59
+ id: "search",
60
+ query: q,
61
+ label: "Search forum for “" + q + "”",
62
+ group: "Search",
63
+ });
64
+ return result;
65
+ }
package/lib/tui-data.mjs CHANGED
@@ -183,7 +183,13 @@ export async function loadPage(api, route, signal) {
183
183
  ...replies.items,
184
184
  ],
185
185
  title: "Reply thread",
186
- subtitle: "Replying to " + (parent?.name || route.parent.name),
186
+ subtitle:
187
+ "Reply #" +
188
+ route.parent.id +
189
+ " · " +
190
+ (parent?.name || route.parent.name) +
191
+ " · " +
192
+ post.title,
187
193
  };
188
194
  }
189
195
  if (route.view === "feedback") {
@@ -220,6 +226,7 @@ export function contentLines(route, data) {
220
226
  return [
221
227
  "A developer workspace, in your terminal.",
222
228
  "",
229
+ "Ctrl+K / /actions Search quick actions, sections and recent views",
223
230
  "Tab Switch navigation / content / command bar",
224
231
  "↑ ↓ Select a section, conversation or reply",
225
232
  "Enter Open the selected item",
@@ -243,7 +250,9 @@ export function contentLines(route, data) {
243
250
  "/ Focus the command / search bar",
244
251
  "Ctrl+E Edit a multiline field in VISUAL / EDITOR",
245
252
  "Ctrl+L Save a draft and reconnect an expired session",
246
- "Ctrl+S Preview the current form",
253
+ "Ctrl+N / Ctrl+P Next / previous step in conversation/project/event forms",
254
+ "Ctrl+S Validate all steps and preview the current form",
255
+ "Full text / finds text; n/N cycles; Home/End; Esc remembers position",
247
256
  "Ctrl+O / Ctrl+G Select a code file / Git diff in a review form",
248
257
  "Ctrl+O Pick a workspace image on logo/banner/screenshot fields",
249
258
  "/drafts Restore locally saved drafts for this account/project",
package/lib/tui-model.mjs CHANGED
@@ -130,6 +130,7 @@ export function parseCommand(text) {
130
130
  "devices",
131
131
  "drafts",
132
132
  "appearance",
133
+ "actions",
133
134
  "projects",
134
135
  "events",
135
136
  ].includes(cmd)
@@ -143,7 +144,7 @@ export function parseCommand(text) {
143
144
  return { command: cmd, arg: Number(arg) };
144
145
  if (["quit", "logout"].includes(cmd)) return { command: cmd };
145
146
  throw Error(
146
- "Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /drafts, /appearance, /help or /quit.",
147
+ "Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /drafts, /appearance, /actions, /projects, /events, /help or /quit.",
147
148
  );
148
149
  }
149
150
  export class PageCache {
package/lib/tui.mjs CHANGED
@@ -43,6 +43,14 @@ import {
43
43
  appearanceFlags,
44
44
  } from "./appearance.mjs";
45
45
  import { liveLoop, anchoredIndex } from "./live.mjs";
46
+ import { paletteItems } from "./palette.mjs";
47
+ import {
48
+ formSteps,
49
+ stepAt,
50
+ readingMatches,
51
+ nextReadingMatch,
52
+ ReadingMemory,
53
+ } from "./workflow.mjs";
46
54
  import { contextActions } from "./context-actions.mjs";
47
55
  import { editExternal } from "./external-editor.mjs";
48
56
  import { markdownRows } from "./rich-text.mjs";
@@ -106,8 +114,13 @@ function openBrowser(url) {
106
114
  child.unref();
107
115
  }
108
116
  function fieldsFor(form) {
109
- if (["catalog", "catalogFilter", "calendar"].includes(form.kind))
110
- return catalogFields(form);
117
+ if (["catalog", "catalogFilter", "calendar"].includes(form.kind)) {
118
+ const fields = catalogFields(form),
119
+ steps = formSteps(form, fields);
120
+ return steps.length
121
+ ? steps.flatMap((step) => step.indexes.map((i) => fields[i]))
122
+ : fields;
123
+ }
111
124
  if (form.kind === "appearance") return appearanceFields;
112
125
  if (form.kind === "publication")
113
126
  return [
@@ -280,8 +293,12 @@ function FormView({ form, active, cursor, width, height }) {
280
293
  const colors = useTheme();
281
294
  const fields = fieldsFor(form),
282
295
  rows = [];
283
- let selectedLine = 0;
296
+ const steps = formSteps(form, fields),
297
+ stepIndex = stepAt(steps, active, fields.length),
298
+ step = steps[stepIndex];
299
+ let selectedLine = rows.length;
284
300
  fields.forEach((field, index) => {
301
+ if (step && !step.indexes.includes(index)) return;
285
302
  if (index === active) selectedLine = rows.length;
286
303
  rows.push({
287
304
  text: (index === active ? "› " : " ") + field.label,
@@ -311,7 +328,10 @@ function FormView({ form, active, cursor, width, height }) {
311
328
  )
312
329
  : 0;
313
330
  const visible = field.multiline
314
- ? Math.min(4, Math.max(2, text.length))
331
+ ? Math.min(
332
+ index === active ? Math.max(1, Math.min(12, height - 3)) : 3,
333
+ Math.max(2, text.length),
334
+ )
315
335
  : 1,
316
336
  start = index === active ? Math.max(0, caretLine - visible + 1) : 0;
317
337
  rows.push(
@@ -326,15 +346,20 @@ function FormView({ form, active, cursor, width, height }) {
326
346
  rows.push("");
327
347
  });
328
348
  if (active === fields.length) selectedLine = rows.length;
329
- rows.push({
330
- text:
331
- (active === fields.length ? "› " : " ") +
332
- (form.kind === "appearance"
333
- ? "Save appearance [Ctrl+S]"
334
- : "Preview publication [Ctrl+S]"),
335
- color: colors.blue,
336
- bold: true,
337
- });
349
+ if (!step || stepIndex === steps.length - 1)
350
+ rows.push({
351
+ text:
352
+ (active === fields.length ? "› " : " ") +
353
+ (form.kind === "appearance"
354
+ ? "Save appearance [Ctrl+S]"
355
+ : form.kind === "catalogFilter"
356
+ ? "Apply filters [Ctrl+S]"
357
+ : form.kind === "calendar"
358
+ ? "Export calendar [Ctrl+S]"
359
+ : "Preview publication [Ctrl+S]"),
360
+ color: colors.blue,
361
+ bold: true,
362
+ });
338
363
  const offset = Math.max(
339
364
  0,
340
365
  Math.min(selectedLine - (height >= 4 ? 1 : 0), rows.length - height),
@@ -576,6 +601,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
576
601
  [picker, setPicker] = useState(null),
577
602
  [draftSaved, setDraftSaved] = useState(false),
578
603
  [actionIndex, setActionIndex] = useState(null),
604
+ [palette, setPalette] = useState(null),
605
+ [readerFind, setReaderFind] = useState({
606
+ query: "",
607
+ cursor: 0,
608
+ editing: false,
609
+ }),
579
610
  [reviewFocus, setReviewFocus] = useState("notes"),
580
611
  [reviewLine, setReviewLine] = useState(0),
581
612
  [reviewAnchor, setReviewAnchor] = useState(null),
@@ -583,6 +614,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
583
614
  [live, setLive] = useState(!flags["no-live"]),
584
615
  [unread, setUnread] = useState(0),
585
616
  [liveStatus, setLiveStatus] = useState("");
617
+ const readingMemory = useRef(new ReadingMemory()),
618
+ readingReturn = useRef(0);
586
619
  const activity = useRef(Date.now()),
587
620
  liveView = useRef(null),
588
621
  notificationGeneration = useRef(0);
@@ -609,7 +642,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
609
642
  );
610
643
  const effectiveAppearance =
611
644
  form?.kind === "appearance" ? normalizeAppearance(form.values) : appearance;
612
- const colors = themeFor(effectiveAppearance),
645
+ const colors = useMemo(
646
+ () => themeFor(effectiveAppearance),
647
+ [effectiveAppearance],
648
+ ),
613
649
  stride = effectiveAppearance.density === "compact" ? 3 : 4;
614
650
  const width = Math.max(1, columns),
615
651
  height = Math.max(1, rows - 1),
@@ -644,6 +680,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
644
680
  dialog ||
645
681
  reading ||
646
682
  actionIndex !== null ||
683
+ palette ||
647
684
  loading ||
648
685
  pending ||
649
686
  focus === "prompt"
@@ -715,6 +752,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
715
752
  const resetAccount = () => {
716
753
  cache.current.clear();
717
754
  history.current = [];
755
+ readingMemory.current.clear();
756
+ setPalette(null);
757
+ setReaderFind({ query: "", cursor: 0, editing: false });
718
758
  setData(null);
719
759
  setForm(null);
720
760
  setPicker(null);
@@ -749,6 +789,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
749
789
  if (!replace)
750
790
  history.current.push({
751
791
  route,
792
+ label: current?.title || labelOf(route),
752
793
  index,
753
794
  scroll,
754
795
  selectedId: selected?.id,
@@ -799,6 +840,123 @@ function App({ initialConfig, flags, root, initialAppearance }) {
799
840
  setError("");
800
841
  } else setFocus("nav");
801
842
  }
843
+ function readerKey(item) {
844
+ const kind =
845
+ item.entryType === "feedbackReply"
846
+ ? "feedbackReply"
847
+ : item.entryType === "note" ||
848
+ route.view === "feedback" ||
849
+ route.tab === "feedback"
850
+ ? "feedback"
851
+ : "reply";
852
+ return `${config?.origin}:${user?.id}:${current?.post?.id}:${kind}:${item.id}:${item.revision || ""}`;
853
+ }
854
+ const readerRows = useMemo(
855
+ () =>
856
+ reading
857
+ ? markdownRows(
858
+ reading.deleted_at
859
+ ? "[deleted — discussion preserved]"
860
+ : reading.body || "",
861
+ contentWidth,
862
+ colors,
863
+ )
864
+ : [],
865
+ [reading, contentWidth, colors],
866
+ );
867
+ const readerHeight = Math.max(1, contentHeight - 2);
868
+ const readerHits = readingMatches(readerRows, readerFind.query);
869
+ useEffect(() => {
870
+ if (reading)
871
+ readingMemory.current.save(
872
+ readerKey(reading),
873
+ Math.min(scroll, Math.max(0, readerRows.length - readerHeight)),
874
+ readerRows,
875
+ );
876
+ }, [reading, scroll, readerRows, readerHeight]);
877
+ function readItem(item) {
878
+ if (!reading) readingReturn.current = scroll;
879
+ const lines = markdownRows(
880
+ item.deleted_at ? "[deleted — discussion preserved]" : item.body || "",
881
+ contentWidth,
882
+ colors,
883
+ );
884
+ setScroll(
885
+ readingMemory.current.restore(readerKey(item), lines, readerHeight),
886
+ );
887
+ setReaderFind({ query: "", cursor: 0, editing: false });
888
+ setReading(item);
889
+ }
890
+ function closeReading() {
891
+ setReading(null);
892
+ setScroll(readingReturn.current);
893
+ setReaderFind({ query: "", cursor: 0, editing: false });
894
+ }
895
+ function moveStep(direction) {
896
+ const fields = fieldsFor(form),
897
+ steps = formSteps(form, fields),
898
+ currentStep = stepAt(steps, field, fields.length);
899
+ const next = currentStep + direction;
900
+ if (direction > 0)
901
+ for (const i of steps[currentStep]?.indexes || []) {
902
+ const f = fields[i],
903
+ value = form.values[f.key] || "";
904
+ if (f.min && value.trim().length < f.min) {
905
+ setField(i);
906
+ setCursor(graphemes(value).length);
907
+ setError(f.label + " needs at least " + f.min + " characters.");
908
+ return;
909
+ }
910
+ }
911
+ if (next >= steps.length) {
912
+ prepare();
913
+ return;
914
+ }
915
+ if (next < 0) return;
916
+ setField(steps[next].indexes[0]);
917
+ setCursor(
918
+ graphemes(form.values[fields[steps[next].indexes[0]].key] || "").length,
919
+ );
920
+ setError("");
921
+ }
922
+ const activeSteps = form ? formSteps(form, fieldsFor(form)) : [],
923
+ activeStep = form ? stepAt(activeSteps, field, fieldsFor(form).length) : 0;
924
+ const quickItems = palette
925
+ ? paletteItems({
926
+ query: palette.query,
927
+ actions,
928
+ history: history.current,
929
+ route,
930
+ })
931
+ : [];
932
+ function chooseQuick(item) {
933
+ if (!item) return;
934
+ setPalette(null);
935
+ if (item.route) {
936
+ navigate(item.route);
937
+ return;
938
+ }
939
+ if (item.id.startsWith("action:")) {
940
+ runAction(item.id.slice(7));
941
+ return;
942
+ }
943
+ if (item.id.startsWith("nav:")) {
944
+ chooseNav(Number(item.id.slice(4)));
945
+ return;
946
+ }
947
+ if (item.id.startsWith("create:")) {
948
+ const kind = item.id.slice(7);
949
+ if (["project", "event"].includes(kind)) composeCatalog(kind);
950
+ else composePublication(kind);
951
+ return;
952
+ }
953
+ if (item.id === "command") {
954
+ runCommand(item.command);
955
+ return;
956
+ }
957
+ if (item.id === "search")
958
+ navigate({ view: "search", query: item.query, page: 1 });
959
+ }
802
960
  async function task(work) {
803
961
  if (busy.current) return;
804
962
  busy.current = true;
@@ -1678,8 +1836,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1678
1836
  return;
1679
1837
  }
1680
1838
  if (route.view === "thread" && item.entryType === "parent") {
1681
- setReading(item);
1682
- setScroll(0);
1839
+ readItem(item);
1683
1840
  return;
1684
1841
  }
1685
1842
  if (
@@ -1695,8 +1852,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1695
1852
  return;
1696
1853
  }
1697
1854
  if (route.view === "feedback") {
1698
- setReading(item);
1699
- setScroll(0);
1855
+ readItem(item);
1700
1856
  return;
1701
1857
  }
1702
1858
  navigate({
@@ -1864,8 +2020,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1864
2020
  }
1865
2021
  if (id === "read") {
1866
2022
  if (current?.post) {
1867
- setReading(selected);
1868
- setScroll(0);
2023
+ readItem(selected);
1869
2024
  } else openSelected();
1870
2025
  } else if (id === "reply") reply();
1871
2026
  else if (id === "comment") lineFeedback();
@@ -1891,8 +2046,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1891
2046
  invalidate("Received notifications marked as read.");
1892
2047
  });
1893
2048
  }
1894
- async function runCommand() {
1895
- const value = command.trim();
2049
+ async function runCommand(override) {
2050
+ const value = (override ?? command).trim();
1896
2051
  if (!value) return;
1897
2052
  setCommand("");
1898
2053
  setCommandCursor(0);
@@ -1924,6 +2079,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1924
2079
  navigate({ view: parsed.command, id: parsed.arg, page: 1 });
1925
2080
  return;
1926
2081
  }
2082
+ if (parsed.command === "actions") {
2083
+ setPalette({ query: "", cursor: 0, index: 0 });
2084
+ return;
2085
+ }
1927
2086
  if (parsed.command === "appearance") {
1928
2087
  openAppearance();
1929
2088
  return;
@@ -2033,6 +2192,45 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2033
2192
  reconnect();
2034
2193
  return;
2035
2194
  }
2195
+ if (palette) {
2196
+ if (keyEvent.escape) {
2197
+ setPalette(null);
2198
+ return;
2199
+ }
2200
+ if (keyEvent.upArrow || keyEvent.downArrow) {
2201
+ setPalette((old) => ({
2202
+ ...old,
2203
+ index: Math.max(
2204
+ 0,
2205
+ Math.min(
2206
+ quickItems.length - 1,
2207
+ old.index + (keyEvent.downArrow ? 1 : -1),
2208
+ ),
2209
+ ),
2210
+ }));
2211
+ return;
2212
+ }
2213
+ if (keyEvent.return) {
2214
+ chooseQuick(quickItems[palette.index]);
2215
+ return;
2216
+ }
2217
+ const updated = editText(palette.query, palette.cursor, input, keyEvent, {
2218
+ max: 120,
2219
+ });
2220
+ setPalette({ query: updated.value, cursor: updated.cursor, index: 0 });
2221
+ return;
2222
+ }
2223
+ if (
2224
+ keyEvent.ctrl &&
2225
+ input === "k" &&
2226
+ !form &&
2227
+ !preview &&
2228
+ focus !== "prompt"
2229
+ ) {
2230
+ setActionIndex(null);
2231
+ setPalette({ query: "", cursor: 0, index: 0 });
2232
+ return;
2233
+ }
2036
2234
  if (actionIndex !== null) {
2037
2235
  if (keyEvent.escape || input === "x" || input === "X")
2038
2236
  setActionIndex(null);
@@ -2043,36 +2241,65 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2043
2241
  return;
2044
2242
  }
2045
2243
  if (reading) {
2046
- const lines = markdownRows(
2047
- reading.deleted_at
2048
- ? "[deleted — discussion preserved]"
2049
- : reading.body || "",
2050
- contentWidth,
2051
- colors,
2052
- ),
2053
- maximum = Math.max(0, lines.length - contentHeight);
2244
+ const maximum = Math.max(0, readerRows.length - readerHeight);
2245
+ if (readerFind.editing) {
2246
+ if (keyEvent.escape) {
2247
+ setReaderFind((old) => ({ ...old, editing: false }));
2248
+ return;
2249
+ }
2250
+ if (keyEvent.return) {
2251
+ setReaderFind((old) => ({ ...old, editing: false }));
2252
+ if (readerHits.length)
2253
+ setScroll(readerHits.find((n) => n >= scroll) ?? readerHits[0]);
2254
+ return;
2255
+ }
2256
+ const updated = editText(
2257
+ readerFind.query,
2258
+ readerFind.cursor,
2259
+ input,
2260
+ keyEvent,
2261
+ { max: 120 },
2262
+ );
2263
+ setReaderFind({
2264
+ query: updated.value,
2265
+ cursor: updated.cursor,
2266
+ editing: true,
2267
+ });
2268
+ return;
2269
+ }
2270
+ if (input === "/") {
2271
+ setReaderFind((old) => ({ ...old, editing: true }));
2272
+ return;
2273
+ }
2274
+ if ((input === "n" || input === "N") && readerFind.query) {
2275
+ setScroll(nextReadingMatch(readerHits, scroll, input === "n" ? 1 : -1));
2276
+ return;
2277
+ }
2054
2278
  if (keyEvent.escape || keyEvent.return || input === " ") {
2055
- setReading(null);
2279
+ closeReading();
2280
+ return;
2281
+ }
2282
+ if (keyEvent.home) {
2056
2283
  setScroll(0);
2057
2284
  return;
2058
2285
  }
2286
+ if (keyEvent.end) {
2287
+ setScroll(maximum);
2288
+ return;
2289
+ }
2059
2290
  if (keyEvent.upArrow || keyEvent.pageUp)
2060
- setScroll((v) =>
2061
- Math.max(0, v - (keyEvent.pageUp ? contentHeight : 1)),
2062
- );
2291
+ setScroll((v) => Math.max(0, v - (keyEvent.pageUp ? readerHeight : 1)));
2063
2292
  else if (keyEvent.downArrow || keyEvent.pageDown)
2064
2293
  setScroll((v) =>
2065
- Math.min(maximum, v + (keyEvent.pageDown ? contentHeight : 1)),
2294
+ Math.min(maximum, v + (keyEvent.pageDown ? readerHeight : 1)),
2066
2295
  );
2067
2296
  else if (input === "r") {
2068
- setReading(null);
2297
+ closeReading();
2069
2298
  reply();
2070
2299
  } else if (input === "e" || input === "E") {
2071
- setReading(null);
2300
+ closeReading();
2072
2301
  editSelected();
2073
- } else if (input === "d" || input === "D") {
2074
- deleteSelected();
2075
- }
2302
+ } else if (input === "d" || input === "D") deleteSelected();
2076
2303
  return;
2077
2304
  }
2078
2305
  if (preview) {
@@ -2124,6 +2351,14 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2124
2351
  setNotice("Default preview restored. Ctrl+S saves; Esc cancels.");
2125
2352
  return;
2126
2353
  }
2354
+ if (
2355
+ keyEvent.ctrl &&
2356
+ ["n", "p"].includes(input) &&
2357
+ formSteps(form, fieldsFor(form)).length
2358
+ ) {
2359
+ moveStep(input === "n" ? 1 : -1);
2360
+ return;
2361
+ }
2127
2362
  if (keyEvent.ctrl && input === "e") {
2128
2363
  externalEdit();
2129
2364
  return;
@@ -2409,8 +2644,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2409
2644
  selected &&
2410
2645
  ["post", "thread", "feedback"].includes(route.view)
2411
2646
  ) {
2412
- setReading(selected);
2413
- setScroll(0);
2647
+ readItem(selected);
2414
2648
  return;
2415
2649
  }
2416
2650
  if (keyEvent.return) {
@@ -2509,7 +2743,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2509
2743
  ? "Saving…"
2510
2744
  : loading
2511
2745
  ? "Loading…"
2512
- : "Shared account · shared conversations · same permissions");
2746
+ : "Ctrl+K quick actions · Shared account · same permissions");
2513
2747
  const spin =
2514
2748
  effectiveAppearance.motion === "reduced" ? "·" : ["◐", "◓", "◑", "◒"][tick];
2515
2749
  let center;
@@ -2652,6 +2886,60 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2652
2886
  animate: true,
2653
2887
  }),
2654
2888
  );
2889
+ } else if (palette) {
2890
+ const count = Math.max(1, bodyHeight - 5),
2891
+ first = Math.max(0, palette.index - count + 1);
2892
+ center = h(
2893
+ Box,
2894
+ {
2895
+ borderStyle: colors.frame,
2896
+ borderColor: colors.purple,
2897
+ backgroundColor: colors.panel,
2898
+ flexDirection: "column",
2899
+ paddingX: 1,
2900
+ height: bodyHeight,
2901
+ overflow: "hidden",
2902
+ },
2903
+ h(TextLine, {
2904
+ text: "QUICK ACTIONS · type to filter",
2905
+ width: width - 6,
2906
+ color: colors.purple,
2907
+ bold: true,
2908
+ }),
2909
+ h(InputText, {
2910
+ value: palette.query,
2911
+ cursor: palette.cursor,
2912
+ width: width - 6,
2913
+ }),
2914
+ h(
2915
+ Box,
2916
+ { flexDirection: "column", height: count, overflow: "hidden" },
2917
+ ...quickItems.slice(first, first + count).map((item, i) =>
2918
+ h(
2919
+ Text,
2920
+ {
2921
+ key: item.id,
2922
+ wrap: "truncate",
2923
+ color: first + i === palette.index ? colors.blue : colors.text,
2924
+ backgroundColor:
2925
+ first + i === palette.index ? colors.selected : undefined,
2926
+ },
2927
+ clip(
2928
+ (first + i === palette.index ? "› " : " ") +
2929
+ item.label +
2930
+ " · " +
2931
+ item.group,
2932
+ width - 6,
2933
+ ),
2934
+ ),
2935
+ ),
2936
+ ),
2937
+ h(TextLine, {
2938
+ text: "↑↓ choose · Enter open · Esc return · No action runs while typing",
2939
+ width: width - 6,
2940
+ color: colors.muted,
2941
+ }),
2942
+ );
2655
2943
  } else if (dialog) {
2656
2944
  center = h(
2657
2945
  Box,
@@ -2691,19 +2979,41 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2691
2979
  } else {
2692
2980
  let body;
2693
2981
  if (reading) {
2694
- const lines = markdownRows(
2695
- reading.deleted_at
2696
- ? "[deleted — discussion preserved]"
2697
- : reading.body || "",
2698
- contentWidth,
2699
- colors,
2982
+ const position = Math.min(
2983
+ scroll,
2984
+ Math.max(0, readerRows.length - readerHeight),
2985
+ );
2986
+ const matches = new Set(readerHits);
2987
+ body = h(
2988
+ Box,
2989
+ { flexDirection: "column", height: contentHeight, overflow: "hidden" },
2990
+ readerFind.editing
2991
+ ? h(InputText, {
2992
+ value: readerFind.query,
2993
+ cursor: readerFind.cursor,
2994
+ width: contentWidth,
2995
+ })
2996
+ : h(TextLine, {
2997
+ text: readerFind.query
2998
+ ? `Find: ${readerFind.query} · ${readerHits.length} matching lines · n next / N previous`
2999
+ : `Reply #${reading.id} · ${stamp(reading.created)} · / find in text`,
3000
+ width: contentWidth,
3001
+ color: colors.muted,
3002
+ }),
3003
+ h(FrameRows, {
3004
+ rows: readerRows.map((row, i) =>
3005
+ matches.has(i) ? { ...row, bold: true, color: colors.blue } : row,
3006
+ ),
3007
+ width: contentWidth,
3008
+ height: readerHeight,
3009
+ offset: position,
3010
+ }),
3011
+ h(TextLine, {
3012
+ text: `Lines ${Math.min(position + 1, readerRows.length)}–${Math.min(position + readerHeight, readerRows.length)} of ${readerRows.length} · Home / End · Esc returns`,
3013
+ width: contentWidth,
3014
+ color: colors.muted,
3015
+ }),
2700
3016
  );
2701
- body = h(FrameRows, {
2702
- rows: lines,
2703
- width: contentWidth,
2704
- height: contentHeight,
2705
- offset: Math.min(scroll, Math.max(0, lines.length - contentHeight)),
2706
- });
2707
3017
  } else if (preview) {
2708
3018
  const payload = preview.payload,
2709
3019
  audience =
@@ -3021,7 +3331,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
3021
3331
  text: form
3022
3332
  ? (form.kind === "appearance"
3023
3333
  ? "Local preferences · Preview now · Ctrl+S save · Esc cancel"
3024
- : "Tab next field · Ctrl+E editor · Ctrl+S preview") +
3334
+ : (activeSteps.length
3335
+ ? `Step ${activeStep + 1}/${activeSteps.length} · ${activeSteps[activeStep].label} · `
3336
+ : "") + "Tab field · Ctrl+N/P steps · Ctrl+S preview") +
3025
3337
  (draftSaved ? " · Draft saved locally" : "")
3026
3338
  : preview
3027
3339
  ? "Scroll to review · ← → choose · Enter confirms"
@@ -3096,8 +3408,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
3096
3408
  compact && focus === "nav" ? null : pane,
3097
3409
  );
3098
3410
  }
3099
- const hint =
3100
- ["projects", "events"].includes(route.view) && !form && !preview && !picker
3411
+ const hint = palette
3412
+ ? "Type to filter · ↑↓ choose · Enter open · Esc return"
3413
+ : ["projects", "events"].includes(route.view) &&
3414
+ !form &&
3415
+ !preview &&
3416
+ !picker
3101
3417
  ? "↑↓ select · Enter open · F filters · N new · [ ] pages · / search"
3102
3418
  : ["project", "event"].includes(route.view) && !form && !preview
3103
3419
  ? "↑↓ scroll · X actions · E edit your publication · Esc back"
@@ -3110,11 +3426,13 @@ function App({ initialConfig, flags, root, initialAppearance }) {
3110
3426
  : picker
3111
3427
  ? "Project files stay local until publication is confirmed"
3112
3428
  : reading
3113
- ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
3429
+ ? readerFind.editing
3430
+ ? "Find in this text · Enter locate · Esc finish search"
3431
+ : "↑↓ scroll · / find · n/N match · Home/End · Esc return"
3114
3432
  : preview
3115
3433
  ? "← → choose · Enter confirm · Esc edit"
3116
3434
  : form
3117
- ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
3435
+ ? "Tab field · Ctrl+N/P steps · Ctrl+E editor · Ctrl+S preview"
3118
3436
  : phase !== "ready"
3119
3437
  ? "Enter connect / open browser · Ctrl+C exit"
3120
3438
  : route.view === "feedback"
@@ -0,0 +1,91 @@
1
+ import { clean } from "./client.mjs";
2
+ // Steps only organize local input. The existing full validation/preview still gates writes.
3
+ export function formSteps(form, fields) {
4
+ let groups;
5
+ if (form.kind === "publication")
6
+ groups = [
7
+ ["About", ["kind", "title", "topic", "community_id"]],
8
+ ["Content", ["file", "lines", "review_focus", "demo_url", "body"]],
9
+ ];
10
+ else if (form.kind === "catalog")
11
+ groups =
12
+ form.entity === "project"
13
+ ? [
14
+ ["Basics", ["name", "tagline", "topic", "stage", "stack"]],
15
+ ["Story", ["description", "help", "problem", "audience"]],
16
+ ["People & links", ["url", "repo", "team", "links"]],
17
+ ["Images", ["logo", "screenshots"]],
18
+ ]
19
+ : [
20
+ [
21
+ "Schedule",
22
+ ["title", "topic", "local_start", "timezone", "duration"],
23
+ ],
24
+ ["Details", ["url", "description", "links"]],
25
+ ["Banner", ["banner"]],
26
+ ];
27
+ else return [];
28
+ return groups
29
+ .map(([label, keys]) => ({
30
+ label,
31
+ indexes: fields.flatMap((field, i) =>
32
+ keys.includes(field.key) ? [i] : [],
33
+ ),
34
+ }))
35
+ .filter((step) => step.indexes.length);
36
+ }
37
+ export function stepAt(steps, active, length) {
38
+ return Math.max(
39
+ 0,
40
+ active === length
41
+ ? steps.length - 1
42
+ : steps.findIndex((step) => step.indexes.includes(active)),
43
+ );
44
+ }
45
+ export const rowText = (row) =>
46
+ clean(
47
+ typeof row === "string"
48
+ ? row
49
+ : (row.text ?? row.spans?.map((s) => s.text).join("") ?? ""),
50
+ );
51
+ export function readingMatches(rows, query) {
52
+ const term = clean(query).normalize("NFKC").toLocaleLowerCase();
53
+ return term
54
+ ? rows.flatMap((row, i) =>
55
+ rowText(row).normalize("NFKC").toLocaleLowerCase().includes(term)
56
+ ? [i]
57
+ : [],
58
+ )
59
+ : [];
60
+ }
61
+ export function nextReadingMatch(matches, offset, direction = 1) {
62
+ if (!matches.length) return offset;
63
+ return direction > 0
64
+ ? (matches.find((n) => n > offset) ?? matches[0])
65
+ : ([...matches].reverse().find((n) => n < offset) ?? matches.at(-1));
66
+ }
67
+ export class ReadingMemory {
68
+ constructor(limit = 80) {
69
+ this.limit = limit;
70
+ this.items = new Map();
71
+ }
72
+ save(key, offset, rows) {
73
+ this.items.delete(key);
74
+ this.items.set(key, { offset, anchor: rowText(rows[offset] || "") });
75
+ while (this.items.size > this.limit)
76
+ this.items.delete(this.items.keys().next().value);
77
+ }
78
+ restore(key, rows, height) {
79
+ const entry = this.items.get(key);
80
+ if (!entry) return 0;
81
+ let offset = entry.offset;
82
+ if (entry.anchor && rowText(rows[offset] || "") !== entry.anchor) {
83
+ const found = rows.findIndex((row) => rowText(row) === entry.anchor);
84
+ if (found >= 0) offset = found;
85
+ }
86
+ return Math.max(0, Math.min(offset, rows.length - height));
87
+ }
88
+ clear() {
89
+ this.items.clear();
90
+ }
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenyokjs/devdevian-cli",
3
- "version": "0.6.0-alpha.1",
3
+ "version": "0.7.0-alpha.1",
4
4
  "description": "Devdevian in your terminal: discussions, questions and human code reviews.",
5
5
  "type": "module",
6
6
  "bin": {