@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.3.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/CHANGELOG.md +560 -0
- package/LICENSE +21 -0
- package/README.md +1213 -2
- package/package.json +45 -4
- package/src/agent/agent.js +1194 -0
- package/src/agent/approvals.js +76 -0
- package/src/agent/context.js +106 -0
- package/src/agent/planner.js +237 -0
- package/src/agent/worklog.js +318 -0
- package/src/cli/app.js +463 -0
- package/src/cli/commands.js +1150 -0
- package/src/cli/input.js +287 -0
- package/src/cli/markdown.js +527 -0
- package/src/cli/prompts.js +255 -0
- package/src/cli/renderer.js +2155 -0
- package/src/cli/settings-editor.js +280 -0
- package/src/cli/settings-tui.js +669 -0
- package/src/cli/tui.js +1737 -0
- package/src/cli/width.js +48 -0
- package/src/config/load.js +132 -0
- package/src/config/scaffold.js +50 -0
- package/src/config/settings-registry.js +495 -0
- package/src/config/settings.js +304 -0
- package/src/config/validate.js +451 -0
- package/src/index.js +449 -0
- package/src/providers/catalog.js +282 -0
- package/src/providers/index.js +84 -0
- package/src/providers/nvidia.js +645 -0
- package/src/providers/registry.js +104 -0
- package/src/services/clipboard.js +38 -0
- package/src/services/diff.js +153 -0
- package/src/services/doctor.js +126 -0
- package/src/services/logger.js +190 -0
- package/src/services/preferences.js +249 -0
- package/src/services/processes.js +345 -0
- package/src/services/provider-settings.js +226 -0
- package/src/services/sessions.js +279 -0
- package/src/services/undo.js +133 -0
- package/src/services/ushell.js +146 -0
- package/src/services/websearch/manager.js +616 -0
- package/src/services/websearch/mcp.js +233 -0
- package/src/services/websearch/normalize.js +284 -0
- package/src/services/websearch/providers.js +178 -0
- package/src/services/websearch/serper.js +263 -0
- package/src/services/workspace.js +589 -0
- package/src/tools/filesystem.js +304 -0
- package/src/tools/git.js +114 -0
- package/src/tools/index.js +48 -0
- package/src/tools/project.js +160 -0
- package/src/tools/registry.js +118 -0
- package/src/tools/search.js +210 -0
- package/src/tools/shell.js +279 -0
- package/src/tools/url.js +542 -0
- package/src/tools/web.js +67 -0
- package/src/utils/errors.js +160 -0
- package/src/utils/network.js +129 -0
- package/src/utils/redaction.js +73 -0
- package/src/utils/validation.js +370 -0
- package/src/utils/version.js +24 -0
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { Chalk } from 'chalk';
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import { PASTE_END, PASTE_START } from './input.js';
|
|
4
|
+
import { charWidth, displayWidth } from './width.js';
|
|
5
|
+
import { maskSettingsSecret, setSetting } from '../config/settings.js';
|
|
6
|
+
import { describeSetting, validateSetting } from '../config/settings-registry.js';
|
|
7
|
+
import { RyuuError } from '../utils/errors.js';
|
|
8
|
+
import { redact } from '../utils/redaction.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `ryuu --settings --tui`: a dedicated settings screen.
|
|
12
|
+
*
|
|
13
|
+
* It is a small retained screen (list + detail + status) rather than a second
|
|
14
|
+
* chat TUI, and it reuses the terminal conventions of the main TUI: alternate
|
|
15
|
+
* screen, raw mode, a resize redraw, masked secrets, and an unconditional
|
|
16
|
+
* teardown that restores the terminal on every exit path.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PALETTE = {
|
|
20
|
+
white: '#e2e8f0',
|
|
21
|
+
sky: '#38bdf8',
|
|
22
|
+
green: '#4ade80',
|
|
23
|
+
red: '#f87171',
|
|
24
|
+
gray: '#9ca3af',
|
|
25
|
+
violet: '#a78bfa',
|
|
26
|
+
amber: '#fbbf24',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const KEYS = [
|
|
30
|
+
['\x1b[A', 'up'],
|
|
31
|
+
['\x1b[B', 'down'],
|
|
32
|
+
['\x1b[C', 'right'],
|
|
33
|
+
['\x1b[D', 'left'],
|
|
34
|
+
['\x1b[H', 'home'],
|
|
35
|
+
['\x1b[F', 'end'],
|
|
36
|
+
['\x1b[1~', 'home'],
|
|
37
|
+
['\x1b[4~', 'end'],
|
|
38
|
+
['\x1b[5~', 'pageup'],
|
|
39
|
+
['\x1b[6~', 'pagedown'],
|
|
40
|
+
['\x1b[3~', 'delete'],
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/** Select-type descriptors: ←/→ cycles them; typing still allows a custom id. */
|
|
44
|
+
const SELECT_TYPES = new Set(['provider', 'enum', 'model']);
|
|
45
|
+
|
|
46
|
+
function truncate(text, width) {
|
|
47
|
+
const value = String(text ?? '');
|
|
48
|
+
if (width <= 0) return '';
|
|
49
|
+
if (displayWidth(value) <= width) return value;
|
|
50
|
+
let output = '';
|
|
51
|
+
let used = 0;
|
|
52
|
+
for (const char of value) {
|
|
53
|
+
const size = charWidth(char.codePointAt(0));
|
|
54
|
+
if (used + size > Math.max(0, width - 1)) break;
|
|
55
|
+
output += char;
|
|
56
|
+
used += size;
|
|
57
|
+
}
|
|
58
|
+
return `${output}…`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function charsOf(text) {
|
|
62
|
+
return Array.from(String(text ?? ''));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Greedy word wrap for the description pane (never splits a word). */
|
|
66
|
+
function wrapWords(text, width) {
|
|
67
|
+
const max = Math.max(8, width);
|
|
68
|
+
const lines = [];
|
|
69
|
+
let line = '';
|
|
70
|
+
for (const word of String(text ?? '').split(/\s+/).filter(Boolean)) {
|
|
71
|
+
if (!line) {
|
|
72
|
+
line = word;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (displayWidth(`${line} ${word}`) <= max) {
|
|
76
|
+
line += ` ${word}`;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
lines.push(line);
|
|
80
|
+
line = word;
|
|
81
|
+
}
|
|
82
|
+
if (line) lines.push(line);
|
|
83
|
+
return lines.length > 0 ? lines : [''];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export class SettingsTui {
|
|
87
|
+
constructor({
|
|
88
|
+
input,
|
|
89
|
+
output,
|
|
90
|
+
store,
|
|
91
|
+
makeRegistry,
|
|
92
|
+
data = null,
|
|
93
|
+
version = '',
|
|
94
|
+
color = null,
|
|
95
|
+
} = {}) {
|
|
96
|
+
if (!store) throw new Error('SettingsTui needs a store.');
|
|
97
|
+
if (typeof makeRegistry !== 'function') throw new Error('SettingsTui needs a makeRegistry function.');
|
|
98
|
+
this.input = input;
|
|
99
|
+
this.output = output;
|
|
100
|
+
this.store = store;
|
|
101
|
+
this.makeRegistry = makeRegistry;
|
|
102
|
+
this.data = data || { version: 1 };
|
|
103
|
+
this.version = version;
|
|
104
|
+
this.terminal = Boolean(input?.isTTY && output?.isTTY);
|
|
105
|
+
const useColor = color === null ? Boolean(output?.isTTY && !process.env.NO_COLOR) : Boolean(color);
|
|
106
|
+
this.c = new Chalk({ level: useColor ? 3 : 0 });
|
|
107
|
+
|
|
108
|
+
this.entries = makeRegistry(this.data);
|
|
109
|
+
this.index = 0;
|
|
110
|
+
this.offset = 0;
|
|
111
|
+
this.editing = null;
|
|
112
|
+
this.status = { text: '', kind: 'info' };
|
|
113
|
+
this.buffer = '';
|
|
114
|
+
this.paste = null;
|
|
115
|
+
this.escTimer = null;
|
|
116
|
+
this.closed = false;
|
|
117
|
+
this.exited = null;
|
|
118
|
+
this.queue = Promise.resolve();
|
|
119
|
+
|
|
120
|
+
this.onData = (chunk) => this.push(chunk);
|
|
121
|
+
this.onResize = () => this.render();
|
|
122
|
+
// A hard exit (uncaught error, SIGTERM) must still give the terminal back.
|
|
123
|
+
this.exitHandler = () => {
|
|
124
|
+
try {
|
|
125
|
+
this.output?.write?.('\x1b[?25h\x1b[?1049l');
|
|
126
|
+
} catch {
|
|
127
|
+
// Ignore: the stream may already be gone.
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
this.input?.setRawMode?.(false);
|
|
131
|
+
} catch {
|
|
132
|
+
// Ignore: the stream may already be gone.
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
paint(name, text) {
|
|
138
|
+
return this.c.hex(PALETTE[name] || PALETTE.white)(String(text ?? ''));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── lifecycle ────────────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
async run() {
|
|
144
|
+
if (!this.terminal) {
|
|
145
|
+
throw new RyuuError(
|
|
146
|
+
'The settings TUI needs an interactive terminal. Run `ryuu --settings` for the line editor.',
|
|
147
|
+
{ code: 'SETTINGS_TTY_REQUIRED' },
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
this.enter();
|
|
151
|
+
await new Promise((resolve) => {
|
|
152
|
+
this.exited = resolve;
|
|
153
|
+
});
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
enter() {
|
|
158
|
+
this.output.write('\x1b[?1049h\x1b[2J\x1b[?25l');
|
|
159
|
+
try {
|
|
160
|
+
this.input.setRawMode?.(true);
|
|
161
|
+
this.input.resume?.();
|
|
162
|
+
} catch {
|
|
163
|
+
// A non-standard stream may refuse raw mode; the screen still renders.
|
|
164
|
+
}
|
|
165
|
+
this.input.on('data', this.onData);
|
|
166
|
+
this.output.on('resize', this.onResize);
|
|
167
|
+
process.once('exit', this.exitHandler);
|
|
168
|
+
this.render();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Restore the terminal exactly once, on every exit path. */
|
|
172
|
+
close() {
|
|
173
|
+
if (this.closed) return;
|
|
174
|
+
this.closed = true;
|
|
175
|
+
if (this.escTimer) clearTimeout(this.escTimer);
|
|
176
|
+
this.escTimer = null;
|
|
177
|
+
this.input?.off?.('data', this.onData);
|
|
178
|
+
this.output?.off?.('resize', this.onResize);
|
|
179
|
+
process.off('exit', this.exitHandler);
|
|
180
|
+
try {
|
|
181
|
+
this.input?.setRawMode?.(false);
|
|
182
|
+
} catch {
|
|
183
|
+
// Ignore: the stream may already be gone.
|
|
184
|
+
}
|
|
185
|
+
this.output?.write?.('\x1b[?25h\x1b[?1049l');
|
|
186
|
+
this.input?.pause?.();
|
|
187
|
+
const resolve = this.exited;
|
|
188
|
+
this.exited = null;
|
|
189
|
+
resolve?.();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── input ────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
push(chunk) {
|
|
195
|
+
if (this.closed) return;
|
|
196
|
+
this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
197
|
+
this.drain();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
drain() {
|
|
201
|
+
for (;;) {
|
|
202
|
+
if (!this.buffer || this.closed) return;
|
|
203
|
+
if (this.paste !== null) {
|
|
204
|
+
const end = this.buffer.indexOf(PASTE_END);
|
|
205
|
+
if (end === -1) {
|
|
206
|
+
this.paste += this.buffer;
|
|
207
|
+
this.buffer = '';
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.paste += this.buffer.slice(0, end);
|
|
211
|
+
this.buffer = this.buffer.slice(end + PASTE_END.length);
|
|
212
|
+
const text = this.paste;
|
|
213
|
+
this.paste = null;
|
|
214
|
+
this.insertText(text);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (this.buffer.startsWith(PASTE_START)) {
|
|
218
|
+
this.buffer = this.buffer.slice(PASTE_START.length);
|
|
219
|
+
this.paste = '';
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (this.buffer[0] === '\x1b') {
|
|
223
|
+
const sequence = this.#readEscape();
|
|
224
|
+
if (sequence === null) {
|
|
225
|
+
this.#armEscape();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
this.buffer = this.buffer.slice(sequence.length);
|
|
229
|
+
this.#dispatchKey(sequence);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const special = this.buffer.search(/[\x00-\x1f\x7f]/);
|
|
233
|
+
if (special === -1) {
|
|
234
|
+
const text = this.buffer;
|
|
235
|
+
this.buffer = '';
|
|
236
|
+
this.insertText(text);
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (special > 0) {
|
|
240
|
+
const text = this.buffer.slice(0, special);
|
|
241
|
+
this.buffer = this.buffer.slice(special);
|
|
242
|
+
this.insertText(text);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const char = this.buffer[0];
|
|
246
|
+
this.buffer = this.buffer.slice(1);
|
|
247
|
+
this.#dispatchControl(char);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
#readEscape() {
|
|
252
|
+
if (this.buffer.length === 1) return null;
|
|
253
|
+
if (this.buffer[1] === '[') {
|
|
254
|
+
for (let index = 2; index < this.buffer.length; index += 1) {
|
|
255
|
+
const code = this.buffer.charCodeAt(index);
|
|
256
|
+
if (code >= 0x40 && code <= 0x7e) return this.buffer.slice(0, index + 1);
|
|
257
|
+
}
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
if (this.buffer[1] === 'O') return this.buffer.length < 3 ? null : this.buffer.slice(0, 3);
|
|
261
|
+
return this.buffer.slice(0, 2);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
#armEscape() {
|
|
265
|
+
if (this.escTimer) return;
|
|
266
|
+
this.escTimer = setTimeout(() => {
|
|
267
|
+
this.escTimer = null;
|
|
268
|
+
if (this.buffer.startsWith('\x1b') && this.buffer.length === 1) {
|
|
269
|
+
this.buffer = '';
|
|
270
|
+
this.#dispatchKey('\x1b');
|
|
271
|
+
}
|
|
272
|
+
}, 40);
|
|
273
|
+
this.escTimer.unref?.();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#dispatchKey(sequence) {
|
|
277
|
+
if (sequence === '\x1b') return this.enqueue('escape');
|
|
278
|
+
const name = KEYS.find(([code]) => code === sequence)?.[1];
|
|
279
|
+
if (name) this.enqueue(name);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#dispatchControl(char) {
|
|
283
|
+
if (char === '\x03') return this.enqueue('ctrl-c');
|
|
284
|
+
if (char === '\r' || char === '\n') return this.enqueue('enter');
|
|
285
|
+
if (char === '\x7f' || char === '\x08') return this.enqueue('backspace');
|
|
286
|
+
if (char === '\t') return this.enqueue('tab');
|
|
287
|
+
if (char === '\x15') {
|
|
288
|
+
if (this.editing) {
|
|
289
|
+
this.editing.buffer = '';
|
|
290
|
+
this.editing.cursor = 0;
|
|
291
|
+
this.render();
|
|
292
|
+
}
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
// Other control keys are ignored: they must never leak into a value.
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
insertText(text) {
|
|
300
|
+
if (!this.editing) return;
|
|
301
|
+
const clean = String(text ?? '').replace(/[\r\n]+/g, '');
|
|
302
|
+
if (!clean) return;
|
|
303
|
+
const chars = charsOf(this.editing.buffer);
|
|
304
|
+
const cursor = this.editing.cursor;
|
|
305
|
+
this.editing.buffer = [...chars.slice(0, cursor), ...Array.from(clean), ...chars.slice(cursor)].join('');
|
|
306
|
+
this.editing.cursor = cursor + Array.from(clean).length;
|
|
307
|
+
this.render();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Serialise key handling so two Enter presses cannot commit twice at once. */
|
|
311
|
+
enqueue(key) {
|
|
312
|
+
this.queue = this.queue
|
|
313
|
+
.then(() => this.handleKey(key))
|
|
314
|
+
.catch(() => {})
|
|
315
|
+
.then(() => this.render());
|
|
316
|
+
return this.queue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ── navigation and editing ───────────────────────────────────────────────
|
|
320
|
+
|
|
321
|
+
get selected() {
|
|
322
|
+
return this.entries[this.index] || null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
select(index) {
|
|
326
|
+
if (this.entries.length === 0) return;
|
|
327
|
+
this.index = Math.max(0, Math.min(index, this.entries.length - 1));
|
|
328
|
+
this.editing = null;
|
|
329
|
+
this.render();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
beginEdit() {
|
|
333
|
+
const entry = this.selected;
|
|
334
|
+
if (!entry) return;
|
|
335
|
+
if (SELECT_TYPES.has(entry.type)) {
|
|
336
|
+
const choices = entry.choices || [];
|
|
337
|
+
const current = entry.value === null || entry.value === undefined ? '' : String(entry.value);
|
|
338
|
+
const at = choices.indexOf(current);
|
|
339
|
+
const buffer = at === -1 ? (choices[0] ?? '') : current;
|
|
340
|
+
this.editing = {
|
|
341
|
+
entry,
|
|
342
|
+
buffer,
|
|
343
|
+
cursor: charsOf(buffer).length,
|
|
344
|
+
secret: Boolean(entry.secret),
|
|
345
|
+
original: entry.value,
|
|
346
|
+
};
|
|
347
|
+
} else {
|
|
348
|
+
const current = entry.secret
|
|
349
|
+
? ''
|
|
350
|
+
: entry.value === null || entry.value === undefined
|
|
351
|
+
? ''
|
|
352
|
+
: Array.isArray(entry.value)
|
|
353
|
+
? entry.value.join(', ')
|
|
354
|
+
: String(entry.value);
|
|
355
|
+
this.editing = {
|
|
356
|
+
entry,
|
|
357
|
+
buffer: current,
|
|
358
|
+
cursor: charsOf(current).length,
|
|
359
|
+
secret: Boolean(entry.secret),
|
|
360
|
+
original: entry.value,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
this.status = {
|
|
364
|
+
text:
|
|
365
|
+
entry.secret && entry.value
|
|
366
|
+
? 'editing a new secret · Enter save · Esc cancel (the stored key stays masked)'
|
|
367
|
+
: SELECT_TYPES.has(entry.type)
|
|
368
|
+
? '←/→ change or type an id · Enter save · Ctrl+U clear · Esc cancel'
|
|
369
|
+
: 'type a value · Enter save · Ctrl+U clear · Esc cancel',
|
|
370
|
+
kind: 'info',
|
|
371
|
+
};
|
|
372
|
+
this.render();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
cancelEdit() {
|
|
376
|
+
this.editing = null;
|
|
377
|
+
this.status = { text: 'Edit cancelled; nothing changed.', kind: 'info' };
|
|
378
|
+
this.render();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
cycle(delta) {
|
|
382
|
+
const edit = this.editing;
|
|
383
|
+
if (!edit) return;
|
|
384
|
+
const choices = edit.entry.choices || [];
|
|
385
|
+
if (choices.length === 0) return;
|
|
386
|
+
const current = choices.indexOf(edit.buffer);
|
|
387
|
+
const next = ((current === -1 ? 0 : current + delta) % choices.length + choices.length) % choices.length;
|
|
388
|
+
edit.buffer = String(choices[next]);
|
|
389
|
+
edit.cursor = edit.buffer.length;
|
|
390
|
+
this.render();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
moveCursor(delta) {
|
|
394
|
+
const edit = this.editing;
|
|
395
|
+
if (!edit) return;
|
|
396
|
+
const chars = charsOf(edit.buffer);
|
|
397
|
+
edit.cursor = Math.max(0, Math.min(edit.cursor + delta, chars.length));
|
|
398
|
+
this.render();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
backspace() {
|
|
402
|
+
const edit = this.editing;
|
|
403
|
+
if (!edit || edit.cursor <= 0) return;
|
|
404
|
+
const chars = charsOf(edit.buffer);
|
|
405
|
+
edit.buffer = [...chars.slice(0, edit.cursor - 1), ...chars.slice(edit.cursor)].join('');
|
|
406
|
+
edit.cursor -= 1;
|
|
407
|
+
this.render();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
deleteForward() {
|
|
411
|
+
const edit = this.editing;
|
|
412
|
+
if (!edit) return;
|
|
413
|
+
const chars = charsOf(edit.buffer);
|
|
414
|
+
if (edit.cursor >= chars.length) return;
|
|
415
|
+
edit.buffer = [...chars.slice(0, edit.cursor), ...chars.slice(edit.cursor + 1)].join('');
|
|
416
|
+
this.render();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async commit() {
|
|
420
|
+
const edit = this.editing;
|
|
421
|
+
if (!edit) return;
|
|
422
|
+
const { entry } = edit;
|
|
423
|
+
const result = validateSetting(entry, edit.buffer);
|
|
424
|
+
if (!result.ok) {
|
|
425
|
+
this.status = { text: `${entry.label}: ${result.error}`, kind: 'error' };
|
|
426
|
+
this.render();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
let saved;
|
|
430
|
+
try {
|
|
431
|
+
saved = await this.store.update((draft) => {
|
|
432
|
+
setSetting(draft, entry.key, result.value);
|
|
433
|
+
return draft;
|
|
434
|
+
});
|
|
435
|
+
} catch (error) {
|
|
436
|
+
this.status = { text: `${entry.label} was NOT saved: ${redact(error.message)}`, kind: 'error' };
|
|
437
|
+
this.render();
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
this.data = saved;
|
|
441
|
+
const index = this.index;
|
|
442
|
+
this.entries = this.makeRegistry(this.data);
|
|
443
|
+
this.index = Math.min(index, this.entries.length - 1);
|
|
444
|
+
this.editing = null;
|
|
445
|
+
const warning = result.warning ? ` — ${result.warning}` : '';
|
|
446
|
+
this.status = { text: `Saved ${entry.label}${warning}`, kind: 'ok' };
|
|
447
|
+
this.render();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Handle one key name. Async only when it saves. */
|
|
451
|
+
async handleKey(name) {
|
|
452
|
+
if (this.closed) return;
|
|
453
|
+
if (this.editing) {
|
|
454
|
+
switch (name) {
|
|
455
|
+
case 'escape':
|
|
456
|
+
this.cancelEdit();
|
|
457
|
+
return;
|
|
458
|
+
case 'enter':
|
|
459
|
+
await this.commit();
|
|
460
|
+
return;
|
|
461
|
+
case 'left':
|
|
462
|
+
if (SELECT_TYPES.has(this.editing.entry.type)) this.cycle(-1);
|
|
463
|
+
else this.moveCursor(-1);
|
|
464
|
+
return;
|
|
465
|
+
case 'right':
|
|
466
|
+
if (SELECT_TYPES.has(this.editing.entry.type)) this.cycle(1);
|
|
467
|
+
else this.moveCursor(1);
|
|
468
|
+
return;
|
|
469
|
+
case 'home':
|
|
470
|
+
if (!SELECT_TYPES.has(this.editing.entry.type)) {
|
|
471
|
+
this.editing.cursor = 0;
|
|
472
|
+
this.render();
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
case 'end':
|
|
476
|
+
if (!SELECT_TYPES.has(this.editing.entry.type)) {
|
|
477
|
+
this.editing.cursor = charsOf(this.editing.buffer).length;
|
|
478
|
+
this.render();
|
|
479
|
+
}
|
|
480
|
+
return;
|
|
481
|
+
case 'backspace':
|
|
482
|
+
this.backspace();
|
|
483
|
+
return;
|
|
484
|
+
case 'delete':
|
|
485
|
+
this.deleteForward();
|
|
486
|
+
return;
|
|
487
|
+
case 'ctrl-c':
|
|
488
|
+
this.cancelEdit();
|
|
489
|
+
return;
|
|
490
|
+
default:
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
switch (name) {
|
|
496
|
+
case 'up':
|
|
497
|
+
this.select(this.index - 1);
|
|
498
|
+
return;
|
|
499
|
+
case 'down':
|
|
500
|
+
this.select(this.index + 1);
|
|
501
|
+
return;
|
|
502
|
+
case 'home':
|
|
503
|
+
this.select(0);
|
|
504
|
+
return;
|
|
505
|
+
case 'end':
|
|
506
|
+
this.select(this.entries.length - 1);
|
|
507
|
+
return;
|
|
508
|
+
case 'pageup':
|
|
509
|
+
this.select(this.index - this.pageSize());
|
|
510
|
+
return;
|
|
511
|
+
case 'pagedown':
|
|
512
|
+
this.select(this.index + this.pageSize());
|
|
513
|
+
return;
|
|
514
|
+
case 'tab':
|
|
515
|
+
this.select((this.index + 1) % Math.max(1, this.entries.length));
|
|
516
|
+
return;
|
|
517
|
+
case 'enter':
|
|
518
|
+
this.beginEdit();
|
|
519
|
+
return;
|
|
520
|
+
case 'left':
|
|
521
|
+
case 'right': {
|
|
522
|
+
const entry = this.selected;
|
|
523
|
+
if (!entry || !SELECT_TYPES.has(entry.type)) return;
|
|
524
|
+
this.beginEdit();
|
|
525
|
+
this.cycle(name === 'left' ? -1 : 1);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
case 'escape':
|
|
529
|
+
case 'ctrl-c':
|
|
530
|
+
this.close();
|
|
531
|
+
return;
|
|
532
|
+
default:
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
pageSize() {
|
|
538
|
+
const height = Math.max(10, Number(this.output?.rows) || 24);
|
|
539
|
+
return Math.max(1, height - 7);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── rendering ────────────────────────────────────────────────────────────
|
|
543
|
+
|
|
544
|
+
renderRows(width, height) {
|
|
545
|
+
const rows = [];
|
|
546
|
+
const inner = Math.max(20, width - 2);
|
|
547
|
+
const headerLeft = ` ${this.paint('sky', '◈')} ${this.c.bold(this.paint('sky', 'RYUU SETTINGS'))}`;
|
|
548
|
+
const headerRight = this.paint('gray', `${this.version ? `v${this.version} · ` : ''}config.json`);
|
|
549
|
+
const room = Math.max(0, inner - displayWidth(headerLeft, { ansi: true }) - displayWidth(headerRight, { ansi: true }));
|
|
550
|
+
rows.push(`${headerLeft}${' '.repeat(room)}${headerRight}`);
|
|
551
|
+
rows.push(this.paint('gray', ` ${'─'.repeat(inner)}`));
|
|
552
|
+
|
|
553
|
+
// Fixed rows: header, rule, column header, rule, two description rows,
|
|
554
|
+
// constraints, control hint and status. The rest is the scrolling list.
|
|
555
|
+
const listRows = Math.max(1, height - 9);
|
|
556
|
+
if (this.index < this.offset) this.offset = this.index;
|
|
557
|
+
if (this.index >= this.offset + listRows) this.offset = this.index - listRows + 1;
|
|
558
|
+
|
|
559
|
+
const labelWidth = Math.min(
|
|
560
|
+
30,
|
|
561
|
+
Math.max(12, ...this.entries.map((entry) => displayWidth(entry.label))),
|
|
562
|
+
Math.max(8, width - 24),
|
|
563
|
+
);
|
|
564
|
+
const valueWidth = Math.max(8, width - labelWidth - 9);
|
|
565
|
+
rows.push(
|
|
566
|
+
` ${this.paint('gray', '#'.padEnd(3))}${this.paint('gray', 'SETTING'.padEnd(labelWidth))} ${this.paint(
|
|
567
|
+
'gray',
|
|
568
|
+
'VALUE',
|
|
569
|
+
)}`,
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
for (let row = 0; row < listRows; row += 1) {
|
|
573
|
+
const entry = this.entries[this.offset + row];
|
|
574
|
+
if (!entry) {
|
|
575
|
+
rows.push('');
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const index = this.offset + row;
|
|
579
|
+
const selected = index === this.index;
|
|
580
|
+
const marker = selected ? this.paint('sky', '▸') : ' ';
|
|
581
|
+
const number = String(index + 1).padEnd(3);
|
|
582
|
+
const label = truncate(entry.label, labelWidth).padEnd(labelWidth);
|
|
583
|
+
|
|
584
|
+
const editing = this.editing && this.editing.entry === entry;
|
|
585
|
+
const labelText = selected ? this.c.bold(this.paint('white', label)) : this.paint('gray', label);
|
|
586
|
+
let valueText;
|
|
587
|
+
if (editing) {
|
|
588
|
+
valueText = this.paint(entry.secret ? 'amber' : 'green', this.#editingDisplay());
|
|
589
|
+
} else if (entry.secret) {
|
|
590
|
+
valueText = this.paint('gray', maskSettingsSecret(entry.value));
|
|
591
|
+
} else {
|
|
592
|
+
valueText = truncate(formatValue(entry), valueWidth);
|
|
593
|
+
}
|
|
594
|
+
rows.push(` ${marker} ${this.paint('gray', number)}${labelText} ${valueText}`);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
rows.push(this.paint('gray', ` ${'─'.repeat(inner)}`));
|
|
598
|
+
const entry = this.selected;
|
|
599
|
+
if (entry) {
|
|
600
|
+
const description = wrapWords(entry.description, Math.max(10, inner - 2));
|
|
601
|
+
const first = description[0] || '';
|
|
602
|
+
const second = description.length > 2 ? `${description[1]}…` : description[1] || '';
|
|
603
|
+
rows.push(` ${this.paint('white', first)}`);
|
|
604
|
+
rows.push(` ${this.paint('white', second)}`);
|
|
605
|
+
const constraints = describeSetting(entry);
|
|
606
|
+
rows.push(` ${this.paint('gray', truncate(constraints, inner - 2))}`);
|
|
607
|
+
if (this.editing) {
|
|
608
|
+
rows.push(
|
|
609
|
+
` ${this.paint('amber', 'editing')} ${this.paint(
|
|
610
|
+
'gray',
|
|
611
|
+
SELECT_TYPES.has(entry.type)
|
|
612
|
+
? '←/→ change · Enter save · Esc cancel'
|
|
613
|
+
: 'Enter save · Esc cancel · Home/End · Backspace',
|
|
614
|
+
)}`,
|
|
615
|
+
);
|
|
616
|
+
} else {
|
|
617
|
+
rows.push(` ${this.paint('gray', 'Enter edit · ↑↓ move · Esc exit · Ctrl+C quit')}`);
|
|
618
|
+
}
|
|
619
|
+
} else {
|
|
620
|
+
rows.push(` ${this.paint('gray', 'No editable settings for this installation.')}`);
|
|
621
|
+
rows.push('');
|
|
622
|
+
rows.push('');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const statusText = this.status.text || `file: ${this.store.file}`;
|
|
626
|
+
const tone = this.status.kind === 'error' ? 'red' : this.status.kind === 'ok' ? 'green' : 'gray';
|
|
627
|
+
rows.push(` ${this.paint(tone, truncate(statusText, inner))}`);
|
|
628
|
+
|
|
629
|
+
// Pad/trim to exactly `height` so stale rows never survive a shrinking frame.
|
|
630
|
+
while (rows.length < height) rows.push('');
|
|
631
|
+
return rows.slice(0, height);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
#editingDisplay() {
|
|
635
|
+
const edit = this.editing;
|
|
636
|
+
const chars = charsOf(edit.buffer);
|
|
637
|
+
const shown = edit.secret ? chars.map(() => '•') : chars;
|
|
638
|
+
const cursor = Math.min(edit.cursor, shown.length);
|
|
639
|
+
const withCursor = [...shown.slice(0, cursor), this.paint('sky', '▏'), ...shown.slice(cursor)].join('');
|
|
640
|
+
if (edit.secret && chars.length === 0) return `${withCursor}${this.paint('gray', ' (new value)')}`;
|
|
641
|
+
return withCursor;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
render() {
|
|
645
|
+
if (!this.terminal || this.closed) return;
|
|
646
|
+
const width = Math.max(30, Number(this.output.columns) || 80);
|
|
647
|
+
const height = Math.max(10, Number(this.output.rows) || 24);
|
|
648
|
+
const rows = this.renderRows(width, height);
|
|
649
|
+
let frame = '\x1b[?25l';
|
|
650
|
+
for (let index = 0; index < rows.length; index += 1) {
|
|
651
|
+
frame += `\x1b[${index + 1};1H${rows[index]}\x1b[K`;
|
|
652
|
+
}
|
|
653
|
+
this.output.write(frame);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function formatValue(entry) {
|
|
658
|
+
if (entry.type === 'provider') return entry.value ? String(entry.value) : 'auto';
|
|
659
|
+
const value = entry.value;
|
|
660
|
+
if (Array.isArray(value)) return value.length > 0 ? value.join(', ') : '(none)';
|
|
661
|
+
if (value === null || value === undefined || value === '') {
|
|
662
|
+
if (entry.defaultValue !== null && entry.defaultValue !== undefined && entry.defaultValue !== '') {
|
|
663
|
+
return `${Array.isArray(entry.defaultValue) ? entry.defaultValue.join(', ') : entry.defaultValue} (default)`;
|
|
664
|
+
}
|
|
665
|
+
return '(not set)';
|
|
666
|
+
}
|
|
667
|
+
return String(value);
|
|
668
|
+
}
|
|
669
|
+
|