@tenyokjs/devdevian-cli 0.6.1-alpha.1 → 0.7.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
@@ -38,7 +38,7 @@ The default `devdevian` command and `devdevian tui` open an alternate terminal s
38
38
  - **N** creates a conversation (a community on the Communities screen); **A** asks a question; **R** replies inside a conversation; **Shift+R** refreshes.
39
39
  - **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.
40
40
  - **Ctrl+E** opens the current multiline field in `$VISUAL` / `$EDITOR` (use `code --wait` for VS Code). Closing the editor restores the form; publishing still requires preview and confirmation. **Ctrl+L** saves an active draft and returns to sign-in.
41
- - 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`.
41
+ - In forms, **Tab/Shift+Tab** change fields or focus the step controls, arrows choose options or move the text cursor, **Enter** adds a line inside multiline text, and **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`.
42
42
  - **Ctrl+C** exits. With an unsent draft, confirm saving and leaving or press Ctrl+C again to force exit.
43
43
 
44
44
  `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.
@@ -59,6 +59,16 @@ Calendar export from **X** writes a new `.ics` file inside the open workspace an
59
59
 
60
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.
61
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 / Shift+Tab** move through the current step and its **Back / Next** controls. Use arrows to choose a control and **Enter** to activate it; the last step offers **Preview**, not immediate publication. **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
+
70
+ Active fields explain their input: Space stays a space, Enter adds a line in multiline fields, and Tab leaves the field. Project feedback accepts text and URLs; dedicated website/repository/additional links are in **People & links**. Additional links use one `Label | https://example.com` per line. Context hints stay below the editor on larger terminals and use the status/hint lines on compact ones. **Ctrl+S** checks the complete form and opens a preview; drafts autosave locally and publication still requires explicit confirmation.
71
+
62
72
  ## Meet Devi
63
73
 
64
74
  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.
@@ -158,7 +168,7 @@ Commands are examples; replace IDs, file paths and topic IDs with your own. Run
158
168
 
159
169
  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.
160
170
 
161
- `--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.
171
+ `--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.
162
172
 
163
173
  ## Connected devices and credentials
164
174
 
package/VERSIONS.md CHANGED
@@ -6,7 +6,23 @@ 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.1-alpha.1 — Clearer forms
12
+
13
+ - Added visible, keyboard-focusable Back / Next controls to staged forms. Tab reaches the controls at step boundaries, arrows choose an available action and Enter activates it. The final action is Preview; publication remains a separate confirmation. Ctrl+N/P shortcuts remain available.
14
+ - Pinned input guidance distinguishes Space, multiline Enter, field navigation and image/code pickers. Project help explicitly points to the dedicated People & links step. Compact layouts prioritize the caret and keep guidance in status/hint lines.
15
+ - Clarified additional-link syntax, preview versus autosaved drafts, and optional URL fields. Opening a new form clears stale success notices.
16
+ - Regression coverage publishes a project through the real terminal and isolated API, checks text spaces/newlines and multiple labeled links from a second account, exercises Back/Next/Preview, preserves Git/image selections and checks multiline editing at 60×18.
17
+ - No API/schema change, new dependency or production-data reset. Per-version history is recorded here; no separate GitHub release is required for this patch.
18
+
19
+ ## 0.7.0-alpha.1 — Everyday flow
20
+
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+ - 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.
25
+ - 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.
10
26
 
11
27
  ## 0.6.1-alpha.1 — A clearer first impression
12
28
 
package/bin/devdevian.mjs CHANGED
@@ -115,7 +115,11 @@ const help = `Devdevian CLI ${version} · alpha
115
115
  devdevian communities [--page 2]
116
116
 
117
117
  Inside the workspace: /projects, /project ID, /events, /event ID
118
- N creates · F filters · E edits your publication · X opens actions
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
+ Forms: Tab fields/buttons, Enter selects Back/Next or adds a line in multiline text
121
+ Full reply text: / find, n/N matches, Home/End, Esc remembers your place
122
+ N creates · F filters · E edits your publication · X opens context actions
119
123
 
120
124
  --theme NAME midnight | nebula | daylight | contrast
121
125
  --accent NAME blue | violet | cyan | rose
@@ -0,0 +1,38 @@
1
+ export function fieldGuidance(field, { wizard = false, project = false } = {}) {
2
+ if (!field)
3
+ return {
4
+ detail: "",
5
+ keys: wizard
6
+ ? "Enter selects the highlighted action"
7
+ : "Enter opens the preview",
8
+ };
9
+ let detail = field.hint || "";
10
+ if (project && field.key === "help")
11
+ detail =
12
+ "Text and links are allowed. Dedicated links: People & links step.";
13
+ if (["url", "repo", "demo_url"].includes(field.key) && !detail)
14
+ detail = "Use a complete https:// or http:// URL.";
15
+ if (["logo", "banner"].includes(field.key))
16
+ return { detail, keys: "Ctrl+O selects an image · Tab next field" };
17
+ if (field.key === "file")
18
+ return { detail, keys: "Ctrl+O file · Ctrl+G Git diff · Tab next field" };
19
+ const keys = field.options
20
+ ? "← / → choose · Tab next field"
21
+ : field.multiline
22
+ ? "Space inserts a space · Enter adds a line · Tab next field"
23
+ : "Space inserts a space · Enter / Tab next field";
24
+ return { detail, keys };
25
+ }
26
+ export function stepControls(steps, index) {
27
+ return [
28
+ { id: "back", label: "← Back", enabled: index > 0 },
29
+ {
30
+ id: "next",
31
+ label:
32
+ index === steps.length - 1
33
+ ? "Preview →"
34
+ : "Next: " + steps[index + 1]?.label + " →",
35
+ enabled: true,
36
+ },
37
+ ];
38
+ }
@@ -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,15 @@ import {
43
43
  appearanceFlags,
44
44
  } from "./appearance.mjs";
45
45
  import { liveLoop, anchoredIndex } from "./live.mjs";
46
+ import { fieldGuidance, stepControls } from "./form-guidance.mjs";
47
+ import { paletteItems } from "./palette.mjs";
48
+ import {
49
+ formSteps,
50
+ stepAt,
51
+ readingMatches,
52
+ nextReadingMatch,
53
+ ReadingMemory,
54
+ } from "./workflow.mjs";
46
55
  import { contextActions } from "./context-actions.mjs";
47
56
  import { editExternal } from "./external-editor.mjs";
48
57
  import { markdownRows } from "./rich-text.mjs";
@@ -106,8 +115,13 @@ function openBrowser(url) {
106
115
  child.unref();
107
116
  }
108
117
  function fieldsFor(form) {
109
- if (["catalog", "catalogFilter", "calendar"].includes(form.kind))
110
- return catalogFields(form);
118
+ if (["catalog", "catalogFilter", "calendar"].includes(form.kind)) {
119
+ const fields = catalogFields(form),
120
+ steps = formSteps(form, fields);
121
+ return steps.length
122
+ ? steps.flatMap((step) => step.indexes.map((i) => fields[i]))
123
+ : fields;
124
+ }
111
125
  if (form.kind === "appearance") return appearanceFields;
112
126
  if (form.kind === "publication")
113
127
  return [
@@ -276,17 +290,29 @@ function InputText({ value, cursor, width, height = 1 }) {
276
290
  }),
277
291
  );
278
292
  }
279
- function FormView({ form, active, cursor, width, height }) {
293
+ function FormView({ form, active, cursor, width, height, navFocus }) {
280
294
  const colors = useTheme();
281
295
  const fields = fieldsFor(form),
282
296
  rows = [];
283
- let selectedLine = 0;
297
+ const steps = formSteps(form, fields),
298
+ stepIndex = stepAt(steps, active, fields.length),
299
+ step = steps[stepIndex];
300
+ const guidance = fieldGuidance(navFocus ? null : fields[active], {
301
+ wizard: !!step,
302
+ project: form.kind === "catalog" && form.entity === "project",
303
+ });
304
+ const helpRows =
305
+ form.kind === "appearance" ? 0 : height >= 8 ? 2 : height >= 5 ? 1 : 0,
306
+ navRows = step && height >= 3 ? 1 : 0;
307
+ const editorHeight = Math.max(1, height - helpRows - navRows);
308
+ let selectedLine = rows.length;
284
309
  fields.forEach((field, index) => {
310
+ if (step && !step.indexes.includes(index)) return;
285
311
  if (index === active) selectedLine = rows.length;
286
312
  rows.push({
287
- text: (index === active ? "› " : " ") + field.label,
288
- color: index === active ? colors.purple : colors.muted,
289
- bold: index === active,
313
+ text: (index === active && !navFocus ? "› " : " ") + field.label,
314
+ color: index === active && !navFocus ? colors.purple : colors.muted,
315
+ bold: index === active && !navFocus,
290
316
  });
291
317
  if (field.options) {
292
318
  const option = field.options.find(
@@ -295,12 +321,12 @@ function FormView({ form, active, cursor, width, height }) {
295
321
  rows.push({
296
322
  text:
297
323
  " ‹ " + (option?.[1] || form.values[field.key] || "Choose") + " ›",
298
- color: index === active ? colors.blue : colors.text,
324
+ color: index === active && !navFocus ? colors.blue : colors.text,
299
325
  });
300
326
  } else {
301
327
  const value = form.values[field.key] || "";
302
328
  const text =
303
- index === active
329
+ index === active && !navFocus
304
330
  ? cursorLines(value, cursor, width - 3).lines
305
331
  : wrap(value || "…", width - 3);
306
332
  const caretLine =
@@ -311,35 +337,90 @@ function FormView({ form, active, cursor, width, height }) {
311
337
  )
312
338
  : 0;
313
339
  const visible = field.multiline
314
- ? Math.min(4, Math.max(2, text.length))
340
+ ? Math.min(
341
+ index === active
342
+ ? Math.max(1, Math.min(12, editorHeight - 2))
343
+ : 3,
344
+ Math.max(2, text.length),
345
+ )
315
346
  : 1,
316
347
  start = index === active ? Math.max(0, caretLine - visible + 1) : 0;
317
348
  rows.push(
318
349
  ...text.slice(start, start + visible).map((line) => ({
319
350
  text: " " + line,
320
- color: index === active ? colors.text : colors.muted,
351
+ color: index === active && !navFocus ? colors.text : colors.muted,
321
352
  })),
322
353
  );
323
354
  }
324
- if (field.hint && active === index)
325
- rows.push({ text: " " + field.hint, color: colors.muted });
326
355
  rows.push("");
327
356
  });
328
357
  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
- });
358
+ if (!step)
359
+ rows.push({
360
+ text:
361
+ (active === fields.length ? "› " : " ") +
362
+ (form.kind === "appearance"
363
+ ? "Save appearance [Ctrl+S]"
364
+ : form.kind === "catalogFilter"
365
+ ? "Apply filters [Ctrl+S]"
366
+ : form.kind === "calendar"
367
+ ? "Export calendar [Ctrl+S]"
368
+ : "Preview publication [Ctrl+S]"),
369
+ color: colors.blue,
370
+ bold: true,
371
+ });
338
372
  const offset = Math.max(
339
373
  0,
340
- Math.min(selectedLine - (height >= 4 ? 1 : 0), rows.length - height),
374
+ Math.min(
375
+ selectedLine - (editorHeight >= 4 ? 1 : 0),
376
+ rows.length - editorHeight,
377
+ ),
378
+ );
379
+ return h(
380
+ Box,
381
+ { flexDirection: "column", height, overflow: "hidden" },
382
+ h(FrameRows, { rows, width, height: editorHeight, offset }),
383
+ helpRows === 2
384
+ ? h(TextLine, {
385
+ text:
386
+ guidance.detail ||
387
+ (form.kind === "catalogFilter"
388
+ ? "Ctrl+S applies these filters."
389
+ : form.kind === "calendar"
390
+ ? "Ctrl+S exports a new calendar file."
391
+ : "Ctrl+S opens a preview; publishing requires separate confirmation."),
392
+ width,
393
+ color: colors.muted,
394
+ })
395
+ : null,
396
+ helpRows
397
+ ? h(TextLine, { text: guidance.keys, width, color: colors.muted })
398
+ : null,
399
+ navRows
400
+ ? h(
401
+ Text,
402
+ { wrap: "truncate" },
403
+ ...stepControls(steps, stepIndex).map((control) =>
404
+ h(
405
+ Text,
406
+ {
407
+ key: control.id,
408
+ color: !control.enabled
409
+ ? colors.muted
410
+ : navFocus === control.id
411
+ ? colors.blue
412
+ : colors.purple,
413
+ bold: navFocus === control.id,
414
+ dimColor: !control.enabled,
415
+ backgroundColor:
416
+ navFocus === control.id ? colors.selected : undefined,
417
+ },
418
+ ` ${navFocus === control.id ? "›" : " "}[ ${control.label} ] `,
419
+ ),
420
+ ),
421
+ )
422
+ : null,
341
423
  );
342
- return h(FrameRows, { rows, width, height, offset });
343
424
  }
344
425
  function listRows(route, data, index, width, colors, density = "comfortable") {
345
426
  const items = data.items || [];
@@ -566,6 +647,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
566
647
  [notice, setNotice] = useState(initialAppearance.warning || "");
567
648
  const [form, setForm] = useState(null),
568
649
  [field, setField] = useState(0),
650
+ [formNav, setFormNav] = useState(null),
569
651
  [cursor, setCursor] = useState(0),
570
652
  [preview, setPreview] = useState(null),
571
653
  [dialog, setDialog] = useState(null),
@@ -576,6 +658,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
576
658
  [picker, setPicker] = useState(null),
577
659
  [draftSaved, setDraftSaved] = useState(false),
578
660
  [actionIndex, setActionIndex] = useState(null),
661
+ [palette, setPalette] = useState(null),
662
+ [readerFind, setReaderFind] = useState({
663
+ query: "",
664
+ cursor: 0,
665
+ editing: false,
666
+ }),
579
667
  [reviewFocus, setReviewFocus] = useState("notes"),
580
668
  [reviewLine, setReviewLine] = useState(0),
581
669
  [reviewAnchor, setReviewAnchor] = useState(null),
@@ -583,6 +671,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
583
671
  [live, setLive] = useState(!flags["no-live"]),
584
672
  [unread, setUnread] = useState(0),
585
673
  [liveStatus, setLiveStatus] = useState("");
674
+ const readingMemory = useRef(new ReadingMemory()),
675
+ readingReturn = useRef(0);
586
676
  const activity = useRef(Date.now()),
587
677
  liveView = useRef(null),
588
678
  notificationGeneration = useRef(0);
@@ -609,7 +699,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
609
699
  );
610
700
  const effectiveAppearance =
611
701
  form?.kind === "appearance" ? normalizeAppearance(form.values) : appearance;
612
- const colors = themeFor(effectiveAppearance),
702
+ const colors = useMemo(
703
+ () => themeFor(effectiveAppearance),
704
+ [effectiveAppearance],
705
+ ),
613
706
  stride = effectiveAppearance.density === "compact" ? 3 : 4;
614
707
  const width = Math.max(1, columns),
615
708
  height = Math.max(1, rows - 1),
@@ -644,6 +737,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
644
737
  dialog ||
645
738
  reading ||
646
739
  actionIndex !== null ||
740
+ palette ||
647
741
  loading ||
648
742
  pending ||
649
743
  focus === "prompt"
@@ -715,6 +809,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
715
809
  const resetAccount = () => {
716
810
  cache.current.clear();
717
811
  history.current = [];
812
+ readingMemory.current.clear();
813
+ setPalette(null);
814
+ setReaderFind({ query: "", cursor: 0, editing: false });
718
815
  setData(null);
719
816
  setForm(null);
720
817
  setPicker(null);
@@ -749,6 +846,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
749
846
  if (!replace)
750
847
  history.current.push({
751
848
  route,
849
+ label: current?.title || labelOf(route),
752
850
  index,
753
851
  scroll,
754
852
  selectedId: selected?.id,
@@ -799,6 +897,124 @@ function App({ initialConfig, flags, root, initialAppearance }) {
799
897
  setError("");
800
898
  } else setFocus("nav");
801
899
  }
900
+ function readerKey(item) {
901
+ const kind =
902
+ item.entryType === "feedbackReply"
903
+ ? "feedbackReply"
904
+ : item.entryType === "note" ||
905
+ route.view === "feedback" ||
906
+ route.tab === "feedback"
907
+ ? "feedback"
908
+ : "reply";
909
+ return `${config?.origin}:${user?.id}:${current?.post?.id}:${kind}:${item.id}:${item.revision || ""}`;
910
+ }
911
+ const readerRows = useMemo(
912
+ () =>
913
+ reading
914
+ ? markdownRows(
915
+ reading.deleted_at
916
+ ? "[deleted — discussion preserved]"
917
+ : reading.body || "",
918
+ contentWidth,
919
+ colors,
920
+ )
921
+ : [],
922
+ [reading, contentWidth, colors],
923
+ );
924
+ const readerHeight = Math.max(1, contentHeight - 2);
925
+ const readerHits = readingMatches(readerRows, readerFind.query);
926
+ useEffect(() => {
927
+ if (reading)
928
+ readingMemory.current.save(
929
+ readerKey(reading),
930
+ Math.min(scroll, Math.max(0, readerRows.length - readerHeight)),
931
+ readerRows,
932
+ );
933
+ }, [reading, scroll, readerRows, readerHeight]);
934
+ function readItem(item) {
935
+ if (!reading) readingReturn.current = scroll;
936
+ const lines = markdownRows(
937
+ item.deleted_at ? "[deleted — discussion preserved]" : item.body || "",
938
+ contentWidth,
939
+ colors,
940
+ );
941
+ setScroll(
942
+ readingMemory.current.restore(readerKey(item), lines, readerHeight),
943
+ );
944
+ setReaderFind({ query: "", cursor: 0, editing: false });
945
+ setReading(item);
946
+ }
947
+ function closeReading() {
948
+ setReading(null);
949
+ setScroll(readingReturn.current);
950
+ setReaderFind({ query: "", cursor: 0, editing: false });
951
+ }
952
+ function moveStep(direction) {
953
+ setFormNav(null);
954
+ const fields = fieldsFor(form),
955
+ steps = formSteps(form, fields),
956
+ currentStep = stepAt(steps, field, fields.length);
957
+ const next = currentStep + direction;
958
+ if (direction > 0)
959
+ for (const i of steps[currentStep]?.indexes || []) {
960
+ const f = fields[i],
961
+ value = form.values[f.key] || "";
962
+ if (f.min && value.trim().length < f.min) {
963
+ setField(i);
964
+ setCursor(graphemes(value).length);
965
+ setError(f.label + " needs at least " + f.min + " characters.");
966
+ return;
967
+ }
968
+ }
969
+ if (next >= steps.length) {
970
+ prepare();
971
+ return;
972
+ }
973
+ if (next < 0) return;
974
+ setField(steps[next].indexes[0]);
975
+ setCursor(
976
+ graphemes(form.values[fields[steps[next].indexes[0]].key] || "").length,
977
+ );
978
+ setError("");
979
+ }
980
+ const activeSteps = form ? formSteps(form, fieldsFor(form)) : [],
981
+ activeStep = form ? stepAt(activeSteps, field, fieldsFor(form).length) : 0;
982
+ const quickItems = palette
983
+ ? paletteItems({
984
+ query: palette.query,
985
+ actions,
986
+ history: history.current,
987
+ route,
988
+ })
989
+ : [];
990
+ function chooseQuick(item) {
991
+ if (!item) return;
992
+ setPalette(null);
993
+ if (item.route) {
994
+ navigate(item.route);
995
+ return;
996
+ }
997
+ if (item.id.startsWith("action:")) {
998
+ runAction(item.id.slice(7));
999
+ return;
1000
+ }
1001
+ if (item.id.startsWith("nav:")) {
1002
+ chooseNav(Number(item.id.slice(4)));
1003
+ return;
1004
+ }
1005
+ if (item.id.startsWith("create:")) {
1006
+ const kind = item.id.slice(7);
1007
+ if (["project", "event"].includes(kind)) composeCatalog(kind);
1008
+ else composePublication(kind);
1009
+ return;
1010
+ }
1011
+ if (item.id === "command") {
1012
+ runCommand(item.command);
1013
+ return;
1014
+ }
1015
+ if (item.id === "search")
1016
+ navigate({ view: "search", query: item.query, page: 1 });
1017
+ }
802
1018
  async function task(work) {
803
1019
  if (busy.current) return;
804
1020
  busy.current = true;
@@ -1234,6 +1450,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1234
1450
  );
1235
1451
  }
1236
1452
  function openForm(next) {
1453
+ setFormNav(null);
1454
+ setNotice("");
1237
1455
  setBooting(false);
1238
1456
  setForm({
1239
1457
  ...next,
@@ -1398,6 +1616,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1398
1616
  }
1399
1617
  async function prepare() {
1400
1618
  if (!form) return;
1619
+ setFormNav(null);
1401
1620
  if (form.kind === "catalogFilter") {
1402
1621
  await task(async () => {
1403
1622
  const values = validateFilter(form.values);
@@ -1678,8 +1897,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1678
1897
  return;
1679
1898
  }
1680
1899
  if (route.view === "thread" && item.entryType === "parent") {
1681
- setReading(item);
1682
- setScroll(0);
1900
+ readItem(item);
1683
1901
  return;
1684
1902
  }
1685
1903
  if (
@@ -1695,8 +1913,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1695
1913
  return;
1696
1914
  }
1697
1915
  if (route.view === "feedback") {
1698
- setReading(item);
1699
- setScroll(0);
1916
+ readItem(item);
1700
1917
  return;
1701
1918
  }
1702
1919
  navigate({
@@ -1864,8 +2081,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1864
2081
  }
1865
2082
  if (id === "read") {
1866
2083
  if (current?.post) {
1867
- setReading(selected);
1868
- setScroll(0);
2084
+ readItem(selected);
1869
2085
  } else openSelected();
1870
2086
  } else if (id === "reply") reply();
1871
2087
  else if (id === "comment") lineFeedback();
@@ -1891,8 +2107,8 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1891
2107
  invalidate("Received notifications marked as read.");
1892
2108
  });
1893
2109
  }
1894
- async function runCommand() {
1895
- const value = command.trim();
2110
+ async function runCommand(override) {
2111
+ const value = (override ?? command).trim();
1896
2112
  if (!value) return;
1897
2113
  setCommand("");
1898
2114
  setCommandCursor(0);
@@ -1924,6 +2140,10 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1924
2140
  navigate({ view: parsed.command, id: parsed.arg, page: 1 });
1925
2141
  return;
1926
2142
  }
2143
+ if (parsed.command === "actions") {
2144
+ setPalette({ query: "", cursor: 0, index: 0 });
2145
+ return;
2146
+ }
1927
2147
  if (parsed.command === "appearance") {
1928
2148
  openAppearance();
1929
2149
  return;
@@ -1969,7 +2189,19 @@ function App({ initialConfig, flags, root, initialAppearance }) {
1969
2189
  }
1970
2190
  function changeField(next) {
1971
2191
  const fields = fieldsFor(form),
1972
- selected = (next + fields.length + 1) % (fields.length + 1);
2192
+ steps = formSteps(form, fields),
2193
+ stepIndex = stepAt(steps, field, fields.length),
2194
+ step = steps[stepIndex];
2195
+ if (
2196
+ step &&
2197
+ !formNav &&
2198
+ ((next > field && field === step.indexes.at(-1)) ||
2199
+ (next < field && field === step.indexes[0]))
2200
+ ) {
2201
+ setFormNav(next < field && stepIndex > 0 ? "back" : "next");
2202
+ return;
2203
+ }
2204
+ const selected = (next + fields.length + 1) % (fields.length + 1);
1973
2205
  setField(selected);
1974
2206
  setCursor(
1975
2207
  selected < fields.length
@@ -2033,6 +2265,45 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2033
2265
  reconnect();
2034
2266
  return;
2035
2267
  }
2268
+ if (palette) {
2269
+ if (keyEvent.escape) {
2270
+ setPalette(null);
2271
+ return;
2272
+ }
2273
+ if (keyEvent.upArrow || keyEvent.downArrow) {
2274
+ setPalette((old) => ({
2275
+ ...old,
2276
+ index: Math.max(
2277
+ 0,
2278
+ Math.min(
2279
+ quickItems.length - 1,
2280
+ old.index + (keyEvent.downArrow ? 1 : -1),
2281
+ ),
2282
+ ),
2283
+ }));
2284
+ return;
2285
+ }
2286
+ if (keyEvent.return) {
2287
+ chooseQuick(quickItems[palette.index]);
2288
+ return;
2289
+ }
2290
+ const updated = editText(palette.query, palette.cursor, input, keyEvent, {
2291
+ max: 120,
2292
+ });
2293
+ setPalette({ query: updated.value, cursor: updated.cursor, index: 0 });
2294
+ return;
2295
+ }
2296
+ if (
2297
+ keyEvent.ctrl &&
2298
+ input === "k" &&
2299
+ !form &&
2300
+ !preview &&
2301
+ focus !== "prompt"
2302
+ ) {
2303
+ setActionIndex(null);
2304
+ setPalette({ query: "", cursor: 0, index: 0 });
2305
+ return;
2306
+ }
2036
2307
  if (actionIndex !== null) {
2037
2308
  if (keyEvent.escape || input === "x" || input === "X")
2038
2309
  setActionIndex(null);
@@ -2043,36 +2314,65 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2043
2314
  return;
2044
2315
  }
2045
2316
  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);
2317
+ const maximum = Math.max(0, readerRows.length - readerHeight);
2318
+ if (readerFind.editing) {
2319
+ if (keyEvent.escape) {
2320
+ setReaderFind((old) => ({ ...old, editing: false }));
2321
+ return;
2322
+ }
2323
+ if (keyEvent.return) {
2324
+ setReaderFind((old) => ({ ...old, editing: false }));
2325
+ if (readerHits.length)
2326
+ setScroll(readerHits.find((n) => n >= scroll) ?? readerHits[0]);
2327
+ return;
2328
+ }
2329
+ const updated = editText(
2330
+ readerFind.query,
2331
+ readerFind.cursor,
2332
+ input,
2333
+ keyEvent,
2334
+ { max: 120 },
2335
+ );
2336
+ setReaderFind({
2337
+ query: updated.value,
2338
+ cursor: updated.cursor,
2339
+ editing: true,
2340
+ });
2341
+ return;
2342
+ }
2343
+ if (input === "/") {
2344
+ setReaderFind((old) => ({ ...old, editing: true }));
2345
+ return;
2346
+ }
2347
+ if ((input === "n" || input === "N") && readerFind.query) {
2348
+ setScroll(nextReadingMatch(readerHits, scroll, input === "n" ? 1 : -1));
2349
+ return;
2350
+ }
2054
2351
  if (keyEvent.escape || keyEvent.return || input === " ") {
2055
- setReading(null);
2352
+ closeReading();
2353
+ return;
2354
+ }
2355
+ if (keyEvent.home) {
2056
2356
  setScroll(0);
2057
2357
  return;
2058
2358
  }
2359
+ if (keyEvent.end) {
2360
+ setScroll(maximum);
2361
+ return;
2362
+ }
2059
2363
  if (keyEvent.upArrow || keyEvent.pageUp)
2060
- setScroll((v) =>
2061
- Math.max(0, v - (keyEvent.pageUp ? contentHeight : 1)),
2062
- );
2364
+ setScroll((v) => Math.max(0, v - (keyEvent.pageUp ? readerHeight : 1)));
2063
2365
  else if (keyEvent.downArrow || keyEvent.pageDown)
2064
2366
  setScroll((v) =>
2065
- Math.min(maximum, v + (keyEvent.pageDown ? contentHeight : 1)),
2367
+ Math.min(maximum, v + (keyEvent.pageDown ? readerHeight : 1)),
2066
2368
  );
2067
2369
  else if (input === "r") {
2068
- setReading(null);
2370
+ closeReading();
2069
2371
  reply();
2070
2372
  } else if (input === "e" || input === "E") {
2071
- setReading(null);
2373
+ closeReading();
2072
2374
  editSelected();
2073
- } else if (input === "d" || input === "D") {
2074
- deleteSelected();
2075
- }
2375
+ } else if (input === "d" || input === "D") deleteSelected();
2076
2376
  return;
2077
2377
  }
2078
2378
  if (preview) {
@@ -2097,7 +2397,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2097
2397
  return;
2098
2398
  }
2099
2399
  if (form) {
2100
- if (keyEvent.ctrl && input === "o" && form.kind === "catalog") {
2400
+ if (
2401
+ keyEvent.ctrl &&
2402
+ input === "o" &&
2403
+ form.kind === "catalog" &&
2404
+ !formNav
2405
+ ) {
2101
2406
  const active = fieldsFor(form)[field];
2102
2407
  if (["logo", "banner", "screenshots"].includes(active?.key)) {
2103
2408
  setPicker({ mediaKey: active.key });
@@ -2124,7 +2429,15 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2124
2429
  setNotice("Default preview restored. Ctrl+S saves; Esc cancels.");
2125
2430
  return;
2126
2431
  }
2127
- if (keyEvent.ctrl && input === "e") {
2432
+ if (
2433
+ keyEvent.ctrl &&
2434
+ ["n", "p"].includes(input) &&
2435
+ formSteps(form, fieldsFor(form)).length
2436
+ ) {
2437
+ moveStep(input === "n" ? 1 : -1);
2438
+ return;
2439
+ }
2440
+ if (keyEvent.ctrl && input === "e" && !formNav) {
2128
2441
  externalEdit();
2129
2442
  return;
2130
2443
  }
@@ -2132,6 +2445,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2132
2445
  keyEvent.ctrl &&
2133
2446
  ["o", "g"].includes(input) &&
2134
2447
  form.kind === "publication" &&
2448
+ !formNav &&
2135
2449
  form.values.kind === "review"
2136
2450
  ) {
2137
2451
  setPicker(input === "g" ? "changes" : "files");
@@ -2155,6 +2469,39 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2155
2469
  prepare();
2156
2470
  return;
2157
2471
  }
2472
+ if (formNav) {
2473
+ const fields = fieldsFor(form),
2474
+ steps = formSteps(form, fields),
2475
+ i = stepAt(steps, field, fields.length),
2476
+ step = steps[i];
2477
+ if (keyEvent.return) {
2478
+ moveStep(formNav === "back" ? -1 : 1);
2479
+ return;
2480
+ }
2481
+ if (keyEvent.leftArrow || keyEvent.upArrow) {
2482
+ setFormNav(i > 0 ? "back" : "next");
2483
+ return;
2484
+ }
2485
+ if (keyEvent.rightArrow || keyEvent.downArrow) {
2486
+ setFormNav("next");
2487
+ return;
2488
+ }
2489
+ if (keyEvent.tab) {
2490
+ if (keyEvent.shift && formNav === "next" && i > 0) {
2491
+ setFormNav("back");
2492
+ return;
2493
+ }
2494
+ if (!keyEvent.shift && formNav === "back") {
2495
+ setFormNav("next");
2496
+ return;
2497
+ }
2498
+ const next = keyEvent.shift ? step.indexes.at(-1) : step.indexes[0];
2499
+ setFormNav(null);
2500
+ setField(next);
2501
+ setCursor(graphemes(form.values[fields[next].key] || "").length);
2502
+ }
2503
+ return;
2504
+ }
2158
2505
  if (keyEvent.tab) {
2159
2506
  changeField(field + (keyEvent.shift ? -1 : 1));
2160
2507
  return;
@@ -2409,8 +2756,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2409
2756
  selected &&
2410
2757
  ["post", "thread", "feedback"].includes(route.view)
2411
2758
  ) {
2412
- setReading(selected);
2413
- setScroll(0);
2759
+ readItem(selected);
2414
2760
  return;
2415
2761
  }
2416
2762
  if (keyEvent.return) {
@@ -2501,15 +2847,23 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2501
2847
  reportSelected();
2502
2848
  }
2503
2849
  });
2850
+ const compactFieldHelp =
2851
+ form && !formNav && form.kind !== "appearance" && contentHeight < 8
2852
+ ? fieldGuidance(fieldsFor(form)[field], {
2853
+ wizard: !!activeSteps.length,
2854
+ project: form.kind === "catalog" && form.entity === "project",
2855
+ }).detail
2856
+ : "";
2504
2857
  const status =
2505
2858
  error ||
2859
+ compactFieldHelp ||
2506
2860
  liveStatus ||
2507
2861
  notice ||
2508
2862
  (pending
2509
2863
  ? "Saving…"
2510
2864
  : loading
2511
2865
  ? "Loading…"
2512
- : "Shared account · shared conversations · same permissions");
2866
+ : "Ctrl+K quick actions · Shared account · same permissions");
2513
2867
  const spin =
2514
2868
  effectiveAppearance.motion === "reduced" ? "·" : ["◐", "◓", "◑", "◒"][tick];
2515
2869
  let center;
@@ -2652,6 +3006,60 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2652
3006
  animate: true,
2653
3007
  }),
2654
3008
  );
3009
+ } else if (palette) {
3010
+ const count = Math.max(1, bodyHeight - 5),
3011
+ first = Math.max(0, palette.index - count + 1);
3012
+ center = h(
3013
+ Box,
3014
+ {
3015
+ borderStyle: colors.frame,
3016
+ borderColor: colors.purple,
3017
+ backgroundColor: colors.panel,
3018
+ flexDirection: "column",
3019
+ paddingX: 1,
3020
+ height: bodyHeight,
3021
+ overflow: "hidden",
3022
+ },
3023
+ h(TextLine, {
3024
+ text: "QUICK ACTIONS · type to filter",
3025
+ width: width - 6,
3026
+ color: colors.purple,
3027
+ bold: true,
3028
+ }),
3029
+ h(InputText, {
3030
+ value: palette.query,
3031
+ cursor: palette.cursor,
3032
+ width: width - 6,
3033
+ }),
3034
+ h(
3035
+ Box,
3036
+ { flexDirection: "column", height: count, overflow: "hidden" },
3037
+ ...quickItems.slice(first, first + count).map((item, i) =>
3038
+ h(
3039
+ Text,
3040
+ {
3041
+ key: item.id,
3042
+ wrap: "truncate",
3043
+ color: first + i === palette.index ? colors.blue : colors.text,
3044
+ backgroundColor:
3045
+ first + i === palette.index ? colors.selected : undefined,
3046
+ },
3047
+ clip(
3048
+ (first + i === palette.index ? "› " : " ") +
3049
+ item.label +
3050
+ " · " +
3051
+ item.group,
3052
+ width - 6,
3053
+ ),
3054
+ ),
3055
+ ),
3056
+ ),
3057
+ h(TextLine, {
3058
+ text: "↑↓ choose · Enter open · Esc return · No action runs while typing",
3059
+ width: width - 6,
3060
+ color: colors.muted,
3061
+ }),
3062
+ );
2655
3063
  } else if (dialog) {
2656
3064
  center = h(
2657
3065
  Box,
@@ -2691,19 +3099,41 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2691
3099
  } else {
2692
3100
  let body;
2693
3101
  if (reading) {
2694
- const lines = markdownRows(
2695
- reading.deleted_at
2696
- ? "[deleted — discussion preserved]"
2697
- : reading.body || "",
2698
- contentWidth,
2699
- colors,
3102
+ const position = Math.min(
3103
+ scroll,
3104
+ Math.max(0, readerRows.length - readerHeight),
3105
+ );
3106
+ const matches = new Set(readerHits);
3107
+ body = h(
3108
+ Box,
3109
+ { flexDirection: "column", height: contentHeight, overflow: "hidden" },
3110
+ readerFind.editing
3111
+ ? h(InputText, {
3112
+ value: readerFind.query,
3113
+ cursor: readerFind.cursor,
3114
+ width: contentWidth,
3115
+ })
3116
+ : h(TextLine, {
3117
+ text: readerFind.query
3118
+ ? `Find: ${readerFind.query} · ${readerHits.length} matching lines · n next / N previous`
3119
+ : `Reply #${reading.id} · ${stamp(reading.created)} · / find in text`,
3120
+ width: contentWidth,
3121
+ color: colors.muted,
3122
+ }),
3123
+ h(FrameRows, {
3124
+ rows: readerRows.map((row, i) =>
3125
+ matches.has(i) ? { ...row, bold: true, color: colors.blue } : row,
3126
+ ),
3127
+ width: contentWidth,
3128
+ height: readerHeight,
3129
+ offset: position,
3130
+ }),
3131
+ h(TextLine, {
3132
+ text: `Lines ${Math.min(position + 1, readerRows.length)}–${Math.min(position + readerHeight, readerRows.length)} of ${readerRows.length} · Home / End · Esc returns`,
3133
+ width: contentWidth,
3134
+ color: colors.muted,
3135
+ }),
2700
3136
  );
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
3137
  } else if (preview) {
2708
3138
  const payload = preview.payload,
2709
3139
  audience =
@@ -2777,6 +3207,7 @@ function App({ initialConfig, flags, root, initialAppearance }) {
2777
3207
  body = h(form.kind === "appearance" ? AppearanceView : FormView, {
2778
3208
  form,
2779
3209
  active: field,
3210
+ navFocus: formNav,
2780
3211
  cursor,
2781
3212
  width: contentWidth,
2782
3213
  height: contentHeight,
@@ -3021,7 +3452,9 @@ function App({ initialConfig, flags, root, initialAppearance }) {
3021
3452
  text: form
3022
3453
  ? (form.kind === "appearance"
3023
3454
  ? "Local preferences · Preview now · Ctrl+S save · Esc cancel"
3024
- : "Tab next field · Ctrl+E editor · Ctrl+S preview") +
3455
+ : (activeSteps.length
3456
+ ? `Step ${activeStep + 1}/${activeSteps.length} · ${activeSteps[activeStep].label} · `
3457
+ : "") + "Tab field · Ctrl+N/P steps · Ctrl+S preview") +
3025
3458
  (draftSaved ? " · Draft saved locally" : "")
3026
3459
  : preview
3027
3460
  ? "Scroll to review · ← → choose · Enter confirms"
@@ -3096,8 +3529,12 @@ function App({ initialConfig, flags, root, initialAppearance }) {
3096
3529
  compact && focus === "nav" ? null : pane,
3097
3530
  );
3098
3531
  }
3099
- const hint =
3100
- ["projects", "events"].includes(route.view) && !form && !preview && !picker
3532
+ const hint = palette
3533
+ ? "Type to filter · ↑↓ choose · Enter open · Esc return"
3534
+ : ["projects", "events"].includes(route.view) &&
3535
+ !form &&
3536
+ !preview &&
3537
+ !picker
3101
3538
  ? "↑↓ select · Enter open · F filters · N new · [ ] pages · / search"
3102
3539
  : ["project", "event"].includes(route.view) && !form && !preview
3103
3540
  ? "↑↓ scroll · X actions · E edit your publication · Esc back"
@@ -3110,11 +3547,17 @@ function App({ initialConfig, flags, root, initialAppearance }) {
3110
3547
  : picker
3111
3548
  ? "Project files stay local until publication is confirmed"
3112
3549
  : reading
3113
- ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
3550
+ ? readerFind.editing
3551
+ ? "Find in this text · Enter locate · Esc finish search"
3552
+ : "↑↓ scroll · / find · n/N match · Home/End · Esc return"
3114
3553
  : preview
3115
3554
  ? "← → choose · Enter confirm · Esc edit"
3116
3555
  : form
3117
- ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
3556
+ ? formNav
3557
+ ? `${stepControls(activeSteps, activeStep).find((control) => control.id === formNav)?.label || "Next"} · Enter select · Tab fields · Ctrl+S preview`
3558
+ : fieldsFor(form)[field]?.multiline
3559
+ ? "Enter new line · Tab next field · Ctrl+N next step · Ctrl+S preview"
3560
+ : "Enter/Tab next field · Ctrl+N/P steps · Ctrl+S preview"
3118
3561
  : phase !== "ready"
3119
3562
  ? "Enter connect / open browser · Ctrl+C exit"
3120
3563
  : 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.1-alpha.1",
3
+ "version": "0.7.1-alpha.1",
4
4
  "description": "Devdevian in your terminal: discussions, questions and human code reviews.",
5
5
  "type": "module",
6
6
  "bin": {