@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 +23 -0
- package/VERSIONS.md +17 -3
- package/bin/devdevian.mjs +46 -1
- package/lib/appearance.mjs +179 -0
- package/lib/picker.mjs +6 -5
- package/lib/review-workspace.mjs +8 -5
- package/lib/rich-text.mjs +4 -4
- package/lib/theme.mjs +66 -13
- package/lib/tui-data.mjs +2 -0
- package/lib/tui-model.mjs +2 -1
- package/lib/tui.mjs +302 -129
- package/package.json +1 -1
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
|
-
##
|
|
7
|
+
## Current milestone
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
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 [
|
|
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 {
|
|
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:
|
|
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:
|
|
334
|
+
borderStyle: colors.frame,
|
|
334
335
|
borderColor: c.blue,
|
|
335
336
|
paddingX: 1,
|
|
336
337
|
overflow: "hidden",
|
package/lib/review-workspace.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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) /
|
|
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 *
|
|
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
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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 {
|
|
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
|
|
194
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
1918
|
+
String(item[0]) === String(old.values[active.key] || ""),
|
|
1783
1919
|
),
|
|
1784
|
-
)
|
|
1785
|
-
|
|
1786
|
-
next
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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 /
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 /
|
|
2527
|
+
const visible = Math.max(1, Math.floor(contentHeight / stride)),
|
|
2378
2528
|
first = Math.max(0, index - visible + 1),
|
|
2379
|
-
rows = listRows(
|
|
2380
|
-
|
|
2381
|
-
|
|
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:
|
|
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
|
-
?
|
|
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:
|
|
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
|
-
|
|
2550
|
-
? "
|
|
2551
|
-
:
|
|
2552
|
-
? "
|
|
2553
|
-
:
|
|
2554
|
-
? "
|
|
2555
|
-
:
|
|
2556
|
-
? "
|
|
2557
|
-
:
|
|
2558
|
-
? "
|
|
2559
|
-
:
|
|
2560
|
-
? "
|
|
2561
|
-
:
|
|
2562
|
-
? "
|
|
2563
|
-
:
|
|
2564
|
-
? "
|
|
2565
|
-
: route.view === "
|
|
2566
|
-
? "
|
|
2567
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
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
|
-
|
|
2589
|
-
{
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
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
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
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
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
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 {
|