@tenyokjs/devdevian-cli 0.4.1-alpha.1 → 0.5.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
@@ -31,6 +31,29 @@ Pages have a bounded 30-second in-memory cache. Refresh explicitly with Shift+R.
31
31
 
32
32
  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.
33
33
 
34
+ ## Themes and layout
35
+
36
+ Open **Appearance** in navigation, type `/appearance`, or press **,** from a reading/list screen. Use arrows to choose options and Tab to move between them. The entire interface previews each change, including code colors, the file picker and navigation. Wide terminals include a sample card next to the controls.
37
+
38
+ - **Midnight** preserves the original blue/purple design; **Nebula** uses a deeper violet surface; **Daylight** is light; **Contrast** emphasizes readable foregrounds.
39
+ - Choose a **blue, violet, cyan or rose accent**, comfortable/compact lists, automatic/hidden sidebar, automatic/stacked review panels, rounded/straight frames and subtle/reduced loading indicators.
40
+ - **Ctrl+S** saves locally. **Esc** cancels the preview. **0** restores the default preview; save to keep it, or cancel to retain existing settings.
41
+ - Hidden navigation stays available with **Tab**. Stacked review panes switch with **W**. Narrow terminals automatically avoid a permanent sidebar and split review layout.
42
+
43
+ Appearance settings belong to this local CLI installation and apply across its accounts/projects; credentials and server profiles are separate. Settings live in `appearance.json` alongside the credentials file, or under `DEVDEVIAN_CONFIG_DIR`. Saving replaces the settings file atomically with private file permissions. Invalid settings fall back to defaults with a warning and remain untouched until you save a new configuration.
44
+
45
+ Appearance can also be configured **offline, without logging in**:
46
+
47
+ ```sh
48
+ devdevian appearance # print settings as JSON
49
+ devdevian appearance --theme daylight --accent violet
50
+ devdevian appearance --density compact --sidebar hidden
51
+ devdevian appearance --review-layout stacked --motion reduced
52
+ devdevian appearance --reset # save defaults
53
+ ```
54
+
55
+ These options can be passed to `tui` or `open` for a **session-only override**, for example `devdevian tui --theme contrast`. Overrides become persistent only if you save in Appearance. Preference changes do not write to the Devdevian API or modify credentials. Current presets use fixed, tested color combinations; the settings file is validated rather than interpreted as terminal code.
56
+
34
57
  ## Code and discussion, together
35
58
 
36
59
  Press **V** in a review to open the code workspace. Wide terminals show the snippet alongside its discussion; narrow terminals show one pane at a time. **W** switches focus. In the code pane, arrows move between lines (left/right scroll horizontally), **Space** anchors a range and **C** opens a prefilled comment form. Remarks may cover at most 50 lines. **Enter** selects a remark covering the current line, if it is on the current page. Markers describe the current page only; **F** cycles all/open/resolved remarks and **[ / ]** page through them.
package/VERSIONS.md CHANGED
@@ -4,10 +4,24 @@ The CLI is a separately versioned Devdevian client. Its npm version does not hav
4
4
 
5
5
  Every published npm artifact has a unique immutable version. We record each version here, even when several ship in one working session. A GitHub release can summarize a day's work and link to the individual entries; it does not need to be created for every npm publication. An unfinished change stays under **Unreleased** until its package version is assigned. Alpha packages use the `alpha` npm tag.
6
6
 
7
- ## Roadmap
7
+ ## Current milestone
8
8
 
9
- - **0.5.0 — Themes and interface customization:** visual presets, appearance preferences and layout customization. Planned, not shipped; exact settings will be agreed before implementation.
10
- - Projects and Events parity is deferred; it is not the 0.5.0 milestone.
9
+ **0.5.0 focuses on themes, interface customization and local appearance preferences.** Projects and Events parity remains a separate future milestone.
10
+
11
+ ## 0.5.0-alpha.1 — Make the terminal yours
12
+
13
+ **Changes**
14
+
15
+ - Appearance workspace with immediate whole-interface previews, a sample panel on wide terminals and explicit save/cancel/default controls. Open it with `/appearance`, navigation or **,**.
16
+ - Midnight, Nebula, Daylight and Contrast themes, with blue/violet/cyan/rose accents shared by navigation, Markdown, code review, forms and the local file/Git picker.
17
+ - Comfortable/compact list density, automatic/hidden navigation, automatic/stacked review panels, rounded/straight frames and reduced loading motion. Compact layouts remain keyboard-accessible.
18
+ - Atomic, validated local preference storage, independent of account credentials and production data. Invalid configuration falls back visibly without overwriting the file.
19
+ - Offline `devdevian appearance` commands and temporary `tui`/`open` appearance flags. Theme text and selection contrast are checked across all preset/accent combinations.
20
+ - Rapid option-arrow changes no longer overwrite each other when input events are batched. Small terminal forms retain the active option in the viewport.
21
+
22
+ **Compatibility and checks**
23
+
24
+ No API/schema changes. Existing credentials and local drafts remain compatible; missing preferences use Midnight defaults. Regression checks cover save/restart, preview cancellation, reset, corrupt files, credential isolation, palette contrast, compact lists, hidden navigation, stacked reviews and 60×18 resizing in a real pseudo-terminal. Preferences apply to the local CLI installation, across its accounts and projects.
11
25
 
12
26
  ## 0.4.1-alpha.1 — Everyday writing and navigation
13
27
 
package/bin/devdevian.mjs CHANGED
@@ -16,6 +16,13 @@ import {
16
16
  clean,
17
17
  } from "../lib/client.mjs";
18
18
  import { editExternal } from "../lib/external-editor.mjs";
19
+ import {
20
+ appearanceFields,
21
+ appearanceFlags,
22
+ defaultAppearance,
23
+ loadAppearance,
24
+ saveAppearance,
25
+ } from "../lib/appearance.mjs";
19
26
  const version = packageInfo.version;
20
27
  const options = {
21
28
  host: { type: "string" },
@@ -40,6 +47,13 @@ const options = {
40
47
  editor: { type: "boolean" },
41
48
  "no-open": { type: "boolean" },
42
49
  "no-live": { type: "boolean" },
50
+ ...Object.fromEntries(
51
+ appearanceFields.map((field) => [
52
+ field.key === "reviewLayout" ? "review-layout" : field.key,
53
+ { type: "string" },
54
+ ]),
55
+ ),
56
+ reset: { type: "boolean" },
43
57
  help: { type: "boolean", short: "h" },
44
58
  version: { type: "boolean", short: "v" },
45
59
  };
@@ -80,6 +94,9 @@ const help = `Devdevian CLI ${version} · alpha
80
94
  devdevian open Open the workspace in a new terminal window
81
95
  devdevian login Authorize in your browser
82
96
  devdevian logout Revoke this terminal
97
+ devdevian appearance Show local theme and layout preferences
98
+ devdevian appearance --theme daylight --accent violet
99
+ devdevian appearance --reset Restore the default appearance
83
100
  devdevian whoami
84
101
  devdevian inbox [--page 2]
85
102
  devdevian feed [--topic web] [--page 2]
@@ -96,6 +113,13 @@ const help = `Devdevian CLI ${version} · alpha
96
113
  devdevian review reopen POST_ID REVIEW_ID
97
114
  devdevian communities [--page 2]
98
115
 
116
+ --theme NAME midnight | nebula | daylight | contrast
117
+ --accent NAME blue | violet | cyan | rose
118
+ --density NAME comfortable | compact
119
+ --sidebar MODE auto | hidden (Tab opens navigation)
120
+ --review-layout MODE auto | stacked
121
+ --frame STYLE round | single
122
+ --motion MODE subtle | reduced
99
123
  --no-live Disable background reads in the live workspace
100
124
  --project PATH Root directory for the live file/diff picker (default: current directory)
101
125
  --community ID Publish to an approved private community
@@ -240,6 +264,20 @@ async function execute(args, flags) {
240
264
  return;
241
265
  }
242
266
  const command = args[0];
267
+ if (command === "appearance") {
268
+ const loaded = await loadAppearance(),
269
+ updates = appearanceFlags(flags);
270
+ if (flags.reset && Object.keys(updates).length)
271
+ throw Error("Use --reset on its own, or choose appearance options.");
272
+ if (loaded.warning) process.stderr.write(loaded.warning + "\n");
273
+ const settings = flags.reset
274
+ ? await saveAppearance(defaultAppearance)
275
+ : Object.keys(updates).length
276
+ ? await saveAppearance({ ...loaded.settings, ...updates })
277
+ : loaded.settings;
278
+ out(JSON.stringify(settings, null, 2));
279
+ return;
280
+ }
243
281
  if (command === "login") return login(flags);
244
282
  const config = await loadConfig();
245
283
  if (!config) throw Error("Sign in first: devdevian login");
@@ -456,7 +494,14 @@ try {
456
494
  await (await import("../lib/tui.mjs")).startTui(values);
457
495
  else if (positionals[0] === "open") {
458
496
  const args = [];
459
- for (const key of ["host", "name", "project"])
497
+ for (const key of [
498
+ "host",
499
+ "name",
500
+ "project",
501
+ ...appearanceFields.map((field) =>
502
+ field.key === "reviewLayout" ? "review-layout" : field.key,
503
+ ),
504
+ ])
460
505
  if (values[key]) args.push("--" + key, values[key]);
461
506
  if (values["no-open"]) args.push("--no-open");
462
507
  if (values["no-live"]) args.push("--no-live");
@@ -0,0 +1,179 @@
1
+ import { dirname, join } from "node:path";
2
+ import {
3
+ mkdir,
4
+ lstat,
5
+ open,
6
+ writeFile,
7
+ rename,
8
+ unlink,
9
+ } from "node:fs/promises";
10
+ import { constants } from "node:fs";
11
+ import { randomBytes } from "node:crypto";
12
+ import { configFile } from "./client.mjs";
13
+
14
+ export const appearanceFields = [
15
+ {
16
+ key: "theme",
17
+ label: "Theme",
18
+ options: [
19
+ ["midnight", "Midnight · original"],
20
+ ["nebula", "Nebula · deep violet"],
21
+ ["daylight", "Daylight · light"],
22
+ ["contrast", "Contrast · high contrast"],
23
+ ],
24
+ },
25
+ {
26
+ key: "accent",
27
+ label: "Accent",
28
+ options: [
29
+ ["blue", "Blue"],
30
+ ["violet", "Violet"],
31
+ ["cyan", "Cyan"],
32
+ ["rose", "Rose"],
33
+ ],
34
+ },
35
+ {
36
+ key: "density",
37
+ label: "List density",
38
+ options: [
39
+ ["comfortable", "Comfortable"],
40
+ ["compact", "Compact"],
41
+ ],
42
+ },
43
+ {
44
+ key: "sidebar",
45
+ label: "Navigation",
46
+ options: [
47
+ ["auto", "Sidebar on wide terminals"],
48
+ ["hidden", "Hidden · Tab opens navigation"],
49
+ ],
50
+ },
51
+ {
52
+ key: "reviewLayout",
53
+ label: "Review panels",
54
+ options: [
55
+ ["auto", "Side by side when space allows"],
56
+ ["stacked", "One panel · W switches"],
57
+ ],
58
+ },
59
+ {
60
+ key: "frame",
61
+ label: "Frames",
62
+ options: [
63
+ ["round", "Rounded"],
64
+ ["single", "Straight"],
65
+ ],
66
+ },
67
+ {
68
+ key: "motion",
69
+ label: "Motion",
70
+ options: [
71
+ ["subtle", "Subtle loading indicators"],
72
+ ["reduced", "Reduced · static indicators"],
73
+ ],
74
+ },
75
+ ];
76
+ export const defaultAppearance = Object.freeze(
77
+ Object.fromEntries(
78
+ appearanceFields.map((field) => [field.key, field.options[0][0]]),
79
+ ),
80
+ );
81
+ export function normalizeAppearance(value = {}) {
82
+ if (!value || typeof value !== "object" || Array.isArray(value))
83
+ throw Error("Appearance settings must be an object.");
84
+ for (const key of Object.keys(value))
85
+ if (!appearanceFields.some((field) => field.key === key))
86
+ throw Error("Unknown appearance option: " + key);
87
+ const settings = { ...defaultAppearance, ...value };
88
+ for (const field of appearanceFields)
89
+ if (!field.options.some(([id]) => id === settings[field.key]))
90
+ throw Error(
91
+ `Invalid ${field.key}. Choose: ${field.options.map(([id]) => id).join(", ")}.`,
92
+ );
93
+ return settings;
94
+ }
95
+ export function appearanceFlags(flags) {
96
+ const updates = {};
97
+ for (const field of appearanceFields) {
98
+ const flag = field.key === "reviewLayout" ? "review-layout" : field.key;
99
+ if (flags[flag] !== undefined) updates[field.key] = flags[flag];
100
+ }
101
+ normalizeAppearance(updates);
102
+ return updates;
103
+ }
104
+ export const appearanceFile = (env = process.env) =>
105
+ join(dirname(configFile(env)), "appearance.json");
106
+ async function safeDirectory(path) {
107
+ const info = await lstat(path);
108
+ if (!info.isDirectory() || info.isSymbolicLink())
109
+ throw Error("The appearance directory must be a real directory.");
110
+ }
111
+ export async function loadAppearance(env = process.env) {
112
+ const file = appearanceFile(env);
113
+ try {
114
+ await safeDirectory(dirname(file));
115
+ const info = await lstat(file);
116
+ if (!info.isFile() || info.isSymbolicLink() || info.size > 4096)
117
+ throw Error("Invalid appearance file.");
118
+ const handle = await open(
119
+ file,
120
+ constants.O_RDONLY |
121
+ (constants.O_NOFOLLOW || 0) |
122
+ (constants.O_NONBLOCK || 0),
123
+ );
124
+ let record;
125
+ try {
126
+ if (!(await handle.stat()).isFile())
127
+ throw Error("Invalid appearance file.");
128
+ const bytes = Buffer.alloc(4097),
129
+ { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
130
+ if (bytesRead > 4096) throw Error("Appearance file is too large.");
131
+ record = JSON.parse(
132
+ new TextDecoder("utf-8", { fatal: true }).decode(
133
+ bytes.subarray(0, bytesRead),
134
+ ),
135
+ );
136
+ } finally {
137
+ await handle.close();
138
+ }
139
+ if (
140
+ record.version !== 1 ||
141
+ !record.settings ||
142
+ typeof record.settings !== "object"
143
+ )
144
+ throw Error("Unsupported appearance file version.");
145
+ return { settings: normalizeAppearance(record.settings), warning: "" };
146
+ } catch (error) {
147
+ return {
148
+ settings: { ...defaultAppearance },
149
+ warning:
150
+ error.code === "ENOENT"
151
+ ? ""
152
+ : "Could not load appearance settings. Using defaults; /appearance can save a fresh configuration.",
153
+ };
154
+ }
155
+ }
156
+ export async function saveAppearance(value, env = process.env) {
157
+ const settings = normalizeAppearance(value),
158
+ file = appearanceFile(env),
159
+ directory = dirname(file);
160
+ await mkdir(directory, { recursive: true, mode: 0o700 });
161
+ await safeDirectory(directory);
162
+ const temporary = join(
163
+ directory,
164
+ ".appearance-" + randomBytes(12).toString("hex"),
165
+ );
166
+ try {
167
+ await writeFile(
168
+ temporary,
169
+ JSON.stringify({ version: 1, settings }, null, 2) + "\n",
170
+ { flag: "wx", mode: 0o600 },
171
+ );
172
+ await rename(temporary, file);
173
+ } finally {
174
+ await unlink(temporary).catch((error) => {
175
+ if (error.code !== "ENOENT") throw error;
176
+ });
177
+ }
178
+ return settings;
179
+ }
package/lib/picker.mjs CHANGED
@@ -9,9 +9,8 @@ import {
9
9
  readDiff,
10
10
  selectLines,
11
11
  } from "./workspace.mjs";
12
- import { colors } from "./theme.mjs";
13
- const h = React.createElement,
14
- c = { ...colors, bg: colors.panel };
12
+ import { useTheme } from "./theme.mjs";
13
+ const h = React.createElement;
15
14
  export function WorkspacePicker({
16
15
  root,
17
16
  width,
@@ -20,6 +19,8 @@ export function WorkspacePicker({
20
19
  onSelect,
21
20
  onCancel,
22
21
  }) {
22
+ const colors = useTheme(),
23
+ c = { ...colors, bg: colors.panel };
23
24
  const [mode, setMode] = useState(initial),
24
25
  [folder, setFolder] = useState("."),
25
26
  [hidden, setHidden] = useState(false),
@@ -265,7 +266,7 @@ export function WorkspacePicker({
265
266
  height: panelHeight,
266
267
  key: "browser",
267
268
  flexDirection: "column",
268
- borderStyle: "round",
269
+ borderStyle: colors.frame,
269
270
  borderColor: c.purple,
270
271
  paddingX: 1,
271
272
  overflow: "hidden",
@@ -330,7 +331,7 @@ export function WorkspacePicker({
330
331
  height: panelHeight,
331
332
  key: "preview",
332
333
  flexDirection: "column",
333
- borderStyle: "round",
334
+ borderStyle: colors.frame,
334
335
  borderColor: c.blue,
335
336
  paddingX: 1,
336
337
  overflow: "hidden",
@@ -2,7 +2,7 @@ import React from "react";
2
2
  import { Box, Text } from "ink";
3
3
  import { clip, graphemes } from "./tui-model.mjs";
4
4
  import { syntaxSpans } from "./rich-text.mjs";
5
- import { colors as c } from "./theme.mjs";
5
+ import { useTheme } from "./theme.mjs";
6
6
  const h = React.createElement;
7
7
  export function codeRange(line, anchor = null) {
8
8
  return {
@@ -32,8 +32,11 @@ export function ReviewWorkspace({
32
32
  anchor,
33
33
  horizontal = 0,
34
34
  rows,
35
+ layout = "auto",
36
+ stride = 4,
35
37
  }) {
36
- const split = width >= 78,
38
+ const c = useTheme();
39
+ const split = width >= 78 && layout !== "stacked",
37
40
  codeWidth = split ? Math.floor(width * 0.55) : width;
38
41
  const selectedNote = note || items[index];
39
42
  const excerpt = reviewExcerpt(post, focus === "code" ? null : selectedNote),
@@ -114,14 +117,14 @@ export function ReviewWorkspace({
114
117
  },
115
118
  ...(text.startsWith("+") || text.startsWith("-")
116
119
  ? [rendered]
117
- : syntaxSpans(rendered).map((span, j) =>
120
+ : syntaxSpans(rendered, c).map((span, j) =>
118
121
  h(Text, { key: j, color: span.color }, span.text),
119
122
  )),
120
123
  );
121
124
  }),
122
125
  );
123
126
  const notesWidth = split ? width - codeWidth - 2 : width;
124
- const visible = Math.max(1, Math.floor((height - 2) / 4)),
127
+ const visible = Math.max(1, Math.floor((height - 2) / stride)),
125
128
  first = Math.max(0, index - visible + 1);
126
129
  const notes = h(
127
130
  Box,
@@ -140,7 +143,7 @@ export function ReviewWorkspace({
140
143
  : null,
141
144
  ...(items.length
142
145
  ? rows(notesWidth)
143
- .slice(first * 4, first * 4 + height - header)
146
+ .slice(first * stride, first * stride + height - header)
144
147
  .map((row, i) =>
145
148
  h(
146
149
  Text,
package/lib/rich-text.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  import { clean } from "./client.mjs";
2
2
  import { wrap } from "./tui-model.mjs";
3
- import { colors as c } from "./theme.mjs";
3
+ import { colors } from "./theme.mjs";
4
4
 
5
5
  // Small, predictable terminal renderer. HTML and terminal hyperlinks are never executed.
6
- export function syntaxSpans(text) {
6
+ export function syntaxSpans(text, c = colors) {
7
7
  const pattern =
8
8
  /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`[^`]*`|\/\/.*$|#.*$|\b(?:async|await|function|return|const|let|var|if|else|throw|new|class|import|from|export|def|None|True|False|true|false|null|try|catch|for|while)\b|\b\d+(?:\.\d+)?\b)/g;
9
9
  const parts = [];
@@ -27,7 +27,7 @@ export function syntaxSpans(text) {
27
27
  if (start < text.length) parts.push({ text: text.slice(start) });
28
28
  return parts;
29
29
  }
30
- export function markdownRows(value, width) {
30
+ export function markdownRows(value, width, c = colors) {
31
31
  let fence = null,
32
32
  number = 0;
33
33
  const rows = [];
@@ -59,7 +59,7 @@ export function markdownRows(value, width) {
59
59
  const prefix = String(++number).padStart(4) + " ";
60
60
  for (const [i, line] of wrap(raw, Math.max(1, width - 6)).entries()) {
61
61
  const text = (i ? " " : prefix) + line;
62
- rows.push({ text, spans: syntaxSpans(text) });
62
+ rows.push({ text, spans: syntaxSpans(text, c) });
63
63
  }
64
64
  continue;
65
65
  }
package/lib/theme.mjs CHANGED
@@ -1,13 +1,66 @@
1
- // One palette for the workspace, file picker and review panes.
2
- export const colors = Object.freeze({
3
- bg: "#0b1020",
4
- panel: "#11182b",
5
- line: "#303d60",
6
- muted: "#8e9ab8",
7
- text: "#e5eaff",
8
- blue: "#86baff",
9
- purple: "#b5a0ff",
10
- green: "#7bdcbd",
11
- red: "#ffa0b4",
12
- selected: "#29365b",
13
- });
1
+ import { createContext, useContext } from "react";
2
+ import { normalizeAppearance } from "./appearance.mjs";
3
+ const presets = {
4
+ midnight: {
5
+ bg: "#0b1020",
6
+ panel: "#11182b",
7
+ line: "#303d60",
8
+ muted: "#96a3c1",
9
+ text: "#e5eaff",
10
+ green: "#7bdcbd",
11
+ red: "#ffa0b4",
12
+ selected: "#29365b",
13
+ },
14
+ nebula: {
15
+ bg: "#100d20",
16
+ panel: "#1a1530",
17
+ line: "#4a3b68",
18
+ muted: "#a49ab9",
19
+ text: "#f0eaff",
20
+ green: "#86ddc3",
21
+ red: "#ffa6bd",
22
+ selected: "#3c2c59",
23
+ },
24
+ daylight: {
25
+ bg: "#f2f5ff",
26
+ panel: "#ffffff",
27
+ line: "#b4c0dc",
28
+ muted: "#53617a",
29
+ text: "#1b2440",
30
+ green: "#16634d",
31
+ red: "#a12444",
32
+ selected: "#dce5ff",
33
+ },
34
+ contrast: {
35
+ bg: "#000000",
36
+ panel: "#080e1c",
37
+ line: "#899cc6",
38
+ muted: "#becae2",
39
+ text: "#ffffff",
40
+ green: "#8cf4c7",
41
+ red: "#ffb0c4",
42
+ selected: "#24345c",
43
+ },
44
+ };
45
+ export function themeFor(value = {}) {
46
+ const settings = normalizeAppearance(value),
47
+ light = settings.theme === "daylight";
48
+ const accents = light
49
+ ? {
50
+ blue: ["#3154b8", "#6b36b6"],
51
+ violet: ["#6b36b6", "#3154b8"],
52
+ cyan: ["#006b80", "#6b36b6"],
53
+ rose: ["#9b246c", "#3154b8"],
54
+ }
55
+ : {
56
+ blue: ["#86baff", "#b5a0ff"],
57
+ violet: ["#c0a6ff", "#86baff"],
58
+ cyan: ["#74d5ed", "#b5a0ff"],
59
+ rose: ["#f4a6cf", "#86baff"],
60
+ };
61
+ const [blue, purple] = accents[settings.accent];
62
+ return { ...presets[settings.theme], blue, purple, frame: settings.frame };
63
+ }
64
+ export const colors = Object.freeze(themeFor());
65
+ export const ThemeContext = createContext(colors);
66
+ export const useTheme = () => useContext(ThemeContext);
package/lib/tui-data.mjs CHANGED
@@ -19,6 +19,7 @@ export const navigation = [
19
19
  ["communities", "Communities", "Your private spaces"],
20
20
  ["search", "Search", "Find a conversation"],
21
21
  ["devices", "Connected terminals", "Manage CLI access"],
22
+ ["appearance", "Appearance", "Themes & layout"],
22
23
  ["help", "Help", "Keys & commands"],
23
24
  ];
24
25
  export async function notificationRoute(api, item) {
@@ -238,6 +239,7 @@ export function contentLines(route, data) {
238
239
  "Ctrl+S Preview the current form",
239
240
  "Ctrl+O / Ctrl+G Select a code file / Git diff in a review form",
240
241
  "/drafts Restore locally saved drafts for this account/project",
242
+ "/appearance Preview and save local themes and layout",
241
243
  "Ctrl+C Exit and restore the original terminal",
242
244
  "",
243
245
  "Commands: /feed /inbox /search words /open 142",
package/lib/tui-model.mjs CHANGED
@@ -129,6 +129,7 @@ export function parseCommand(text) {
129
129
  "help",
130
130
  "devices",
131
131
  "drafts",
132
+ "appearance",
132
133
  ].includes(cmd)
133
134
  )
134
135
  return { command: cmd, arg };
@@ -140,7 +141,7 @@ export function parseCommand(text) {
140
141
  return { command: cmd, arg: Number(arg) };
141
142
  if (["quit", "logout"].includes(cmd)) return { command: cmd };
142
143
  throw Error(
143
- "Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /drafts, /help or /quit.",
144
+ "Use /feed, /inbox, /search words, /open ID, /ask, /review, /communities, /devices, /drafts, /appearance, /help or /quit.",
144
145
  );
145
146
  }
146
147
  export class PageCache {
package/lib/tui.mjs CHANGED
@@ -32,7 +32,15 @@ import {
32
32
  notificationRoute,
33
33
  } from "./tui-data.mjs";
34
34
  import { basename } from "node:path";
35
- import { colors } from "./theme.mjs";
35
+ import { themeFor, ThemeContext, useTheme } from "./theme.mjs";
36
+ import {
37
+ appearanceFields,
38
+ defaultAppearance,
39
+ loadAppearance,
40
+ saveAppearance,
41
+ normalizeAppearance,
42
+ appearanceFlags,
43
+ } from "./appearance.mjs";
36
44
  import { liveLoop, anchoredIndex } from "./live.mjs";
37
45
  import { contextActions } from "./context-actions.mjs";
38
46
  import { editExternal } from "./external-editor.mjs";
@@ -83,6 +91,7 @@ function openBrowser(url) {
83
91
  child.unref();
84
92
  }
85
93
  function fieldsFor(form) {
94
+ if (form.kind === "appearance") return appearanceFields;
86
95
  if (form.kind === "publication")
87
96
  return [
88
97
  {
@@ -190,10 +199,16 @@ function fieldsFor(form) {
190
199
  },
191
200
  ];
192
201
  }
193
- function TextLine({ text, width, color = colors.text, bold = false }) {
194
- return h(Text, { color, bold, wrap: "truncate" }, clip(text, width));
202
+ function TextLine({ text, width, color, bold = false }) {
203
+ const colors = useTheme();
204
+ return h(
205
+ Text,
206
+ { color: color || colors.text, bold, wrap: "truncate" },
207
+ clip(text, width),
208
+ );
195
209
  }
196
210
  function FrameRows({ rows, width, height, offset = 0 }) {
211
+ const colors = useTheme();
197
212
  return h(
198
213
  Box,
199
214
  { flexDirection: "column", height, overflow: "hidden" },
@@ -226,6 +241,7 @@ function FrameRows({ rows, width, height, offset = 0 }) {
226
241
  );
227
242
  }
228
243
  function InputText({ value, cursor, width, height = 1 }) {
244
+ const colors = useTheme();
229
245
  const result = cursorLines(value, cursor, width),
230
246
  start = Math.max(0, result.line - height + 1);
231
247
  return h(
@@ -244,6 +260,7 @@ function InputText({ value, cursor, width, height = 1 }) {
244
260
  );
245
261
  }
246
262
  function FormView({ form, active, cursor, width, height }) {
263
+ const colors = useTheme();
247
264
  const fields = fieldsFor(form),
248
265
  rows = [];
249
266
  let selectedLine = 0;
@@ -295,14 +312,19 @@ function FormView({ form, active, cursor, width, height }) {
295
312
  rows.push({
296
313
  text:
297
314
  (active === fields.length ? "› " : " ") +
298
- "Preview publication [Ctrl+S]",
315
+ (form.kind === "appearance"
316
+ ? "Save appearance [Ctrl+S]"
317
+ : "Preview publication [Ctrl+S]"),
299
318
  color: colors.blue,
300
319
  bold: true,
301
320
  });
302
- const offset = Math.max(0, Math.min(selectedLine - 1, rows.length - height));
321
+ const offset = Math.max(
322
+ 0,
323
+ Math.min(selectedLine - (height >= 4 ? 1 : 0), rows.length - height),
324
+ );
303
325
  return h(FrameRows, { rows, width, height, offset });
304
326
  }
305
- function listRows(route, data, index, width) {
327
+ function listRows(route, data, index, width, colors, density = "comfortable") {
306
328
  const items = data.items || [];
307
329
  return items.flatMap((item, i) => {
308
330
  let title = item.title || item.name || "",
@@ -388,21 +410,28 @@ function listRows(route, data, index, width) {
388
410
  color: selected ? colors.text : colors.muted,
389
411
  selected,
390
412
  },
391
- { text: " " + "─".repeat(Math.max(0, width - 4)), color: colors.line },
413
+ ...(density === "compact"
414
+ ? []
415
+ : [
416
+ {
417
+ text: " " + "─".repeat(Math.max(0, width - 4)),
418
+ color: colors.line,
419
+ },
420
+ ]),
392
421
  ];
393
422
  });
394
423
  }
395
- function documentRows(route, data, width) {
424
+ function documentRows(route, data, width, colors) {
396
425
  if (route.view !== "post" || (route.tab && route.tab !== "overview"))
397
426
  return contentLines(route, data).flatMap((line) => wrap(line, width));
398
427
  const post = data?.post;
399
428
  if (!post) return [];
400
- const rows = markdownRows(post.body || "", width);
429
+ const rows = markdownRows(post.body || "", width, colors);
401
430
  if (post.review_focus)
402
431
  rows.push(
403
432
  { text: "" },
404
433
  { text: "REVIEW FOCUS", color: colors.purple, bold: true },
405
- ...markdownRows(post.review_focus, width),
434
+ ...markdownRows(post.review_focus, width, colors),
406
435
  );
407
436
  if (post.code) {
408
437
  // Pick a fence longer than any backtick run in the snippet.
@@ -414,7 +443,7 @@ function documentRows(route, data, width) {
414
443
  );
415
444
  rows.push(
416
445
  { text: "" },
417
- ...markdownRows(fence + "\n" + post.code + "\n" + fence, width),
446
+ ...markdownRows(fence + "\n" + post.code + "\n" + fence, width, colors),
418
447
  );
419
448
  }
420
449
  if (post.links?.length)
@@ -431,9 +460,63 @@ function documentRows(route, data, width) {
431
460
  });
432
461
  return rows;
433
462
  }
434
- function App({ initialConfig, flags, root }) {
463
+ function AppearanceView({ form, active, cursor, width, height }) {
464
+ const c = useTheme(),
465
+ wide = width >= 100;
466
+ return h(
467
+ Box,
468
+ { height, overflow: "hidden", gap: wide ? 2 : 0 },
469
+ h(
470
+ Box,
471
+ { width: wide ? width - 36 : width, flexDirection: "column" },
472
+ h(FormView, {
473
+ form,
474
+ active,
475
+ cursor,
476
+ width: wide ? width - 36 : width,
477
+ height,
478
+ }),
479
+ ),
480
+ wide
481
+ ? h(
482
+ Box,
483
+ {
484
+ width: 34,
485
+ height,
486
+ borderStyle: c.frame,
487
+ borderColor: c.line,
488
+ paddingX: 1,
489
+ flexDirection: "column",
490
+ overflow: "hidden",
491
+ },
492
+ h(Text, { color: c.purple, bold: true }, "LIVE PREVIEW"),
493
+ h(Text, { color: c.muted }, "Only this terminal changes."),
494
+ h(Text, null, " "),
495
+ h(
496
+ Text,
497
+ { color: c.text, bold: true },
498
+ "A good conversation starts here",
499
+ ),
500
+ h(
501
+ Text,
502
+ { color: c.blue, backgroundColor: c.selected },
503
+ "▸ Your selected conversation",
504
+ ),
505
+ h(Text, { color: c.muted }, "Ada · code review · just now"),
506
+ h(Text, { color: c.green }, "+ ship with confidence"),
507
+ h(Text, { color: c.red }, "− leave the guesswork behind"),
508
+ h(Text, null, " "),
509
+ h(Text, { color: c.purple }, "← → change · Tab next"),
510
+ h(Text, { color: c.text }, "Ctrl+S save · Esc cancel"),
511
+ h(Text, { color: c.muted }, "0 restores the default preview"),
512
+ )
513
+ : null,
514
+ );
515
+ }
516
+ function App({ initialConfig, flags, root, initialAppearance }) {
435
517
  const { exit, suspendTerminal } = useApp(),
436
518
  { columns, rows } = useWindowSize();
519
+ const [appearance, setAppearance] = useState(initialAppearance.settings);
437
520
  const [config, setConfig] = useState(initialConfig),
438
521
  [phase, setPhase] = useState(
439
522
  initialConfig && !flags.initialLogin ? "checking" : "auth",
@@ -452,7 +535,7 @@ function App({ initialConfig, flags, root }) {
452
535
  [command, setCommand] = useState(""),
453
536
  [commandCursor, setCommandCursor] = useState(0),
454
537
  [error, setError] = useState(""),
455
- [notice, setNotice] = useState("");
538
+ [notice, setNotice] = useState(initialAppearance.warning || "");
456
539
  const [form, setForm] = useState(null),
457
540
  [field, setField] = useState(0),
458
541
  [cursor, setCursor] = useState(0),
@@ -496,9 +579,13 @@ function App({ initialConfig, flags, root }) {
496
579
  () => (config ? client(config.origin, config.token) : null),
497
580
  [config?.origin, config?.token],
498
581
  );
582
+ const effectiveAppearance =
583
+ form?.kind === "appearance" ? normalizeAppearance(form.values) : appearance;
584
+ const colors = themeFor(effectiveAppearance),
585
+ stride = effectiveAppearance.density === "compact" ? 3 : 4;
499
586
  const width = Math.max(1, columns),
500
587
  height = Math.max(1, rows - 1),
501
- compact = width < 90,
588
+ compact = width < 90 || effectiveAppearance.sidebar === "hidden",
502
589
  sidebar = compact ? 0 : 25,
503
590
  bodyHeight = Math.max(1, height - 9),
504
591
  contentWidth = Math.max(1, width - sidebar - 6),
@@ -786,10 +873,14 @@ function App({ initialConfig, flags, root }) {
786
873
  };
787
874
  }, []);
788
875
  useEffect(() => {
789
- if (!loading && !pending && phase !== "pending") return;
876
+ if (
877
+ effectiveAppearance.motion === "reduced" ||
878
+ (!loading && !pending && phase !== "pending")
879
+ )
880
+ return;
790
881
  const timer = setInterval(() => setTick((v) => (v + 1) % 4), 180);
791
882
  return () => clearInterval(timer);
792
- }, [loading, pending, phase]);
883
+ }, [loading, pending, phase, effectiveAppearance.motion]);
793
884
  useEffect(() => {
794
885
  if (phase !== "checking" || !api) return;
795
886
  const controller = new AbortController();
@@ -944,6 +1035,7 @@ function App({ initialConfig, flags, root }) {
944
1035
  async function chooseNav(i) {
945
1036
  const name = navigation[i]?.[0];
946
1037
  setNavIndex(i);
1038
+ if (name === "appearance") return openAppearance();
947
1039
  if (name === "ask" || name === "review")
948
1040
  return composePublication(name === "review" ? "review" : "question");
949
1041
  if (name === "open")
@@ -959,6 +1051,16 @@ function App({ initialConfig, flags, root }) {
959
1051
  setCommandCursor(0);
960
1052
  }
961
1053
  }
1054
+ function openAppearance() {
1055
+ openForm({
1056
+ kind: "appearance",
1057
+ title: "Appearance",
1058
+ values: { ...appearance },
1059
+ });
1060
+ setNotice(
1061
+ "Live preview · ← → change · Ctrl+S save · Esc cancel · 0 defaults",
1062
+ );
1063
+ }
962
1064
  function openForm(next) {
963
1065
  setForm({
964
1066
  ...next,
@@ -1123,6 +1225,18 @@ function App({ initialConfig, flags, root }) {
1123
1225
  }
1124
1226
  async function prepare() {
1125
1227
  if (!form) return;
1228
+ if (form.kind === "appearance") {
1229
+ await task(async () => {
1230
+ const saved = await saveAppearance(form.values);
1231
+ setAppearance(saved);
1232
+ setForm(null);
1233
+ setFocus("content");
1234
+ setNotice(
1235
+ "Appearance saved locally. Your account and other devices are unchanged.",
1236
+ );
1237
+ });
1238
+ return;
1239
+ }
1126
1240
  await task(async () => {
1127
1241
  if (
1128
1242
  form.unavailableAudience &&
@@ -1571,6 +1685,10 @@ function App({ initialConfig, flags, root }) {
1571
1685
  );
1572
1686
  return;
1573
1687
  }
1688
+ if (parsed.command === "appearance") {
1689
+ openAppearance();
1690
+ return;
1691
+ }
1574
1692
  if (parsed.command === "open") {
1575
1693
  navigate({ view: "post", id: parsed.arg, tab: "overview", page: 1 });
1576
1694
  return;
@@ -1591,6 +1709,10 @@ function App({ initialConfig, flags, root }) {
1591
1709
  }
1592
1710
  }
1593
1711
  function askQuit() {
1712
+ if (form?.kind === "appearance") {
1713
+ exit();
1714
+ return;
1715
+ }
1594
1716
  if (form) {
1595
1717
  askConfirm(
1596
1718
  "Leave this draft?",
@@ -1677,6 +1799,7 @@ function App({ initialConfig, flags, root }) {
1677
1799
  ? "[deleted — discussion preserved]"
1678
1800
  : reading.body || "",
1679
1801
  contentWidth,
1802
+ colors,
1680
1803
  ),
1681
1804
  maximum = Math.max(0, lines.length - contentHeight);
1682
1805
  if (keyEvent.escape || keyEvent.return || input === " ") {
@@ -1725,6 +1848,17 @@ function App({ initialConfig, flags, root }) {
1725
1848
  return;
1726
1849
  }
1727
1850
  if (form) {
1851
+ if (form.kind === "appearance" && keyEvent.escape) {
1852
+ setForm(null);
1853
+ setFocus("content");
1854
+ setNotice("Appearance preview cancelled. Saved preferences kept.");
1855
+ return;
1856
+ }
1857
+ if (form.kind === "appearance" && input === "0") {
1858
+ setForm((old) => ({ ...old, values: { ...defaultAppearance } }));
1859
+ setNotice("Default preview restored. Ctrl+S saves; Esc cancels.");
1860
+ return;
1861
+ }
1728
1862
  if (keyEvent.ctrl && input === "e") {
1729
1863
  externalEdit();
1730
1864
  return;
@@ -1775,20 +1909,20 @@ function App({ initialConfig, flags, root }) {
1775
1909
  keyEvent.downArrow
1776
1910
  ) {
1777
1911
  const opts = active.options,
1778
- i = Math.max(
1912
+ step = keyEvent.leftArrow || keyEvent.upArrow ? -1 : 1;
1913
+ setForm((old) => {
1914
+ const i = Math.max(
1779
1915
  0,
1780
1916
  opts.findIndex(
1781
1917
  (item) =>
1782
- String(item[0]) === String(form.values[active.key] || ""),
1918
+ String(item[0]) === String(old.values[active.key] || ""),
1783
1919
  ),
1784
- ),
1785
- step = keyEvent.leftArrow || keyEvent.upArrow ? -1 : 1,
1786
- next = opts[(i + step + opts.length) % opts.length];
1787
- if (next)
1788
- setForm((old) => ({
1789
- ...old,
1790
- values: { ...old.values, [active.key]: next[0] },
1791
- }));
1920
+ );
1921
+ const next = opts[(i + step + opts.length) % opts.length];
1922
+ return next
1923
+ ? { ...old, values: { ...old.values, [active.key]: next[0] } }
1924
+ : old;
1925
+ });
1792
1926
  } else if (keyEvent.return) changeField(field + 1);
1793
1927
  return;
1794
1928
  }
@@ -1862,6 +1996,10 @@ function App({ initialConfig, flags, root }) {
1862
1996
  else if (keyEvent.rightArrow) setFocus("content");
1863
1997
  return;
1864
1998
  }
1999
+ if (input === ",") {
2000
+ openAppearance();
2001
+ return;
2002
+ }
1865
2003
  if (input === "x" || input === "X" || input === "?") {
1866
2004
  setActionIndex(0);
1867
2005
  return;
@@ -1948,13 +2086,13 @@ function App({ initialConfig, flags, root }) {
1948
2086
  ) {
1949
2087
  const step =
1950
2088
  keyEvent.pageUp || keyEvent.pageDown
1951
- ? Math.max(1, Math.floor(contentHeight / 4))
2089
+ ? Math.max(1, Math.floor(contentHeight / stride))
1952
2090
  : 1,
1953
2091
  delta = keyEvent.upArrow || keyEvent.pageUp ? -step : step;
1954
2092
  if (prose) {
1955
2093
  const maximum = Math.max(
1956
2094
  0,
1957
- documentRows(route, current || {}, contentWidth).length -
2095
+ documentRows(route, current || {}, contentWidth, colors).length -
1958
2096
  contentHeight,
1959
2097
  );
1960
2098
  const amount =
@@ -2079,7 +2217,8 @@ function App({ initialConfig, flags, root }) {
2079
2217
  : loading
2080
2218
  ? "Loading…"
2081
2219
  : "Shared account · shared conversations · same permissions");
2082
- const spin = ["◐", "◓", "◑", "◒"][tick];
2220
+ const spin =
2221
+ effectiveAppearance.motion === "reduced" ? "·" : ["◐", "◓", "◑", "◒"][tick];
2083
2222
  let center;
2084
2223
  if (width < 60 || rows < 18)
2085
2224
  center = h(
@@ -2143,7 +2282,7 @@ function App({ initialConfig, flags, root }) {
2143
2282
  center = h(
2144
2283
  Box,
2145
2284
  {
2146
- borderStyle: "round",
2285
+ borderStyle: colors.frame,
2147
2286
  borderColor: colors.purple,
2148
2287
  flexDirection: "column",
2149
2288
  paddingX: 2,
@@ -2162,7 +2301,7 @@ function App({ initialConfig, flags, root }) {
2162
2301
  center = h(
2163
2302
  Box,
2164
2303
  {
2165
- borderStyle: "round",
2304
+ borderStyle: colors.frame,
2166
2305
  borderColor: colors.purple,
2167
2306
  flexDirection: "column",
2168
2307
  paddingX: 2,
@@ -2202,6 +2341,7 @@ function App({ initialConfig, flags, root }) {
2202
2341
  ? "[deleted — discussion preserved]"
2203
2342
  : reading.body || "",
2204
2343
  contentWidth,
2344
+ colors,
2205
2345
  );
2206
2346
  body = h(FrameRows, {
2207
2347
  rows: lines,
@@ -2258,7 +2398,7 @@ function App({ initialConfig, flags, root }) {
2258
2398
  ),
2259
2399
  );
2260
2400
  } else if (form)
2261
- body = h(FormView, {
2401
+ body = h(form.kind === "appearance" ? AppearanceView : FormView, {
2262
2402
  form,
2263
2403
  active: field,
2264
2404
  cursor,
@@ -2324,7 +2464,17 @@ function App({ initialConfig, flags, root }) {
2324
2464
  line: reviewLine,
2325
2465
  anchor: reviewAnchor,
2326
2466
  horizontal: reviewHorizontal,
2327
- rows: (w) => listRows(route, current, index, w),
2467
+ layout: effectiveAppearance.reviewLayout,
2468
+ stride,
2469
+ rows: (w) =>
2470
+ listRows(
2471
+ route,
2472
+ current,
2473
+ index,
2474
+ w,
2475
+ colors,
2476
+ effectiveAppearance.density,
2477
+ ),
2328
2478
  });
2329
2479
  else if (error && !current)
2330
2480
  body = h(FrameRows, {
@@ -2342,7 +2492,7 @@ function App({ initialConfig, flags, root }) {
2342
2492
  height: contentHeight,
2343
2493
  });
2344
2494
  else {
2345
- const lines = documentRows(route, current || {}, contentWidth);
2495
+ const lines = documentRows(route, current || {}, contentWidth, colors);
2346
2496
  if (lines.length)
2347
2497
  body = h(FrameRows, {
2348
2498
  rows: lines,
@@ -2374,12 +2524,16 @@ function App({ initialConfig, flags, root }) {
2374
2524
  ),
2375
2525
  );
2376
2526
  else {
2377
- const visible = Math.max(1, Math.floor(contentHeight / 4)),
2527
+ const visible = Math.max(1, Math.floor(contentHeight / stride)),
2378
2528
  first = Math.max(0, index - visible + 1),
2379
- rows = listRows(route, current, index, contentWidth).slice(
2380
- first * 4,
2381
- first * 4 + contentHeight,
2382
- );
2529
+ rows = listRows(
2530
+ route,
2531
+ current,
2532
+ index,
2533
+ contentWidth,
2534
+ colors,
2535
+ effectiveAppearance.density,
2536
+ ).slice(first * stride, first * stride + contentHeight);
2383
2537
  body = h(
2384
2538
  Box,
2385
2539
  {
@@ -2419,7 +2573,7 @@ function App({ initialConfig, flags, root }) {
2419
2573
  const pane = h(
2420
2574
  Box,
2421
2575
  {
2422
- borderStyle: "round",
2576
+ borderStyle: colors.frame,
2423
2577
  borderColor: focus === "nav" && !compact ? colors.line : colors.purple,
2424
2578
  backgroundColor: colors.panel,
2425
2579
  flexDirection: "column",
@@ -2470,7 +2624,9 @@ function App({ initialConfig, flags, root }) {
2470
2624
  )
2471
2625
  : h(TextLine, {
2472
2626
  text: form
2473
- ? "Tab next field · Ctrl+E editor · Ctrl+S preview" +
2627
+ ? (form.kind === "appearance"
2628
+ ? "Local preferences · Preview now · Ctrl+S save · Esc cancel"
2629
+ : "Tab next field · Ctrl+E editor · Ctrl+S preview") +
2474
2630
  (draftSaved ? " · Draft saved locally" : "")
2475
2631
  : preview
2476
2632
  ? "Scroll to review · ← → choose · Enter confirms"
@@ -2493,7 +2649,7 @@ function App({ initialConfig, flags, root }) {
2493
2649
  ? h(
2494
2650
  Box,
2495
2651
  {
2496
- borderStyle: "round",
2652
+ borderStyle: colors.frame,
2497
2653
  borderColor: focus === "nav" ? colors.blue : colors.line,
2498
2654
  backgroundColor: colors.panel,
2499
2655
  flexDirection: "column",
@@ -2546,97 +2702,103 @@ function App({ initialConfig, flags, root }) {
2546
2702
  );
2547
2703
  }
2548
2704
  const hint =
2549
- actionIndex !== null
2550
- ? "Actions are scoped to the selected item · Esc closes"
2551
- : reviewing && !form && !preview && !reading
2552
- ? "W code / discussion · Space select · C comment · X actions · F filter"
2553
- : picker
2554
- ? "Project files stay local until publication is confirmed"
2555
- : reading
2556
- ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2557
- : preview
2558
- ? "← → choose · Enter confirm · Esc edit"
2559
- : form
2560
- ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
2561
- : phase !== "ready"
2562
- ? "Enter connect / open browser · Ctrl+C exit"
2563
- : route.view === "feedback"
2564
- ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2565
- : route.view === "post"
2566
- ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2567
- : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2705
+ form?.kind === "appearance"
2706
+ ? "← → choose · Tab next · Ctrl+S save · Esc cancel · 0 default preview"
2707
+ : actionIndex !== null
2708
+ ? "Actions are scoped to the selected item · Esc closes"
2709
+ : reviewing && !form && !preview && !reading
2710
+ ? "W code / discussion · Space select · C comment · X actions · F filter"
2711
+ : picker
2712
+ ? "Project files stay local until publication is confirmed"
2713
+ : reading
2714
+ ? "↑↓ scroll · R reply · E edit · D delete · Esc close"
2715
+ : preview
2716
+ ? "← → choose · Enter confirm · Esc edit"
2717
+ : form
2718
+ ? "Tab field · Ctrl+E editor · Ctrl+O files · Ctrl+G diff · Ctrl+S preview"
2719
+ : phase !== "ready"
2720
+ ? "Enter connect / open browser · Ctrl+C exit"
2721
+ : route.view === "feedback"
2722
+ ? "↑↓ select · R reply · E edit · D delete · S resolve · ! report"
2723
+ : route.view === "post"
2724
+ ? "←→ tabs · ↑↓ select · R reply · E edit · D delete · Space read"
2725
+ : "↑↓ select · Enter open · Tab sections · [ ] pages · N new · / search";
2568
2726
  return h(
2569
- Box,
2570
- {
2571
- flexDirection: "column",
2572
- width,
2573
- height,
2574
- overflow: "hidden",
2575
- backgroundColor: colors.bg,
2576
- },
2727
+ ThemeContext.Provider,
2728
+ { value: colors },
2577
2729
  h(
2578
2730
  Box,
2579
2731
  {
2580
- height: 3,
2581
- borderStyle: "round",
2582
- borderColor: colors.line,
2583
- paddingX: 1,
2584
- justifyContent: "space-between",
2732
+ flexDirection: "column",
2733
+ width,
2734
+ height,
2585
2735
  overflow: "hidden",
2736
+ backgroundColor: colors.bg,
2586
2737
  },
2587
2738
  h(
2588
- Text,
2589
- { color: colors.purple, bold: true },
2590
- clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2591
- ),
2592
- h(
2593
- Text,
2594
- { color: colors.blue },
2595
- clip(
2596
- user
2597
- ? "@" +
2598
- user.username +
2599
- " · " +
2600
- unread +
2601
- " unread · " +
2602
- (live ? "● Live" : "○ Paused")
2603
- : "CLI · ALPHA",
2604
- 32,
2739
+ Box,
2740
+ {
2741
+ height: 3,
2742
+ borderStyle: colors.frame,
2743
+ borderColor: colors.line,
2744
+ paddingX: 1,
2745
+ justifyContent: "space-between",
2746
+ overflow: "hidden",
2747
+ },
2748
+ h(
2749
+ Text,
2750
+ { color: colors.purple, bold: true },
2751
+ clip("◈ DEVDEVIAN · " + basename(root), Math.max(10, width - 36)),
2752
+ ),
2753
+ h(
2754
+ Text,
2755
+ { color: colors.blue },
2756
+ clip(
2757
+ user
2758
+ ? "@" +
2759
+ user.username +
2760
+ " · " +
2761
+ unread +
2762
+ " unread · " +
2763
+ (live ? "● Live" : "○ Paused")
2764
+ : "CLI · ALPHA",
2765
+ 32,
2766
+ ),
2605
2767
  ),
2606
2768
  ),
2607
- ),
2608
- center,
2609
- h(TextLine, {
2610
- text: clip((pending || loading ? spin + " " : "") + status, width),
2611
- width,
2612
- color: error ? colors.red : notice ? colors.green : colors.muted,
2613
- }),
2614
- h(
2615
- Box,
2616
- {
2617
- height: 3,
2618
- borderStyle: "round",
2619
- borderColor: focus === "prompt" ? colors.blue : colors.line,
2620
- paddingX: 1,
2621
- overflow: "hidden",
2622
- },
2623
- h(Text, { color: colors.purple, bold: true }, "› "),
2624
- focus === "prompt"
2625
- ? h(InputText, {
2626
- value: command,
2627
- cursor: commandCursor,
2628
- width: Math.max(1, width - 6),
2629
- })
2630
- : h(
2631
- Text,
2632
- { color: colors.muted },
2633
- clip(
2634
- "Search or /command · Press / to type · X actions",
2635
- width - 6,
2769
+ center,
2770
+ h(TextLine, {
2771
+ text: clip((pending || loading ? spin + " " : "") + status, width),
2772
+ width,
2773
+ color: error ? colors.red : notice ? colors.green : colors.muted,
2774
+ }),
2775
+ h(
2776
+ Box,
2777
+ {
2778
+ height: 3,
2779
+ borderStyle: colors.frame,
2780
+ borderColor: focus === "prompt" ? colors.blue : colors.line,
2781
+ paddingX: 1,
2782
+ overflow: "hidden",
2783
+ },
2784
+ h(Text, { color: colors.purple, bold: true }, "› "),
2785
+ focus === "prompt"
2786
+ ? h(InputText, {
2787
+ value: command,
2788
+ cursor: commandCursor,
2789
+ width: Math.max(1, width - 6),
2790
+ })
2791
+ : h(
2792
+ Text,
2793
+ { color: colors.muted },
2794
+ clip(
2795
+ "Search or /command · Press / to type · X actions · , appearance",
2796
+ width - 6,
2797
+ ),
2636
2798
  ),
2637
- ),
2799
+ ),
2800
+ h(TextLine, { text: hint, width, color: colors.muted }),
2638
2801
  ),
2639
- h(TextLine, { text: hint, width, color: colors.muted }),
2640
2802
  );
2641
2803
  }
2642
2804
  export async function startTui(flags = {}) {
@@ -2648,13 +2810,24 @@ export async function startTui(flags = {}) {
2648
2810
  if (flags.host && config && originOf(flags.host) !== config.origin)
2649
2811
  config = null;
2650
2812
  const root = await workspaceRoot(flags.project || process.cwd());
2651
- const app = render(h(App, { initialConfig: config, flags, root }), {
2652
- alternateScreen: true,
2653
- exitOnCtrlC: false,
2654
- patchConsole: true,
2655
- maxFps: 20,
2656
- incrementalRendering: false,
2657
- });
2813
+ const loaded = await loadAppearance();
2814
+ const initialAppearance = {
2815
+ ...loaded,
2816
+ settings: normalizeAppearance({
2817
+ ...loaded.settings,
2818
+ ...appearanceFlags(flags),
2819
+ }),
2820
+ };
2821
+ const app = render(
2822
+ h(App, { initialConfig: config, flags, root, initialAppearance }),
2823
+ {
2824
+ alternateScreen: true,
2825
+ exitOnCtrlC: false,
2826
+ patchConsole: true,
2827
+ maxFps: 20,
2828
+ incrementalRendering: false,
2829
+ },
2830
+ );
2658
2831
  try {
2659
2832
  await app.waitUntilExit();
2660
2833
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tenyokjs/devdevian-cli",
3
- "version": "0.4.1-alpha.1",
3
+ "version": "0.5.0-alpha.1",
4
4
  "description": "Devdevian in your terminal: discussions, questions and human code reviews.",
5
5
  "type": "module",
6
6
  "bin": {