@tenyokjs/devdevian-cli 0.4.1-alpha.1 → 0.5.1-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -0
- package/VERSIONS.md +26 -3
- package/bin/devdevian.mjs +47 -1
- package/lib/appearance.mjs +187 -0
- package/lib/devi.mjs +137 -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 +425 -178
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -31,6 +31,42 @@ 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
|
+
## Meet Devi
|
|
35
|
+
|
|
36
|
+
Devi is the Devdevian mascot: a small ghost with a laptop and an orbit. The terminal uses colored ASCII art, so it needs no image protocol or image downloads. A compact drawing is used when space is limited; authentication codes, addresses and controls take priority over decoration.
|
|
37
|
+
|
|
38
|
+
- **“Devi missed you.”** appears while checking your saved session and loading the first page. It disappears as soon as the page is ready; there is no minimum splash duration or fake progress bar.
|
|
39
|
+
- **“Devi saved you a seat.”** welcomes you to browser-approved sign-in. Sign-in instructions stay visible in small terminals.
|
|
40
|
+
- Empty feed, Inbox, communities, local drafts and search views show a still mascot with a relevant next step. Errors remain error states; they do not become a happy empty view.
|
|
41
|
+
- Subtle mode animates a blink only during startup work or pending browser approval. **Reduced motion** keeps the drawing static. Disable it entirely in **Appearance → Devi greeting**, with `devdevian appearance --welcome off`, or just for one launch with `devdevian tui --welcome off`.
|
|
42
|
+
- A failed session check offers **R / Enter** to retry the existing session. **Ctrl+C** exits during loading. Script commands and `--json` output never print the mascot.
|
|
43
|
+
|
|
44
|
+
All mascot captions are in English. Welcome preferences are local and compatible with existing 0.5.0 configuration files.
|
|
45
|
+
|
|
46
|
+
## Themes and layout
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
- **Midnight** preserves the original blue/purple design; **Nebula** uses a deeper violet surface; **Daylight** is light; **Contrast** emphasizes readable foregrounds.
|
|
51
|
+
- 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.
|
|
52
|
+
- **Devi greeting** shows/hides the terminal mascot on startup, sign-in and supported empty sections. Reduced motion keeps Devi still.
|
|
53
|
+
- **Ctrl+S** saves locally. **Esc** cancels the preview. **0** restores the default preview; save to keep it, or cancel to retain existing settings.
|
|
54
|
+
- Hidden navigation stays available with **Tab**. Stacked review panes switch with **W**. Narrow terminals automatically avoid a permanent sidebar and split review layout.
|
|
55
|
+
|
|
56
|
+
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.
|
|
57
|
+
|
|
58
|
+
Appearance can also be configured **offline, without logging in**:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
devdevian appearance # print settings as JSON
|
|
62
|
+
devdevian appearance --theme daylight --accent violet
|
|
63
|
+
devdevian appearance --density compact --sidebar hidden
|
|
64
|
+
devdevian appearance --review-layout stacked --motion reduced
|
|
65
|
+
devdevian appearance --reset # save defaults
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
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.
|
|
69
|
+
|
|
34
70
|
## Code and discussion, together
|
|
35
71
|
|
|
36
72
|
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,33 @@ 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.1-alpha.1 — Meet Devi
|
|
12
|
+
|
|
13
|
+
- Added a terminal-native ASCII mascot based on the ghost/laptop/orbit logo, with full and compact drawings and English greetings: “Devi missed you.” and “Devi saved you a seat.”
|
|
14
|
+
- Startup follows the actual session check and first page request, with no minimum duration or artificial progress. Connection errors stop the loading state and offer an existing-session retry.
|
|
15
|
+
- Supported empty sections show a still Devi and a contextual next step. Sign-in codes, addresses and controls take priority on small screens.
|
|
16
|
+
- Subtle motion blinks only during active startup or browser approval; Reduced motion is static. Appearance includes a persistent welcome on/off option, also available to offline commands and session-only launch flags.
|
|
17
|
+
- Script/JSON commands remain banner-free. No additional image dependency, API change, schema migration or production-data change. Existing preferences default the new setting to on.
|
|
18
|
+
- Checked delayed/failing startup, retry, animation/static/off modes, English captions, first-page handoff, empty Inbox, script output and 60×18 device approval in unit and pseudo-terminal regressions.
|
|
19
|
+
|
|
20
|
+
## 0.5.0-alpha.1 — Make the terminal yours
|
|
21
|
+
|
|
22
|
+
**Changes**
|
|
23
|
+
|
|
24
|
+
- 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 **,**.
|
|
25
|
+
- 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.
|
|
26
|
+
- Comfortable/compact list density, automatic/hidden navigation, automatic/stacked review panels, rounded/straight frames and reduced loading motion. Compact layouts remain keyboard-accessible.
|
|
27
|
+
- Atomic, validated local preference storage, independent of account credentials and production data. Invalid configuration falls back visibly without overwriting the file.
|
|
28
|
+
- Offline `devdevian appearance` commands and temporary `tui`/`open` appearance flags. Theme text and selection contrast are checked across all preset/accent combinations.
|
|
29
|
+
- Rapid option-arrow changes no longer overwrite each other when input events are batched. Small terminal forms retain the active option in the viewport.
|
|
30
|
+
|
|
31
|
+
**Compatibility and checks**
|
|
32
|
+
|
|
33
|
+
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
34
|
|
|
12
35
|
## 0.4.1-alpha.1 — Everyday writing and navigation
|
|
13
36
|
|
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,14 @@ 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
|
|
123
|
+
--welcome MODE on | off (Devi greeting)
|
|
99
124
|
--no-live Disable background reads in the live workspace
|
|
100
125
|
--project PATH Root directory for the live file/diff picker (default: current directory)
|
|
101
126
|
--community ID Publish to an approved private community
|
|
@@ -240,6 +265,20 @@ async function execute(args, flags) {
|
|
|
240
265
|
return;
|
|
241
266
|
}
|
|
242
267
|
const command = args[0];
|
|
268
|
+
if (command === "appearance") {
|
|
269
|
+
const loaded = await loadAppearance(),
|
|
270
|
+
updates = appearanceFlags(flags);
|
|
271
|
+
if (flags.reset && Object.keys(updates).length)
|
|
272
|
+
throw Error("Use --reset on its own, or choose appearance options.");
|
|
273
|
+
if (loaded.warning) process.stderr.write(loaded.warning + "\n");
|
|
274
|
+
const settings = flags.reset
|
|
275
|
+
? await saveAppearance(defaultAppearance)
|
|
276
|
+
: Object.keys(updates).length
|
|
277
|
+
? await saveAppearance({ ...loaded.settings, ...updates })
|
|
278
|
+
: loaded.settings;
|
|
279
|
+
out(JSON.stringify(settings, null, 2));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
243
282
|
if (command === "login") return login(flags);
|
|
244
283
|
const config = await loadConfig();
|
|
245
284
|
if (!config) throw Error("Sign in first: devdevian login");
|
|
@@ -456,7 +495,14 @@ try {
|
|
|
456
495
|
await (await import("../lib/tui.mjs")).startTui(values);
|
|
457
496
|
else if (positionals[0] === "open") {
|
|
458
497
|
const args = [];
|
|
459
|
-
for (const key of [
|
|
498
|
+
for (const key of [
|
|
499
|
+
"host",
|
|
500
|
+
"name",
|
|
501
|
+
"project",
|
|
502
|
+
...appearanceFields.map((field) =>
|
|
503
|
+
field.key === "reviewLayout" ? "review-layout" : field.key,
|
|
504
|
+
),
|
|
505
|
+
])
|
|
460
506
|
if (values[key]) args.push("--" + key, values[key]);
|
|
461
507
|
if (values["no-open"]) args.push("--no-open");
|
|
462
508
|
if (values["no-live"]) args.push("--no-live");
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
key: "welcome",
|
|
77
|
+
label: "Devi greeting",
|
|
78
|
+
options: [
|
|
79
|
+
["on", "Show Devi"],
|
|
80
|
+
["off", "Hide Devi"],
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
];
|
|
84
|
+
export const defaultAppearance = Object.freeze(
|
|
85
|
+
Object.fromEntries(
|
|
86
|
+
appearanceFields.map((field) => [field.key, field.options[0][0]]),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
export function normalizeAppearance(value = {}) {
|
|
90
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
91
|
+
throw Error("Appearance settings must be an object.");
|
|
92
|
+
for (const key of Object.keys(value))
|
|
93
|
+
if (!appearanceFields.some((field) => field.key === key))
|
|
94
|
+
throw Error("Unknown appearance option: " + key);
|
|
95
|
+
const settings = { ...defaultAppearance, ...value };
|
|
96
|
+
for (const field of appearanceFields)
|
|
97
|
+
if (!field.options.some(([id]) => id === settings[field.key]))
|
|
98
|
+
throw Error(
|
|
99
|
+
`Invalid ${field.key}. Choose: ${field.options.map(([id]) => id).join(", ")}.`,
|
|
100
|
+
);
|
|
101
|
+
return settings;
|
|
102
|
+
}
|
|
103
|
+
export function appearanceFlags(flags) {
|
|
104
|
+
const updates = {};
|
|
105
|
+
for (const field of appearanceFields) {
|
|
106
|
+
const flag = field.key === "reviewLayout" ? "review-layout" : field.key;
|
|
107
|
+
if (flags[flag] !== undefined) updates[field.key] = flags[flag];
|
|
108
|
+
}
|
|
109
|
+
normalizeAppearance(updates);
|
|
110
|
+
return updates;
|
|
111
|
+
}
|
|
112
|
+
export const appearanceFile = (env = process.env) =>
|
|
113
|
+
join(dirname(configFile(env)), "appearance.json");
|
|
114
|
+
async function safeDirectory(path) {
|
|
115
|
+
const info = await lstat(path);
|
|
116
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
117
|
+
throw Error("The appearance directory must be a real directory.");
|
|
118
|
+
}
|
|
119
|
+
export async function loadAppearance(env = process.env) {
|
|
120
|
+
const file = appearanceFile(env);
|
|
121
|
+
try {
|
|
122
|
+
await safeDirectory(dirname(file));
|
|
123
|
+
const info = await lstat(file);
|
|
124
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > 4096)
|
|
125
|
+
throw Error("Invalid appearance file.");
|
|
126
|
+
const handle = await open(
|
|
127
|
+
file,
|
|
128
|
+
constants.O_RDONLY |
|
|
129
|
+
(constants.O_NOFOLLOW || 0) |
|
|
130
|
+
(constants.O_NONBLOCK || 0),
|
|
131
|
+
);
|
|
132
|
+
let record;
|
|
133
|
+
try {
|
|
134
|
+
if (!(await handle.stat()).isFile())
|
|
135
|
+
throw Error("Invalid appearance file.");
|
|
136
|
+
const bytes = Buffer.alloc(4097),
|
|
137
|
+
{ bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
|
|
138
|
+
if (bytesRead > 4096) throw Error("Appearance file is too large.");
|
|
139
|
+
record = JSON.parse(
|
|
140
|
+
new TextDecoder("utf-8", { fatal: true }).decode(
|
|
141
|
+
bytes.subarray(0, bytesRead),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
await handle.close();
|
|
146
|
+
}
|
|
147
|
+
if (
|
|
148
|
+
record.version !== 1 ||
|
|
149
|
+
!record.settings ||
|
|
150
|
+
typeof record.settings !== "object"
|
|
151
|
+
)
|
|
152
|
+
throw Error("Unsupported appearance file version.");
|
|
153
|
+
return { settings: normalizeAppearance(record.settings), warning: "" };
|
|
154
|
+
} catch (error) {
|
|
155
|
+
return {
|
|
156
|
+
settings: { ...defaultAppearance },
|
|
157
|
+
warning:
|
|
158
|
+
error.code === "ENOENT"
|
|
159
|
+
? ""
|
|
160
|
+
: "Could not load appearance settings. Using defaults; /appearance can save a fresh configuration.",
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export async function saveAppearance(value, env = process.env) {
|
|
165
|
+
const settings = normalizeAppearance(value),
|
|
166
|
+
file = appearanceFile(env),
|
|
167
|
+
directory = dirname(file);
|
|
168
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
169
|
+
await safeDirectory(directory);
|
|
170
|
+
const temporary = join(
|
|
171
|
+
directory,
|
|
172
|
+
".appearance-" + randomBytes(12).toString("hex"),
|
|
173
|
+
);
|
|
174
|
+
try {
|
|
175
|
+
await writeFile(
|
|
176
|
+
temporary,
|
|
177
|
+
JSON.stringify({ version: 1, settings }, null, 2) + "\n",
|
|
178
|
+
{ flag: "wx", mode: 0o600 },
|
|
179
|
+
);
|
|
180
|
+
await rename(temporary, file);
|
|
181
|
+
} finally {
|
|
182
|
+
await unlink(temporary).catch((error) => {
|
|
183
|
+
if (error.code !== "ENOENT") throw error;
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return settings;
|
|
187
|
+
}
|
package/lib/devi.mjs
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
import stringWidth from "string-width";
|
|
4
|
+
import { clip, wrap } from "./tui-model.mjs";
|
|
5
|
+
import { useTheme } from "./theme.mjs";
|
|
6
|
+
const h = React.createElement;
|
|
7
|
+
// Terminal-native mascot: ghost, laptop and orbit, drawn without image protocols.
|
|
8
|
+
const full = [
|
|
9
|
+
' .-""""-.',
|
|
10
|
+
" / \\",
|
|
11
|
+
" / .----. \\ +",
|
|
12
|
+
" | ( o o ) |",
|
|
13
|
+
" .---| '----' .-------.",
|
|
14
|
+
" .-' | / </> / '-.",
|
|
15
|
+
"( | _ /______/ )",
|
|
16
|
+
" '---.._ \\_/ \\_/ _..---'",
|
|
17
|
+
" '----------'",
|
|
18
|
+
];
|
|
19
|
+
const small = [
|
|
20
|
+
" .---. .-----.",
|
|
21
|
+
" ( o o ) / </> /",
|
|
22
|
+
" /_ _\\ /_____/",
|
|
23
|
+
" '---'",
|
|
24
|
+
];
|
|
25
|
+
export function deviArt(compact = false, frame = 0) {
|
|
26
|
+
const blink = frame % 6 === 4;
|
|
27
|
+
return (compact ? small : full).map((line) =>
|
|
28
|
+
blink ? line.replace("o o", "- -").replace("o o", "- -") : line,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
// Required status and controls get space before decoration. No minimum display timer.
|
|
32
|
+
export function deviLayout({
|
|
33
|
+
width,
|
|
34
|
+
height,
|
|
35
|
+
message,
|
|
36
|
+
status = [],
|
|
37
|
+
footer = [],
|
|
38
|
+
show = true,
|
|
39
|
+
frame = 0,
|
|
40
|
+
}) {
|
|
41
|
+
width = Math.max(1, width);
|
|
42
|
+
height = Math.max(1, height);
|
|
43
|
+
const instructions = footer.flatMap((line) => wrap(line, width));
|
|
44
|
+
const details = status.flatMap((line) => wrap(line, width));
|
|
45
|
+
const footerRows = instructions.slice(0, height);
|
|
46
|
+
const budget = Math.max(0, height - footerRows.length);
|
|
47
|
+
const detailRows = details.slice(0, budget);
|
|
48
|
+
let remaining = height - footerRows.length - detailRows.length;
|
|
49
|
+
const title = message ? wrap(message, width) : [];
|
|
50
|
+
let art = [];
|
|
51
|
+
if (show) {
|
|
52
|
+
for (const compact of [false, true]) {
|
|
53
|
+
const candidate = deviArt(compact, frame);
|
|
54
|
+
if (
|
|
55
|
+
candidate.every((line) => stringWidth(line) <= width) &&
|
|
56
|
+
candidate.length + title.length + 2 <= remaining
|
|
57
|
+
) {
|
|
58
|
+
art = candidate;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const rows = [];
|
|
64
|
+
if (art.length) {
|
|
65
|
+
rows.push(
|
|
66
|
+
...art.map((text) => ({
|
|
67
|
+
text: text.padEnd(Math.max(...art.map((line) => stringWidth(line)))),
|
|
68
|
+
role: text.includes("</>") ? "blue" : "purple",
|
|
69
|
+
})),
|
|
70
|
+
{ text: "" },
|
|
71
|
+
);
|
|
72
|
+
remaining -= art.length + 1;
|
|
73
|
+
}
|
|
74
|
+
if (title.length && title.length <= remaining) {
|
|
75
|
+
rows.push(...title.map((text) => ({ text, role: "text", bold: true })));
|
|
76
|
+
remaining -= title.length;
|
|
77
|
+
}
|
|
78
|
+
if (remaining && rows.length) rows.push({ text: "" });
|
|
79
|
+
rows.push(
|
|
80
|
+
...detailRows.map((text) => ({ text, role: "text" })),
|
|
81
|
+
...footerRows.map((text) => ({ text, role: "muted" })),
|
|
82
|
+
);
|
|
83
|
+
return rows
|
|
84
|
+
.slice(0, height)
|
|
85
|
+
.map((row) => ({ ...row, text: clip(row.text, width) }));
|
|
86
|
+
}
|
|
87
|
+
export function DeviPanel({
|
|
88
|
+
width,
|
|
89
|
+
height,
|
|
90
|
+
message = "Devi missed you.",
|
|
91
|
+
status,
|
|
92
|
+
footer,
|
|
93
|
+
show = true,
|
|
94
|
+
animate = false,
|
|
95
|
+
motion = "subtle",
|
|
96
|
+
}) {
|
|
97
|
+
const c = useTheme(),
|
|
98
|
+
[frame, setFrame] = useState(0);
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!show || !animate || motion === "reduced") return;
|
|
101
|
+
const timer = setInterval(() => setFrame((value) => (value + 1) % 6), 400);
|
|
102
|
+
return () => clearInterval(timer);
|
|
103
|
+
}, [show, animate, motion]);
|
|
104
|
+
const rows = deviLayout({
|
|
105
|
+
width,
|
|
106
|
+
height,
|
|
107
|
+
message,
|
|
108
|
+
status,
|
|
109
|
+
footer,
|
|
110
|
+
show,
|
|
111
|
+
frame: motion === "reduced" ? 0 : frame,
|
|
112
|
+
});
|
|
113
|
+
return h(
|
|
114
|
+
Box,
|
|
115
|
+
{
|
|
116
|
+
width,
|
|
117
|
+
height,
|
|
118
|
+
flexDirection: "column",
|
|
119
|
+
justifyContent: "center",
|
|
120
|
+
overflow: "hidden",
|
|
121
|
+
},
|
|
122
|
+
...rows.map((row, index) =>
|
|
123
|
+
h(
|
|
124
|
+
Text,
|
|
125
|
+
{
|
|
126
|
+
key: index,
|
|
127
|
+
color: c[row.role] || c.text,
|
|
128
|
+
bold: row.bold,
|
|
129
|
+
wrap: "truncate",
|
|
130
|
+
},
|
|
131
|
+
" ".repeat(
|
|
132
|
+
Math.max(0, Math.floor((width - stringWidth(row.text)) / 2)),
|
|
133
|
+
) + row.text,
|
|
134
|
+
),
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
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
|
}
|