From 2e7628b354bf6157cec2f62422820a12a7b09a29 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 08:13:18 +0800 Subject: [PATCH 01/24] feat(devnet): add interactive config editor with non-interactive --set support --- README.md | 30 ++- src/cli.ts | 14 ++ src/cmd/devnet-config.ts | 70 +++++++ src/node/devnet-config-editor.ts | 317 +++++++++++++++++++++++++++++ src/tui/devnet-config-tui.ts | 217 ++++++++++++++++++++ tests/devnet-config-editor.test.ts | 146 +++++++++++++ 6 files changed, 790 insertions(+), 4 deletions(-) create mode 100644 src/cmd/devnet-config.ts create mode 100644 src/node/devnet-config-editor.ts create mode 100644 src/tui/devnet-config-tui.ts create mode 100644 tests/devnet-config-editor.test.ts diff --git a/README.md b/README.md index 164f98c..08205c2 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,29 @@ offckb system-scripts --output By default, OffCKB use a fixed Devnet config. You can customize it, for example by modifying the default log level (`warn,ckb-script=debug`). -1. Locate your Devnet config folder: +1. Open the interactive Devnet config editor: + +```sh +offckb devnet config +``` + +The editor provides a safe subset of common options from `ckb.toml` and `ckb-miner.toml` (for example logger and RPC settings), with keyboard navigation and validation. + +You can also update the same fields non-interactively (useful for scripts/CI): + +```sh +offckb devnet config --set ckb.logger.filter=info +offckb devnet config --set ckb.rpc.enable_deprecated_rpc=true --set miner.client.poll_interval=1500 +``` + +1. Save changes and restart devnet: + +```sh +offckb clean -d +offckb node +``` + +1. (Advanced) Locate your Devnet config folder for manual edits: ```sh offckb config list @@ -295,9 +317,9 @@ Example result: ``` Pay attention to the `devnet.configPath` and `devnet.dataPath`. -2. `cd` into the `devnet.configPath` . Modify the config files as needed. See [Custom Devnet Setup](https://docs.nervos.org/docs/node/run-devnet-node#custom-devnet-setup) and [Configure CKB](https://github.com/nervosnetwork/ckb/blob/develop/docs/configure.md) for details. -3. After modifications, run `offckb clean -d` to remove the chain data if needed while keeping the updated config files. -4. Restart local blockchain by running `offckb node` +1. `cd` into the `devnet.configPath` . Modify the config files as needed. See [Custom Devnet Setup](https://docs.nervos.org/docs/node/run-devnet-node#custom-devnet-setup) and [Configure CKB](https://github.com/nervosnetwork/ckb/blob/develop/docs/configure.md) for details. +1. After modifications, run `offckb clean -d` to remove the chain data if needed while keeping the updated config files. +1. Restart local blockchain by running `offckb node` ## Config Setting diff --git a/src/cli.ts b/src/cli.ts index 5a24f9a..61602df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ import { Network } from './type/base'; const version = require('../package.json').version; const description = require('../package.json').description; +const { devnetConfig } = require('./cmd/devnet-config'); // fix windows terminal encoding of simplified chinese text setUTF8EncodingForWindows(); @@ -164,6 +165,19 @@ program .description('do a configuration action') .action((action, item, value) => Config(action, item as ConfigItem, value)); +const devnetCommand = program.command('devnet').description('Devnet utility commands'); + +devnetCommand + .command('config') + .description('Open a full-screen editor to tweak devnet config files') + .option( + '-s, --set ', + 'Set a devnet config field non-interactively (repeatable), e.g. --set ckb.logger.filter=info', + (value: string, previous: string[] = []) => [...previous, value], + [], + ) + .action(devnetConfig); + program.parse(process.argv); // If no command is specified, display help diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts new file mode 100644 index 0000000..5f48cb9 --- /dev/null +++ b/src/cmd/devnet-config.ts @@ -0,0 +1,70 @@ +import { readSettings } from '../cfg/setting'; +import { logger } from '../util/logger'; +import { createDevnetConfigEditor } from '../node/devnet-config-editor'; +import { runDevnetConfigTui } from '../tui/devnet-config-tui'; + +export interface DevnetConfigOptions { + set?: string[]; +} + +export interface ParsedSetItem { + key: string; + value: string; +} + +export function parseSetItem(item: string): ParsedSetItem { + const separator = item.indexOf('='); + if (separator <= 0 || separator === item.length - 1) { + throw new Error(`Invalid --set item '${item}'. Use key=value format.`); + } + + const key = item.slice(0, separator).trim(); + const value = item.slice(separator + 1).trim(); + if (!key || !value) { + throw new Error(`Invalid --set item '${item}'. Key and value must not be empty.`); + } + + return { key, value }; +} + +export function applySetItems(editor: ReturnType, items: string[]): ParsedSetItem[] { + const parsedItems = items.map(parseSetItem); + for (const parsedItem of parsedItems) { + editor.setFieldValue(parsedItem.key, parsedItem.value); + } + editor.save(); + return parsedItems; +} + +export async function devnetConfig(options: DevnetConfigOptions = {}) { + const settings = readSettings(); + const configPath = settings.devnet.configPath; + + try { + const editor = createDevnetConfigEditor(configPath); + + if (options.set && options.set.length > 0) { + const parsedItems = applySetItems(editor, options.set); + logger.success(`Devnet config updated at: ${configPath}`); + logger.info( + `Applied ${parsedItems.length} setting(s): ${parsedItems.map((item) => `${item.key}=${item.value}`).join(', ')}`, + ); + logger.info('Restart devnet to apply changes: offckb clean -d && offckb node'); + return; + } + + const isSaved = await runDevnetConfigTui(editor, configPath); + + if (isSaved) { + logger.success(`Devnet config updated at: ${configPath}`); + logger.info('Restart devnet to apply changes: offckb clean -d && offckb node'); + return; + } + + logger.info('No changes saved.'); + } catch (error) { + logger.error((error as Error).message); + logger.info('Tip: run `offckb node` once to initialize devnet config files first.'); + process.exitCode = 1; + } +} diff --git a/src/node/devnet-config-editor.ts b/src/node/devnet-config-editor.ts new file mode 100644 index 0000000..f356b07 --- /dev/null +++ b/src/node/devnet-config-editor.ts @@ -0,0 +1,317 @@ +import fs from 'fs'; +import path from 'path'; +import toml, { JsonMap } from '@iarna/toml'; + +type FieldType = 'string' | 'number' | 'boolean'; + +type EditableFieldValue = string | number | boolean; + +interface EditableFieldDefinition { + id: string; + file: 'ckb' | 'miner'; + label: string; + description: string; + type: FieldType; + path: Array; +} + +export interface EditableField extends EditableFieldDefinition { + value: EditableFieldValue; +} + +const editableFieldDefinitions: EditableFieldDefinition[] = [ + { + id: 'ckb.logger.filter', + file: 'ckb', + label: 'Logger filter', + description: 'CKB log filter string', + type: 'string', + path: ['logger', 'filter'], + }, + { + id: 'ckb.logger.color', + file: 'ckb', + label: 'Logger color output', + description: 'Enable colorful logs', + type: 'boolean', + path: ['logger', 'color'], + }, + { + id: 'ckb.logger.log_to_file', + file: 'ckb', + label: 'Logger output to file', + description: 'Write logs to file', + type: 'boolean', + path: ['logger', 'log_to_file'], + }, + { + id: 'ckb.logger.log_to_stdout', + file: 'ckb', + label: 'Logger output to stdout', + description: 'Write logs to stdout', + type: 'boolean', + path: ['logger', 'log_to_stdout'], + }, + { + id: 'ckb.rpc.listen_address', + file: 'ckb', + label: 'RPC listen address', + description: 'Host:port for CKB RPC', + type: 'string', + path: ['rpc', 'listen_address'], + }, + { + id: 'ckb.rpc.max_request_body_size', + file: 'ckb', + label: 'RPC max request body size', + description: 'Maximum request body size in bytes', + type: 'number', + path: ['rpc', 'max_request_body_size'], + }, + { + id: 'ckb.rpc.enable_deprecated_rpc', + file: 'ckb', + label: 'Enable deprecated RPC', + description: 'Allow deprecated CKB RPC methods', + type: 'boolean', + path: ['rpc', 'enable_deprecated_rpc'], + }, + { + id: 'miner.client.rpc_url', + file: 'miner', + label: 'Miner RPC URL', + description: 'CKB node URL used by miner', + type: 'string', + path: ['miner', 'client', 'rpc_url'], + }, + { + id: 'miner.client.block_on_submit', + file: 'miner', + label: 'Miner block on submit', + description: 'Wait for submit result before next request', + type: 'boolean', + path: ['miner', 'client', 'block_on_submit'], + }, + { + id: 'miner.client.poll_interval', + file: 'miner', + label: 'Miner poll interval', + description: 'Block template polling interval in milliseconds', + type: 'number', + path: ['miner', 'client', 'poll_interval'], + }, +]; + +function getByPath(target: Record, keyPath: Array): unknown { + let current: unknown = target; + for (const key of keyPath) { + if (current == null || typeof current !== 'object') { + return undefined; + } + current = (current as Record)[String(key)]; + } + return current; +} + +function setByPath(target: Record, keyPath: Array, value: unknown): void { + if (keyPath.length === 0) { + return; + } + + let current: Record = target; + for (let i = 0; i < keyPath.length - 1; i++) { + const part = String(keyPath[i]); + const next = current[part]; + if (next == null || typeof next !== 'object') { + current[part] = {}; + } + current = current[part] as Record; + } + + current[String(keyPath[keyPath.length - 1])] = value; +} + +function validateHostPort(value: string): boolean { + const trimmed = value.trim(); + const separator = trimmed.lastIndexOf(':'); + if (separator <= 0 || separator === trimmed.length - 1) { + return false; + } + + const port = Number(trimmed.slice(separator + 1)); + return Number.isInteger(port) && port > 0 && port <= 65535; +} + +function validateHttpUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} + +function normalizeBooleanInput(value: string): boolean | null { + const normalized = value.trim().toLowerCase(); + if (['true', '1', 'yes', 'y', 'on'].includes(normalized)) { + return true; + } + if (['false', '0', 'no', 'n', 'off'].includes(normalized)) { + return false; + } + return null; +} + +function readTomlFile(filePath: string): Record { + const text = fs.readFileSync(filePath, 'utf8'); + return toml.parse(text) as unknown as Record; +} + +function writeTomlFileAtomic(filePath: string, data: Record) { + const tempFilePath = `${filePath}.tmp`; + const text = toml.stringify(data as unknown as JsonMap); + fs.writeFileSync(tempFilePath, text, 'utf8'); + fs.renameSync(tempFilePath, filePath); +} + +export class DevnetConfigEditor { + readonly configPath: string; + readonly ckbTomlPath: string; + readonly minerTomlPath: string; + + private ckbConfig: Record; + private minerConfig: Record; + private values: Record; + + constructor(configPath: string, ckbConfig: Record, minerConfig: Record) { + this.configPath = configPath; + this.ckbTomlPath = path.join(configPath, 'ckb.toml'); + this.minerTomlPath = path.join(configPath, 'ckb-miner.toml'); + this.ckbConfig = ckbConfig; + this.minerConfig = minerConfig; + + this.values = {}; + for (const definition of editableFieldDefinitions) { + const source = definition.file === 'ckb' ? this.ckbConfig : this.minerConfig; + const value = getByPath(source, definition.path); + if (value == null) { + throw new Error(`Unsupported config layout: missing field '${definition.id}' in devnet config files.`); + } + + if (definition.type === 'string' && typeof value !== 'string') { + throw new Error(`Unexpected type for '${definition.id}', expected string.`); + } + if (definition.type === 'number' && typeof value !== 'number') { + throw new Error(`Unexpected type for '${definition.id}', expected number.`); + } + if (definition.type === 'boolean' && typeof value !== 'boolean') { + throw new Error(`Unexpected type for '${definition.id}', expected boolean.`); + } + + this.values[definition.id] = value as EditableFieldValue; + } + } + + getFields(): EditableField[] { + return editableFieldDefinitions.map((definition) => ({ + ...definition, + value: this.values[definition.id], + })); + } + + getField(fieldId: string): EditableField { + const definition = editableFieldDefinitions.find((item) => item.id === fieldId); + if (definition == null) { + throw new Error(`Unknown field '${fieldId}'.`); + } + + return { + ...definition, + value: this.values[definition.id], + }; + } + + setFieldValue(fieldId: string, rawInput: string): EditableFieldValue { + const definition = editableFieldDefinitions.find((item) => item.id === fieldId); + if (definition == null) { + throw new Error(`Unknown field '${fieldId}'.`); + } + + const trimmed = rawInput.trim(); + if (definition.type === 'string') { + if (!trimmed) { + throw new Error('Value cannot be empty.'); + } + + if (definition.id === 'ckb.rpc.listen_address' && !validateHostPort(trimmed)) { + throw new Error('RPC listen address must be in host:port format.'); + } + + if (definition.id === 'miner.client.rpc_url' && !validateHttpUrl(trimmed)) { + throw new Error('Miner RPC URL must be a valid HTTP/HTTPS URL.'); + } + + this.values[fieldId] = trimmed; + return this.values[fieldId]; + } + + if (definition.type === 'number') { + const parsed = Number(trimmed); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error('Value must be a positive integer.'); + } + + this.values[fieldId] = parsed; + return this.values[fieldId]; + } + + const parsedBoolean = normalizeBooleanInput(trimmed); + if (parsedBoolean == null) { + throw new Error('Boolean value must be one of: true/false/yes/no/1/0.'); + } + + this.values[fieldId] = parsedBoolean; + return this.values[fieldId]; + } + + toggleBooleanField(fieldId: string): EditableFieldValue { + const field = this.getField(fieldId); + if (field.type !== 'boolean') { + throw new Error(`Field '${fieldId}' is not boolean.`); + } + + const nextValue = !field.value; + this.values[fieldId] = nextValue; + return nextValue; + } + + save(): void { + for (const definition of editableFieldDefinitions) { + const target = definition.file === 'ckb' ? this.ckbConfig : this.minerConfig; + setByPath(target, definition.path, this.values[definition.id]); + } + + writeTomlFileAtomic(this.ckbTomlPath, this.ckbConfig); + writeTomlFileAtomic(this.minerTomlPath, this.minerConfig); + } +} + +export function createDevnetConfigEditor(configPath: string): DevnetConfigEditor { + const ckbTomlPath = path.join(configPath, 'ckb.toml'); + const minerTomlPath = path.join(configPath, 'ckb-miner.toml'); + + if (!fs.existsSync(configPath)) { + throw new Error(`Devnet config path does not exist: ${configPath}`); + } + if (!fs.existsSync(ckbTomlPath)) { + throw new Error(`Missing file: ${ckbTomlPath}`); + } + if (!fs.existsSync(minerTomlPath)) { + throw new Error(`Missing file: ${minerTomlPath}`); + } + + const ckbConfig = readTomlFile(ckbTomlPath); + const minerConfig = readTomlFile(minerTomlPath); + + return new DevnetConfigEditor(configPath, ckbConfig, minerConfig); +} diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts new file mode 100644 index 0000000..8178331 --- /dev/null +++ b/src/tui/devnet-config-tui.ts @@ -0,0 +1,217 @@ +import readline from 'node:readline'; +import { DevnetConfigEditor } from '../node/devnet-config-editor'; + +const ansi = { + clear: '\u001B[2J\u001B[H', + hideCursor: '\u001B[?25l', + showCursor: '\u001B[?25h', + bold: '\u001B[1m', + reset: '\u001B[0m', +}; + +function formatFieldValue(value: string | number | boolean): string { + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + return String(value); +} + +async function askLine(question: string): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + return new Promise((resolve) => { + rl.question(question, (answer) => { + rl.close(); + resolve(answer); + }); + }); +} + +export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: string): Promise { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('Interactive TUI requires a TTY terminal.'); + } + + let selectedIndex = 0; + let isSaved = false; + let isDone = false; + let statusMessage = 'Use ↑/↓ to navigate, Enter to edit, Space to toggle booleans, s to save, q to quit.'; + + const initialFields = editor.getFields(); + const initialState = new Map(initialFields.map((field) => [field.id, field.value])); + + const hasChanges = () => { + const current = editor.getFields(); + return current.some((field) => initialState.get(field.id) !== field.value); + }; + + const render = () => { + const fields = editor.getFields(); + const lines: string[] = []; + + lines.push(`${ansi.bold}OffCKB Devnet Config Editor${ansi.reset}`); + lines.push(`Path: ${configPath}`); + lines.push(''); + + lines.push(`${ansi.bold}Editable fields (safe subset)${ansi.reset}`); + for (let i = 0; i < fields.length; i++) { + const field = fields[i]; + const marker = i === selectedIndex ? '›' : ' '; + lines.push(`${marker} ${String(i + 1).padStart(2, '0')}. ${field.label}: ${formatFieldValue(field.value)}`); + lines.push(` ${field.description} (${field.id})`); + } + + lines.push(''); + lines.push(`${ansi.bold}Keys${ansi.reset}: ↑/↓ move Enter edit Space toggle s save q quit Ctrl+C quit`); + lines.push(`Changes pending: ${hasChanges() ? 'yes' : 'no'}`); + lines.push(`Status: ${statusMessage}`); + + process.stdout.write(`${ansi.clear}${ansi.hideCursor}${lines.join('\n')}\n`); + }; + + const setRawMode = (enabled: boolean) => { + if (process.stdin.isTTY) { + process.stdin.setRawMode(enabled); + } + }; + + const cleanup = () => { + process.stdin.off('keypress', onKeypress); + setRawMode(false); + process.stdout.write(`${ansi.showCursor}\n`); + }; + + const confirmDiscard = async (): Promise => { + setRawMode(false); + process.stdout.write(`${ansi.showCursor}`); + const answer = await askLine('Discard unsaved changes? [y/N]: '); + setRawMode(true); + process.stdout.write(ansi.hideCursor); + const normalized = answer.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; + }; + + const editSelectedField = async () => { + const selectedField = editor.getFields()[selectedIndex]; + + if (selectedField.type === 'boolean') { + const nextValue = editor.toggleBooleanField(selectedField.id); + statusMessage = `${selectedField.label} updated to ${formatFieldValue(nextValue)}.`; + return; + } + + setRawMode(false); + process.stdout.write(ansi.showCursor); + const answer = await askLine(`${selectedField.label} (${formatFieldValue(selectedField.value)}): `); + setRawMode(true); + process.stdout.write(ansi.hideCursor); + + if (!answer.trim()) { + statusMessage = 'No input provided, keeping current value.'; + return; + } + + try { + const nextValue = editor.setFieldValue(selectedField.id, answer); + statusMessage = `${selectedField.label} updated to ${formatFieldValue(nextValue)}.`; + } catch (error) { + statusMessage = `Validation error: ${(error as Error).message}`; + } + }; + + const onKeypress = async (_chunk: string, key: readline.Key) => { + if (isDone) { + return; + } + + const fields = editor.getFields(); + + if (key.ctrl && key.name === 'c') { + if (hasChanges()) { + const shouldDiscard = await confirmDiscard(); + if (!shouldDiscard) { + statusMessage = 'Continue editing.'; + render(); + return; + } + } + + statusMessage = 'Canceled.'; + isDone = true; + cleanup(); + return; + } + + switch (key.name) { + case 'up': { + selectedIndex = selectedIndex === 0 ? fields.length - 1 : selectedIndex - 1; + break; + } + case 'down': { + selectedIndex = selectedIndex === fields.length - 1 ? 0 : selectedIndex + 1; + break; + } + case 'return': { + await editSelectedField(); + break; + } + case 'space': { + const selectedField = fields[selectedIndex]; + if (selectedField.type !== 'boolean') { + statusMessage = `Field ${selectedField.label} is not boolean.`; + } else { + const nextValue = editor.toggleBooleanField(selectedField.id); + statusMessage = `${selectedField.label} updated to ${formatFieldValue(nextValue)}.`; + } + break; + } + default: { + if (_chunk === 'k') { + selectedIndex = selectedIndex === 0 ? fields.length - 1 : selectedIndex - 1; + } else if (_chunk === 'j') { + selectedIndex = selectedIndex === fields.length - 1 ? 0 : selectedIndex + 1; + } else if (_chunk === 'e') { + await editSelectedField(); + } else if (_chunk === 's') { + editor.save(); + statusMessage = 'Saved.'; + isSaved = true; + isDone = true; + cleanup(); + return; + } else if (_chunk === 'q') { + if (hasChanges()) { + const shouldDiscard = await confirmDiscard(); + if (!shouldDiscard) { + statusMessage = 'Continue editing.'; + render(); + return; + } + } + + statusMessage = 'Canceled.'; + isDone = true; + cleanup(); + return; + } + break; + } + } + + render(); + }; + + readline.emitKeypressEvents(process.stdin); + process.stdin.on('keypress', onKeypress); + setRawMode(true); + render(); + + while (!isDone) { + await new Promise((resolve) => setTimeout(resolve, 40)); + } + + return isSaved; +} diff --git a/tests/devnet-config-editor.test.ts b/tests/devnet-config-editor.test.ts new file mode 100644 index 0000000..07d3bf0 --- /dev/null +++ b/tests/devnet-config-editor.test.ts @@ -0,0 +1,146 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import toml from '@iarna/toml'; +import { createDevnetConfigEditor } from '../src/node/devnet-config-editor'; +import { applySetItems, parseSetItem } from '../src/cmd/devnet-config'; + +function writeFixtureConfig(configPath: string) { + fs.mkdirSync(configPath, { recursive: true }); + + fs.writeFileSync( + path.join(configPath, 'ckb.toml'), + toml.stringify({ + logger: { + filter: 'warn,ckb-script=debug', + color: true, + log_to_file: true, + log_to_stdout: true, + }, + rpc: { + listen_address: '0.0.0.0:8114', + max_request_body_size: 10_485_760, + enable_deprecated_rpc: false, + }, + network: { + max_peers: 125, + }, + }), + 'utf8', + ); + + fs.writeFileSync( + path.join(configPath, 'ckb-miner.toml'), + toml.stringify({ + miner: { + client: { + rpc_url: 'http://ckb:8114/', + block_on_submit: true, + poll_interval: 1000, + }, + }, + }), + 'utf8', + ); +} + +describe('DevnetConfigEditor', () => { + let tempRoot: string; + let configPath: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'offckb-devnet-config-')); + configPath = path.join(tempRoot, 'devnet'); + writeFixtureConfig(configPath); + }); + + afterEach(() => { + fs.rmSync(tempRoot, { recursive: true, force: true }); + }); + + it('loads editable safe-subset fields', () => { + const editor = createDevnetConfigEditor(configPath); + const fields = editor.getFields(); + + expect(fields.length).toBe(10); + expect(fields.find((field) => field.id === 'ckb.rpc.listen_address')?.value).toBe('0.0.0.0:8114'); + expect(fields.find((field) => field.id === 'miner.client.poll_interval')?.value).toBe(1000); + }); + + it('validates updated values', () => { + const editor = createDevnetConfigEditor(configPath); + + expect(() => editor.setFieldValue('ckb.rpc.listen_address', 'invalid')).toThrow( + 'RPC listen address must be in host:port format.', + ); + + expect(() => editor.setFieldValue('miner.client.rpc_url', 'ftp://ckb:8114/')).toThrow( + 'Miner RPC URL must be a valid HTTP/HTTPS URL.', + ); + + expect(() => editor.setFieldValue('miner.client.poll_interval', '0')).toThrow('Value must be a positive integer.'); + }); + + it('saves edited values and keeps unrelated keys', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setFieldValue('ckb.logger.filter', 'info'); + editor.setFieldValue('ckb.rpc.listen_address', '127.0.0.1:18114'); + editor.setFieldValue('miner.client.poll_interval', '500'); + editor.toggleBooleanField('ckb.rpc.enable_deprecated_rpc'); + editor.save(); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + const minerToml = toml.parse( + fs.readFileSync(path.join(configPath, 'ckb-miner.toml'), 'utf8'), + ) as unknown as Record; + + expect(ckbToml.logger.filter).toBe('info'); + expect(ckbToml.rpc.listen_address).toBe('127.0.0.1:18114'); + expect(ckbToml.rpc.enable_deprecated_rpc).toBe(true); + expect(ckbToml.network.max_peers).toBe(125); + expect(minerToml.miner.client.poll_interval).toBe(500); + }); + + it('throws when config files are missing', () => { + fs.rmSync(path.join(configPath, 'ckb-miner.toml')); + expect(() => createDevnetConfigEditor(configPath)).toThrow('Missing file'); + }); + + it('parses --set key=value items', () => { + expect(parseSetItem('ckb.logger.filter=info')).toEqual({ + key: 'ckb.logger.filter', + value: 'info', + }); + + expect(() => parseSetItem('ckb.logger.filter')).toThrow('Invalid --set item'); + expect(() => parseSetItem('=info')).toThrow('Invalid --set item'); + }); + + it('applies repeatable --set items and persists files', () => { + const editor = createDevnetConfigEditor(configPath); + + const applied = applySetItems(editor, [ + 'ckb.logger.filter=info', + 'ckb.rpc.enable_deprecated_rpc=true', + 'miner.client.poll_interval=1500', + ]); + + expect(applied).toHaveLength(3); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + const minerToml = toml.parse( + fs.readFileSync(path.join(configPath, 'ckb-miner.toml'), 'utf8'), + ) as unknown as Record; + + expect(ckbToml.logger.filter).toBe('info'); + expect(ckbToml.rpc.enable_deprecated_rpc).toBe(true); + expect(minerToml.miner.client.poll_interval).toBe(1500); + }); +}); From 6e5f9d7b06ca56adac91fd4c4f586f94d732ee04 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 08:38:42 +0800 Subject: [PATCH 02/24] feat(devnet): migrate config tui to blessed with full key browser --- package.json | 2 + pnpm-lock.yaml | 28 ++ src/node/devnet-config-editor.ts | 176 +++++++++++++ src/tui/devnet-config-tui.ts | 409 +++++++++++++++++------------ tests/devnet-config-editor.test.ts | 24 ++ 5 files changed, 476 insertions(+), 163 deletions(-) diff --git a/package.json b/package.json index f10bb31..366b3ec 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.5", + "@types/blessed": "^0.1.27", "@types/jest": "^30.0.0", "@types/node": "^20.17.24", "@types/node-fetch": "^2.6.11", @@ -80,6 +81,7 @@ "@inquirer/prompts": "^7.8.6", "@types/http-proxy": "^1.17.15", "adm-zip": "^0.5.10", + "blessed": "^0.1.81", "chalk": "4.1.2", "child_process": "^1.0.2", "ckb-transaction-dumper": "^0.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 30e1002..d474791 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: adm-zip: specifier: ^0.5.10 version: 0.5.16 + blessed: + specifier: ^0.1.81 + version: 0.1.81 chalk: specifier: 4.1.2 version: 4.1.2 @@ -57,6 +60,9 @@ importers: '@types/adm-zip': specifier: ^0.5.5 version: 0.5.7 + '@types/blessed': + specifier: ^0.1.27 + version: 0.1.27 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -719,6 +725,9 @@ packages: '@types/babel__traverse@7.28.0': resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/blessed@0.1.27': + resolution: {integrity: sha512-ZOQGjLvWDclAXp0rW5iuUBXeD6Gr1PkitN7tj7/G8FCoSzTsij6OhXusOzMKhwrZ9YlL2Pmu0d6xJ9zVvk+Hsg==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -873,41 +882,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1114,6 +1131,11 @@ packages: resolution: {integrity: sha512-103Wy3xg8Y9o+pdhGP4M3/mtQQuUWs6sPuOp1mYphSUoSMHjHTlkj32K4zxU8qMH0Ckv23emfkGlFWtoWZ7YFA==} engines: {node: '>=0.10'} + blessed@0.1.81: + resolution: {integrity: sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ==} + engines: {node: '>= 0.8.0'} + hasBin: true + bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} @@ -3860,6 +3882,10 @@ snapshots: dependencies: '@babel/types': 7.28.6 + '@types/blessed@0.1.27': + dependencies: + '@types/node': 20.17.24 + '@types/estree@1.0.8': {} '@types/http-proxy@1.17.16': @@ -4244,6 +4270,8 @@ snapshots: secp256k1: 3.8.1 varuint-bitcoin: 1.1.2 + blessed@0.1.81: {} + bn.js@4.12.3: {} body-parser@2.2.2: diff --git a/src/node/devnet-config-editor.ts b/src/node/devnet-config-editor.ts index f356b07..41d1c65 100644 --- a/src/node/devnet-config-editor.ts +++ b/src/node/devnet-config-editor.ts @@ -6,6 +6,12 @@ type FieldType = 'string' | 'number' | 'boolean'; type EditableFieldValue = string | number | boolean; +export type TomlPrimitive = string | number | boolean; +export type TomlValue = TomlPrimitive | TomlObject | TomlValue[]; +export interface TomlObject { + [key: string]: TomlValue; +} + interface EditableFieldDefinition { id: string; file: 'ckb' | 'miner'; @@ -19,6 +25,22 @@ export interface EditableField extends EditableFieldDefinition { value: EditableFieldValue; } +export interface TomlDocument { + id: 'ckb' | 'miner'; + title: string; + filePath: string; + data: TomlObject; +} + +export interface TomlEntry { + documentId: 'ckb' | 'miner'; + path: string[]; + pathText: string; + type: 'object' | 'array' | 'string' | 'number' | 'boolean'; + valuePreview: string; + editable: boolean; +} + const editableFieldDefinitions: EditableFieldDefinition[] = [ { id: 'ckb.logger.filter', @@ -102,6 +124,13 @@ const editableFieldDefinitions: EditableFieldDefinition[] = [ }, ]; +const safeFieldDefinitionMap = new Map( + editableFieldDefinitions.map((definition) => [ + `${definition.file}:${definition.path.map((item) => String(item)).join('.')}`, + definition, + ]), +); + function getByPath(target: Record, keyPath: Array): unknown { let current: unknown = target; for (const key of keyPath) { @@ -131,6 +160,46 @@ function setByPath(target: Record, keyPath: Array { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function isTomlPrimitive(value: unknown): value is TomlPrimitive { + return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'; +} + +function getTypeOfTomlValue(value: unknown): TomlEntry['type'] { + if (Array.isArray(value)) { + return 'array'; + } + if (isPlainObject(value)) { + return 'object'; + } + if (typeof value === 'string') { + return 'string'; + } + if (typeof value === 'number') { + return 'number'; + } + return 'boolean'; +} + +function valuePreview(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.length}]`; + } + if (isPlainObject(value)) { + return `{${Object.keys(value).length}}`; + } + if (typeof value === 'string') { + if (value.length > 80) { + return `${value.slice(0, 80)}...`; + } + return value; + } + return String(value); +} + function validateHostPort(value: string): boolean { const trimmed = value.trim(); const separator = trimmed.lastIndexOf(':'); @@ -162,6 +231,30 @@ function normalizeBooleanInput(value: string): boolean | null { return null; } +function parseValueByExistingType(rawInput: string, existingValue: unknown): TomlPrimitive { + if (typeof existingValue === 'boolean') { + const parsedBoolean = normalizeBooleanInput(rawInput); + if (parsedBoolean == null) { + throw new Error('Boolean value must be one of: true/false/yes/no/1/0.'); + } + return parsedBoolean; + } + + if (typeof existingValue === 'number') { + const parsedNumber = Number(rawInput.trim()); + if (!Number.isFinite(parsedNumber)) { + throw new Error('Number value must be finite.'); + } + return parsedNumber; + } + + const trimmed = rawInput.trim(); + if (!trimmed) { + throw new Error('Value cannot be empty.'); + } + return trimmed; +} + function readTomlFile(filePath: string): Record { const text = fs.readFileSync(filePath, 'utf8'); return toml.parse(text) as unknown as Record; @@ -182,6 +275,7 @@ export class DevnetConfigEditor { private ckbConfig: Record; private minerConfig: Record; private values: Record; + private documents: Record<'ckb' | 'miner', TomlDocument>; constructor(configPath: string, ckbConfig: Record, minerConfig: Record) { this.configPath = configPath; @@ -210,6 +304,66 @@ export class DevnetConfigEditor { this.values[definition.id] = value as EditableFieldValue; } + + this.documents = { + ckb: { + id: 'ckb', + title: 'ckb.toml', + filePath: this.ckbTomlPath, + data: this.ckbConfig as TomlObject, + }, + miner: { + id: 'miner', + title: 'ckb-miner.toml', + filePath: this.minerTomlPath, + data: this.minerConfig as TomlObject, + }, + }; + } + + getDocuments(): TomlDocument[] { + return [this.documents.ckb, this.documents.miner]; + } + + getDocument(documentId: 'ckb' | 'miner'): TomlDocument { + return this.documents[documentId]; + } + + getEntriesForDocument(documentId: 'ckb' | 'miner'): TomlEntry[] { + const entries: TomlEntry[] = []; + const document = this.getDocument(documentId); + + const walk = (value: unknown, currentPath: string[]) => { + if (currentPath.length > 0) { + const entryType = getTypeOfTomlValue(value); + entries.push({ + documentId, + path: currentPath, + pathText: currentPath.join('.'), + type: entryType, + valuePreview: valuePreview(value), + editable: entryType === 'string' || entryType === 'number' || entryType === 'boolean', + }); + } + + if (Array.isArray(value)) { + value.forEach((item, index) => walk(item, [...currentPath, String(index)])); + return; + } + + if (isPlainObject(value)) { + for (const key of Object.keys(value)) { + walk(value[key], [...currentPath, key]); + } + } + }; + + walk(document.data, []); + return entries; + } + + getEntryValue(documentId: 'ckb' | 'miner', pathParts: string[]): unknown { + return getByPath(this.getDocument(documentId).data as Record, pathParts); } getFields(): EditableField[] { @@ -274,6 +428,28 @@ export class DevnetConfigEditor { return this.values[fieldId]; } + setDocumentValue(documentId: 'ckb' | 'miner', pathParts: string[], rawInput: string): TomlPrimitive { + if (pathParts.length === 0) { + throw new Error('Cannot edit the root node.'); + } + + const fileKey = `${documentId}:${pathParts.join('.')}`; + const safeDefinition = safeFieldDefinitionMap.get(fileKey); + if (safeDefinition != null) { + return this.setFieldValue(safeDefinition.id, rawInput); + } + + const document = this.getDocument(documentId); + const currentValue = getByPath(document.data as Record, pathParts); + if (!isTomlPrimitive(currentValue)) { + throw new Error('Only primitive values can be edited in phase 1.'); + } + + const parsedValue = parseValueByExistingType(rawInput, currentValue); + setByPath(document.data as Record, pathParts, parsedValue); + return parsedValue; + } + toggleBooleanField(fieldId: string): EditableFieldValue { const field = this.getField(fieldId); if (field.type !== 'boolean') { diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 8178331..e9d70b4 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -1,30 +1,33 @@ -import readline from 'node:readline'; -import { DevnetConfigEditor } from '../node/devnet-config-editor'; - -const ansi = { - clear: '\u001B[2J\u001B[H', - hideCursor: '\u001B[?25l', - showCursor: '\u001B[?25h', - bold: '\u001B[1m', - reset: '\u001B[0m', -}; - -function formatFieldValue(value: string | number | boolean): string { - if (typeof value === 'boolean') { - return value ? 'true' : 'false'; - } - return String(value); +import blessed, { Widgets } from 'blessed'; +import { DevnetConfigEditor, TomlEntry } from '../node/devnet-config-editor'; + +type FocusPane = 'files' | 'entries'; + +function formatEntryLine(entry: TomlEntry): string { + return `${entry.pathText} = ${entry.valuePreview}`; } -async function askLine(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, +function waitForQuestion( + screen: Widgets.Screen, + prompt: Widgets.PromptElement, + questionText: string, +): Promise { + return new Promise((resolve) => { + prompt.input(questionText, '', (_error, value) => { + screen.render(); + resolve(value == null ? null : value); + }); }); +} +function waitForConfirm( + screen: Widgets.Screen, + question: Widgets.QuestionElement, + text: string, +): Promise { return new Promise((resolve) => { - rl.question(question, (answer) => { - rl.close(); + question.ask(text, (answer) => { + screen.render(); resolve(answer); }); }); @@ -35,183 +38,263 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: throw new Error('Interactive TUI requires a TTY terminal.'); } - let selectedIndex = 0; - let isSaved = false; - let isDone = false; - let statusMessage = 'Use ↑/↓ to navigate, Enter to edit, Space to toggle booleans, s to save, q to quit.'; + const documents = editor.getDocuments(); + let selectedDocumentIndex = 0; + let selectedEntryIndex = 0; + let focusPane: FocusPane = 'entries'; + let hasUnsavedChanges = false; + let didSave = false; + let statusMessage = 'Tab switch focus | Enter edit | s save | / search (next phase) | q quit'; + + const screen = blessed.screen({ + smartCSR: true, + title: 'OffCKB Devnet Config Editor', + fullUnicode: true, + }); - const initialFields = editor.getFields(); - const initialState = new Map(initialFields.map((field) => [field.id, field.value])); + const filesList = blessed.list({ + parent: screen, + label: ' Files ', + top: 0, + left: 0, + width: '22%', + height: '90%', + border: 'line', + keys: true, + vi: true, + style: { + selected: { bg: 'blue' }, + border: { fg: 'gray' }, + }, + }); - const hasChanges = () => { - const current = editor.getFields(); - return current.some((field) => initialState.get(field.id) !== field.value); - }; + const entriesList = blessed.list({ + parent: screen, + label: ' Keys ', + top: 0, + left: '22%', + width: '43%', + height: '90%', + border: 'line', + keys: true, + vi: true, + style: { + selected: { bg: 'blue' }, + border: { fg: 'gray' }, + }, + }); - const render = () => { - const fields = editor.getFields(); - const lines: string[] = []; + const detailsBox = blessed.box({ + parent: screen, + label: ' Value / Details ', + top: 0, + left: '65%', + width: '35%', + height: '90%', + border: 'line', + tags: true, + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + style: { + border: { fg: 'gray' }, + }, + }); - lines.push(`${ansi.bold}OffCKB Devnet Config Editor${ansi.reset}`); - lines.push(`Path: ${configPath}`); - lines.push(''); + const statusBar = blessed.box({ + parent: screen, + bottom: 0, + left: 0, + width: '100%', + height: '10%', + border: 'line', + tags: true, + content: '', + style: { + border: { fg: 'gray' }, + }, + }); - lines.push(`${ansi.bold}Editable fields (safe subset)${ansi.reset}`); - for (let i = 0; i < fields.length; i++) { - const field = fields[i]; - const marker = i === selectedIndex ? '›' : ' '; - lines.push(`${marker} ${String(i + 1).padStart(2, '0')}. ${field.label}: ${formatFieldValue(field.value)}`); - lines.push(` ${field.description} (${field.id})`); - } + const prompt = blessed.prompt({ + parent: screen, + border: 'line', + height: 9, + width: '70%', + top: 'center', + left: 'center', + label: ' Edit Value ', + keys: true, + vi: true, + tags: true, + hidden: true, + }); + + const question = blessed.question({ + parent: screen, + border: 'line', + height: 8, + width: '60%', + top: 'center', + left: 'center', + label: ' Confirm ', + keys: true, + vi: true, + tags: true, + hidden: true, + }); - lines.push(''); - lines.push(`${ansi.bold}Keys${ansi.reset}: ↑/↓ move Enter edit Space toggle s save q quit Ctrl+C quit`); - lines.push(`Changes pending: ${hasChanges() ? 'yes' : 'no'}`); - lines.push(`Status: ${statusMessage}`); + const refreshUi = () => { + const fileItems = documents.map((document) => document.title); + filesList.setItems(fileItems); + filesList.select(selectedDocumentIndex); - process.stdout.write(`${ansi.clear}${ansi.hideCursor}${lines.join('\n')}\n`); - }; + const selectedDocument = documents[selectedDocumentIndex]; + const entries = editor.getEntriesForDocument(selectedDocument.id); + const entryLines = entries.map(formatEntryLine); + entriesList.setItems(entryLines); + + if (entries.length === 0) { + selectedEntryIndex = 0; + detailsBox.setContent('{yellow-fg}No keys found in selected document.{/yellow-fg}'); + } else { + if (selectedEntryIndex >= entries.length) { + selectedEntryIndex = entries.length - 1; + } + entriesList.select(selectedEntryIndex); + + const selectedEntry = entries[selectedEntryIndex]; + const rawValue = editor.getEntryValue(selectedEntry.documentId, selectedEntry.path); + const valueText = typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue, null, 2); - const setRawMode = (enabled: boolean) => { - if (process.stdin.isTTY) { - process.stdin.setRawMode(enabled); + detailsBox.setContent( + [ + `{bold}File{/bold}: ${selectedDocument.title}`, + `{bold}Path{/bold}: ${selectedEntry.pathText}`, + `{bold}Type{/bold}: ${selectedEntry.type}`, + `{bold}Editable{/bold}: ${selectedEntry.editable ? 'yes' : 'no'}`, + '', + '{bold}Current Value{/bold}', + valueText ?? 'null', + ].join('\n'), + ); + } + + const dirtyText = hasUnsavedChanges ? '{yellow-fg}yes{/yellow-fg}' : '{green-fg}no{/green-fg}'; + statusBar.setContent( + [ + `Path: ${configPath}`, + `Focus: ${focusPane} | Unsaved: ${dirtyText}`, + `Status: ${statusMessage}`, + ].join('\n'), + ); + + if (focusPane === 'files') { + filesList.focus(); + } else { + entriesList.focus(); } - }; - const cleanup = () => { - process.stdin.off('keypress', onKeypress); - setRawMode(false); - process.stdout.write(`${ansi.showCursor}\n`); + screen.render(); }; - const confirmDiscard = async (): Promise => { - setRawMode(false); - process.stdout.write(`${ansi.showCursor}`); - const answer = await askLine('Discard unsaved changes? [y/N]: '); - setRawMode(true); - process.stdout.write(ansi.hideCursor); - const normalized = answer.trim().toLowerCase(); - return normalized === 'y' || normalized === 'yes'; + const saveAndExit = () => { + editor.save(); + hasUnsavedChanges = false; + didSave = true; + statusMessage = 'Saved.'; + screen.destroy(); }; - const editSelectedField = async () => { - const selectedField = editor.getFields()[selectedIndex]; + const quitFlow = async () => { + if (!hasUnsavedChanges) { + screen.destroy(); + return; + } - if (selectedField.type === 'boolean') { - const nextValue = editor.toggleBooleanField(selectedField.id); - statusMessage = `${selectedField.label} updated to ${formatFieldValue(nextValue)}.`; + const shouldDiscard = await waitForConfirm(screen, question, 'Discard unsaved changes?'); + if (shouldDiscard) { + screen.destroy(); return; } - setRawMode(false); - process.stdout.write(ansi.showCursor); - const answer = await askLine(`${selectedField.label} (${formatFieldValue(selectedField.value)}): `); - setRawMode(true); - process.stdout.write(ansi.hideCursor); + statusMessage = 'Continue editing.'; + refreshUi(); + }; - if (!answer.trim()) { - statusMessage = 'No input provided, keeping current value.'; + const editCurrentEntry = async () => { + const selectedDocument = documents[selectedDocumentIndex]; + const entries = editor.getEntriesForDocument(selectedDocument.id); + if (entries.length === 0) { + return; + } + + const selectedEntry = entries[selectedEntryIndex]; + if (!selectedEntry.editable) { + statusMessage = `Path ${selectedEntry.pathText} is not primitive-editable yet.`; + refreshUi(); + return; + } + + const value = editor.getEntryValue(selectedEntry.documentId, selectedEntry.path); + const valueText = value == null ? '' : String(value); + const answer = await waitForQuestion(screen, prompt, `${selectedEntry.pathText} = ${valueText}`); + if (answer == null) { + statusMessage = 'Edit canceled.'; + refreshUi(); return; } try { - const nextValue = editor.setFieldValue(selectedField.id, answer); - statusMessage = `${selectedField.label} updated to ${formatFieldValue(nextValue)}.`; + editor.setDocumentValue(selectedEntry.documentId, selectedEntry.path, answer); + hasUnsavedChanges = true; + statusMessage = `Updated ${selectedEntry.pathText}.`; } catch (error) { statusMessage = `Validation error: ${(error as Error).message}`; } + + refreshUi(); }; - const onKeypress = async (_chunk: string, key: readline.Key) => { - if (isDone) { + filesList.on('select', (_, index) => { + if (index == null) { return; } + selectedDocumentIndex = index; + selectedEntryIndex = 0; + refreshUi(); + }); - const fields = editor.getFields(); - - if (key.ctrl && key.name === 'c') { - if (hasChanges()) { - const shouldDiscard = await confirmDiscard(); - if (!shouldDiscard) { - statusMessage = 'Continue editing.'; - render(); - return; - } - } - - statusMessage = 'Canceled.'; - isDone = true; - cleanup(); + entriesList.on('select', (_, index) => { + if (index == null) { return; } + selectedEntryIndex = index; + refreshUi(); + }); - switch (key.name) { - case 'up': { - selectedIndex = selectedIndex === 0 ? fields.length - 1 : selectedIndex - 1; - break; - } - case 'down': { - selectedIndex = selectedIndex === fields.length - 1 ? 0 : selectedIndex + 1; - break; - } - case 'return': { - await editSelectedField(); - break; - } - case 'space': { - const selectedField = fields[selectedIndex]; - if (selectedField.type !== 'boolean') { - statusMessage = `Field ${selectedField.label} is not boolean.`; - } else { - const nextValue = editor.toggleBooleanField(selectedField.id); - statusMessage = `${selectedField.label} updated to ${formatFieldValue(nextValue)}.`; - } - break; - } - default: { - if (_chunk === 'k') { - selectedIndex = selectedIndex === 0 ? fields.length - 1 : selectedIndex - 1; - } else if (_chunk === 'j') { - selectedIndex = selectedIndex === fields.length - 1 ? 0 : selectedIndex + 1; - } else if (_chunk === 'e') { - await editSelectedField(); - } else if (_chunk === 's') { - editor.save(); - statusMessage = 'Saved.'; - isSaved = true; - isDone = true; - cleanup(); - return; - } else if (_chunk === 'q') { - if (hasChanges()) { - const shouldDiscard = await confirmDiscard(); - if (!shouldDiscard) { - statusMessage = 'Continue editing.'; - render(); - return; - } - } - - statusMessage = 'Canceled.'; - isDone = true; - cleanup(); - return; - } - break; - } - } + screen.key(['tab'], () => { + focusPane = focusPane === 'files' ? 'entries' : 'files'; + refreshUi(); + }); - render(); - }; + screen.key(['s'], () => { + saveAndExit(); + }); - readline.emitKeypressEvents(process.stdin); - process.stdin.on('keypress', onKeypress); - setRawMode(true); - render(); + screen.key(['q', 'C-c'], () => { + void quitFlow(); + }); - while (!isDone) { - await new Promise((resolve) => setTimeout(resolve, 40)); - } + screen.key(['enter'], () => { + void editCurrentEntry(); + }); - return isSaved; + refreshUi(); + + return new Promise((resolve) => { + screen.once('destroy', () => { + resolve(didSave); + }); + }); } diff --git a/tests/devnet-config-editor.test.ts b/tests/devnet-config-editor.test.ts index 07d3bf0..f515ea3 100644 --- a/tests/devnet-config-editor.test.ts +++ b/tests/devnet-config-editor.test.ts @@ -143,4 +143,28 @@ describe('DevnetConfigEditor', () => { expect(ckbToml.rpc.enable_deprecated_rpc).toBe(true); expect(minerToml.miner.client.poll_interval).toBe(1500); }); + + it('provides full TOML document and flattened entries', () => { + const editor = createDevnetConfigEditor(configPath); + + const documents = editor.getDocuments(); + expect(documents.map((document) => document.id)).toEqual(['ckb', 'miner']); + + const ckbEntries = editor.getEntriesForDocument('ckb'); + expect(ckbEntries.some((entry) => entry.pathText === 'logger.filter')).toBe(true); + expect(ckbEntries.some((entry) => entry.pathText === 'network.max_peers')).toBe(true); + }); + + it('edits primitive value via generic document path api', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setDocumentValue('ckb', ['network', 'max_peers'], '256'); + editor.save(); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + expect(ckbToml.network.max_peers).toBe(256); + }); }); From 24593ae13bbe2604f4f100d34cf3cbe6f9ec9022 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 08:45:28 +0800 Subject: [PATCH 03/24] feat(devnet): add search/add/delete interactions to config TUI --- src/node/devnet-config-editor.ts | 109 +++++++++++++++++++-- src/tui/devnet-config-tui.ts | 160 ++++++++++++++++++++++++++++--- 2 files changed, 250 insertions(+), 19 deletions(-) diff --git a/src/node/devnet-config-editor.ts b/src/node/devnet-config-editor.ts index 41d1c65..7318712 100644 --- a/src/node/devnet-config-editor.ts +++ b/src/node/devnet-config-editor.ts @@ -255,6 +255,25 @@ function parseValueByExistingType(rawInput: string, existingValue: unknown): Tom return trimmed; } +function parseInputAsTomlPrimitive(rawInput: string): TomlPrimitive { + const trimmed = rawInput.trim(); + if (!trimmed) { + throw new Error('Value cannot be empty.'); + } + + const parsedBoolean = normalizeBooleanInput(trimmed); + if (parsedBoolean != null) { + return parsedBoolean; + } + + const parsedNumber = Number(trimmed); + if (!Number.isNaN(parsedNumber) && Number.isFinite(parsedNumber)) { + return parsedNumber; + } + + return trimmed; +} + function readTomlFile(filePath: string): Record { const text = fs.readFileSync(filePath, 'utf8'); return toml.parse(text) as unknown as Record; @@ -406,6 +425,11 @@ export class DevnetConfigEditor { } this.values[fieldId] = trimmed; + setByPath( + this.getDocument(definition.file).data as Record, + definition.path, + this.values[fieldId], + ); return this.values[fieldId]; } @@ -416,6 +440,11 @@ export class DevnetConfigEditor { } this.values[fieldId] = parsed; + setByPath( + this.getDocument(definition.file).data as Record, + definition.path, + this.values[fieldId], + ); return this.values[fieldId]; } @@ -425,6 +454,11 @@ export class DevnetConfigEditor { } this.values[fieldId] = parsedBoolean; + setByPath( + this.getDocument(definition.file).data as Record, + definition.path, + this.values[fieldId], + ); return this.values[fieldId]; } @@ -442,7 +476,7 @@ export class DevnetConfigEditor { const document = this.getDocument(documentId); const currentValue = getByPath(document.data as Record, pathParts); if (!isTomlPrimitive(currentValue)) { - throw new Error('Only primitive values can be edited in phase 1.'); + throw new Error('Only primitive values can be edited directly.'); } const parsedValue = parseValueByExistingType(rawInput, currentValue); @@ -450,6 +484,65 @@ export class DevnetConfigEditor { return parsedValue; } + addObjectEntry(documentId: 'ckb' | 'miner', pathParts: string[], key: string, rawValue: string): TomlPrimitive { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!isPlainObject(target)) { + throw new Error('Target path is not an object.'); + } + + const normalizedKey = key.trim(); + if (!normalizedKey) { + throw new Error('Key cannot be empty.'); + } + if (normalizedKey in target) { + throw new Error(`Key '${normalizedKey}' already exists.`); + } + + const parsedValue = parseInputAsTomlPrimitive(rawValue); + target[normalizedKey] = parsedValue; + return parsedValue; + } + + appendArrayEntry(documentId: 'ckb' | 'miner', pathParts: string[], rawValue: string): TomlPrimitive { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!Array.isArray(target)) { + throw new Error('Target path is not an array.'); + } + + const parsedValue = parseInputAsTomlPrimitive(rawValue); + target.push(parsedValue); + return parsedValue; + } + + deleteDocumentPath(documentId: 'ckb' | 'miner', pathParts: string[]): void { + if (pathParts.length === 0) { + throw new Error('Cannot delete the root node.'); + } + + const parentPath = pathParts.slice(0, -1); + const key = pathParts[pathParts.length - 1]; + const parent = getByPath(this.getDocument(documentId).data as Record, parentPath); + + if (Array.isArray(parent)) { + const index = Number(key); + if (!Number.isInteger(index) || index < 0 || index >= parent.length) { + throw new Error('Invalid array index for deletion.'); + } + parent.splice(index, 1); + return; + } + + if (isPlainObject(parent)) { + if (!(key in parent)) { + throw new Error(`Key '${key}' does not exist.`); + } + delete parent[key]; + return; + } + + throw new Error('Target path cannot be deleted.'); + } + toggleBooleanField(fieldId: string): EditableFieldValue { const field = this.getField(fieldId); if (field.type !== 'boolean') { @@ -458,17 +551,17 @@ export class DevnetConfigEditor { const nextValue = !field.value; this.values[fieldId] = nextValue; + setByPath( + this.getDocument(field.file).data as Record, + field.path, + nextValue, + ); return nextValue; } save(): void { - for (const definition of editableFieldDefinitions) { - const target = definition.file === 'ckb' ? this.ckbConfig : this.minerConfig; - setByPath(target, definition.path, this.values[definition.id]); - } - - writeTomlFileAtomic(this.ckbTomlPath, this.ckbConfig); - writeTomlFileAtomic(this.minerTomlPath, this.minerConfig); + writeTomlFileAtomic(this.ckbTomlPath, this.documents.ckb.data as unknown as Record); + writeTomlFileAtomic(this.minerTomlPath, this.documents.miner.data as unknown as Record); } } diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index e9d70b4..c4239c5 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -44,7 +44,9 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: let focusPane: FocusPane = 'entries'; let hasUnsavedChanges = false; let didSave = false; - let statusMessage = 'Tab switch focus | Enter edit | s save | / search (next phase) | q quit'; + let searchTerm = ''; + let statusMessage = 'Tab focus | Enter edit | a add | d delete | / search | s save | q quit'; + let visibleEntries: TomlEntry[] = []; const screen = blessed.screen({ smartCSR: true, @@ -144,6 +146,17 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: hidden: true, }); + const getVisibleEntries = (entries: TomlEntry[]): TomlEntry[] => { + const term = searchTerm.trim().toLowerCase(); + if (!term) { + return entries; + } + return entries.filter((entry) => { + const text = `${entry.pathText} ${entry.valuePreview} ${entry.type}`.toLowerCase(); + return text.includes(term); + }); + }; + const refreshUi = () => { const fileItems = documents.map((document) => document.title); filesList.setItems(fileItems); @@ -151,19 +164,24 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const selectedDocument = documents[selectedDocumentIndex]; const entries = editor.getEntriesForDocument(selectedDocument.id); - const entryLines = entries.map(formatEntryLine); + visibleEntries = getVisibleEntries(entries); + const entryLines = visibleEntries.map(formatEntryLine); entriesList.setItems(entryLines); - if (entries.length === 0) { + if (visibleEntries.length === 0) { selectedEntryIndex = 0; - detailsBox.setContent('{yellow-fg}No keys found in selected document.{/yellow-fg}'); + detailsBox.setContent( + searchTerm + ? '{yellow-fg}No keys match search filter.{/yellow-fg}' + : '{yellow-fg}No keys found in selected document.{/yellow-fg}', + ); } else { - if (selectedEntryIndex >= entries.length) { - selectedEntryIndex = entries.length - 1; + if (selectedEntryIndex >= visibleEntries.length) { + selectedEntryIndex = visibleEntries.length - 1; } entriesList.select(selectedEntryIndex); - const selectedEntry = entries[selectedEntryIndex]; + const selectedEntry = visibleEntries[selectedEntryIndex]; const rawValue = editor.getEntryValue(selectedEntry.documentId, selectedEntry.path); const valueText = typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue, null, 2); @@ -184,7 +202,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: statusBar.setContent( [ `Path: ${configPath}`, - `Focus: ${focusPane} | Unsaved: ${dirtyText}`, + `Focus: ${focusPane} | Search: ${searchTerm || '(none)'} | Unsaved: ${dirtyText}`, `Status: ${statusMessage}`, ].join('\n'), ); @@ -224,12 +242,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const editCurrentEntry = async () => { const selectedDocument = documents[selectedDocumentIndex]; - const entries = editor.getEntriesForDocument(selectedDocument.id); - if (entries.length === 0) { + if (visibleEntries.length === 0) { return; } - const selectedEntry = entries[selectedEntryIndex]; + const selectedEntry = visibleEntries[selectedEntryIndex]; if (!selectedEntry.editable) { statusMessage = `Path ${selectedEntry.pathText} is not primitive-editable yet.`; refreshUi(); @@ -256,6 +273,115 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: refreshUi(); }; + const searchEntries = async () => { + const answer = await waitForQuestion( + screen, + prompt, + `Search (path/type/value, empty to clear): ${searchTerm || ''}`, + ); + if (answer == null) { + statusMessage = 'Search canceled.'; + refreshUi(); + return; + } + + searchTerm = answer.trim(); + selectedEntryIndex = 0; + statusMessage = searchTerm ? `Filter applied: ${searchTerm}` : 'Search filter cleared.'; + refreshUi(); + }; + + const addEntry = async () => { + const selectedDocument = documents[selectedDocumentIndex]; + + const targetEntry = + visibleEntries.length > 0 ? visibleEntries[Math.min(selectedEntryIndex, visibleEntries.length - 1)] : null; + + const targetPath = targetEntry?.path ?? []; + const targetValue = editor.getEntryValue(selectedDocument.id, targetPath); + + if (targetEntry == null && !Array.isArray(targetValue) && (targetValue == null || typeof targetValue !== 'object')) { + statusMessage = 'No target object/array selected for add.'; + refreshUi(); + return; + } + + if (targetEntry?.type === 'object' || (targetEntry == null && targetValue != null && typeof targetValue === 'object')) { + const keyAnswer = await waitForQuestion(screen, prompt, 'New key name:'); + if (keyAnswer == null) { + statusMessage = 'Add canceled.'; + refreshUi(); + return; + } + + const valueAnswer = await waitForQuestion(screen, prompt, `Value for ${keyAnswer.trim()} (auto parse bool/number):`); + if (valueAnswer == null) { + statusMessage = 'Add canceled.'; + refreshUi(); + return; + } + + try { + editor.addObjectEntry(selectedDocument.id, targetPath, keyAnswer, valueAnswer); + hasUnsavedChanges = true; + statusMessage = `Added key '${keyAnswer.trim()}' under ${targetPath.join('.') || ''}.`; + } catch (error) { + statusMessage = `Add failed: ${(error as Error).message}`; + } + refreshUi(); + return; + } + + if (targetEntry?.type === 'array') { + const valueAnswer = await waitForQuestion(screen, prompt, `Append value to ${targetEntry.pathText}:`); + if (valueAnswer == null) { + statusMessage = 'Append canceled.'; + refreshUi(); + return; + } + + try { + editor.appendArrayEntry(selectedDocument.id, targetEntry.path, valueAnswer); + hasUnsavedChanges = true; + statusMessage = `Appended value to ${targetEntry.pathText}.`; + } catch (error) { + statusMessage = `Append failed: ${(error as Error).message}`; + } + refreshUi(); + return; + } + + statusMessage = 'Select an object or array node to add items.'; + refreshUi(); + }; + + const deleteEntry = async () => { + const selectedDocument = documents[selectedDocumentIndex]; + if (visibleEntries.length === 0) { + statusMessage = 'No selected entry to delete.'; + refreshUi(); + return; + } + + const selectedEntry = visibleEntries[selectedEntryIndex]; + const confirmed = await waitForConfirm(screen, question, `Delete ${selectedEntry.pathText}?`); + if (!confirmed) { + statusMessage = 'Delete canceled.'; + refreshUi(); + return; + } + + try { + editor.deleteDocumentPath(selectedDocument.id, selectedEntry.path); + hasUnsavedChanges = true; + selectedEntryIndex = Math.max(0, selectedEntryIndex - 1); + statusMessage = `Deleted ${selectedEntry.pathText}.`; + } catch (error) { + statusMessage = `Delete failed: ${(error as Error).message}`; + } + refreshUi(); + }; + filesList.on('select', (_, index) => { if (index == null) { return; @@ -290,6 +416,18 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: void editCurrentEntry(); }); + screen.key(['/'], () => { + void searchEntries(); + }); + + screen.key(['a'], () => { + void addEntry(); + }); + + screen.key(['d'], () => { + void deleteEntry(); + }); + refreshUi(); return new Promise((resolve) => { From d082480a5ce33c179c96a08dfd56e164fcb220e6 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 08:49:36 +0800 Subject: [PATCH 04/24] feat(devnet): add array insert/move and search match navigation --- README.md | 4 +- src/node/devnet-config-editor.ts | 41 +++++++ src/tui/devnet-config-tui.ts | 172 ++++++++++++++++++++++++++++- tests/devnet-config-editor.test.ts | 16 +++ 4 files changed, 231 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 08205c2..cd11649 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,9 @@ By default, OffCKB use a fixed Devnet config. You can customize it, for example offckb devnet config ``` -The editor provides a safe subset of common options from `ckb.toml` and `ckb-miner.toml` (for example logger and RPC settings), with keyboard navigation and validation. +The editor supports full key browsing/editing for `ckb.toml` and `ckb-miner.toml`, including primitive value edits, object key add, array append/insert/move, search filter, and path delete. + +Common shortcuts: `Enter` edit primitive, `a` add key/item, `i` insert array item, `m` move array item, `d` delete path, `/` search filter, `n`/`N` next/previous search match, `s` save, `q` quit. You can also update the same fields non-interactively (useful for scripts/CI): diff --git a/src/node/devnet-config-editor.ts b/src/node/devnet-config-editor.ts index 7318712..0978493 100644 --- a/src/node/devnet-config-editor.ts +++ b/src/node/devnet-config-editor.ts @@ -514,6 +514,47 @@ export class DevnetConfigEditor { return parsedValue; } + insertArrayEntry( + documentId: 'ckb' | 'miner', + pathParts: string[], + index: number, + rawValue: string, + ): TomlPrimitive { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!Array.isArray(target)) { + throw new Error('Target path is not an array.'); + } + + if (!Number.isInteger(index) || index < 0 || index > target.length) { + throw new Error(`Insert index must be between 0 and ${target.length}.`); + } + + const parsedValue = parseInputAsTomlPrimitive(rawValue); + target.splice(index, 0, parsedValue); + return parsedValue; + } + + moveArrayEntry(documentId: 'ckb' | 'miner', pathParts: string[], fromIndex: number, toIndex: number): void { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!Array.isArray(target)) { + throw new Error('Target path is not an array.'); + } + + if (!Number.isInteger(fromIndex) || fromIndex < 0 || fromIndex >= target.length) { + throw new Error(`Source index must be between 0 and ${Math.max(0, target.length - 1)}.`); + } + if (!Number.isInteger(toIndex) || toIndex < 0 || toIndex >= target.length) { + throw new Error(`Target index must be between 0 and ${Math.max(0, target.length - 1)}.`); + } + + if (fromIndex === toIndex) { + return; + } + + const [item] = target.splice(fromIndex, 1); + target.splice(toIndex, 0, item); + } + deleteDocumentPath(documentId: 'ckb' | 'miner', pathParts: string[]): void { if (pathParts.length === 0) { throw new Error('Cannot delete the root node.'); diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index c4239c5..2ce3222 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -45,7 +45,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: let hasUnsavedChanges = false; let didSave = false; let searchTerm = ''; - let statusMessage = 'Tab focus | Enter edit | a add | d delete | / search | s save | q quit'; + let statusMessage = 'Tab focus | Enter edit | a add | i insert | m move | d delete | / search n/N | s save | q quit'; let visibleEntries: TomlEntry[] = []; const screen = blessed.screen({ @@ -216,6 +216,30 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: screen.render(); }; + const parseNonNegativeInteger = (value: string, fieldName: string): number => { + const parsed = Number(value.trim()); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${fieldName} must be a non-negative integer.`); + } + return parsed; + }; + + const resolveArrayTarget = ( + selectedEntry: TomlEntry, + ): { arrayPath: string[]; suggestedIndex: number | null } | null => { + if (selectedEntry.type === 'array') { + return { arrayPath: selectedEntry.path, suggestedIndex: null }; + } + + const lastPart = selectedEntry.path[selectedEntry.path.length - 1]; + const parsedIndex = Number(lastPart); + if (Number.isInteger(parsedIndex) && parsedIndex >= 0) { + return { arrayPath: selectedEntry.path.slice(0, -1), suggestedIndex: parsedIndex }; + } + + return null; + }; + const saveAndExit = () => { editor.save(); hasUnsavedChanges = false; @@ -291,6 +315,24 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: refreshUi(); }; + const jumpSearchMatch = (direction: 'next' | 'prev') => { + if (visibleEntries.length === 0) { + statusMessage = searchTerm ? 'No search matches to jump.' : 'Set search filter first with /.'; + refreshUi(); + return; + } + + if (direction === 'next') { + selectedEntryIndex = (selectedEntryIndex + 1) % visibleEntries.length; + statusMessage = `Jumped to next match (${selectedEntryIndex + 1}/${visibleEntries.length}).`; + } else { + selectedEntryIndex = (selectedEntryIndex - 1 + visibleEntries.length) % visibleEntries.length; + statusMessage = `Jumped to previous match (${selectedEntryIndex + 1}/${visibleEntries.length}).`; + } + + refreshUi(); + }; + const addEntry = async () => { const selectedDocument = documents[selectedDocumentIndex]; @@ -355,6 +397,118 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: refreshUi(); }; + const insertArrayEntry = async () => { + if (visibleEntries.length === 0) { + statusMessage = 'No selected entry for insert.'; + refreshUi(); + return; + } + + const selectedDocument = documents[selectedDocumentIndex]; + const selectedEntry = visibleEntries[selectedEntryIndex]; + const target = resolveArrayTarget(selectedEntry); + if (target == null) { + statusMessage = 'Select an array or array item to insert.'; + refreshUi(); + return; + } + + const arrayValue = editor.getEntryValue(selectedDocument.id, target.arrayPath); + const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; + const indexAnswer = await waitForQuestion( + screen, + prompt, + `Insert index (0-${arrayLength}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, + ); + if (indexAnswer == null) { + statusMessage = 'Insert canceled.'; + refreshUi(); + return; + } + + const valueAnswer = await waitForQuestion( + screen, + prompt, + `Value to insert at ${target.arrayPath.join('.')} (auto parse bool/number):`, + ); + if (valueAnswer == null) { + statusMessage = 'Insert canceled.'; + refreshUi(); + return; + } + + try { + const indexInput = indexAnswer.trim(); + const insertIndex = + indexInput === '' && target.suggestedIndex != null + ? target.suggestedIndex + : parseNonNegativeInteger(indexAnswer, 'Insert index'); + + editor.insertArrayEntry(selectedDocument.id, target.arrayPath, insertIndex, valueAnswer); + hasUnsavedChanges = true; + statusMessage = `Inserted array item at ${target.arrayPath.join('.')}[${insertIndex}].`; + } catch (error) { + statusMessage = `Insert failed: ${(error as Error).message}`; + } + + refreshUi(); + }; + + const moveArrayEntry = async () => { + if (visibleEntries.length === 0) { + statusMessage = 'No selected entry for move.'; + refreshUi(); + return; + } + + const selectedDocument = documents[selectedDocumentIndex]; + const selectedEntry = visibleEntries[selectedEntryIndex]; + const target = resolveArrayTarget(selectedEntry); + if (target == null) { + statusMessage = 'Select an array or array item to move.'; + refreshUi(); + return; + } + + const arrayValue = editor.getEntryValue(selectedDocument.id, target.arrayPath); + const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; + + const fromAnswer = await waitForQuestion( + screen, + prompt, + `Move from index (0-${Math.max(0, arrayLength - 1)}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, + ); + if (fromAnswer == null) { + statusMessage = 'Move canceled.'; + refreshUi(); + return; + } + + const toAnswer = await waitForQuestion(screen, prompt, `Move to index (0-${Math.max(0, arrayLength - 1)}):`); + if (toAnswer == null) { + statusMessage = 'Move canceled.'; + refreshUi(); + return; + } + + try { + const fromInput = fromAnswer.trim(); + const fromIndex = + fromInput === '' && target.suggestedIndex != null + ? target.suggestedIndex + : parseNonNegativeInteger(fromAnswer, 'Source index'); + const toIndex = parseNonNegativeInteger(toAnswer, 'Target index'); + + editor.moveArrayEntry(selectedDocument.id, target.arrayPath, fromIndex, toIndex); + hasUnsavedChanges = true; + statusMessage = `Moved item in ${target.arrayPath.join('.')} from ${fromIndex} to ${toIndex}.`; + } catch (error) { + statusMessage = `Move failed: ${(error as Error).message}`; + } + + refreshUi(); + }; + const deleteEntry = async () => { const selectedDocument = documents[selectedDocumentIndex]; if (visibleEntries.length === 0) { @@ -428,6 +582,22 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: void deleteEntry(); }); + screen.key(['i'], () => { + void insertArrayEntry(); + }); + + screen.key(['m'], () => { + void moveArrayEntry(); + }); + + screen.key(['n'], () => { + jumpSearchMatch('next'); + }); + + screen.key(['N'], () => { + jumpSearchMatch('prev'); + }); + refreshUi(); return new Promise((resolve) => { diff --git a/tests/devnet-config-editor.test.ts b/tests/devnet-config-editor.test.ts index f515ea3..295a4d0 100644 --- a/tests/devnet-config-editor.test.ts +++ b/tests/devnet-config-editor.test.ts @@ -24,6 +24,7 @@ function writeFixtureConfig(configPath: string) { }, network: { max_peers: 125, + bootnodes: ['node-a', 'node-b'], }, }), 'utf8', @@ -167,4 +168,19 @@ describe('DevnetConfigEditor', () => { >; expect(ckbToml.network.max_peers).toBe(256); }); + + it('inserts and moves array entries via document path api', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.insertArrayEntry('ckb', ['network', 'bootnodes'], 1, 'node-x'); + editor.moveArrayEntry('ckb', ['network', 'bootnodes'], 2, 0); + editor.save(); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + + expect(ckbToml.network.bootnodes).toEqual(['node-b', 'node-a', 'node-x']); + }); }); From 34a549e568ca2493f2ece22f8ebe60868f1d4979 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 09:09:05 +0800 Subject: [PATCH 05/24] fix(devnet): sync file pane navigation with key list refresh --- src/tui/devnet-config-tui.ts | 407 ++++++++++++++++++++++++++++++----- 1 file changed, 353 insertions(+), 54 deletions(-) diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 2ce3222..50aefba 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -4,32 +4,290 @@ import { DevnetConfigEditor, TomlEntry } from '../node/devnet-config-editor'; type FocusPane = 'files' | 'entries'; function formatEntryLine(entry: TomlEntry): string { - return `${entry.pathText} = ${entry.valuePreview}`; + const depth = Math.max(0, entry.path.length - 1); + const lastPathPart = entry.path[entry.path.length - 1] ?? ''; + const nodeName = /^\d+$/.test(lastPathPart) ? `[${lastPathPart}]` : lastPathPart; + const indent = ' '.repeat(depth); + + if (entry.type === 'object') { + return `${indent}▸ ${nodeName} ${entry.valuePreview}`; + } + + if (entry.type === 'array') { + return `${indent}▾ ${nodeName} ${entry.valuePreview}`; + } + + return `${indent} ${nodeName} = ${entry.valuePreview}`; } -function waitForQuestion( +function waitForInput( screen: Widgets.Screen, - prompt: Widgets.PromptElement, + title: string, questionText: string, + initialValue: string, ): Promise { return new Promise((resolve) => { - prompt.input(questionText, '', (_error, value) => { + const dialog = blessed.box({ + parent: screen, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '70%', + height: 13, + keys: true, + tags: true, + style: { + border: { fg: 'cyan' }, + }, + }); + + blessed.box({ + parent: dialog, + top: 1, + left: 2, + width: '95%-4', + height: 2, + tags: true, + content: questionText, + }); + + const input = blessed.textbox({ + parent: dialog, + top: 3, + left: 2, + width: '100%-4', + height: 3, + border: 'line', + inputOnFocus: true, + keys: true, + vi: true, + style: { + border: { fg: 'gray' }, + }, + }); + + const okButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 8, + left: '40%-8', + height: 1, + content: ' OK ', + style: { + bg: 'blue', + focus: { bg: 'blue' }, + }, + }); + + const cancelButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 8, + left: '40%+4', + height: 1, + content: ' Cancel ', + style: { + bg: 'gray', + focus: { bg: 'gray' }, + }, + }); + + type InputDialogFocus = 'input' | 'ok' | 'cancel'; + let currentFocus: InputDialogFocus = 'input'; + + const cleanup = (value: string | null) => { + dialog.destroy(); + screen.render(); + resolve(value); + }; + + const setFocus = (nextFocus: InputDialogFocus) => { + currentFocus = nextFocus; + if (nextFocus === 'input') { + input.style.border = { fg: 'cyan' }; + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'gray'; + input.focus(); + } else if (nextFocus === 'ok') { + input.style.border = { fg: 'gray' }; + okButton.style.bg = 'cyan'; + cancelButton.style.bg = 'gray'; + okButton.focus(); + } else { + input.style.border = { fg: 'gray' }; + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'cyan'; + cancelButton.focus(); + } screen.render(); - resolve(value == null ? null : value); + }; + + const nextFocus = () => { + if (currentFocus === 'input') { + setFocus('ok'); + return; + } + if (currentFocus === 'ok') { + setFocus('cancel'); + return; + } + setFocus('input'); + }; + + const prevFocus = () => { + if (currentFocus === 'cancel') { + setFocus('ok'); + return; + } + if (currentFocus === 'ok') { + setFocus('input'); + return; + } + setFocus('cancel'); + }; + + const getInputValue = () => input.getValue() ?? ''; + + okButton.on('press', () => cleanup(getInputValue())); + cancelButton.on('press', () => cleanup(null)); + + dialog.key(['escape'], () => cleanup(null)); + dialog.key(['tab', 'down'], () => nextFocus()); + dialog.key(['S-tab', 'up'], () => prevFocus()); + dialog.key(['left'], () => { + if (currentFocus !== 'input') { + prevFocus(); + } + }); + dialog.key(['right'], () => { + if (currentFocus !== 'input') { + nextFocus(); + } + }); + dialog.key(['enter'], () => { + if (currentFocus === 'input') { + setFocus('ok'); + return; + } + if (currentFocus === 'ok') { + cleanup(getInputValue()); + return; + } + cleanup(null); + }); + + input.key(['enter'], () => { + setFocus('ok'); }); + + input.setValue(initialValue); + setFocus('input'); + input.readInput(); + screen.render(); }); } function waitForConfirm( screen: Widgets.Screen, - question: Widgets.QuestionElement, + title: string, text: string, ): Promise { return new Promise((resolve) => { - question.ask(text, (answer) => { + const dialog = blessed.box({ + parent: screen, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '60%', + height: 10, + keys: true, + tags: true, + style: { + border: { fg: 'cyan' }, + }, + }); + + blessed.box({ + parent: dialog, + top: 2, + left: 2, + width: '100%-4', + height: 2, + tags: true, + content: text, + }); + + const okButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 5, + left: '40%-8', + height: 1, + content: ' OK ', + style: { + bg: 'blue', + focus: { bg: 'blue' }, + }, + }); + + const cancelButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 5, + left: '40%+4', + height: 1, + content: ' Cancel ', + style: { + bg: 'gray', + focus: { bg: 'gray' }, + }, + }); + + let focusButton: 'ok' | 'cancel' = 'cancel'; + + const cleanup = (answer: boolean) => { + dialog.destroy(); screen.render(); resolve(answer); + }; + + const setFocus = (focus: 'ok' | 'cancel') => { + focusButton = focus; + if (focus === 'ok') { + okButton.style.bg = 'cyan'; + cancelButton.style.bg = 'gray'; + okButton.focus(); + } else { + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'cyan'; + cancelButton.focus(); + } + screen.render(); + }; + + okButton.on('press', () => cleanup(true)); + cancelButton.on('press', () => cleanup(false)); + + dialog.key(['escape'], () => cleanup(false)); + dialog.key(['tab', 'left', 'right'], () => { + setFocus(focusButton === 'ok' ? 'cancel' : 'ok'); }); + dialog.key(['enter'], () => { + cleanup(focusButton === 'ok'); + }); + + setFocus('cancel'); + screen.render(); }); } @@ -41,11 +299,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const documents = editor.getDocuments(); let selectedDocumentIndex = 0; let selectedEntryIndex = 0; - let focusPane: FocusPane = 'entries'; + let focusPane: FocusPane = 'files'; let hasUnsavedChanges = false; let didSave = false; let searchTerm = ''; - let statusMessage = 'Tab focus | Enter edit | a add | i insert | m move | d delete | / search n/N | s save | q quit'; + let statusMessage = 'Ready'; let visibleEntries: TomlEntry[] = []; const screen = blessed.screen({ @@ -68,6 +326,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: selected: { bg: 'blue' }, border: { fg: 'gray' }, }, + tags: true, }); const entriesList = blessed.list({ @@ -84,6 +343,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: selected: { bg: 'blue' }, border: { fg: 'gray' }, }, + tags: true, }); const detailsBox = blessed.box({ @@ -118,34 +378,6 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: }, }); - const prompt = blessed.prompt({ - parent: screen, - border: 'line', - height: 9, - width: '70%', - top: 'center', - left: 'center', - label: ' Edit Value ', - keys: true, - vi: true, - tags: true, - hidden: true, - }); - - const question = blessed.question({ - parent: screen, - border: 'line', - height: 8, - width: '60%', - top: 'center', - left: 'center', - label: ' Confirm ', - keys: true, - vi: true, - tags: true, - hidden: true, - }); - const getVisibleEntries = (entries: TomlEntry[]): TomlEntry[] => { const term = searchTerm.trim().toLowerCase(); if (!term) { @@ -168,6 +400,10 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const entryLines = visibleEntries.map(formatEntryLine); entriesList.setItems(entryLines); + filesList.style.border = { fg: focusPane === 'files' ? 'cyan' : 'gray' }; + entriesList.style.border = { fg: focusPane === 'entries' ? 'cyan' : 'gray' }; + detailsBox.style.border = { fg: 'gray' }; + if (visibleEntries.length === 0) { selectedEntryIndex = 0; detailsBox.setContent( @@ -202,7 +438,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: statusBar.setContent( [ `Path: ${configPath}`, - `Focus: ${focusPane} | Search: ${searchTerm || '(none)'} | Unsaved: ${dirtyText}`, + `File: ${documents[selectedDocumentIndex].title} | Focus: ${focusPane} | Search: ${searchTerm || '(none)'} | Unsaved: ${dirtyText}`, `Status: ${statusMessage}`, ].join('\n'), ); @@ -254,7 +490,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: return; } - const shouldDiscard = await waitForConfirm(screen, question, 'Discard unsaved changes?'); + const shouldDiscard = await waitForConfirm(screen, 'Discard Changes', 'Discard unsaved changes?'); if (shouldDiscard) { screen.destroy(); return; @@ -265,6 +501,12 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: }; const editCurrentEntry = async () => { + if (focusPane === 'files') { + focusPane = 'entries'; + refreshUi(); + return; + } + const selectedDocument = documents[selectedDocumentIndex]; if (visibleEntries.length === 0) { return; @@ -279,7 +521,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const value = editor.getEntryValue(selectedEntry.documentId, selectedEntry.path); const valueText = value == null ? '' : String(value); - const answer = await waitForQuestion(screen, prompt, `${selectedEntry.pathText} = ${valueText}`); + const answer = await waitForInput(screen, 'Edit Value', selectedEntry.pathText, valueText); if (answer == null) { statusMessage = 'Edit canceled.'; refreshUi(); @@ -298,10 +540,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: }; const searchEntries = async () => { - const answer = await waitForQuestion( + const answer = await waitForInput( screen, - prompt, - `Search (path/type/value, empty to clear): ${searchTerm || ''}`, + 'Search', + 'Path/type/value filter (empty clears):', + searchTerm, ); if (answer == null) { statusMessage = 'Search canceled.'; @@ -349,14 +592,19 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: } if (targetEntry?.type === 'object' || (targetEntry == null && targetValue != null && typeof targetValue === 'object')) { - const keyAnswer = await waitForQuestion(screen, prompt, 'New key name:'); + const keyAnswer = await waitForInput(screen, 'Add Object Key', 'New key name:', ''); if (keyAnswer == null) { statusMessage = 'Add canceled.'; refreshUi(); return; } - const valueAnswer = await waitForQuestion(screen, prompt, `Value for ${keyAnswer.trim()} (auto parse bool/number):`); + const valueAnswer = await waitForInput( + screen, + 'Add Object Key', + `Value for ${keyAnswer.trim()} (auto parse bool/number):`, + '', + ); if (valueAnswer == null) { statusMessage = 'Add canceled.'; refreshUi(); @@ -375,7 +623,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: } if (targetEntry?.type === 'array') { - const valueAnswer = await waitForQuestion(screen, prompt, `Append value to ${targetEntry.pathText}:`); + const valueAnswer = await waitForInput(screen, 'Append Array Item', `Append value to ${targetEntry.pathText}:`, ''); if (valueAnswer == null) { statusMessage = 'Append canceled.'; refreshUi(); @@ -415,10 +663,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const arrayValue = editor.getEntryValue(selectedDocument.id, target.arrayPath); const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; - const indexAnswer = await waitForQuestion( + const indexAnswer = await waitForInput( screen, - prompt, + 'Insert Array Item', `Insert index (0-${arrayLength}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, + target.suggestedIndex != null ? String(target.suggestedIndex) : String(arrayLength), ); if (indexAnswer == null) { statusMessage = 'Insert canceled.'; @@ -426,10 +675,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: return; } - const valueAnswer = await waitForQuestion( + const valueAnswer = await waitForInput( screen, - prompt, + 'Insert Array Item', `Value to insert at ${target.arrayPath.join('.')} (auto parse bool/number):`, + '', ); if (valueAnswer == null) { statusMessage = 'Insert canceled.'; @@ -473,10 +723,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const arrayValue = editor.getEntryValue(selectedDocument.id, target.arrayPath); const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; - const fromAnswer = await waitForQuestion( + const fromAnswer = await waitForInput( screen, - prompt, + 'Move Array Item', `Move from index (0-${Math.max(0, arrayLength - 1)}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, + target.suggestedIndex != null ? String(target.suggestedIndex) : '0', ); if (fromAnswer == null) { statusMessage = 'Move canceled.'; @@ -484,7 +735,12 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: return; } - const toAnswer = await waitForQuestion(screen, prompt, `Move to index (0-${Math.max(0, arrayLength - 1)}):`); + const toAnswer = await waitForInput( + screen, + 'Move Array Item', + `Move to index (0-${Math.max(0, arrayLength - 1)}):`, + '0', + ); if (toAnswer == null) { statusMessage = 'Move canceled.'; refreshUi(); @@ -518,7 +774,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: } const selectedEntry = visibleEntries[selectedEntryIndex]; - const confirmed = await waitForConfirm(screen, question, `Delete ${selectedEntry.pathText}?`); + const confirmed = await waitForConfirm(screen, 'Delete Path', `Delete ${selectedEntry.pathText}?`); if (!confirmed) { statusMessage = 'Delete canceled.'; refreshUi(); @@ -536,6 +792,19 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: refreshUi(); }; + const syncDocumentSelectionFromFilesList = () => { + const listIndex = (filesList as unknown as { selected?: number }).selected; + if (listIndex == null || listIndex < 0 || listIndex >= documents.length) { + return; + } + if (listIndex !== selectedDocumentIndex) { + selectedDocumentIndex = listIndex; + selectedEntryIndex = 0; + statusMessage = `Switched to ${documents[selectedDocumentIndex].title}.`; + refreshUi(); + } + }; + filesList.on('select', (_, index) => { if (index == null) { return; @@ -553,11 +822,36 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: refreshUi(); }); + filesList.on('keypress', (_, key) => { + const navKeys = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; + if (!key?.name || !navKeys.includes(key.name)) { + return; + } + + setTimeout(() => { + syncDocumentSelectionFromFilesList(); + }, 0); + }); + screen.key(['tab'], () => { focusPane = focusPane === 'files' ? 'entries' : 'files'; refreshUi(); }); + screen.key(['left', 'h'], () => { + if (focusPane === 'entries') { + focusPane = 'files'; + refreshUi(); + } + }); + + screen.key(['right', 'l'], () => { + if (focusPane === 'files') { + focusPane = 'entries'; + refreshUi(); + } + }); + screen.key(['s'], () => { saveAndExit(); }); @@ -598,6 +892,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: jumpSearchMatch('prev'); }); + filesList.key(['enter'], () => { + focusPane = 'entries'; + refreshUi(); + }); + refreshUi(); return new Promise((resolve) => { From 0fc3ed27262c1abc0bd9f91a7e004198508fee16 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 12:15:16 +0800 Subject: [PATCH 06/24] feat(devnet): redesign config tui with inline docs and fixed-array checklist --- src/tui/devnet-config-metadata.ts | 212 +++++++++++++++++ src/tui/devnet-config-tui.ts | 365 +++++++++++++++++++++++++----- 2 files changed, 523 insertions(+), 54 deletions(-) create mode 100644 src/tui/devnet-config-metadata.ts diff --git a/src/tui/devnet-config-metadata.ts b/src/tui/devnet-config-metadata.ts new file mode 100644 index 0000000..de847d0 --- /dev/null +++ b/src/tui/devnet-config-metadata.ts @@ -0,0 +1,212 @@ +export interface ConfigDoc { + summary: string; + source: string; +} + +interface ConfigDocRule { + pathPattern: string; + doc: ConfigDoc; +} + +export interface FixedArraySpec { + pathPattern: string; + label: string; + options: string[]; + unique: boolean; + allowCustom: boolean; + source: string; +} + +const CONFIG_DOCS: ConfigDocRule[] = [ + { + pathPattern: 'logger.filter', + doc: { + summary: 'Rust log filter directive list used by CKB logger.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'rpc.listen_address', + doc: { + summary: 'Address for JSON-RPC server binding (host:port).', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'rpc.max_request_body_size', + doc: { + summary: 'Maximum HTTP RPC request body size in bytes.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'rpc.modules', + doc: { + summary: 'Enabled RPC module names exposed by this node.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'rpc.modules.#', + doc: { + summary: 'One enabled RPC module entry.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.listen_addresses', + doc: { + summary: 'Node P2P listen multiaddr list.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.listen_addresses.#', + doc: { + summary: 'One P2P listen multiaddr entry.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.bootnodes', + doc: { + summary: 'Seed peers used for bootstrap discovery.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.bootnodes.#', + doc: { + summary: 'One bootnode multiaddr entry.', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.max_peers', + doc: { + summary: 'Maximum connected peers (inbound + outbound).', + source: 'CKB configure docs', + }, + }, + { + pathPattern: 'network.support_protocols', + doc: { + summary: 'Enabled CKB p2p protocol list.', + source: 'CKB configure docs / CKB protocol definitions', + }, + }, + { + pathPattern: 'network.support_protocols.#', + doc: { + summary: 'One enabled p2p protocol name.', + source: 'CKB protocol definitions', + }, + }, + { + pathPattern: 'miner.client.rpc_url', + doc: { + summary: 'CKB RPC endpoint used by ckb-miner.', + source: 'CKB miner configure docs', + }, + }, + { + pathPattern: 'miner.client.poll_interval', + doc: { + summary: 'Polling interval for new block templates (ms).', + source: 'CKB miner configure docs', + }, + }, + { + pathPattern: 'miner.client.block_on_submit', + doc: { + summary: 'Wait for submit completion before next loop.', + source: 'CKB miner configure docs', + }, + }, +]; + +const FIXED_ARRAY_SPECS: FixedArraySpec[] = [ + { + pathPattern: 'network.support_protocols', + label: 'Network Protocols', + options: ['Ping', 'Discovery', 'Identify', 'Feeler', 'DisconnectMessage', 'Sync', 'Relay', 'Time', 'Alert', 'LightClient', 'Filter'], + unique: true, + allowCustom: false, + source: 'CKB protocol definitions', + }, + { + pathPattern: 'rpc.modules', + label: 'RPC Modules', + options: ['Net', 'Pool', 'Miner', 'Chain', 'Stats', 'Experiment', 'Debug', 'IntegrationTest', 'Indexer', 'Subscription'], + unique: true, + allowCustom: true, + source: 'CKB configure docs', + }, +]; + +function splitPattern(pattern: string): string[] { + return pattern.split('.').filter(Boolean); +} + +function isNumericSegment(value: string): boolean { + return /^\d+$/.test(value); +} + +function matchPattern(pathSegments: string[], patternSegments: string[]): boolean { + if (pathSegments.length !== patternSegments.length) { + return false; + } + + return patternSegments.every((patternSegment, index) => { + if (patternSegment === '*') { + return true; + } + + if (patternSegment === '#') { + return isNumericSegment(pathSegments[index]); + } + + return pathSegments[index] === patternSegment; + }); +} + +function wildcardScore(patternSegments: string[]): number { + return patternSegments.filter((segment) => segment === '*' || segment === '#').length; +} + +export function getConfigDoc(pathSegments: string[]): ConfigDoc | null { + const matches = CONFIG_DOCS.filter((rule) => matchPattern(pathSegments, splitPattern(rule.pathPattern))).sort((a, b) => { + const aScore = wildcardScore(splitPattern(a.pathPattern)); + const bScore = wildcardScore(splitPattern(b.pathPattern)); + return aScore - bScore; + }); + + if (matches.length === 0) { + return null; + } + + return matches[0].doc; +} + +export function getFixedArraySpec(pathSegments: string[]): FixedArraySpec | null { + const match = FIXED_ARRAY_SPECS.find((spec) => matchPattern(pathSegments, splitPattern(spec.pathPattern))); + return match ?? null; +} + +export function getFixedArraySpecFromEntryPath(pathSegments: string[]): FixedArraySpec | null { + const direct = getFixedArraySpec(pathSegments); + if (direct != null) { + return direct; + } + + if (pathSegments.length === 0) { + return null; + } + + const last = pathSegments[pathSegments.length - 1]; + if (!isNumericSegment(last)) { + return null; + } + + return getFixedArraySpec(pathSegments.slice(0, -1)); +} diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 50aefba..82759f9 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -1,5 +1,6 @@ import blessed, { Widgets } from 'blessed'; import { DevnetConfigEditor, TomlEntry } from '../node/devnet-config-editor'; +import { getConfigDoc, getFixedArraySpecFromEntryPath, FixedArraySpec } from './devnet-config-metadata'; type FocusPane = 'files' | 'entries'; @@ -7,17 +8,212 @@ function formatEntryLine(entry: TomlEntry): string { const depth = Math.max(0, entry.path.length - 1); const lastPathPart = entry.path[entry.path.length - 1] ?? ''; const nodeName = /^\d+$/.test(lastPathPart) ? `[${lastPathPart}]` : lastPathPart; - const indent = ' '.repeat(depth); + const treeIndent = depth === 0 ? '' : `${'│ '.repeat(Math.max(0, depth - 1))}`; + const branch = depth === 0 ? '' : '├─ '; + const keyDoc = getConfigDoc(entry.path); + const docText = keyDoc != null ? ` {gray-fg}// ${keyDoc.summary}{/gray-fg}` : ''; + const valueColor = entry.type === 'string' ? 'green' : entry.type === 'number' ? 'yellow' : 'magenta'; + const keyColor = depth === 0 ? 'cyan' : 'white'; if (entry.type === 'object') { - return `${indent}▸ ${nodeName} ${entry.valuePreview}`; + return `${treeIndent}${branch}{cyan-fg}▸ ${nodeName}{/cyan-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${docText}`; } if (entry.type === 'array') { - return `${indent}▾ ${nodeName} ${entry.valuePreview}`; + return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${docText}`; } - return `${indent} ${nodeName} = ${entry.valuePreview}`; + return `${treeIndent}${branch}{${keyColor}-fg}${nodeName}{/${keyColor}-fg} = {${valueColor}-fg}${entry.valuePreview}{/${valueColor}-fg}${docText}`; +} + +async function waitForFixedArraySelection( + screen: Widgets.Screen, + title: string, + spec: FixedArraySpec, + currentValues: string[], +): Promise { + return new Promise((resolve) => { + const maxVisibleRows = 14; + const visibleRows = Math.min(Math.max(spec.options.length, 1), maxVisibleRows); + const listHeight = visibleRows + 2; + const dialogHeight = listHeight + 6; + + const overlay = blessed.box({ + parent: screen, + top: 0, + left: 0, + width: '100%', + height: '100%', + mouse: true, + keys: true, + tags: true, + }); + + const dialog = blessed.box({ + parent: overlay, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '62%', + height: dialogHeight, + mouse: true, + keys: true, + tags: true, + style: { + border: { fg: 'cyan' }, + }, + }); + + const selectedValues = new Set(currentValues); + + const list = blessed.list({ + parent: dialog, + top: 1, + left: 1, + width: '100%-2', + height: listHeight, + border: 'line', + mouse: true, + keys: true, + vi: true, + tags: true, + style: { + selected: { bg: 'blue' }, + border: { fg: 'gray' }, + }, + }); + + const footer = blessed.box({ + parent: dialog, + top: listHeight + 1, + left: 2, + width: '100%-4', + height: 2, + tags: true, + content: '{gray-fg}Space toggle Enter apply Esc cancel{/gray-fg}', + }); + + const renderList = () => { + const items = spec.options.map((option) => { + const checked = selectedValues.has(option) ? 'x' : ' '; + return `[${checked}] ${option}`; + }); + list.setItems(items); + }; + + renderList(); + list.select(0); + + const screenWithGrab = screen as unknown as { grabKeys?: boolean; grabMouse?: boolean }; + const previousGrabKeys = screenWithGrab.grabKeys; + const previousGrabMouse = screenWithGrab.grabMouse; + screenWithGrab.grabKeys = true; + screenWithGrab.grabMouse = true; + + const cleanup = (value: string[] | null) => { + screenWithGrab.grabKeys = previousGrabKeys; + screenWithGrab.grabMouse = previousGrabMouse; + overlay.destroy(); + screen.render(); + resolve(value); + }; + + const selectedOption = () => { + const selectedIndex = (list as unknown as { selected?: number }).selected ?? 0; + return spec.options[selectedIndex] ?? null; + }; + + const applySelection = () => { + const values = spec.options.filter((option) => selectedValues.has(option)); + cleanup(values); + }; + + const toggleSelectedOption = () => { + const option = selectedOption(); + if (option == null) { + return; + } + + if (selectedValues.has(option)) { + selectedValues.delete(option); + } else { + selectedValues.add(option); + } + renderList(); + const selectedIndex = (list as unknown as { selected?: number }).selected ?? 0; + list.select(selectedIndex); + screen.render(); + }; + + list.on('select', () => { + toggleSelectedOption(); + }); + + list.key(['enter'], () => { + applySelection(); + }); + + list.key(['up', 'k'], () => { + list.up(1); + screen.render(); + }); + + list.key(['down', 'j'], () => { + list.down(1); + screen.render(); + }); + + list.key(['space'], () => { + toggleSelectedOption(); + }); + + dialog.key(['escape'], () => cleanup(null)); + dialog.key(['A-a'], () => { + spec.options.forEach((option) => selectedValues.add(option)); + renderList(); + screen.render(); + }); + dialog.key(['A-d'], () => { + selectedValues.clear(); + renderList(); + screen.render(); + }); + + list.focus(); + footer.setContent('{gray-fg}Space toggle Enter apply Esc cancel Alt+a all Alt+d none{/gray-fg}'); + screen.render(); + }); +} + +async function waitForArrayValue( + screen: Widgets.Screen, + spec: FixedArraySpec | null, + title: string, + questionText: string, + initialValue: string, +): Promise { + if (spec == null) { + return waitForInput(screen, title, questionText, initialValue); + } + + if (spec.options.includes(initialValue)) { + const selected = await waitForFixedArraySelection(screen, `${title} (${spec.label})`, spec, [initialValue]); + if (selected == null || selected.length === 0) { + return null; + } + return selected[0]; + } + + if (!spec.allowCustom) { + const selected = await waitForFixedArraySelection(screen, `${title} (${spec.label})`, spec, []); + if (selected == null || selected.length === 0) { + return null; + } + return selected[0]; + } + + return waitForInput(screen, title, questionText, initialValue); } function waitForInput( @@ -331,10 +527,10 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const entriesList = blessed.list({ parent: screen, - label: ' Keys ', + label: ' Config ', top: 0, left: '22%', - width: '43%', + width: '78%', height: '90%', border: 'line', keys: true, @@ -346,24 +542,6 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: tags: true, }); - const detailsBox = blessed.box({ - parent: screen, - label: ' Value / Details ', - top: 0, - left: '65%', - width: '35%', - height: '90%', - border: 'line', - tags: true, - scrollable: true, - alwaysScroll: true, - keys: true, - vi: true, - style: { - border: { fg: 'gray' }, - }, - }); - const statusBar = blessed.box({ parent: screen, bottom: 0, @@ -402,44 +580,26 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: filesList.style.border = { fg: focusPane === 'files' ? 'cyan' : 'gray' }; entriesList.style.border = { fg: focusPane === 'entries' ? 'cyan' : 'gray' }; - detailsBox.style.border = { fg: 'gray' }; if (visibleEntries.length === 0) { selectedEntryIndex = 0; - detailsBox.setContent( - searchTerm - ? '{yellow-fg}No keys match search filter.{/yellow-fg}' - : '{yellow-fg}No keys found in selected document.{/yellow-fg}', - ); } else { if (selectedEntryIndex >= visibleEntries.length) { selectedEntryIndex = visibleEntries.length - 1; } entriesList.select(selectedEntryIndex); - - const selectedEntry = visibleEntries[selectedEntryIndex]; - const rawValue = editor.getEntryValue(selectedEntry.documentId, selectedEntry.path); - const valueText = typeof rawValue === 'string' ? rawValue : JSON.stringify(rawValue, null, 2); - - detailsBox.setContent( - [ - `{bold}File{/bold}: ${selectedDocument.title}`, - `{bold}Path{/bold}: ${selectedEntry.pathText}`, - `{bold}Type{/bold}: ${selectedEntry.type}`, - `{bold}Editable{/bold}: ${selectedEntry.editable ? 'yes' : 'no'}`, - '', - '{bold}Current Value{/bold}', - valueText ?? 'null', - ].join('\n'), - ); } const dirtyText = hasUnsavedChanges ? '{yellow-fg}yes{/yellow-fg}' : '{green-fg}no{/green-fg}'; + const selectedEntry = visibleEntries[selectedEntryIndex]; + const keyDoc = selectedEntry != null ? getConfigDoc(selectedEntry.path) : null; + const docLine = keyDoc != null ? `${keyDoc.summary} (${keyDoc.source})` : 'No inline doc for this key yet.'; statusBar.setContent( [ `Path: ${configPath}`, `File: ${documents[selectedDocumentIndex].title} | Focus: ${focusPane} | Search: ${searchTerm || '(none)'} | Unsaved: ${dirtyText}`, `Status: ${statusMessage}`, + `Doc: ${docLine}`, ].join('\n'), ); @@ -476,6 +636,51 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: return null; }; + const resolveFixedArrayTarget = (selectedEntry: TomlEntry): { arrayPath: string[]; spec: FixedArraySpec } | null => { + const spec = getFixedArraySpecFromEntryPath(selectedEntry.path); + if (spec == null) { + return null; + } + + if (selectedEntry.type === 'array') { + return { arrayPath: selectedEntry.path, spec }; + } + + const lastPart = selectedEntry.path[selectedEntry.path.length - 1]; + if (/^\d+$/.test(lastPart)) { + return { arrayPath: selectedEntry.path.slice(0, -1), spec }; + } + + return { arrayPath: selectedEntry.path, spec }; + }; + + const editFixedArraySelection = async ( + documentId: 'ckb' | 'miner', + arrayPath: string[], + spec: FixedArraySpec, + ) => { + const rawArrayValue = editor.getEntryValue(documentId, arrayPath); + if (!Array.isArray(rawArrayValue)) { + statusMessage = `Path ${arrayPath.join('.')} is not an array.`; + refreshUi(); + return; + } + + const currentValues = rawArrayValue.map((item) => String(item)); + const selectedValues = await waitForFixedArraySelection(screen, `Edit ${spec.label}`, spec, currentValues); + if (selectedValues == null) { + statusMessage = 'Edit canceled.'; + refreshUi(); + return; + } + + const nextValues = spec.unique ? Array.from(new Set(selectedValues)) : selectedValues; + rawArrayValue.splice(0, rawArrayValue.length, ...nextValues); + hasUnsavedChanges = true; + statusMessage = `Updated ${arrayPath.join('.')} (${nextValues.length} selected).`; + refreshUi(); + }; + const saveAndExit = () => { editor.save(); hasUnsavedChanges = false; @@ -513,6 +718,12 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: } const selectedEntry = visibleEntries[selectedEntryIndex]; + const fixedArrayTarget = resolveFixedArrayTarget(selectedEntry); + if (fixedArrayTarget != null) { + await editFixedArraySelection(selectedDocument.id, fixedArrayTarget.arrayPath, fixedArrayTarget.spec); + return; + } + if (!selectedEntry.editable) { statusMessage = `Path ${selectedEntry.pathText} is not primitive-editable yet.`; refreshUi(); @@ -521,7 +732,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const value = editor.getEntryValue(selectedEntry.documentId, selectedEntry.path); const valueText = value == null ? '' : String(value); - const answer = await waitForInput(screen, 'Edit Value', selectedEntry.pathText, valueText); + const answer = await waitForArrayValue(screen, null, 'Edit Value', selectedEntry.pathText, valueText); if (answer == null) { statusMessage = 'Edit canceled.'; refreshUi(); @@ -623,7 +834,13 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: } if (targetEntry?.type === 'array') { - const valueAnswer = await waitForInput(screen, 'Append Array Item', `Append value to ${targetEntry.pathText}:`, ''); + const fixedArrayTarget = resolveFixedArrayTarget(targetEntry); + if (fixedArrayTarget != null) { + await editFixedArraySelection(selectedDocument.id, fixedArrayTarget.arrayPath, fixedArrayTarget.spec); + return; + } + + const valueAnswer = await waitForArrayValue(screen, null, 'Append Array Item', `Append value to ${targetEntry.pathText}:`, ''); if (valueAnswer == null) { statusMessage = 'Append canceled.'; refreshUi(); @@ -661,6 +878,12 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: return; } + const fixedArraySpec = getFixedArraySpecFromEntryPath(target.arrayPath); + if (fixedArraySpec != null) { + await editFixedArraySelection(selectedDocument.id, target.arrayPath, fixedArraySpec); + return; + } + const arrayValue = editor.getEntryValue(selectedDocument.id, target.arrayPath); const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; const indexAnswer = await waitForInput( @@ -675,12 +898,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: return; } - const valueAnswer = await waitForInput( - screen, - 'Insert Array Item', - `Value to insert at ${target.arrayPath.join('.')} (auto parse bool/number):`, - '', - ); + const valueAnswer = await waitForArrayValue(screen, null, 'Insert Array Item', `Value to insert at ${target.arrayPath.join('.')} (auto parse bool/number):`, ''); if (valueAnswer == null) { statusMessage = 'Insert canceled.'; refreshUi(); @@ -720,6 +938,12 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: return; } + const fixedArraySpec = getFixedArraySpecFromEntryPath(target.arrayPath); + if (fixedArraySpec != null) { + await editFixedArraySelection(selectedDocument.id, target.arrayPath, fixedArraySpec); + return; + } + const arrayValue = editor.getEntryValue(selectedDocument.id, target.arrayPath); const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; @@ -774,6 +998,12 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: } const selectedEntry = visibleEntries[selectedEntryIndex]; + const fixedArrayTarget = resolveFixedArrayTarget(selectedEntry); + if (fixedArrayTarget != null) { + await editFixedArraySelection(selectedDocument.id, fixedArrayTarget.arrayPath, fixedArrayTarget.spec); + return; + } + const confirmed = await waitForConfirm(screen, 'Delete Path', `Delete ${selectedEntry.pathText}?`); if (!confirmed) { statusMessage = 'Delete canceled.'; @@ -805,6 +1035,17 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: } }; + const syncEntrySelectionFromEntriesList = () => { + const listIndex = (entriesList as unknown as { selected?: number }).selected; + if (listIndex == null || listIndex < 0 || listIndex >= visibleEntries.length) { + return; + } + if (listIndex !== selectedEntryIndex) { + selectedEntryIndex = listIndex; + refreshUi(); + } + }; + filesList.on('select', (_, index) => { if (index == null) { return; @@ -822,6 +1063,17 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: refreshUi(); }); + entriesList.on('keypress', (_, key) => { + const navKeys = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; + if (!key?.name || !navKeys.includes(key.name)) { + return; + } + + setTimeout(() => { + syncEntrySelectionFromEntriesList(); + }, 0); + }); + filesList.on('keypress', (_, key) => { const navKeys = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; if (!key?.name || !navKeys.includes(key.name)) { @@ -861,6 +1113,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: }); screen.key(['enter'], () => { + syncEntrySelectionFromEntriesList(); void editCurrentEntry(); }); @@ -869,18 +1122,22 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: }); screen.key(['a'], () => { + syncEntrySelectionFromEntriesList(); void addEntry(); }); screen.key(['d'], () => { + syncEntrySelectionFromEntriesList(); void deleteEntry(); }); screen.key(['i'], () => { + syncEntrySelectionFromEntriesList(); void insertArrayEntry(); }); screen.key(['m'], () => { + syncEntrySelectionFromEntriesList(); void moveArrayEntry(); }); From e1c01178963f9272201bfc5b14fa2934b1ae4adf Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 12:43:54 +0800 Subject: [PATCH 07/24] feat(devnet): compact fixed arrays and edit from array row --- src/tui/devnet-config-tui.ts | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 82759f9..d9e6284 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -20,7 +20,9 @@ function formatEntryLine(entry: TomlEntry): string { } if (entry.type === 'array') { - return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${docText}`; + const fixedArraySpec = getFixedArraySpecFromEntryPath(entry.path); + const fixedArrayTag = fixedArraySpec != null ? ' {green-fg}[editable set]{/green-fg}' : ''; + return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${fixedArrayTag}${docText}`; } return `${treeIndent}${branch}{${keyColor}-fg}${nodeName}{/${keyColor}-fg} = {${valueColor}-fg}${entry.valuePreview}{/${valueColor}-fg}${docText}`; @@ -557,11 +559,25 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: }); const getVisibleEntries = (entries: TomlEntry[]): TomlEntry[] => { + const compactEntries = entries.filter((entry) => { + if (entry.path.length === 0) { + return true; + } + + const lastPathPart = entry.path[entry.path.length - 1]; + const isArrayItem = /^\d+$/.test(lastPathPart); + if (!isArrayItem) { + return true; + } + + return getFixedArraySpecFromEntryPath(entry.path) == null; + }); + const term = searchTerm.trim().toLowerCase(); if (!term) { - return entries; + return compactEntries; } - return entries.filter((entry) => { + return compactEntries.filter((entry) => { const text = `${entry.pathText} ${entry.valuePreview} ${entry.type}`.toLowerCase(); return text.includes(term); }); @@ -1063,6 +1079,18 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: refreshUi(); }); + entriesList.on('action', () => { + syncEntrySelectionFromEntriesList(); + const selectedEntry = visibleEntries[selectedEntryIndex]; + if (selectedEntry == null) { + return; + } + + if (getFixedArraySpecFromEntryPath(selectedEntry.path) != null) { + void editCurrentEntry(); + } + }); + entriesList.on('keypress', (_, key) => { const navKeys = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; if (!key?.name || !navKeys.includes(key.name)) { From 8e3d6517c09ec45341fb37e31dc49548516c277d Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 15:01:05 +0800 Subject: [PATCH 08/24] refactor(tui): decompose 1220-line monolith into focused modules\n\nBreak devnet-config-tui.ts into 7 modules:\n- tui-state.ts: TuiState/TuiWidgets interfaces + factory\n- dialogs.ts: 4 reusable dialog primitives (input, confirm, fixed-array, array-value)\n- actions.ts: all user actions (edit, add, delete, insert, move, search, quit, save)\n- format.ts: tree-view entry line formatter\n- blessed-helpers.ts: type-safe wrapper for untyped list.selected\n- devnet-config-tui.ts: thin orchestrator (layout, refreshUi, key bindings)\n\nAlso includes bug fixes from earlier review:\n- Add dialogLock to prevent global keys firing during modal dialogs\n- Add resolved guards to prevent double-resolve in all dialogs\n- Fix Enter event bubbling in waitForInput\n- Remove list.on('select') in fixed-array dialog to prevent toggle+apply conflict\n\nOther improvements:\n- Add editor.setArrayValues() to fix encapsulation leak (was direct splice)\n- Introduce guardedKey/guardedKeyAsync helpers to eliminate repeated dialogLock checks\n- Replace closure variables with centralized TuiState object\n- Add ActionContext pattern for testable action functions" --- docs/tui-refactoring-plan.md | 173 +++++ src/node/devnet-config-editor.ts | 8 + src/tui/actions.ts | 473 ++++++++++++ src/tui/blessed-helpers.ts | 9 + src/tui/devnet-config-tui.ts | 1185 ++++-------------------------- src/tui/dialogs.ts | 416 +++++++++++ src/tui/format.ts | 26 + src/tui/tui-state.ts | 43 ++ 8 files changed, 1291 insertions(+), 1042 deletions(-) create mode 100644 docs/tui-refactoring-plan.md create mode 100644 src/tui/actions.ts create mode 100644 src/tui/blessed-helpers.ts create mode 100644 src/tui/dialogs.ts create mode 100644 src/tui/format.ts create mode 100644 src/tui/tui-state.ts diff --git a/docs/tui-refactoring-plan.md b/docs/tui-refactoring-plan.md new file mode 100644 index 0000000..d50d759 --- /dev/null +++ b/docs/tui-refactoring-plan.md @@ -0,0 +1,173 @@ +# TUI Refactoring Plan + +## Status Tracker + +- [x] Step 1: Write refactoring plan doc +- [x] Step 2: Add `setArrayValues()` to `DevnetConfigEditor` +- [x] Step 3: Create `src/tui/blessed-helpers.ts` +- [x] Step 4: Create `src/tui/tui-state.ts` +- [x] Step 5: Create `src/tui/dialogs.ts` (3 dialog functions) +- [x] Step 6: Create `src/tui/format.ts` (entry line formatter) +- [x] Step 7: Create `src/tui/actions.ts` (all 8 action functions) +- [x] Step 8: Rewrite `src/tui/devnet-config-tui.ts` as thin orchestrator +- [x] Step 9: Verify build + tests pass + +## Current State + +Three files, ~2060 lines total: +- `src/node/devnet-config-editor.ts` (628 lines) — data layer, well structured +- `src/tui/devnet-config-metadata.ts` (213 lines) — metadata, fine as-is +- `src/tui/devnet-config-tui.ts` (1220 lines) — **needs refactoring** + +The TUI file has one 700-line god function (`runDevnetConfigTui`) with 10 closure +variables shared by 30+ inner functions, 3 duplicated dialog patterns, and 14 +repeated `if (dialogLock) return;` guards. + +## Target File Structure + +``` +src/tui/ + blessed-helpers.ts (~30 lines) - getListSelected(), type helpers + tui-state.ts (~70 lines) - TuiState interface + factory + dialogs.ts (~220 lines) - 3 dialog functions (input, confirm, select) + format.ts (~35 lines) - formatEntryLine() + actions.ts (~330 lines) - all 8 action functions + devnet-config-tui.ts (~150 lines) - layout, keybindings, main orchestrator + devnet-config-metadata.ts - UNCHANGED +``` + +Total: ~835 lines (down from 1220), no function over ~70 lines. + +## Shared Interfaces + +### TuiState (tui-state.ts) + +```typescript +import { Widgets } from 'blessed'; +import { DevnetConfigEditor, TomlEntry, TomlDocument } from '../node/devnet-config-editor'; + +export type FocusPane = 'files' | 'entries'; + +export interface TuiState { + editor: DevnetConfigEditor; + configPath: string; + documents: TomlDocument[]; + selectedDocumentIndex: number; + selectedEntryIndex: number; + focusPane: FocusPane; + hasUnsavedChanges: boolean; + didSave: boolean; + searchTerm: string; + statusMessage: string; + visibleEntries: TomlEntry[]; + dialogLock: boolean; +} + +export interface TuiWidgets { + screen: Widgets.Screen; + filesList: Widgets.ListElement; + entriesList: Widgets.ListElement; + statusBar: Widgets.BoxElement; +} + +export function createTuiState(editor, configPath): TuiState { ... } +``` + +### Action Function Signature + +Every action receives `(state, widgets)` and returns `Promise`. +Actions mutate `state` directly (it's a mutable bag). They call +`refreshUi(state, widgets)` at the end. + +### Dialog Functions + +Unchanged signatures — they take `screen` and return Promise. They are already +reasonably independent; we just consolidate them into one file and remove +duplicated boilerplate. + +## Step-by-Step Execution + +### Step 2: Add `setArrayValues()` to editor + +Fix the encapsulation leak where TUI directly `splice()`s editor internals. +Add a proper method to `DevnetConfigEditor`: + +```typescript +setArrayValues(documentId, pathParts, values: string[]): void +``` + +### Step 3: Create `blessed-helpers.ts` + +Extract the repeated `as unknown as { selected?: number }` pattern: + +```typescript +export function getListSelected(list: Widgets.ListElement): number +``` + +### Step 4: Create `tui-state.ts` + +Extract `TuiState`, `TuiWidgets`, `FocusPane` types and `createTuiState()`. + +### Step 5: Create `dialogs.ts` + +Move `waitForInput`, `waitForConfirm`, `waitForFixedArraySelection`, +`waitForArrayValue` into this file. No structural changes to the functions +themselves — just relocation + import cleanup. + +### Step 6: Create `format.ts` + +Move `formatEntryLine()` into its own file. + +### Step 7: Create `actions.ts` + +Extract all action logic from the god function into standalone functions: + +```typescript +export async function editCurrentEntry(state, widgets): Promise +export async function addEntry(state, widgets): Promise +export async function deleteEntry(state, widgets): Promise +export async function insertArrayEntry(state, widgets): Promise +export async function moveArrayEntry(state, widgets): Promise +export async function searchEntries(state, widgets): Promise +export function jumpSearchMatch(state, widgets, direction): void +export async function editFixedArraySelection(state, widgets, ...): Promise + +// Also extract these pure helpers: +export function resolveArrayTarget(entry): { arrayPath, suggestedIndex } | null +export function resolveFixedArrayTarget(entry): { arrayPath, spec } | null +export function parseNonNegativeInteger(value, fieldName): number +``` + +Each function takes `(state: TuiState, widgets: TuiWidgets, ...)` explicitly. + +### Step 8: Rewrite main `devnet-config-tui.ts` + +The main file becomes a thin orchestrator (~150 lines): +1. TTY check +2. Create screen + layout widgets +3. Create TuiState +4. `refreshUi()` function +5. `withDialogLock()` helper +6. `guardedKey()` helper — eliminates 14x `if (dialogLock) return;` +7. All key bindings (compact, using guardedKey) +8. List sync event handlers +9. Return `Promise` on screen destroy + +### Step 9: Verify + +- `npx tsc --noEmit` passes +- `npx jest tests/ --no-coverage` all pass +- No new lint errors + +## Key Design Decisions + +1. **State is a plain mutable object, not a class** — keeps it simple, + avoids getter/setter boilerplate. Actions mutate it directly. +2. **`refreshUi` stays in the main file** — it's the only function that + needs all widgets + state together. Actions call it via the widgets ref. +3. **Dialogs remain standalone functions** — they don't need state, only screen. +4. **No event emitter / pub-sub** — overkill for this scale. Direct function + calls are clearer. +5. **`devnet-config-metadata.ts` unchanged** — it's already clean. +6. **`devnet-config-editor.ts` gets one new method** — `setArrayValues()` to + fix the encapsulation leak. diff --git a/src/node/devnet-config-editor.ts b/src/node/devnet-config-editor.ts index 0978493..70d5f6e 100644 --- a/src/node/devnet-config-editor.ts +++ b/src/node/devnet-config-editor.ts @@ -555,6 +555,14 @@ export class DevnetConfigEditor { target.splice(toIndex, 0, item); } + setArrayValues(documentId: 'ckb' | 'miner', pathParts: string[], values: string[]): void { + const target = getByPath(this.getDocument(documentId).data as Record, pathParts); + if (!Array.isArray(target)) { + throw new Error('Target path is not an array.'); + } + target.splice(0, target.length, ...values); + } + deleteDocumentPath(documentId: 'ckb' | 'miner', pathParts: string[]): void { if (pathParts.length === 0) { throw new Error('Cannot delete the root node.'); diff --git a/src/tui/actions.ts b/src/tui/actions.ts new file mode 100644 index 0000000..21cf612 --- /dev/null +++ b/src/tui/actions.ts @@ -0,0 +1,473 @@ +import { TomlEntry } from '../node/devnet-config-editor'; +import { getFixedArraySpecFromEntryPath, FixedArraySpec } from './devnet-config-metadata'; +import { waitForInput, waitForConfirm, waitForFixedArraySelection, waitForArrayValue } from './dialogs'; +import { TuiState, TuiWidgets } from './tui-state'; + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +export function parseNonNegativeInteger(value: string, fieldName: string): number { + const parsed = Number(value.trim()); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${fieldName} must be a non-negative integer.`); + } + return parsed; +} + +export function resolveArrayTarget( + selectedEntry: TomlEntry, +): { arrayPath: string[]; suggestedIndex: number | null } | null { + if (selectedEntry.type === 'array') { + return { arrayPath: selectedEntry.path, suggestedIndex: null }; + } + + const lastPart = selectedEntry.path[selectedEntry.path.length - 1]; + const parsedIndex = Number(lastPart); + if (Number.isInteger(parsedIndex) && parsedIndex >= 0) { + return { arrayPath: selectedEntry.path.slice(0, -1), suggestedIndex: parsedIndex }; + } + + return null; +} + +export function resolveFixedArrayTarget( + selectedEntry: TomlEntry, +): { arrayPath: string[]; spec: FixedArraySpec } | null { + const spec = getFixedArraySpecFromEntryPath(selectedEntry.path); + if (spec == null) return null; + + if (selectedEntry.type === 'array') { + return { arrayPath: selectedEntry.path, spec }; + } + + const lastPart = selectedEntry.path[selectedEntry.path.length - 1]; + if (/^\d+$/.test(lastPart)) { + return { arrayPath: selectedEntry.path.slice(0, -1), spec }; + } + + return { arrayPath: selectedEntry.path, spec }; +} + +// --------------------------------------------------------------------------- +// Shared sub-action: edit a fixed-array via multi-select dialog +// --------------------------------------------------------------------------- + +export async function editFixedArraySelection( + state: TuiState, + screen: import('blessed').Widgets.Screen, + documentId: 'ckb' | 'miner', + arrayPath: string[], + spec: FixedArraySpec, + refreshUi: () => void, +): Promise { + const rawArrayValue = state.editor.getEntryValue(documentId, arrayPath); + if (!Array.isArray(rawArrayValue)) { + state.statusMessage = `Path ${arrayPath.join('.')} is not an array.`; + refreshUi(); + return; + } + + const currentValues = rawArrayValue.map((item) => String(item)); + const selectedValues = await waitForFixedArraySelection(screen, `Edit ${spec.label}`, spec, currentValues); + if (selectedValues == null) { + state.statusMessage = 'Edit canceled.'; + refreshUi(); + return; + } + + const nextValues = spec.unique ? Array.from(new Set(selectedValues)) : selectedValues; + state.editor.setArrayValues(documentId, arrayPath, nextValues); + state.hasUnsavedChanges = true; + state.statusMessage = `Updated ${arrayPath.join('.')} (${nextValues.length} selected).`; + refreshUi(); +} + +// --------------------------------------------------------------------------- +// Action context: common args passed to every action +// --------------------------------------------------------------------------- + +export interface ActionContext { + state: TuiState; + widgets: TuiWidgets; + refreshUi: () => void; +} + +function currentDocument(ctx: ActionContext) { + return ctx.state.documents[ctx.state.selectedDocumentIndex]; +} + +function currentEntry(ctx: ActionContext): TomlEntry | null { + if (ctx.state.visibleEntries.length === 0) return null; + return ctx.state.visibleEntries[ctx.state.selectedEntryIndex] ?? null; +} + +// --------------------------------------------------------------------------- +// Actions +// --------------------------------------------------------------------------- + +export async function editCurrentEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + if (state.focusPane === 'files') { + state.focusPane = 'entries'; + refreshUi(); + return; + } + + const doc = currentDocument(ctx); + const entry = currentEntry(ctx); + if (entry == null) return; + + const fixedTarget = resolveFixedArrayTarget(entry); + if (fixedTarget != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, fixedTarget.arrayPath, fixedTarget.spec, refreshUi); + return; + } + + if (!entry.editable) { + state.statusMessage = `Path ${entry.pathText} is not primitive-editable yet.`; + refreshUi(); + return; + } + + const value = state.editor.getEntryValue(entry.documentId, entry.path); + const valueText = value == null ? '' : String(value); + const answer = await waitForArrayValue(widgets.screen, null, 'Edit Value', entry.pathText, valueText); + if (answer == null) { + state.statusMessage = 'Edit canceled.'; + refreshUi(); + return; + } + + try { + state.editor.setDocumentValue(entry.documentId, entry.path, answer); + state.hasUnsavedChanges = true; + state.statusMessage = `Updated ${entry.pathText}.`; + } catch (error) { + state.statusMessage = `Validation error: ${(error as Error).message}`; + } + + refreshUi(); +} + +export async function searchEntries(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + const answer = await waitForInput( + widgets.screen, + 'Search', + 'Path/type/value filter (empty clears):', + state.searchTerm, + ); + if (answer == null) { + state.statusMessage = 'Search canceled.'; + refreshUi(); + return; + } + + state.searchTerm = answer.trim(); + state.selectedEntryIndex = 0; + state.statusMessage = state.searchTerm ? `Filter applied: ${state.searchTerm}` : 'Search filter cleared.'; + refreshUi(); +} + +export function jumpSearchMatch(ctx: ActionContext, direction: 'next' | 'prev'): void { + const { state, refreshUi } = ctx; + + if (state.visibleEntries.length === 0) { + state.statusMessage = state.searchTerm ? 'No search matches to jump.' : 'Set search filter first with /.'; + refreshUi(); + return; + } + + if (direction === 'next') { + state.selectedEntryIndex = (state.selectedEntryIndex + 1) % state.visibleEntries.length; + state.statusMessage = `Jumped to next match (${state.selectedEntryIndex + 1}/${state.visibleEntries.length}).`; + } else { + state.selectedEntryIndex = (state.selectedEntryIndex - 1 + state.visibleEntries.length) % state.visibleEntries.length; + state.statusMessage = `Jumped to previous match (${state.selectedEntryIndex + 1}/${state.visibleEntries.length}).`; + } + + refreshUi(); +} + +export async function addEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + const doc = currentDocument(ctx); + + const targetEntry = state.visibleEntries.length > 0 + ? state.visibleEntries[Math.min(state.selectedEntryIndex, state.visibleEntries.length - 1)] + : null; + + const targetPath = targetEntry?.path ?? []; + const targetValue = state.editor.getEntryValue(doc.id, targetPath); + + if (targetEntry == null && !Array.isArray(targetValue) && (targetValue == null || typeof targetValue !== 'object')) { + state.statusMessage = 'No target object/array selected for add.'; + refreshUi(); + return; + } + + if (targetEntry?.type === 'object' || (targetEntry == null && targetValue != null && typeof targetValue === 'object')) { + const keyAnswer = await waitForInput(widgets.screen, 'Add Object Key', 'New key name:', ''); + if (keyAnswer == null) { + state.statusMessage = 'Add canceled.'; + refreshUi(); + return; + } + + const valueAnswer = await waitForInput( + widgets.screen, + 'Add Object Key', + `Value for ${keyAnswer.trim()} (auto parse bool/number):`, + '', + ); + if (valueAnswer == null) { + state.statusMessage = 'Add canceled.'; + refreshUi(); + return; + } + + try { + state.editor.addObjectEntry(doc.id, targetPath, keyAnswer, valueAnswer); + state.hasUnsavedChanges = true; + state.statusMessage = `Added key '${keyAnswer.trim()}' under ${targetPath.join('.') || ''}.`; + } catch (error) { + state.statusMessage = `Add failed: ${(error as Error).message}`; + } + refreshUi(); + return; + } + + if (targetEntry?.type === 'array') { + const fixedTarget = resolveFixedArrayTarget(targetEntry); + if (fixedTarget != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, fixedTarget.arrayPath, fixedTarget.spec, refreshUi); + return; + } + + const valueAnswer = await waitForArrayValue( + widgets.screen, + null, + 'Append Array Item', + `Append value to ${targetEntry.pathText}:`, + '', + ); + if (valueAnswer == null) { + state.statusMessage = 'Append canceled.'; + refreshUi(); + return; + } + + try { + state.editor.appendArrayEntry(doc.id, targetEntry.path, valueAnswer); + state.hasUnsavedChanges = true; + state.statusMessage = `Appended value to ${targetEntry.pathText}.`; + } catch (error) { + state.statusMessage = `Append failed: ${(error as Error).message}`; + } + refreshUi(); + return; + } + + state.statusMessage = 'Select an object or array node to add items.'; + refreshUi(); +} + +export async function insertArrayEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + const entry = currentEntry(ctx); + if (entry == null) { + state.statusMessage = 'No selected entry for insert.'; + refreshUi(); + return; + } + + const doc = currentDocument(ctx); + const target = resolveArrayTarget(entry); + if (target == null) { + state.statusMessage = 'Select an array or array item to insert.'; + refreshUi(); + return; + } + + const fixedSpec = getFixedArraySpecFromEntryPath(target.arrayPath); + if (fixedSpec != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, target.arrayPath, fixedSpec, refreshUi); + return; + } + + const arrayValue = state.editor.getEntryValue(doc.id, target.arrayPath); + const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; + const indexAnswer = await waitForInput( + widgets.screen, + 'Insert Array Item', + `Insert index (0-${arrayLength}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, + target.suggestedIndex != null ? String(target.suggestedIndex) : String(arrayLength), + ); + if (indexAnswer == null) { + state.statusMessage = 'Insert canceled.'; + refreshUi(); + return; + } + + const valueAnswer = await waitForArrayValue( + widgets.screen, + null, + 'Insert Array Item', + `Value to insert at ${target.arrayPath.join('.')} (auto parse bool/number):`, + '', + ); + if (valueAnswer == null) { + state.statusMessage = 'Insert canceled.'; + refreshUi(); + return; + } + + try { + const indexInput = indexAnswer.trim(); + const insertIndex = indexInput === '' && target.suggestedIndex != null + ? target.suggestedIndex + : parseNonNegativeInteger(indexAnswer, 'Insert index'); + + state.editor.insertArrayEntry(doc.id, target.arrayPath, insertIndex, valueAnswer); + state.hasUnsavedChanges = true; + state.statusMessage = `Inserted array item at ${target.arrayPath.join('.')}[${insertIndex}].`; + } catch (error) { + state.statusMessage = `Insert failed: ${(error as Error).message}`; + } + + refreshUi(); +} + +export async function moveArrayEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + const entry = currentEntry(ctx); + if (entry == null) { + state.statusMessage = 'No selected entry for move.'; + refreshUi(); + return; + } + + const doc = currentDocument(ctx); + const target = resolveArrayTarget(entry); + if (target == null) { + state.statusMessage = 'Select an array or array item to move.'; + refreshUi(); + return; + } + + const fixedSpec = getFixedArraySpecFromEntryPath(target.arrayPath); + if (fixedSpec != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, target.arrayPath, fixedSpec, refreshUi); + return; + } + + const arrayValue = state.editor.getEntryValue(doc.id, target.arrayPath); + const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; + + const fromAnswer = await waitForInput( + widgets.screen, + 'Move Array Item', + `Move from index (0-${Math.max(0, arrayLength - 1)}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, + target.suggestedIndex != null ? String(target.suggestedIndex) : '0', + ); + if (fromAnswer == null) { + state.statusMessage = 'Move canceled.'; + refreshUi(); + return; + } + + const toAnswer = await waitForInput( + widgets.screen, + 'Move Array Item', + `Move to index (0-${Math.max(0, arrayLength - 1)}):`, + '0', + ); + if (toAnswer == null) { + state.statusMessage = 'Move canceled.'; + refreshUi(); + return; + } + + try { + const fromInput = fromAnswer.trim(); + const fromIndex = fromInput === '' && target.suggestedIndex != null + ? target.suggestedIndex + : parseNonNegativeInteger(fromAnswer, 'Source index'); + const toIndex = parseNonNegativeInteger(toAnswer, 'Target index'); + + state.editor.moveArrayEntry(doc.id, target.arrayPath, fromIndex, toIndex); + state.hasUnsavedChanges = true; + state.statusMessage = `Moved item in ${target.arrayPath.join('.')} from ${fromIndex} to ${toIndex}.`; + } catch (error) { + state.statusMessage = `Move failed: ${(error as Error).message}`; + } + + refreshUi(); +} + +export async function deleteEntry(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + const doc = currentDocument(ctx); + + const entry = currentEntry(ctx); + if (entry == null) { + state.statusMessage = 'No selected entry to delete.'; + refreshUi(); + return; + } + + const fixedTarget = resolveFixedArrayTarget(entry); + if (fixedTarget != null) { + await editFixedArraySelection(state, widgets.screen, doc.id, fixedTarget.arrayPath, fixedTarget.spec, refreshUi); + return; + } + + const confirmed = await waitForConfirm(widgets.screen, 'Delete Path', `Delete ${entry.pathText}?`); + if (!confirmed) { + state.statusMessage = 'Delete canceled.'; + refreshUi(); + return; + } + + try { + state.editor.deleteDocumentPath(doc.id, entry.path); + state.hasUnsavedChanges = true; + state.selectedEntryIndex = Math.max(0, state.selectedEntryIndex - 1); + state.statusMessage = `Deleted ${entry.pathText}.`; + } catch (error) { + state.statusMessage = `Delete failed: ${(error as Error).message}`; + } + refreshUi(); +} + +export async function quitFlow(ctx: ActionContext): Promise { + const { state, widgets, refreshUi } = ctx; + + if (!state.hasUnsavedChanges) { + widgets.screen.destroy(); + return; + } + + const shouldDiscard = await waitForConfirm(widgets.screen, 'Discard Changes', 'Discard unsaved changes?'); + if (shouldDiscard) { + widgets.screen.destroy(); + return; + } + + state.statusMessage = 'Continue editing.'; + refreshUi(); +} + +export function saveAndExit(ctx: ActionContext): void { + const { state, widgets } = ctx; + state.editor.save(); + state.hasUnsavedChanges = false; + state.didSave = true; + state.statusMessage = 'Saved.'; + widgets.screen.destroy(); +} diff --git a/src/tui/blessed-helpers.ts b/src/tui/blessed-helpers.ts new file mode 100644 index 0000000..2fac76b --- /dev/null +++ b/src/tui/blessed-helpers.ts @@ -0,0 +1,9 @@ +import { Widgets } from 'blessed'; + +/** + * Safely read the `selected` index from a blessed list element. + * Blessed exposes `.selected` at runtime but the type declarations omit it. + */ +export function getListSelected(list: Widgets.ListElement): number { + return (list as unknown as { selected?: number }).selected ?? 0; +} diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index d9e6284..99a0cf3 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -1,509 +1,56 @@ -import blessed, { Widgets } from 'blessed'; +import blessed from 'blessed'; import { DevnetConfigEditor, TomlEntry } from '../node/devnet-config-editor'; -import { getConfigDoc, getFixedArraySpecFromEntryPath, FixedArraySpec } from './devnet-config-metadata'; - -type FocusPane = 'files' | 'entries'; - -function formatEntryLine(entry: TomlEntry): string { - const depth = Math.max(0, entry.path.length - 1); - const lastPathPart = entry.path[entry.path.length - 1] ?? ''; - const nodeName = /^\d+$/.test(lastPathPart) ? `[${lastPathPart}]` : lastPathPart; - const treeIndent = depth === 0 ? '' : `${'│ '.repeat(Math.max(0, depth - 1))}`; - const branch = depth === 0 ? '' : '├─ '; - const keyDoc = getConfigDoc(entry.path); - const docText = keyDoc != null ? ` {gray-fg}// ${keyDoc.summary}{/gray-fg}` : ''; - const valueColor = entry.type === 'string' ? 'green' : entry.type === 'number' ? 'yellow' : 'magenta'; - const keyColor = depth === 0 ? 'cyan' : 'white'; - - if (entry.type === 'object') { - return `${treeIndent}${branch}{cyan-fg}▸ ${nodeName}{/cyan-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${docText}`; - } - - if (entry.type === 'array') { - const fixedArraySpec = getFixedArraySpecFromEntryPath(entry.path); - const fixedArrayTag = fixedArraySpec != null ? ' {green-fg}[editable set]{/green-fg}' : ''; - return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${fixedArrayTag}${docText}`; - } - - return `${treeIndent}${branch}{${keyColor}-fg}${nodeName}{/${keyColor}-fg} = {${valueColor}-fg}${entry.valuePreview}{/${valueColor}-fg}${docText}`; -} - -async function waitForFixedArraySelection( - screen: Widgets.Screen, - title: string, - spec: FixedArraySpec, - currentValues: string[], -): Promise { - return new Promise((resolve) => { - const maxVisibleRows = 14; - const visibleRows = Math.min(Math.max(spec.options.length, 1), maxVisibleRows); - const listHeight = visibleRows + 2; - const dialogHeight = listHeight + 6; - - const overlay = blessed.box({ - parent: screen, - top: 0, - left: 0, - width: '100%', - height: '100%', - mouse: true, - keys: true, - tags: true, - }); - - const dialog = blessed.box({ - parent: overlay, - label: ` ${title} `, - border: 'line', - top: 'center', - left: 'center', - width: '62%', - height: dialogHeight, - mouse: true, - keys: true, - tags: true, - style: { - border: { fg: 'cyan' }, - }, - }); - - const selectedValues = new Set(currentValues); - - const list = blessed.list({ - parent: dialog, - top: 1, - left: 1, - width: '100%-2', - height: listHeight, - border: 'line', - mouse: true, - keys: true, - vi: true, - tags: true, - style: { - selected: { bg: 'blue' }, - border: { fg: 'gray' }, - }, - }); - - const footer = blessed.box({ - parent: dialog, - top: listHeight + 1, - left: 2, - width: '100%-4', - height: 2, - tags: true, - content: '{gray-fg}Space toggle Enter apply Esc cancel{/gray-fg}', - }); - - const renderList = () => { - const items = spec.options.map((option) => { - const checked = selectedValues.has(option) ? 'x' : ' '; - return `[${checked}] ${option}`; - }); - list.setItems(items); - }; - - renderList(); - list.select(0); - - const screenWithGrab = screen as unknown as { grabKeys?: boolean; grabMouse?: boolean }; - const previousGrabKeys = screenWithGrab.grabKeys; - const previousGrabMouse = screenWithGrab.grabMouse; - screenWithGrab.grabKeys = true; - screenWithGrab.grabMouse = true; - - const cleanup = (value: string[] | null) => { - screenWithGrab.grabKeys = previousGrabKeys; - screenWithGrab.grabMouse = previousGrabMouse; - overlay.destroy(); - screen.render(); - resolve(value); - }; - - const selectedOption = () => { - const selectedIndex = (list as unknown as { selected?: number }).selected ?? 0; - return spec.options[selectedIndex] ?? null; - }; - - const applySelection = () => { - const values = spec.options.filter((option) => selectedValues.has(option)); - cleanup(values); - }; - - const toggleSelectedOption = () => { - const option = selectedOption(); - if (option == null) { - return; - } - - if (selectedValues.has(option)) { - selectedValues.delete(option); - } else { - selectedValues.add(option); - } - renderList(); - const selectedIndex = (list as unknown as { selected?: number }).selected ?? 0; - list.select(selectedIndex); - screen.render(); - }; - - list.on('select', () => { - toggleSelectedOption(); - }); - - list.key(['enter'], () => { - applySelection(); - }); - - list.key(['up', 'k'], () => { - list.up(1); - screen.render(); - }); - - list.key(['down', 'j'], () => { - list.down(1); - screen.render(); - }); - - list.key(['space'], () => { - toggleSelectedOption(); - }); - - dialog.key(['escape'], () => cleanup(null)); - dialog.key(['A-a'], () => { - spec.options.forEach((option) => selectedValues.add(option)); - renderList(); - screen.render(); - }); - dialog.key(['A-d'], () => { - selectedValues.clear(); - renderList(); - screen.render(); - }); - - list.focus(); - footer.setContent('{gray-fg}Space toggle Enter apply Esc cancel Alt+a all Alt+d none{/gray-fg}'); - screen.render(); +import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; +import { formatEntryLine } from './format'; +import { createTuiState, TuiWidgets } from './tui-state'; +import { getListSelected } from './blessed-helpers'; +import { + ActionContext, + editCurrentEntry, + searchEntries, + jumpSearchMatch, + addEntry, + deleteEntry, + insertArrayEntry, + moveArrayEntry, + quitFlow, + saveAndExit, +} from './actions'; + +// --------------------------------------------------------------------------- +// Visible-entry filter (compact fixed-array items + search term) +// --------------------------------------------------------------------------- + +function getVisibleEntries(entries: TomlEntry[], searchTerm: string): TomlEntry[] { + const compactEntries = entries.filter((entry) => { + if (entry.path.length === 0) return true; + const lastPathPart = entry.path[entry.path.length - 1]; + const isArrayItem = /^\d+$/.test(lastPathPart); + if (!isArrayItem) return true; + return getFixedArraySpecFromEntryPath(entry.path) == null; }); -} - -async function waitForArrayValue( - screen: Widgets.Screen, - spec: FixedArraySpec | null, - title: string, - questionText: string, - initialValue: string, -): Promise { - if (spec == null) { - return waitForInput(screen, title, questionText, initialValue); - } - - if (spec.options.includes(initialValue)) { - const selected = await waitForFixedArraySelection(screen, `${title} (${spec.label})`, spec, [initialValue]); - if (selected == null || selected.length === 0) { - return null; - } - return selected[0]; - } - - if (!spec.allowCustom) { - const selected = await waitForFixedArraySelection(screen, `${title} (${spec.label})`, spec, []); - if (selected == null || selected.length === 0) { - return null; - } - return selected[0]; - } - - return waitForInput(screen, title, questionText, initialValue); -} -function waitForInput( - screen: Widgets.Screen, - title: string, - questionText: string, - initialValue: string, -): Promise { - return new Promise((resolve) => { - const dialog = blessed.box({ - parent: screen, - label: ` ${title} `, - border: 'line', - top: 'center', - left: 'center', - width: '70%', - height: 13, - keys: true, - tags: true, - style: { - border: { fg: 'cyan' }, - }, - }); - - blessed.box({ - parent: dialog, - top: 1, - left: 2, - width: '95%-4', - height: 2, - tags: true, - content: questionText, - }); - - const input = blessed.textbox({ - parent: dialog, - top: 3, - left: 2, - width: '100%-4', - height: 3, - border: 'line', - inputOnFocus: true, - keys: true, - vi: true, - style: { - border: { fg: 'gray' }, - }, - }); - - const okButton = blessed.button({ - parent: dialog, - mouse: true, - keys: true, - shrink: true, - top: 8, - left: '40%-8', - height: 1, - content: ' OK ', - style: { - bg: 'blue', - focus: { bg: 'blue' }, - }, - }); - - const cancelButton = blessed.button({ - parent: dialog, - mouse: true, - keys: true, - shrink: true, - top: 8, - left: '40%+4', - height: 1, - content: ' Cancel ', - style: { - bg: 'gray', - focus: { bg: 'gray' }, - }, - }); - - type InputDialogFocus = 'input' | 'ok' | 'cancel'; - let currentFocus: InputDialogFocus = 'input'; - - const cleanup = (value: string | null) => { - dialog.destroy(); - screen.render(); - resolve(value); - }; - - const setFocus = (nextFocus: InputDialogFocus) => { - currentFocus = nextFocus; - if (nextFocus === 'input') { - input.style.border = { fg: 'cyan' }; - okButton.style.bg = 'blue'; - cancelButton.style.bg = 'gray'; - input.focus(); - } else if (nextFocus === 'ok') { - input.style.border = { fg: 'gray' }; - okButton.style.bg = 'cyan'; - cancelButton.style.bg = 'gray'; - okButton.focus(); - } else { - input.style.border = { fg: 'gray' }; - okButton.style.bg = 'blue'; - cancelButton.style.bg = 'cyan'; - cancelButton.focus(); - } - screen.render(); - }; - - const nextFocus = () => { - if (currentFocus === 'input') { - setFocus('ok'); - return; - } - if (currentFocus === 'ok') { - setFocus('cancel'); - return; - } - setFocus('input'); - }; - - const prevFocus = () => { - if (currentFocus === 'cancel') { - setFocus('ok'); - return; - } - if (currentFocus === 'ok') { - setFocus('input'); - return; - } - setFocus('cancel'); - }; - - const getInputValue = () => input.getValue() ?? ''; - - okButton.on('press', () => cleanup(getInputValue())); - cancelButton.on('press', () => cleanup(null)); - - dialog.key(['escape'], () => cleanup(null)); - dialog.key(['tab', 'down'], () => nextFocus()); - dialog.key(['S-tab', 'up'], () => prevFocus()); - dialog.key(['left'], () => { - if (currentFocus !== 'input') { - prevFocus(); - } - }); - dialog.key(['right'], () => { - if (currentFocus !== 'input') { - nextFocus(); - } - }); - dialog.key(['enter'], () => { - if (currentFocus === 'input') { - setFocus('ok'); - return; - } - if (currentFocus === 'ok') { - cleanup(getInputValue()); - return; - } - cleanup(null); - }); - - input.key(['enter'], () => { - setFocus('ok'); - }); - - input.setValue(initialValue); - setFocus('input'); - input.readInput(); - screen.render(); + const term = searchTerm.trim().toLowerCase(); + if (!term) return compactEntries; + return compactEntries.filter((entry) => { + const text = `${entry.pathText} ${entry.valuePreview} ${entry.type}`.toLowerCase(); + return text.includes(term); }); } -function waitForConfirm( - screen: Widgets.Screen, - title: string, - text: string, -): Promise { - return new Promise((resolve) => { - const dialog = blessed.box({ - parent: screen, - label: ` ${title} `, - border: 'line', - top: 'center', - left: 'center', - width: '60%', - height: 10, - keys: true, - tags: true, - style: { - border: { fg: 'cyan' }, - }, - }); - - blessed.box({ - parent: dialog, - top: 2, - left: 2, - width: '100%-4', - height: 2, - tags: true, - content: text, - }); - - const okButton = blessed.button({ - parent: dialog, - mouse: true, - keys: true, - shrink: true, - top: 5, - left: '40%-8', - height: 1, - content: ' OK ', - style: { - bg: 'blue', - focus: { bg: 'blue' }, - }, - }); - - const cancelButton = blessed.button({ - parent: dialog, - mouse: true, - keys: true, - shrink: true, - top: 5, - left: '40%+4', - height: 1, - content: ' Cancel ', - style: { - bg: 'gray', - focus: { bg: 'gray' }, - }, - }); - - let focusButton: 'ok' | 'cancel' = 'cancel'; - - const cleanup = (answer: boolean) => { - dialog.destroy(); - screen.render(); - resolve(answer); - }; - - const setFocus = (focus: 'ok' | 'cancel') => { - focusButton = focus; - if (focus === 'ok') { - okButton.style.bg = 'cyan'; - cancelButton.style.bg = 'gray'; - okButton.focus(); - } else { - okButton.style.bg = 'blue'; - cancelButton.style.bg = 'cyan'; - cancelButton.focus(); - } - screen.render(); - }; - - okButton.on('press', () => cleanup(true)); - cancelButton.on('press', () => cleanup(false)); - - dialog.key(['escape'], () => cleanup(false)); - dialog.key(['tab', 'left', 'right'], () => { - setFocus(focusButton === 'ok' ? 'cancel' : 'ok'); - }); - dialog.key(['enter'], () => { - cleanup(focusButton === 'ok'); - }); - - setFocus('cancel'); - screen.render(); - }); -} +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: string): Promise { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new Error('Interactive TUI requires a TTY terminal.'); } - const documents = editor.getDocuments(); - let selectedDocumentIndex = 0; - let selectedEntryIndex = 0; - let focusPane: FocusPane = 'files'; - let hasUnsavedChanges = false; - let didSave = false; - let searchTerm = ''; - let statusMessage = 'Ready'; - let visibleEntries: TomlEntry[] = []; + // ---- state ---- + const state = createTuiState(editor, configPath); + // ---- widgets ---- const screen = blessed.screen({ smartCSR: true, title: 'OffCKB Devnet Config Editor', @@ -520,10 +67,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: border: 'line', keys: true, vi: true, - style: { - selected: { bg: 'blue' }, - border: { fg: 'gray' }, - }, + style: { selected: { bg: 'blue' }, border: { fg: 'gray' } }, tags: true, }); @@ -537,10 +81,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: border: 'line', keys: true, vi: true, - style: { - selected: { bg: 'blue' }, - border: { fg: 'gray' }, - }, + style: { selected: { bg: 'blue' }, border: { fg: 'gray' } }, tags: true, }); @@ -553,73 +94,48 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: border: 'line', tags: true, content: '', - style: { - border: { fg: 'gray' }, - }, + style: { border: { fg: 'gray' } }, }); - const getVisibleEntries = (entries: TomlEntry[]): TomlEntry[] => { - const compactEntries = entries.filter((entry) => { - if (entry.path.length === 0) { - return true; - } - - const lastPathPart = entry.path[entry.path.length - 1]; - const isArrayItem = /^\d+$/.test(lastPathPart); - if (!isArrayItem) { - return true; - } - - return getFixedArraySpecFromEntryPath(entry.path) == null; - }); - - const term = searchTerm.trim().toLowerCase(); - if (!term) { - return compactEntries; - } - return compactEntries.filter((entry) => { - const text = `${entry.pathText} ${entry.valuePreview} ${entry.type}`.toLowerCase(); - return text.includes(term); - }); - }; + const widgets: TuiWidgets = { screen, filesList, entriesList, statusBar }; + // ---- refresh ---- const refreshUi = () => { - const fileItems = documents.map((document) => document.title); + const fileItems = state.documents.map((d) => d.title); filesList.setItems(fileItems); - filesList.select(selectedDocumentIndex); + filesList.select(state.selectedDocumentIndex); - const selectedDocument = documents[selectedDocumentIndex]; - const entries = editor.getEntriesForDocument(selectedDocument.id); - visibleEntries = getVisibleEntries(entries); - const entryLines = visibleEntries.map(formatEntryLine); - entriesList.setItems(entryLines); + const doc = state.documents[state.selectedDocumentIndex]; + const entries = state.editor.getEntriesForDocument(doc.id); + state.visibleEntries = getVisibleEntries(entries, state.searchTerm); + entriesList.setItems(state.visibleEntries.map(formatEntryLine)); - filesList.style.border = { fg: focusPane === 'files' ? 'cyan' : 'gray' }; - entriesList.style.border = { fg: focusPane === 'entries' ? 'cyan' : 'gray' }; + filesList.style.border = { fg: state.focusPane === 'files' ? 'cyan' : 'gray' }; + entriesList.style.border = { fg: state.focusPane === 'entries' ? 'cyan' : 'gray' }; - if (visibleEntries.length === 0) { - selectedEntryIndex = 0; + if (state.visibleEntries.length === 0) { + state.selectedEntryIndex = 0; } else { - if (selectedEntryIndex >= visibleEntries.length) { - selectedEntryIndex = visibleEntries.length - 1; + if (state.selectedEntryIndex >= state.visibleEntries.length) { + state.selectedEntryIndex = state.visibleEntries.length - 1; } - entriesList.select(selectedEntryIndex); + entriesList.select(state.selectedEntryIndex); } - const dirtyText = hasUnsavedChanges ? '{yellow-fg}yes{/yellow-fg}' : '{green-fg}no{/green-fg}'; - const selectedEntry = visibleEntries[selectedEntryIndex]; + const dirtyText = state.hasUnsavedChanges ? '{yellow-fg}yes{/yellow-fg}' : '{green-fg}no{/green-fg}'; + const selectedEntry = state.visibleEntries[state.selectedEntryIndex]; const keyDoc = selectedEntry != null ? getConfigDoc(selectedEntry.path) : null; const docLine = keyDoc != null ? `${keyDoc.summary} (${keyDoc.source})` : 'No inline doc for this key yet.'; statusBar.setContent( [ - `Path: ${configPath}`, - `File: ${documents[selectedDocumentIndex].title} | Focus: ${focusPane} | Search: ${searchTerm || '(none)'} | Unsaved: ${dirtyText}`, - `Status: ${statusMessage}`, + `Path: ${state.configPath}`, + `File: ${doc.title} | Focus: ${state.focusPane} | Search: ${state.searchTerm || '(none)'} | Unsaved: ${dirtyText}`, + `Status: ${state.statusMessage}`, `Doc: ${docLine}`, ].join('\n'), ); - if (focusPane === 'files') { + if (state.focusPane === 'files') { filesList.focus(); } else { entriesList.focus(); @@ -628,565 +144,150 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: screen.render(); }; - const parseNonNegativeInteger = (value: string, fieldName: string): number => { - const parsed = Number(value.trim()); - if (!Number.isInteger(parsed) || parsed < 0) { - throw new Error(`${fieldName} must be a non-negative integer.`); - } - return parsed; - }; - - const resolveArrayTarget = ( - selectedEntry: TomlEntry, - ): { arrayPath: string[]; suggestedIndex: number | null } | null => { - if (selectedEntry.type === 'array') { - return { arrayPath: selectedEntry.path, suggestedIndex: null }; - } - - const lastPart = selectedEntry.path[selectedEntry.path.length - 1]; - const parsedIndex = Number(lastPart); - if (Number.isInteger(parsedIndex) && parsedIndex >= 0) { - return { arrayPath: selectedEntry.path.slice(0, -1), suggestedIndex: parsedIndex }; - } - - return null; - }; - - const resolveFixedArrayTarget = (selectedEntry: TomlEntry): { arrayPath: string[]; spec: FixedArraySpec } | null => { - const spec = getFixedArraySpecFromEntryPath(selectedEntry.path); - if (spec == null) { - return null; - } - - if (selectedEntry.type === 'array') { - return { arrayPath: selectedEntry.path, spec }; - } - - const lastPart = selectedEntry.path[selectedEntry.path.length - 1]; - if (/^\d+$/.test(lastPart)) { - return { arrayPath: selectedEntry.path.slice(0, -1), spec }; - } - - return { arrayPath: selectedEntry.path, spec }; - }; - - const editFixedArraySelection = async ( - documentId: 'ckb' | 'miner', - arrayPath: string[], - spec: FixedArraySpec, - ) => { - const rawArrayValue = editor.getEntryValue(documentId, arrayPath); - if (!Array.isArray(rawArrayValue)) { - statusMessage = `Path ${arrayPath.join('.')} is not an array.`; - refreshUi(); - return; - } - - const currentValues = rawArrayValue.map((item) => String(item)); - const selectedValues = await waitForFixedArraySelection(screen, `Edit ${spec.label}`, spec, currentValues); - if (selectedValues == null) { - statusMessage = 'Edit canceled.'; - refreshUi(); - return; - } - - const nextValues = spec.unique ? Array.from(new Set(selectedValues)) : selectedValues; - rawArrayValue.splice(0, rawArrayValue.length, ...nextValues); - hasUnsavedChanges = true; - statusMessage = `Updated ${arrayPath.join('.')} (${nextValues.length} selected).`; - refreshUi(); - }; - - const saveAndExit = () => { - editor.save(); - hasUnsavedChanges = false; - didSave = true; - statusMessage = 'Saved.'; - screen.destroy(); - }; - - const quitFlow = async () => { - if (!hasUnsavedChanges) { - screen.destroy(); - return; - } - - const shouldDiscard = await waitForConfirm(screen, 'Discard Changes', 'Discard unsaved changes?'); - if (shouldDiscard) { - screen.destroy(); - return; - } - - statusMessage = 'Continue editing.'; - refreshUi(); - }; - - const editCurrentEntry = async () => { - if (focusPane === 'files') { - focusPane = 'entries'; - refreshUi(); - return; - } - - const selectedDocument = documents[selectedDocumentIndex]; - if (visibleEntries.length === 0) { - return; - } - - const selectedEntry = visibleEntries[selectedEntryIndex]; - const fixedArrayTarget = resolveFixedArrayTarget(selectedEntry); - if (fixedArrayTarget != null) { - await editFixedArraySelection(selectedDocument.id, fixedArrayTarget.arrayPath, fixedArrayTarget.spec); - return; - } - - if (!selectedEntry.editable) { - statusMessage = `Path ${selectedEntry.pathText} is not primitive-editable yet.`; - refreshUi(); - return; - } - - const value = editor.getEntryValue(selectedEntry.documentId, selectedEntry.path); - const valueText = value == null ? '' : String(value); - const answer = await waitForArrayValue(screen, null, 'Edit Value', selectedEntry.pathText, valueText); - if (answer == null) { - statusMessage = 'Edit canceled.'; - refreshUi(); - return; - } - - try { - editor.setDocumentValue(selectedEntry.documentId, selectedEntry.path, answer); - hasUnsavedChanges = true; - statusMessage = `Updated ${selectedEntry.pathText}.`; - } catch (error) { - statusMessage = `Validation error: ${(error as Error).message}`; - } - - refreshUi(); - }; - - const searchEntries = async () => { - const answer = await waitForInput( - screen, - 'Search', - 'Path/type/value filter (empty clears):', - searchTerm, - ); - if (answer == null) { - statusMessage = 'Search canceled.'; - refreshUi(); - return; - } - - searchTerm = answer.trim(); - selectedEntryIndex = 0; - statusMessage = searchTerm ? `Filter applied: ${searchTerm}` : 'Search filter cleared.'; - refreshUi(); - }; - - const jumpSearchMatch = (direction: 'next' | 'prev') => { - if (visibleEntries.length === 0) { - statusMessage = searchTerm ? 'No search matches to jump.' : 'Set search filter first with /.'; - refreshUi(); - return; - } - - if (direction === 'next') { - selectedEntryIndex = (selectedEntryIndex + 1) % visibleEntries.length; - statusMessage = `Jumped to next match (${selectedEntryIndex + 1}/${visibleEntries.length}).`; - } else { - selectedEntryIndex = (selectedEntryIndex - 1 + visibleEntries.length) % visibleEntries.length; - statusMessage = `Jumped to previous match (${selectedEntryIndex + 1}/${visibleEntries.length}).`; - } - - refreshUi(); - }; - - const addEntry = async () => { - const selectedDocument = documents[selectedDocumentIndex]; - - const targetEntry = - visibleEntries.length > 0 ? visibleEntries[Math.min(selectedEntryIndex, visibleEntries.length - 1)] : null; - - const targetPath = targetEntry?.path ?? []; - const targetValue = editor.getEntryValue(selectedDocument.id, targetPath); - - if (targetEntry == null && !Array.isArray(targetValue) && (targetValue == null || typeof targetValue !== 'object')) { - statusMessage = 'No target object/array selected for add.'; - refreshUi(); - return; - } - - if (targetEntry?.type === 'object' || (targetEntry == null && targetValue != null && typeof targetValue === 'object')) { - const keyAnswer = await waitForInput(screen, 'Add Object Key', 'New key name:', ''); - if (keyAnswer == null) { - statusMessage = 'Add canceled.'; - refreshUi(); - return; - } - - const valueAnswer = await waitForInput( - screen, - 'Add Object Key', - `Value for ${keyAnswer.trim()} (auto parse bool/number):`, - '', - ); - if (valueAnswer == null) { - statusMessage = 'Add canceled.'; - refreshUi(); - return; - } - - try { - editor.addObjectEntry(selectedDocument.id, targetPath, keyAnswer, valueAnswer); - hasUnsavedChanges = true; - statusMessage = `Added key '${keyAnswer.trim()}' under ${targetPath.join('.') || ''}.`; - } catch (error) { - statusMessage = `Add failed: ${(error as Error).message}`; - } - refreshUi(); - return; - } - - if (targetEntry?.type === 'array') { - const fixedArrayTarget = resolveFixedArrayTarget(targetEntry); - if (fixedArrayTarget != null) { - await editFixedArraySelection(selectedDocument.id, fixedArrayTarget.arrayPath, fixedArrayTarget.spec); - return; - } - - const valueAnswer = await waitForArrayValue(screen, null, 'Append Array Item', `Append value to ${targetEntry.pathText}:`, ''); - if (valueAnswer == null) { - statusMessage = 'Append canceled.'; - refreshUi(); - return; - } - - try { - editor.appendArrayEntry(selectedDocument.id, targetEntry.path, valueAnswer); - hasUnsavedChanges = true; - statusMessage = `Appended value to ${targetEntry.pathText}.`; - } catch (error) { - statusMessage = `Append failed: ${(error as Error).message}`; - } - refreshUi(); - return; - } - - statusMessage = 'Select an object or array node to add items.'; - refreshUi(); - }; - - const insertArrayEntry = async () => { - if (visibleEntries.length === 0) { - statusMessage = 'No selected entry for insert.'; - refreshUi(); - return; - } - - const selectedDocument = documents[selectedDocumentIndex]; - const selectedEntry = visibleEntries[selectedEntryIndex]; - const target = resolveArrayTarget(selectedEntry); - if (target == null) { - statusMessage = 'Select an array or array item to insert.'; - refreshUi(); - return; - } - - const fixedArraySpec = getFixedArraySpecFromEntryPath(target.arrayPath); - if (fixedArraySpec != null) { - await editFixedArraySelection(selectedDocument.id, target.arrayPath, fixedArraySpec); - return; - } - - const arrayValue = editor.getEntryValue(selectedDocument.id, target.arrayPath); - const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; - const indexAnswer = await waitForInput( - screen, - 'Insert Array Item', - `Insert index (0-${arrayLength}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, - target.suggestedIndex != null ? String(target.suggestedIndex) : String(arrayLength), - ); - if (indexAnswer == null) { - statusMessage = 'Insert canceled.'; - refreshUi(); - return; - } - - const valueAnswer = await waitForArrayValue(screen, null, 'Insert Array Item', `Value to insert at ${target.arrayPath.join('.')} (auto parse bool/number):`, ''); - if (valueAnswer == null) { - statusMessage = 'Insert canceled.'; - refreshUi(); - return; - } - - try { - const indexInput = indexAnswer.trim(); - const insertIndex = - indexInput === '' && target.suggestedIndex != null - ? target.suggestedIndex - : parseNonNegativeInteger(indexAnswer, 'Insert index'); - - editor.insertArrayEntry(selectedDocument.id, target.arrayPath, insertIndex, valueAnswer); - hasUnsavedChanges = true; - statusMessage = `Inserted array item at ${target.arrayPath.join('.')}[${insertIndex}].`; - } catch (error) { - statusMessage = `Insert failed: ${(error as Error).message}`; - } - - refreshUi(); - }; - - const moveArrayEntry = async () => { - if (visibleEntries.length === 0) { - statusMessage = 'No selected entry for move.'; - refreshUi(); - return; - } - - const selectedDocument = documents[selectedDocumentIndex]; - const selectedEntry = visibleEntries[selectedEntryIndex]; - const target = resolveArrayTarget(selectedEntry); - if (target == null) { - statusMessage = 'Select an array or array item to move.'; - refreshUi(); - return; - } - - const fixedArraySpec = getFixedArraySpecFromEntryPath(target.arrayPath); - if (fixedArraySpec != null) { - await editFixedArraySelection(selectedDocument.id, target.arrayPath, fixedArraySpec); - return; - } - - const arrayValue = editor.getEntryValue(selectedDocument.id, target.arrayPath); - const arrayLength = Array.isArray(arrayValue) ? arrayValue.length : 0; - - const fromAnswer = await waitForInput( - screen, - 'Move Array Item', - `Move from index (0-${Math.max(0, arrayLength - 1)}${target.suggestedIndex != null ? `, default ${target.suggestedIndex}` : ''}):`, - target.suggestedIndex != null ? String(target.suggestedIndex) : '0', - ); - if (fromAnswer == null) { - statusMessage = 'Move canceled.'; - refreshUi(); - return; - } - - const toAnswer = await waitForInput( - screen, - 'Move Array Item', - `Move to index (0-${Math.max(0, arrayLength - 1)}):`, - '0', - ); - if (toAnswer == null) { - statusMessage = 'Move canceled.'; - refreshUi(); - return; - } - - try { - const fromInput = fromAnswer.trim(); - const fromIndex = - fromInput === '' && target.suggestedIndex != null - ? target.suggestedIndex - : parseNonNegativeInteger(fromAnswer, 'Source index'); - const toIndex = parseNonNegativeInteger(toAnswer, 'Target index'); - - editor.moveArrayEntry(selectedDocument.id, target.arrayPath, fromIndex, toIndex); - hasUnsavedChanges = true; - statusMessage = `Moved item in ${target.arrayPath.join('.')} from ${fromIndex} to ${toIndex}.`; - } catch (error) { - statusMessage = `Move failed: ${(error as Error).message}`; - } - - refreshUi(); + // ---- helpers ---- + const withDialogLock = (fn: () => Promise) => { + if (state.dialogLock) return; + state.dialogLock = true; + fn().finally(() => { + state.dialogLock = false; + }); }; - const deleteEntry = async () => { - const selectedDocument = documents[selectedDocumentIndex]; - if (visibleEntries.length === 0) { - statusMessage = 'No selected entry to delete.'; - refreshUi(); - return; - } - - const selectedEntry = visibleEntries[selectedEntryIndex]; - const fixedArrayTarget = resolveFixedArrayTarget(selectedEntry); - if (fixedArrayTarget != null) { - await editFixedArraySelection(selectedDocument.id, fixedArrayTarget.arrayPath, fixedArrayTarget.spec); - return; - } - - const confirmed = await waitForConfirm(screen, 'Delete Path', `Delete ${selectedEntry.pathText}?`); - if (!confirmed) { - statusMessage = 'Delete canceled.'; - refreshUi(); - return; - } - - try { - editor.deleteDocumentPath(selectedDocument.id, selectedEntry.path); - hasUnsavedChanges = true; - selectedEntryIndex = Math.max(0, selectedEntryIndex - 1); - statusMessage = `Deleted ${selectedEntry.pathText}.`; - } catch (error) { - statusMessage = `Delete failed: ${(error as Error).message}`; - } - refreshUi(); - }; + const ctx: ActionContext = { state, widgets, refreshUi }; const syncDocumentSelectionFromFilesList = () => { - const listIndex = (filesList as unknown as { selected?: number }).selected; - if (listIndex == null || listIndex < 0 || listIndex >= documents.length) { - return; - } - if (listIndex !== selectedDocumentIndex) { - selectedDocumentIndex = listIndex; - selectedEntryIndex = 0; - statusMessage = `Switched to ${documents[selectedDocumentIndex].title}.`; + const listIndex = getListSelected(filesList); + if (listIndex < 0 || listIndex >= state.documents.length) return; + if (listIndex !== state.selectedDocumentIndex) { + state.selectedDocumentIndex = listIndex; + state.selectedEntryIndex = 0; + state.statusMessage = `Switched to ${state.documents[state.selectedDocumentIndex].title}.`; refreshUi(); } }; const syncEntrySelectionFromEntriesList = () => { - const listIndex = (entriesList as unknown as { selected?: number }).selected; - if (listIndex == null || listIndex < 0 || listIndex >= visibleEntries.length) { - return; - } - if (listIndex !== selectedEntryIndex) { - selectedEntryIndex = listIndex; + const listIndex = getListSelected(entriesList); + if (listIndex < 0 || listIndex >= state.visibleEntries.length) return; + if (listIndex !== state.selectedEntryIndex) { + state.selectedEntryIndex = listIndex; refreshUi(); } }; - filesList.on('select', (_, index) => { - if (index == null) { - return; - } - selectedDocumentIndex = index; - selectedEntryIndex = 0; + // ---- list events ---- + filesList.on('select', (_: unknown, index: number) => { + if (index == null) return; + state.selectedDocumentIndex = index; + state.selectedEntryIndex = 0; refreshUi(); }); - entriesList.on('select', (_, index) => { - if (index == null) { - return; - } - selectedEntryIndex = index; + entriesList.on('select', (_: unknown, index: number) => { + if (index == null) return; + state.selectedEntryIndex = index; refreshUi(); }); entriesList.on('action', () => { + if (state.dialogLock) return; syncEntrySelectionFromEntriesList(); - const selectedEntry = visibleEntries[selectedEntryIndex]; - if (selectedEntry == null) { - return; - } - - if (getFixedArraySpecFromEntryPath(selectedEntry.path) != null) { - void editCurrentEntry(); + const entry = state.visibleEntries[state.selectedEntryIndex]; + if (entry == null) return; + if (getFixedArraySpecFromEntryPath(entry.path) != null) { + withDialogLock(() => editCurrentEntry(ctx)); } }); - entriesList.on('keypress', (_, key) => { - const navKeys = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; - if (!key?.name || !navKeys.includes(key.name)) { - return; - } + const NAV_KEYS = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; - setTimeout(() => { - syncEntrySelectionFromEntriesList(); - }, 0); + entriesList.on('keypress', (_: unknown, key: { name?: string }) => { + if (!key?.name || !NAV_KEYS.includes(key.name)) return; + setTimeout(() => syncEntrySelectionFromEntriesList(), 0); }); - filesList.on('keypress', (_, key) => { - const navKeys = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; - if (!key?.name || !navKeys.includes(key.name)) { - return; - } - - setTimeout(() => { - syncDocumentSelectionFromFilesList(); - }, 0); + filesList.on('keypress', (_: unknown, key: { name?: string }) => { + if (!key?.name || !NAV_KEYS.includes(key.name)) return; + setTimeout(() => syncDocumentSelectionFromFilesList(), 0); }); - screen.key(['tab'], () => { - focusPane = focusPane === 'files' ? 'entries' : 'files'; + // ---- guarded key helpers ---- + const guardedKey = (keys: string[], fn: () => void) => { + screen.key(keys, () => { + if (!state.dialogLock) fn(); + }); + }; + + const guardedKeyAsync = (keys: string[], fn: () => Promise) => { + screen.key(keys, () => { + if (!state.dialogLock) withDialogLock(fn); + }); + }; + + // ---- key bindings ---- + guardedKey(['tab'], () => { + state.focusPane = state.focusPane === 'files' ? 'entries' : 'files'; refreshUi(); }); - screen.key(['left', 'h'], () => { - if (focusPane === 'entries') { - focusPane = 'files'; + guardedKey(['left', 'h'], () => { + if (state.focusPane === 'entries') { + state.focusPane = 'files'; refreshUi(); } }); - screen.key(['right', 'l'], () => { - if (focusPane === 'files') { - focusPane = 'entries'; + guardedKey(['right', 'l'], () => { + if (state.focusPane === 'files') { + state.focusPane = 'entries'; refreshUi(); } }); - screen.key(['s'], () => { - saveAndExit(); - }); + guardedKey(['s'], () => saveAndExit(ctx)); - screen.key(['q', 'C-c'], () => { - void quitFlow(); - }); + guardedKeyAsync(['q', 'C-c'], () => quitFlow(ctx)); - screen.key(['enter'], () => { + guardedKeyAsync(['enter'], () => { syncEntrySelectionFromEntriesList(); - void editCurrentEntry(); + return editCurrentEntry(ctx); }); - screen.key(['/'], () => { - void searchEntries(); - }); + guardedKeyAsync(['/'], () => searchEntries(ctx)); - screen.key(['a'], () => { + guardedKeyAsync(['a'], () => { syncEntrySelectionFromEntriesList(); - void addEntry(); + return addEntry(ctx); }); - screen.key(['d'], () => { + guardedKeyAsync(['d'], () => { syncEntrySelectionFromEntriesList(); - void deleteEntry(); + return deleteEntry(ctx); }); - screen.key(['i'], () => { + guardedKeyAsync(['i'], () => { syncEntrySelectionFromEntriesList(); - void insertArrayEntry(); + return insertArrayEntry(ctx); }); - screen.key(['m'], () => { + guardedKeyAsync(['m'], () => { syncEntrySelectionFromEntriesList(); - void moveArrayEntry(); + return moveArrayEntry(ctx); }); - screen.key(['n'], () => { - jumpSearchMatch('next'); - }); - - screen.key(['N'], () => { - jumpSearchMatch('prev'); - }); + guardedKey(['n'], () => jumpSearchMatch(ctx, 'next')); + guardedKey(['N'], () => jumpSearchMatch(ctx, 'prev')); filesList.key(['enter'], () => { - focusPane = 'entries'; + if (state.dialogLock) return; + state.focusPane = 'entries'; refreshUi(); }); + // ---- start ---- refreshUi(); return new Promise((resolve) => { - screen.once('destroy', () => { - resolve(didSave); - }); + screen.once('destroy', () => resolve(state.didSave)); }); } diff --git a/src/tui/dialogs.ts b/src/tui/dialogs.ts new file mode 100644 index 0000000..5d913b3 --- /dev/null +++ b/src/tui/dialogs.ts @@ -0,0 +1,416 @@ +import blessed, { Widgets } from 'blessed'; +import { FixedArraySpec } from './devnet-config-metadata'; +import { getListSelected } from './blessed-helpers'; + +// --------------------------------------------------------------------------- +// Fixed-array multi-select dialog +// --------------------------------------------------------------------------- + +export async function waitForFixedArraySelection( + screen: Widgets.Screen, + title: string, + spec: FixedArraySpec, + currentValues: string[], +): Promise { + return new Promise((resolve) => { + const maxVisibleRows = 14; + const visibleRows = Math.min(Math.max(spec.options.length, 1), maxVisibleRows); + const listHeight = visibleRows + 2; + const dialogHeight = listHeight + 6; + + const overlay = blessed.box({ + parent: screen, + top: 0, + left: 0, + width: '100%', + height: '100%', + mouse: true, + keys: true, + tags: true, + }); + + const dialog = blessed.box({ + parent: overlay, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '62%', + height: dialogHeight, + mouse: true, + keys: true, + tags: true, + style: { border: { fg: 'cyan' } }, + }); + + const selectedValues = new Set(currentValues); + + const list = blessed.list({ + parent: dialog, + top: 1, + left: 1, + width: '100%-2', + height: listHeight, + border: 'line', + mouse: true, + keys: true, + vi: true, + tags: true, + style: { + selected: { bg: 'blue' }, + border: { fg: 'gray' }, + }, + }); + + blessed.box({ + parent: dialog, + top: listHeight + 1, + left: 2, + width: '100%-4', + height: 2, + tags: true, + content: '{gray-fg}Space toggle Enter apply Esc cancel Alt+a all Alt+d none{/gray-fg}', + }); + + const renderList = () => { + const items = spec.options.map((option) => { + const checked = selectedValues.has(option) ? 'x' : ' '; + return `[${checked}] ${option}`; + }); + list.setItems(items); + }; + + renderList(); + list.select(0); + + const screenWithGrab = screen as unknown as { grabKeys?: boolean; grabMouse?: boolean }; + const previousGrabKeys = screenWithGrab.grabKeys; + const previousGrabMouse = screenWithGrab.grabMouse; + screenWithGrab.grabKeys = true; + screenWithGrab.grabMouse = true; + + let resolved = false; + const cleanup = (value: string[] | null) => { + if (resolved) return; + resolved = true; + screenWithGrab.grabKeys = previousGrabKeys; + screenWithGrab.grabMouse = previousGrabMouse; + overlay.destroy(); + screen.render(); + resolve(value); + }; + + const selectedOption = () => { + const selectedIndex = getListSelected(list); + return spec.options[selectedIndex] ?? null; + }; + + const applySelection = () => { + const values = spec.options.filter((option) => selectedValues.has(option)); + cleanup(values); + }; + + const toggleSelectedOption = () => { + const option = selectedOption(); + if (option == null) return; + + if (selectedValues.has(option)) { + selectedValues.delete(option); + } else { + selectedValues.add(option); + } + renderList(); + list.select(getListSelected(list)); + screen.render(); + }; + + list.key(['enter'], () => applySelection()); + list.key(['up', 'k'], () => { list.up(1); screen.render(); }); + list.key(['down', 'j'], () => { list.down(1); screen.render(); }); + list.key(['space'], () => toggleSelectedOption()); + + dialog.key(['escape'], () => cleanup(null)); + dialog.key(['A-a'], () => { + spec.options.forEach((option) => selectedValues.add(option)); + renderList(); + screen.render(); + }); + dialog.key(['A-d'], () => { + selectedValues.clear(); + renderList(); + screen.render(); + }); + + list.focus(); + screen.render(); + }); +} + +// --------------------------------------------------------------------------- +// Text input dialog +// --------------------------------------------------------------------------- + +export function waitForInput( + screen: Widgets.Screen, + title: string, + questionText: string, + initialValue: string, +): Promise { + return new Promise((resolve) => { + const dialog = blessed.box({ + parent: screen, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '70%', + height: 13, + keys: true, + tags: true, + style: { border: { fg: 'cyan' } }, + }); + + blessed.box({ + parent: dialog, + top: 1, + left: 2, + width: '95%-4', + height: 2, + tags: true, + content: questionText, + }); + + const input = blessed.textbox({ + parent: dialog, + top: 3, + left: 2, + width: '100%-4', + height: 3, + border: 'line', + inputOnFocus: true, + keys: true, + vi: true, + style: { border: { fg: 'gray' } }, + }); + + const okButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 8, + left: '40%-8', + height: 1, + content: ' OK ', + style: { bg: 'blue', focus: { bg: 'blue' } }, + }); + + const cancelButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 8, + left: '40%+4', + height: 1, + content: ' Cancel ', + style: { bg: 'gray', focus: { bg: 'gray' } }, + }); + + type InputDialogFocus = 'input' | 'ok' | 'cancel'; + let currentFocus: InputDialogFocus = 'input'; + + let resolved = false; + const cleanup = (value: string | null) => { + if (resolved) return; + resolved = true; + dialog.destroy(); + screen.render(); + resolve(value); + }; + + const setFocus = (nextFocus: InputDialogFocus) => { + if (resolved) return; + currentFocus = nextFocus; + if (nextFocus === 'input') { + input.style.border = { fg: 'cyan' }; + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'gray'; + input.focus(); + } else if (nextFocus === 'ok') { + input.style.border = { fg: 'gray' }; + okButton.style.bg = 'cyan'; + cancelButton.style.bg = 'gray'; + okButton.focus(); + } else { + input.style.border = { fg: 'gray' }; + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'cyan'; + cancelButton.focus(); + } + screen.render(); + }; + + const nextFocus = () => { + if (currentFocus === 'input') { setFocus('ok'); return; } + if (currentFocus === 'ok') { setFocus('cancel'); return; } + setFocus('input'); + }; + + const prevFocus = () => { + if (currentFocus === 'cancel') { setFocus('ok'); return; } + if (currentFocus === 'ok') { setFocus('input'); return; } + setFocus('cancel'); + }; + + const getInputValue = () => input.getValue() ?? ''; + + okButton.on('press', () => cleanup(getInputValue())); + cancelButton.on('press', () => cleanup(null)); + + dialog.key(['escape'], () => cleanup(null)); + dialog.key(['tab', 'down'], () => nextFocus()); + dialog.key(['S-tab', 'up'], () => prevFocus()); + dialog.key(['left'], () => { if (currentFocus !== 'input') prevFocus(); }); + dialog.key(['right'], () => { if (currentFocus !== 'input') nextFocus(); }); + dialog.key(['enter'], () => { + if (currentFocus === 'input') { cleanup(getInputValue()); return; } + if (currentFocus === 'ok') { cleanup(getInputValue()); return; } + cleanup(null); + }); + + input.key(['enter'], () => cleanup(getInputValue())); + + input.setValue(initialValue); + setFocus('input'); + input.readInput(); + screen.render(); + }); +} + +// --------------------------------------------------------------------------- +// Confirmation dialog +// --------------------------------------------------------------------------- + +export function waitForConfirm( + screen: Widgets.Screen, + title: string, + text: string, +): Promise { + return new Promise((resolve) => { + const dialog = blessed.box({ + parent: screen, + label: ` ${title} `, + border: 'line', + top: 'center', + left: 'center', + width: '60%', + height: 10, + keys: true, + tags: true, + style: { border: { fg: 'cyan' } }, + }); + + blessed.box({ + parent: dialog, + top: 2, + left: 2, + width: '100%-4', + height: 2, + tags: true, + content: text, + }); + + const okButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 5, + left: '40%-8', + height: 1, + content: ' OK ', + style: { bg: 'blue', focus: { bg: 'blue' } }, + }); + + const cancelButton = blessed.button({ + parent: dialog, + mouse: true, + keys: true, + shrink: true, + top: 5, + left: '40%+4', + height: 1, + content: ' Cancel ', + style: { bg: 'gray', focus: { bg: 'gray' } }, + }); + + let focusButton: 'ok' | 'cancel' = 'cancel'; + + let resolved = false; + const cleanup = (answer: boolean) => { + if (resolved) return; + resolved = true; + dialog.destroy(); + screen.render(); + resolve(answer); + }; + + const setFocus = (focus: 'ok' | 'cancel') => { + if (resolved) return; + focusButton = focus; + if (focus === 'ok') { + okButton.style.bg = 'cyan'; + cancelButton.style.bg = 'gray'; + okButton.focus(); + } else { + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'cyan'; + cancelButton.focus(); + } + screen.render(); + }; + + okButton.on('press', () => cleanup(true)); + cancelButton.on('press', () => cleanup(false)); + + dialog.key(['escape'], () => cleanup(false)); + dialog.key(['tab', 'left', 'right'], () => { + setFocus(focusButton === 'ok' ? 'cancel' : 'ok'); + }); + dialog.key(['enter'], () => cleanup(focusButton === 'ok')); + + setFocus('cancel'); + screen.render(); + }); +} + +// --------------------------------------------------------------------------- +// Array-value input (routes to fixed-array dialog or text input) +// --------------------------------------------------------------------------- + +export async function waitForArrayValue( + screen: Widgets.Screen, + spec: FixedArraySpec | null, + title: string, + questionText: string, + initialValue: string, +): Promise { + if (spec == null) { + return waitForInput(screen, title, questionText, initialValue); + } + + if (spec.options.includes(initialValue)) { + const selected = await waitForFixedArraySelection(screen, `${title} (${spec.label})`, spec, [initialValue]); + if (selected == null || selected.length === 0) return null; + return selected[0]; + } + + if (!spec.allowCustom) { + const selected = await waitForFixedArraySelection(screen, `${title} (${spec.label})`, spec, []); + if (selected == null || selected.length === 0) return null; + return selected[0]; + } + + return waitForInput(screen, title, questionText, initialValue); +} diff --git a/src/tui/format.ts b/src/tui/format.ts new file mode 100644 index 0000000..1b0dcea --- /dev/null +++ b/src/tui/format.ts @@ -0,0 +1,26 @@ +import { TomlEntry } from '../node/devnet-config-editor'; +import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; + +export function formatEntryLine(entry: TomlEntry): string { + const depth = Math.max(0, entry.path.length - 1); + const lastPathPart = entry.path[entry.path.length - 1] ?? ''; + const nodeName = /^\d+$/.test(lastPathPart) ? `[${lastPathPart}]` : lastPathPart; + const treeIndent = depth === 0 ? '' : `${'│ '.repeat(Math.max(0, depth - 1))}`; + const branch = depth === 0 ? '' : '├─ '; + const keyDoc = getConfigDoc(entry.path); + const docText = keyDoc != null ? ` {gray-fg}// ${keyDoc.summary}{/gray-fg}` : ''; + const valueColor = entry.type === 'string' ? 'green' : entry.type === 'number' ? 'yellow' : 'magenta'; + const keyColor = depth === 0 ? 'cyan' : 'white'; + + if (entry.type === 'object') { + return `${treeIndent}${branch}{cyan-fg}▸ ${nodeName}{/cyan-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${docText}`; + } + + if (entry.type === 'array') { + const fixedArraySpec = getFixedArraySpecFromEntryPath(entry.path); + const fixedArrayTag = fixedArraySpec != null ? ' {green-fg}[editable set]{/green-fg}' : ''; + return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${fixedArrayTag}${docText}`; + } + + return `${treeIndent}${branch}{${keyColor}-fg}${nodeName}{/${keyColor}-fg} = {${valueColor}-fg}${entry.valuePreview}{/${valueColor}-fg}${docText}`; +} diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts new file mode 100644 index 0000000..4454a73 --- /dev/null +++ b/src/tui/tui-state.ts @@ -0,0 +1,43 @@ +import { Widgets } from 'blessed'; +import { DevnetConfigEditor, TomlDocument, TomlEntry } from '../node/devnet-config-editor'; + +export type FocusPane = 'files' | 'entries'; + +export interface TuiState { + readonly editor: DevnetConfigEditor; + readonly configPath: string; + readonly documents: TomlDocument[]; + selectedDocumentIndex: number; + selectedEntryIndex: number; + focusPane: FocusPane; + hasUnsavedChanges: boolean; + didSave: boolean; + searchTerm: string; + statusMessage: string; + visibleEntries: TomlEntry[]; + dialogLock: boolean; +} + +export interface TuiWidgets { + screen: Widgets.Screen; + filesList: Widgets.ListElement; + entriesList: Widgets.ListElement; + statusBar: Widgets.BoxElement; +} + +export function createTuiState(editor: DevnetConfigEditor, configPath: string): TuiState { + return { + editor, + configPath, + documents: editor.getDocuments(), + selectedDocumentIndex: 0, + selectedEntryIndex: 0, + focusPane: 'files', + hasUnsavedChanges: false, + didSave: false, + searchTerm: '', + statusMessage: 'Ready', + visibleEntries: [], + dialogLock: false, + }; +} From 6e9a5d74b28733fbe46c04e2d2fedcb630ceaa8b Mon Sep 17 00:00:00 2001 From: RetricSu Date: Thu, 26 Feb 2026 16:10:49 +0800 Subject: [PATCH 09/24] style(tui): tune inline doc comment contrast and stabilize tag rendering --- src/tui/dialogs.ts | 2 +- src/tui/format.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tui/dialogs.ts b/src/tui/dialogs.ts index 5d913b3..4113bc5 100644 --- a/src/tui/dialogs.ts +++ b/src/tui/dialogs.ts @@ -69,7 +69,7 @@ export async function waitForFixedArraySelection( width: '100%-4', height: 2, tags: true, - content: '{gray-fg}Space toggle Enter apply Esc cancel Alt+a all Alt+d none{/gray-fg}', + content: 'Space toggle Enter apply Esc cancel Alt+a all Alt+d none', }); const renderList = () => { diff --git a/src/tui/format.ts b/src/tui/format.ts index 1b0dcea..cdbaeae 100644 --- a/src/tui/format.ts +++ b/src/tui/format.ts @@ -8,7 +8,7 @@ export function formatEntryLine(entry: TomlEntry): string { const treeIndent = depth === 0 ? '' : `${'│ '.repeat(Math.max(0, depth - 1))}`; const branch = depth === 0 ? '' : '├─ '; const keyDoc = getConfigDoc(entry.path); - const docText = keyDoc != null ? ` {gray-fg}// ${keyDoc.summary}{/gray-fg}` : ''; + const docText = keyDoc != null ? ` {245-fg}// ${keyDoc.summary}{/245-fg}` : ''; const valueColor = entry.type === 'string' ? 'green' : entry.type === 'number' ? 'yellow' : 'magenta'; const keyColor = depth === 0 ? 'cyan' : 'white'; From 8e2449e5395bb4e4d1b1f38227267b71e612b4ed Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 08:34:14 +0800 Subject: [PATCH 10/24] feat(tui): improve section layout and fixed-array presentation --- src/tui/devnet-config-tui.ts | 83 +++++++++++++++++++++++++++++++----- src/tui/format.ts | 24 ++++++++++- 2 files changed, 96 insertions(+), 11 deletions(-) diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 99a0cf3..937ae45 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -1,7 +1,7 @@ import blessed from 'blessed'; import { DevnetConfigEditor, TomlEntry } from '../node/devnet-config-editor'; import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; -import { formatEntryLine } from './format'; +import { formatEntryLine, formatFixedArrayDetailLine } from './format'; import { createTuiState, TuiWidgets } from './tui-state'; import { getListSelected } from './blessed-helpers'; import { @@ -17,6 +17,12 @@ import { saveAndExit, } from './actions'; +interface EntryRenderRow { + text: string; + entryIndex: number; + selectable: boolean; +} + // --------------------------------------------------------------------------- // Visible-entry filter (compact fixed-array items + search term) // --------------------------------------------------------------------------- @@ -98,6 +104,8 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: }); const widgets: TuiWidgets = { screen, filesList, entriesList, statusBar }; + let renderedRows: EntryRenderRow[] = []; + let entryToRowIndex: number[] = []; // ---- refresh ---- const refreshUi = () => { @@ -108,18 +116,54 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const doc = state.documents[state.selectedDocumentIndex]; const entries = state.editor.getEntriesForDocument(doc.id); state.visibleEntries = getVisibleEntries(entries, state.searchTerm); - entriesList.setItems(state.visibleEntries.map(formatEntryLine)); + + renderedRows = []; + entryToRowIndex = []; + let hasSeenTopLevelSection = false; + + state.visibleEntries.forEach((entry, entryIndex) => { + if (entry.path.length === 1 && hasSeenTopLevelSection) { + renderedRows.push({ text: ' ', entryIndex: -1, selectable: false }); + } + if (entry.path.length === 1) { + hasSeenTopLevelSection = true; + } + + const entryValue = state.editor.getEntryValue(doc.id, entry.path); + entryToRowIndex[entryIndex] = renderedRows.length; + renderedRows.push({ + text: formatEntryLine(entry, entryValue), + entryIndex, + selectable: true, + }); + + const fixedArraySpec = entry.type === 'array' ? getFixedArraySpecFromEntryPath(entry.path) : null; + if (fixedArraySpec != null && Array.isArray(entryValue)) { + renderedRows.push({ + text: formatFixedArrayDetailLine( + Math.max(0, entry.path.length - 1), + entryValue.map((value) => String(value)), + fixedArraySpec.options, + ), + entryIndex, + selectable: false, + }); + } + }); + + entriesList.setItems(renderedRows.map((row) => row.text)); filesList.style.border = { fg: state.focusPane === 'files' ? 'cyan' : 'gray' }; entriesList.style.border = { fg: state.focusPane === 'entries' ? 'cyan' : 'gray' }; - if (state.visibleEntries.length === 0) { + if (state.visibleEntries.length === 0 || renderedRows.length === 0) { state.selectedEntryIndex = 0; } else { if (state.selectedEntryIndex >= state.visibleEntries.length) { state.selectedEntryIndex = state.visibleEntries.length - 1; } - entriesList.select(state.selectedEntryIndex); + const selectedRowIndex = entryToRowIndex[state.selectedEntryIndex] ?? 0; + entriesList.select(selectedRowIndex); } const dirtyText = state.hasUnsavedChanges ? '{yellow-fg}yes{/yellow-fg}' : '{green-fg}no{/green-fg}'; @@ -167,10 +211,30 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: }; const syncEntrySelectionFromEntriesList = () => { - const listIndex = getListSelected(entriesList); - if (listIndex < 0 || listIndex >= state.visibleEntries.length) return; - if (listIndex !== state.selectedEntryIndex) { - state.selectedEntryIndex = listIndex; + const rowIndex = getListSelected(entriesList); + if (rowIndex < 0 || rowIndex >= renderedRows.length) return; + + let mappedEntryIndex = renderedRows[rowIndex]?.entryIndex ?? -1; + if (mappedEntryIndex < 0) { + for (let i = rowIndex - 1; i >= 0; i--) { + if (renderedRows[i].selectable) { + mappedEntryIndex = renderedRows[i].entryIndex; + break; + } + } + } + if (mappedEntryIndex < 0) { + for (let i = rowIndex + 1; i < renderedRows.length; i++) { + if (renderedRows[i].selectable) { + mappedEntryIndex = renderedRows[i].entryIndex; + break; + } + } + } + + if (mappedEntryIndex < 0 || mappedEntryIndex >= state.visibleEntries.length) return; + if (mappedEntryIndex !== state.selectedEntryIndex) { + state.selectedEntryIndex = mappedEntryIndex; refreshUi(); } }; @@ -185,8 +249,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: entriesList.on('select', (_: unknown, index: number) => { if (index == null) return; - state.selectedEntryIndex = index; - refreshUi(); + syncEntrySelectionFromEntriesList(); }); entriesList.on('action', () => { diff --git a/src/tui/format.ts b/src/tui/format.ts index cdbaeae..1fc63c3 100644 --- a/src/tui/format.ts +++ b/src/tui/format.ts @@ -1,7 +1,29 @@ import { TomlEntry } from '../node/devnet-config-editor'; import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; -export function formatEntryLine(entry: TomlEntry): string { +function formatFixedArrayInline(values: string[], options: string[]): string { + const selectedSet = new Set(values); + const optionSet = new Set(options); + const customCount = values.filter((value) => !optionSet.has(value)).length; + + const optionChunks = options.map((option) => { + const marker = selectedSet.has(option) ? '{green-fg}[x]{/green-fg}' : '{245-fg}[ ]{/245-fg}'; + return `${marker}${option}`; + }); + + if (customCount > 0) { + optionChunks.push(`{yellow-fg}[+${customCount} custom]{/yellow-fg}`); + } + + return optionChunks.join(' '); +} + +export function formatFixedArrayDetailLine(depth: number, values: string[], options: string[]): string { + const detailIndent = `${'│ '.repeat(Math.max(0, depth))} `; + return `${detailIndent}${formatFixedArrayInline(values, options)}`; +} + +export function formatEntryLine(entry: TomlEntry, entryValue?: unknown): string { const depth = Math.max(0, entry.path.length - 1); const lastPathPart = entry.path[entry.path.length - 1] ?? ''; const nodeName = /^\d+$/.test(lastPathPart) ? `[${lastPathPart}]` : lastPathPart; From 688ad0d369644bb177a153f46de1ac71ace7c29f Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 09:06:29 +0800 Subject: [PATCH 11/24] feat(tui): refine section rendering and array-list presentation - Add clearer section spacing with dedicated render rows - Render fixed arrays as separate detail lines - Simplify fixed-array values to bracketed list format - Tune inline docs/value colors for readability - Fix object preview format to avoid blessed tag parsing artifacts --- src/node/devnet-config-editor.ts | 4 ++-- src/tui/devnet-config-tui.ts | 1 - src/tui/format.ts | 26 ++++++++------------------ 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/src/node/devnet-config-editor.ts b/src/node/devnet-config-editor.ts index 70d5f6e..5f9bb61 100644 --- a/src/node/devnet-config-editor.ts +++ b/src/node/devnet-config-editor.ts @@ -189,7 +189,7 @@ function valuePreview(value: unknown): string { return `[${value.length}]`; } if (isPlainObject(value)) { - return `{${Object.keys(value).length}}`; + return `[${Object.keys(value).length} keys]`; } if (typeof value === 'string') { if (value.length > 80) { @@ -360,7 +360,7 @@ export class DevnetConfigEditor { path: currentPath, pathText: currentPath.join('.'), type: entryType, - valuePreview: valuePreview(value), + valuePreview: entryType === 'object' ? '' : valuePreview(value), editable: entryType === 'string' || entryType === 'number' || entryType === 'boolean', }); } diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 937ae45..d94b5e1 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -143,7 +143,6 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: text: formatFixedArrayDetailLine( Math.max(0, entry.path.length - 1), entryValue.map((value) => String(value)), - fixedArraySpec.options, ), entryIndex, selectable: false, diff --git a/src/tui/format.ts b/src/tui/format.ts index 1fc63c3..9e5745e 100644 --- a/src/tui/format.ts +++ b/src/tui/format.ts @@ -1,26 +1,17 @@ import { TomlEntry } from '../node/devnet-config-editor'; import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; -function formatFixedArrayInline(values: string[], options: string[]): string { - const selectedSet = new Set(values); - const optionSet = new Set(options); - const customCount = values.filter((value) => !optionSet.has(value)).length; - - const optionChunks = options.map((option) => { - const marker = selectedSet.has(option) ? '{green-fg}[x]{/green-fg}' : '{245-fg}[ ]{/245-fg}'; - return `${marker}${option}`; - }); - - if (customCount > 0) { - optionChunks.push(`{yellow-fg}[+${customCount} custom]{/yellow-fg}`); +function formatFixedArrayInline(values: string[]): string { + if (values.length === 0) { + return '{light-cyan-fg}[]{/light-cyan-fg}'; } - return optionChunks.join(' '); + return `{light-cyan-fg}[${values.join(', ')}]{/light-cyan-fg}`; } -export function formatFixedArrayDetailLine(depth: number, values: string[], options: string[]): string { +export function formatFixedArrayDetailLine(depth: number, values: string[]): string { const detailIndent = `${'│ '.repeat(Math.max(0, depth))} `; - return `${detailIndent}${formatFixedArrayInline(values, options)}`; + return `${detailIndent}${formatFixedArrayInline(values)}`; } export function formatEntryLine(entry: TomlEntry, entryValue?: unknown): string { @@ -30,7 +21,7 @@ export function formatEntryLine(entry: TomlEntry, entryValue?: unknown): string const treeIndent = depth === 0 ? '' : `${'│ '.repeat(Math.max(0, depth - 1))}`; const branch = depth === 0 ? '' : '├─ '; const keyDoc = getConfigDoc(entry.path); - const docText = keyDoc != null ? ` {245-fg}// ${keyDoc.summary}{/245-fg}` : ''; + const docText = keyDoc != null ? ` {243-fg}// ${keyDoc.summary}{/243-fg}` : ''; const valueColor = entry.type === 'string' ? 'green' : entry.type === 'number' ? 'yellow' : 'magenta'; const keyColor = depth === 0 ? 'cyan' : 'white'; @@ -40,8 +31,7 @@ export function formatEntryLine(entry: TomlEntry, entryValue?: unknown): string if (entry.type === 'array') { const fixedArraySpec = getFixedArraySpecFromEntryPath(entry.path); - const fixedArrayTag = fixedArraySpec != null ? ' {green-fg}[editable set]{/green-fg}' : ''; - return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {gray-fg}${entry.valuePreview}{/gray-fg}${fixedArrayTag}${docText}`; + return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {white-fg}${entry.valuePreview}{/white-fg}${docText}`; } return `${treeIndent}${branch}{${keyColor}-fg}${nodeName}{/${keyColor}-fg} = {${valueColor}-fg}${entry.valuePreview}{/${valueColor}-fg}${docText}`; From 73b89fcc08f0f35ac270786d9b0ed7c8b07b44d0 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 09:54:36 +0800 Subject: [PATCH 12/24] fix(tui): stabilize modal/quit keyboard flow and confirm UX - Fix fixed-array modal key handling (single-step navigation, esc/cmd all/none) - Bind Esc on main view to quit flow without modal re-entry - Improve confirm dialog keyboard handling (tab/shift-tab/enter variants) - Clarify unsaved-changes prompt with explicit action labels - Fix confirm button layout and focus-state consistency --- src/tui/actions.ts | 11 ++++- src/tui/devnet-config-tui.ts | 12 +---- src/tui/dialogs.ts | 95 +++++++++++++++++++++++++++++------- 3 files changed, 88 insertions(+), 30 deletions(-) diff --git a/src/tui/actions.ts b/src/tui/actions.ts index 21cf612..23cb424 100644 --- a/src/tui/actions.ts +++ b/src/tui/actions.ts @@ -453,7 +453,16 @@ export async function quitFlow(ctx: ActionContext): Promise { return; } - const shouldDiscard = await waitForConfirm(widgets.screen, 'Discard Changes', 'Discard unsaved changes?'); + const shouldDiscard = await waitForConfirm( + widgets.screen, + 'Unsaved Changes', + 'You have unsaved changes. Press S to save and exit, or discard changes and exit.', + { + confirmLabel: 'Discard & Exit', + cancelLabel: 'Keep Editing', + defaultFocus: 'cancel', + }, + ); if (shouldDiscard) { widgets.screen.destroy(); return; diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index d94b5e1..004a2c1 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -251,16 +251,6 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: syncEntrySelectionFromEntriesList(); }); - entriesList.on('action', () => { - if (state.dialogLock) return; - syncEntrySelectionFromEntriesList(); - const entry = state.visibleEntries[state.selectedEntryIndex]; - if (entry == null) return; - if (getFixedArraySpecFromEntryPath(entry.path) != null) { - withDialogLock(() => editCurrentEntry(ctx)); - } - }); - const NAV_KEYS = ['up', 'down', 'k', 'j', 'pageup', 'pagedown', 'home', 'end']; entriesList.on('keypress', (_: unknown, key: { name?: string }) => { @@ -308,7 +298,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: guardedKey(['s'], () => saveAndExit(ctx)); - guardedKeyAsync(['q', 'C-c'], () => quitFlow(ctx)); + guardedKeyAsync(['q', 'C-c', 'escape'], () => quitFlow(ctx)); guardedKeyAsync(['enter'], () => { syncEntrySelectionFromEntriesList(); diff --git a/src/tui/dialogs.ts b/src/tui/dialogs.ts index 4113bc5..0c2ddb1 100644 --- a/src/tui/dialogs.ts +++ b/src/tui/dialogs.ts @@ -69,7 +69,7 @@ export async function waitForFixedArraySelection( width: '100%-4', height: 2, tags: true, - content: 'Space toggle Enter apply Esc cancel Alt+a all Alt+d none', + content: 'Space toggle Enter apply Esc cancel Ctrl/Alt+a all Ctrl/Alt+d none', }); const renderList = () => { @@ -125,17 +125,26 @@ export async function waitForFixedArraySelection( }; list.key(['enter'], () => applySelection()); - list.key(['up', 'k'], () => { list.up(1); screen.render(); }); - list.key(['down', 'j'], () => { list.down(1); screen.render(); }); list.key(['space'], () => toggleSelectedOption()); + list.key(['escape'], () => cleanup(null)); + list.key(['C-a', 'A-a', 'M-a'], () => { + spec.options.forEach((option) => selectedValues.add(option)); + renderList(); + screen.render(); + }); + list.key(['C-d', 'A-d', 'M-d'], () => { + selectedValues.clear(); + renderList(); + screen.render(); + }); dialog.key(['escape'], () => cleanup(null)); - dialog.key(['A-a'], () => { + dialog.key(['C-a', 'A-a', 'M-a'], () => { spec.options.forEach((option) => selectedValues.add(option)); renderList(); screen.render(); }); - dialog.key(['A-d'], () => { + dialog.key(['C-d', 'A-d', 'M-d'], () => { selectedValues.clear(); renderList(); screen.render(); @@ -296,8 +305,21 @@ export function waitForConfirm( screen: Widgets.Screen, title: string, text: string, + options?: { + confirmLabel?: string; + cancelLabel?: string; + defaultFocus?: 'confirm' | 'cancel'; + }, ): Promise { return new Promise((resolve) => { + const confirmLabel = options?.confirmLabel ?? 'OK'; + const cancelLabel = options?.cancelLabel ?? 'Cancel'; + const defaultFocus: 'ok' | 'cancel' = options?.defaultFocus === 'confirm' ? 'ok' : 'cancel'; + const buttonGap = 3; + const buttonWidth = Math.max(confirmLabel.length, cancelLabel.length) + 4; + const leftHalfOffset = buttonWidth + Math.ceil(buttonGap / 2); + const rightHalfOffset = Math.floor(buttonGap / 2); + const dialog = blessed.box({ parent: screen, label: ` ${title} `, @@ -325,11 +347,12 @@ export function waitForConfirm( parent: dialog, mouse: true, keys: true, - shrink: true, + shrink: false, top: 5, - left: '40%-8', + left: `50%-${leftHalfOffset}`, + width: buttonWidth, height: 1, - content: ' OK ', + content: ` ${confirmLabel} `, style: { bg: 'blue', focus: { bg: 'blue' } }, }); @@ -337,11 +360,12 @@ export function waitForConfirm( parent: dialog, mouse: true, keys: true, - shrink: true, + shrink: false, top: 5, - left: '40%+4', + left: `50%+${rightHalfOffset}`, + width: buttonWidth, height: 1, - content: ' Cancel ', + content: ` ${cancelLabel} `, style: { bg: 'gray', focus: { bg: 'gray' } }, }); @@ -371,16 +395,51 @@ export function waitForConfirm( screen.render(); }; - okButton.on('press', () => cleanup(true)); - cancelButton.on('press', () => cleanup(false)); - - dialog.key(['escape'], () => cleanup(false)); - dialog.key(['tab', 'left', 'right'], () => { + const toggleFocus = () => { setFocus(focusButton === 'ok' ? 'cancel' : 'ok'); + }; + + okButton.on('focus', () => { + if (resolved) return; + focusButton = 'ok'; + okButton.style.bg = 'cyan'; + cancelButton.style.bg = 'gray'; + screen.render(); + }); + + cancelButton.on('focus', () => { + if (resolved) return; + focusButton = 'cancel'; + okButton.style.bg = 'blue'; + cancelButton.style.bg = 'cyan'; + screen.render(); }); - dialog.key(['enter'], () => cleanup(focusButton === 'ok')); - setFocus('cancel'); + const accept = () => cleanup(true); + const cancel = () => cleanup(false); + + okButton.on('press', () => accept()); + cancelButton.on('press', () => cancel()); + + dialog.key(['escape'], () => cancel()); + dialog.key(['tab', 'left', 'right'], () => toggleFocus()); + dialog.key(['S-tab'], () => toggleFocus()); + dialog.key(['enter', 'return', 'C-m'], () => { + if (focusButton === 'ok') { + accept(); + } else { + cancel(); + } + }); + + okButton.key(['tab', 'right', 'left', 'S-tab'], () => toggleFocus()); + cancelButton.key(['tab', 'right', 'left', 'S-tab'], () => toggleFocus()); + okButton.key(['escape'], () => cancel()); + cancelButton.key(['escape'], () => cancel()); + okButton.key(['enter', 'return', 'C-m'], () => accept()); + cancelButton.key(['enter', 'return', 'C-m'], () => cancel()); + + setFocus(defaultFocus); screen.render(); }); } From 59814e2a785defd0a9fbbc0f87179647a8cf83e8 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 10:01:53 +0800 Subject: [PATCH 13/24] refactor(tui): deduplicate confirm dialog keybinding logic --- src/tui/dialogs.ts | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/tui/dialogs.ts b/src/tui/dialogs.ts index 0c2ddb1..a06b829 100644 --- a/src/tui/dialogs.ts +++ b/src/tui/dialogs.ts @@ -369,6 +369,9 @@ export function waitForConfirm( style: { bg: 'gray', focus: { bg: 'gray' } }, }); + const focusNavKeys = ['tab', 'right', 'left', 'S-tab']; + const confirmKeys = ['enter', 'return', 'C-m']; + let focusButton: 'ok' | 'cancel' = 'cancel'; let resolved = false; @@ -380,16 +383,23 @@ export function waitForConfirm( resolve(answer); }; - const setFocus = (focus: 'ok' | 'cancel') => { - if (resolved) return; - focusButton = focus; + const applyFocusStyles = (focus: 'ok' | 'cancel') => { if (focus === 'ok') { okButton.style.bg = 'cyan'; cancelButton.style.bg = 'gray'; - okButton.focus(); } else { okButton.style.bg = 'blue'; cancelButton.style.bg = 'cyan'; + } + }; + + const setFocus = (focus: 'ok' | 'cancel') => { + if (resolved) return; + focusButton = focus; + applyFocusStyles(focus); + if (focus === 'ok') { + okButton.focus(); + } else { cancelButton.focus(); } screen.render(); @@ -402,16 +412,14 @@ export function waitForConfirm( okButton.on('focus', () => { if (resolved) return; focusButton = 'ok'; - okButton.style.bg = 'cyan'; - cancelButton.style.bg = 'gray'; + applyFocusStyles('ok'); screen.render(); }); cancelButton.on('focus', () => { if (resolved) return; focusButton = 'cancel'; - okButton.style.bg = 'blue'; - cancelButton.style.bg = 'cyan'; + applyFocusStyles('cancel'); screen.render(); }); @@ -424,7 +432,7 @@ export function waitForConfirm( dialog.key(['escape'], () => cancel()); dialog.key(['tab', 'left', 'right'], () => toggleFocus()); dialog.key(['S-tab'], () => toggleFocus()); - dialog.key(['enter', 'return', 'C-m'], () => { + dialog.key(confirmKeys, () => { if (focusButton === 'ok') { accept(); } else { @@ -432,12 +440,13 @@ export function waitForConfirm( } }); - okButton.key(['tab', 'right', 'left', 'S-tab'], () => toggleFocus()); - cancelButton.key(['tab', 'right', 'left', 'S-tab'], () => toggleFocus()); - okButton.key(['escape'], () => cancel()); - cancelButton.key(['escape'], () => cancel()); - okButton.key(['enter', 'return', 'C-m'], () => accept()); - cancelButton.key(['enter', 'return', 'C-m'], () => cancel()); + const buttons = [okButton, cancelButton]; + buttons.forEach((button) => { + button.key(focusNavKeys, () => toggleFocus()); + button.key(['escape'], () => cancel()); + }); + okButton.key(confirmKeys, () => accept()); + cancelButton.key(confirmKeys, () => cancel()); setFocus(defaultFocus); screen.render(); From c40ad59750f75301e8c03cf3891a7640eb28712f Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 10:08:14 +0800 Subject: [PATCH 14/24] refactor(devnet): move config editor module to src/devnet - Move devnet config editor from src/node/devnet-config-editor.ts to src/devnet/config-editor.ts - Update all imports in cmd, tui, and tests - Align refactoring doc references with new module path --- docs/tui-refactoring-plan.md | 4 ++-- src/cmd/devnet-config.ts | 2 +- src/{node/devnet-config-editor.ts => devnet/config-editor.ts} | 0 src/tui/actions.ts | 2 +- src/tui/devnet-config-tui.ts | 2 +- src/tui/format.ts | 2 +- src/tui/tui-state.ts | 2 +- tests/devnet-config-editor.test.ts | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) rename src/{node/devnet-config-editor.ts => devnet/config-editor.ts} (100%) diff --git a/docs/tui-refactoring-plan.md b/docs/tui-refactoring-plan.md index d50d759..62771d7 100644 --- a/docs/tui-refactoring-plan.md +++ b/docs/tui-refactoring-plan.md @@ -15,7 +15,7 @@ ## Current State Three files, ~2060 lines total: -- `src/node/devnet-config-editor.ts` (628 lines) — data layer, well structured +- `src/devnet/config-editor.ts` (628 lines) — data layer, well structured - `src/tui/devnet-config-metadata.ts` (213 lines) — metadata, fine as-is - `src/tui/devnet-config-tui.ts` (1220 lines) — **needs refactoring** @@ -44,7 +44,7 @@ Total: ~835 lines (down from 1220), no function over ~70 lines. ```typescript import { Widgets } from 'blessed'; -import { DevnetConfigEditor, TomlEntry, TomlDocument } from '../node/devnet-config-editor'; +import { DevnetConfigEditor, TomlEntry, TomlDocument } from '../devnet/config-editor'; export type FocusPane = 'files' | 'entries'; diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index 5f48cb9..276f17e 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -1,6 +1,6 @@ import { readSettings } from '../cfg/setting'; import { logger } from '../util/logger'; -import { createDevnetConfigEditor } from '../node/devnet-config-editor'; +import { createDevnetConfigEditor } from '../devnet/config-editor'; import { runDevnetConfigTui } from '../tui/devnet-config-tui'; export interface DevnetConfigOptions { diff --git a/src/node/devnet-config-editor.ts b/src/devnet/config-editor.ts similarity index 100% rename from src/node/devnet-config-editor.ts rename to src/devnet/config-editor.ts diff --git a/src/tui/actions.ts b/src/tui/actions.ts index 23cb424..135dd02 100644 --- a/src/tui/actions.ts +++ b/src/tui/actions.ts @@ -1,4 +1,4 @@ -import { TomlEntry } from '../node/devnet-config-editor'; +import { TomlEntry } from '../devnet/config-editor'; import { getFixedArraySpecFromEntryPath, FixedArraySpec } from './devnet-config-metadata'; import { waitForInput, waitForConfirm, waitForFixedArraySelection, waitForArrayValue } from './dialogs'; import { TuiState, TuiWidgets } from './tui-state'; diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 004a2c1..d60dbb5 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -1,5 +1,5 @@ import blessed from 'blessed'; -import { DevnetConfigEditor, TomlEntry } from '../node/devnet-config-editor'; +import { DevnetConfigEditor, TomlEntry } from '../devnet/config-editor'; import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; import { formatEntryLine, formatFixedArrayDetailLine } from './format'; import { createTuiState, TuiWidgets } from './tui-state'; diff --git a/src/tui/format.ts b/src/tui/format.ts index 9e5745e..8b5bf62 100644 --- a/src/tui/format.ts +++ b/src/tui/format.ts @@ -1,4 +1,4 @@ -import { TomlEntry } from '../node/devnet-config-editor'; +import { TomlEntry } from '../devnet/config-editor'; import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; function formatFixedArrayInline(values: string[]): string { diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index 4454a73..f524060 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -1,5 +1,5 @@ import { Widgets } from 'blessed'; -import { DevnetConfigEditor, TomlDocument, TomlEntry } from '../node/devnet-config-editor'; +import { DevnetConfigEditor, TomlDocument, TomlEntry } from '../devnet/config-editor'; export type FocusPane = 'files' | 'entries'; diff --git a/tests/devnet-config-editor.test.ts b/tests/devnet-config-editor.test.ts index 295a4d0..c4f39e7 100644 --- a/tests/devnet-config-editor.test.ts +++ b/tests/devnet-config-editor.test.ts @@ -2,7 +2,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import toml from '@iarna/toml'; -import { createDevnetConfigEditor } from '../src/node/devnet-config-editor'; +import { createDevnetConfigEditor } from '../src/devnet/config-editor'; import { applySetItems, parseSetItem } from '../src/cmd/devnet-config'; function writeFixtureConfig(configPath: string) { From 74a5808070b0b0d01128f065173f551bd2ed079d Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 10:37:25 +0800 Subject: [PATCH 15/24] refactor(cli): use ES import for devnet config command --- src/cli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 61602df..dc82791 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { TransferOptions, transfer } from './cmd/transfer'; import { BalanceOption, balanceOf } from './cmd/balance'; import { createScriptProject, CreateScriptProjectOptions } from './cmd/create'; import { Config, ConfigItem } from './cmd/config'; +import { devnetConfig } from './cmd/devnet-config'; import { debugSingleScript, debugTransaction, parseSingleScriptOption } from './cmd/debug'; import { printSystemScripts } from './cmd/system-scripts'; import { transferAll } from './cmd/transfer-all'; @@ -20,7 +21,6 @@ import { Network } from './type/base'; const version = require('../package.json').version; const description = require('../package.json').description; -const { devnetConfig } = require('./cmd/devnet-config'); // fix windows terminal encoding of simplified chinese text setUTF8EncodingForWindows(); From aaa6d4857fe305276ca775a2b8938b8d9170c205 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 10:38:48 +0800 Subject: [PATCH 16/24] style(tui): apply formatting updates across devnet editor modules --- src/devnet/config-editor.ts | 19 +++------------ src/tui/actions.ts | 29 ++++++++++++++--------- src/tui/devnet-config-metadata.ts | 39 +++++++++++++++++++++++++------ src/tui/dialogs.ts | 38 +++++++++++++++++++++++------- 4 files changed, 83 insertions(+), 42 deletions(-) diff --git a/src/devnet/config-editor.ts b/src/devnet/config-editor.ts index 5f9bb61..a98da13 100644 --- a/src/devnet/config-editor.ts +++ b/src/devnet/config-editor.ts @@ -454,11 +454,7 @@ export class DevnetConfigEditor { } this.values[fieldId] = parsedBoolean; - setByPath( - this.getDocument(definition.file).data as Record, - definition.path, - this.values[fieldId], - ); + setByPath(this.getDocument(definition.file).data as Record, definition.path, this.values[fieldId]); return this.values[fieldId]; } @@ -514,12 +510,7 @@ export class DevnetConfigEditor { return parsedValue; } - insertArrayEntry( - documentId: 'ckb' | 'miner', - pathParts: string[], - index: number, - rawValue: string, - ): TomlPrimitive { + insertArrayEntry(documentId: 'ckb' | 'miner', pathParts: string[], index: number, rawValue: string): TomlPrimitive { const target = getByPath(this.getDocument(documentId).data as Record, pathParts); if (!Array.isArray(target)) { throw new Error('Target path is not an array.'); @@ -600,11 +591,7 @@ export class DevnetConfigEditor { const nextValue = !field.value; this.values[fieldId] = nextValue; - setByPath( - this.getDocument(field.file).data as Record, - field.path, - nextValue, - ); + setByPath(this.getDocument(field.file).data as Record, field.path, nextValue); return nextValue; } diff --git a/src/tui/actions.ts b/src/tui/actions.ts index 135dd02..8547c2d 100644 --- a/src/tui/actions.ts +++ b/src/tui/actions.ts @@ -185,7 +185,8 @@ export function jumpSearchMatch(ctx: ActionContext, direction: 'next' | 'prev'): state.selectedEntryIndex = (state.selectedEntryIndex + 1) % state.visibleEntries.length; state.statusMessage = `Jumped to next match (${state.selectedEntryIndex + 1}/${state.visibleEntries.length}).`; } else { - state.selectedEntryIndex = (state.selectedEntryIndex - 1 + state.visibleEntries.length) % state.visibleEntries.length; + state.selectedEntryIndex = + (state.selectedEntryIndex - 1 + state.visibleEntries.length) % state.visibleEntries.length; state.statusMessage = `Jumped to previous match (${state.selectedEntryIndex + 1}/${state.visibleEntries.length}).`; } @@ -196,9 +197,10 @@ export async function addEntry(ctx: ActionContext): Promise { const { state, widgets, refreshUi } = ctx; const doc = currentDocument(ctx); - const targetEntry = state.visibleEntries.length > 0 - ? state.visibleEntries[Math.min(state.selectedEntryIndex, state.visibleEntries.length - 1)] - : null; + const targetEntry = + state.visibleEntries.length > 0 + ? state.visibleEntries[Math.min(state.selectedEntryIndex, state.visibleEntries.length - 1)] + : null; const targetPath = targetEntry?.path ?? []; const targetValue = state.editor.getEntryValue(doc.id, targetPath); @@ -209,7 +211,10 @@ export async function addEntry(ctx: ActionContext): Promise { return; } - if (targetEntry?.type === 'object' || (targetEntry == null && targetValue != null && typeof targetValue === 'object')) { + if ( + targetEntry?.type === 'object' || + (targetEntry == null && targetValue != null && typeof targetValue === 'object') + ) { const keyAnswer = await waitForInput(widgets.screen, 'Add Object Key', 'New key name:', ''); if (keyAnswer == null) { state.statusMessage = 'Add canceled.'; @@ -328,9 +333,10 @@ export async function insertArrayEntry(ctx: ActionContext): Promise { try { const indexInput = indexAnswer.trim(); - const insertIndex = indexInput === '' && target.suggestedIndex != null - ? target.suggestedIndex - : parseNonNegativeInteger(indexAnswer, 'Insert index'); + const insertIndex = + indexInput === '' && target.suggestedIndex != null + ? target.suggestedIndex + : parseNonNegativeInteger(indexAnswer, 'Insert index'); state.editor.insertArrayEntry(doc.id, target.arrayPath, insertIndex, valueAnswer); state.hasUnsavedChanges = true; @@ -395,9 +401,10 @@ export async function moveArrayEntry(ctx: ActionContext): Promise { try { const fromInput = fromAnswer.trim(); - const fromIndex = fromInput === '' && target.suggestedIndex != null - ? target.suggestedIndex - : parseNonNegativeInteger(fromAnswer, 'Source index'); + const fromIndex = + fromInput === '' && target.suggestedIndex != null + ? target.suggestedIndex + : parseNonNegativeInteger(fromAnswer, 'Source index'); const toIndex = parseNonNegativeInteger(toAnswer, 'Target index'); state.editor.moveArrayEntry(doc.id, target.arrayPath, fromIndex, toIndex); diff --git a/src/tui/devnet-config-metadata.ts b/src/tui/devnet-config-metadata.ts index de847d0..42d135e 100644 --- a/src/tui/devnet-config-metadata.ts +++ b/src/tui/devnet-config-metadata.ts @@ -129,7 +129,19 @@ const FIXED_ARRAY_SPECS: FixedArraySpec[] = [ { pathPattern: 'network.support_protocols', label: 'Network Protocols', - options: ['Ping', 'Discovery', 'Identify', 'Feeler', 'DisconnectMessage', 'Sync', 'Relay', 'Time', 'Alert', 'LightClient', 'Filter'], + options: [ + 'Ping', + 'Discovery', + 'Identify', + 'Feeler', + 'DisconnectMessage', + 'Sync', + 'Relay', + 'Time', + 'Alert', + 'LightClient', + 'Filter', + ], unique: true, allowCustom: false, source: 'CKB protocol definitions', @@ -137,7 +149,18 @@ const FIXED_ARRAY_SPECS: FixedArraySpec[] = [ { pathPattern: 'rpc.modules', label: 'RPC Modules', - options: ['Net', 'Pool', 'Miner', 'Chain', 'Stats', 'Experiment', 'Debug', 'IntegrationTest', 'Indexer', 'Subscription'], + options: [ + 'Net', + 'Pool', + 'Miner', + 'Chain', + 'Stats', + 'Experiment', + 'Debug', + 'IntegrationTest', + 'Indexer', + 'Subscription', + ], unique: true, allowCustom: true, source: 'CKB configure docs', @@ -175,11 +198,13 @@ function wildcardScore(patternSegments: string[]): number { } export function getConfigDoc(pathSegments: string[]): ConfigDoc | null { - const matches = CONFIG_DOCS.filter((rule) => matchPattern(pathSegments, splitPattern(rule.pathPattern))).sort((a, b) => { - const aScore = wildcardScore(splitPattern(a.pathPattern)); - const bScore = wildcardScore(splitPattern(b.pathPattern)); - return aScore - bScore; - }); + const matches = CONFIG_DOCS.filter((rule) => matchPattern(pathSegments, splitPattern(rule.pathPattern))).sort( + (a, b) => { + const aScore = wildcardScore(splitPattern(a.pathPattern)); + const bScore = wildcardScore(splitPattern(b.pathPattern)); + return aScore - bScore; + }, + ); if (matches.length === 0) { return null; diff --git a/src/tui/dialogs.ts b/src/tui/dialogs.ts index a06b829..9156a9a 100644 --- a/src/tui/dialogs.ts +++ b/src/tui/dialogs.ts @@ -261,14 +261,26 @@ export function waitForInput( }; const nextFocus = () => { - if (currentFocus === 'input') { setFocus('ok'); return; } - if (currentFocus === 'ok') { setFocus('cancel'); return; } + if (currentFocus === 'input') { + setFocus('ok'); + return; + } + if (currentFocus === 'ok') { + setFocus('cancel'); + return; + } setFocus('input'); }; const prevFocus = () => { - if (currentFocus === 'cancel') { setFocus('ok'); return; } - if (currentFocus === 'ok') { setFocus('input'); return; } + if (currentFocus === 'cancel') { + setFocus('ok'); + return; + } + if (currentFocus === 'ok') { + setFocus('input'); + return; + } setFocus('cancel'); }; @@ -280,11 +292,21 @@ export function waitForInput( dialog.key(['escape'], () => cleanup(null)); dialog.key(['tab', 'down'], () => nextFocus()); dialog.key(['S-tab', 'up'], () => prevFocus()); - dialog.key(['left'], () => { if (currentFocus !== 'input') prevFocus(); }); - dialog.key(['right'], () => { if (currentFocus !== 'input') nextFocus(); }); + dialog.key(['left'], () => { + if (currentFocus !== 'input') prevFocus(); + }); + dialog.key(['right'], () => { + if (currentFocus !== 'input') nextFocus(); + }); dialog.key(['enter'], () => { - if (currentFocus === 'input') { cleanup(getInputValue()); return; } - if (currentFocus === 'ok') { cleanup(getInputValue()); return; } + if (currentFocus === 'input') { + cleanup(getInputValue()); + return; + } + if (currentFocus === 'ok') { + cleanup(getInputValue()); + return; + } cleanup(null); }); From 01f6fa3028be9ebea7a87910c828fe3bed18881a Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 10:47:50 +0800 Subject: [PATCH 17/24] fix(tui): resolve unused vars in format rendering --- src/tui/format.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/tui/format.ts b/src/tui/format.ts index 8b5bf62..6053c0d 100644 --- a/src/tui/format.ts +++ b/src/tui/format.ts @@ -1,5 +1,5 @@ import { TomlEntry } from '../devnet/config-editor'; -import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; +import { getConfigDoc } from './devnet-config-metadata'; function formatFixedArrayInline(values: string[]): string { if (values.length === 0) { @@ -14,7 +14,7 @@ export function formatFixedArrayDetailLine(depth: number, values: string[]): str return `${detailIndent}${formatFixedArrayInline(values)}`; } -export function formatEntryLine(entry: TomlEntry, entryValue?: unknown): string { +export function formatEntryLine(entry: TomlEntry, _entryValue?: unknown): string { const depth = Math.max(0, entry.path.length - 1); const lastPathPart = entry.path[entry.path.length - 1] ?? ''; const nodeName = /^\d+$/.test(lastPathPart) ? `[${lastPathPart}]` : lastPathPart; @@ -30,7 +30,6 @@ export function formatEntryLine(entry: TomlEntry, entryValue?: unknown): string } if (entry.type === 'array') { - const fixedArraySpec = getFixedArraySpecFromEntryPath(entry.path); return `${treeIndent}${branch}{magenta-fg}▾ ${nodeName}{/magenta-fg} {white-fg}${entry.valuePreview}{/white-fg}${docText}`; } From 2fe0763c20049878396835dd54913520e94b67c0 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 14:23:49 +0800 Subject: [PATCH 18/24] fix(tui): force xterm terminal profile for blessed compatibility --- docs/tui-refactoring-plan.md | 173 ----------------------------------- src/tui/devnet-config-tui.ts | 1 + 2 files changed, 1 insertion(+), 173 deletions(-) delete mode 100644 docs/tui-refactoring-plan.md diff --git a/docs/tui-refactoring-plan.md b/docs/tui-refactoring-plan.md deleted file mode 100644 index 62771d7..0000000 --- a/docs/tui-refactoring-plan.md +++ /dev/null @@ -1,173 +0,0 @@ -# TUI Refactoring Plan - -## Status Tracker - -- [x] Step 1: Write refactoring plan doc -- [x] Step 2: Add `setArrayValues()` to `DevnetConfigEditor` -- [x] Step 3: Create `src/tui/blessed-helpers.ts` -- [x] Step 4: Create `src/tui/tui-state.ts` -- [x] Step 5: Create `src/tui/dialogs.ts` (3 dialog functions) -- [x] Step 6: Create `src/tui/format.ts` (entry line formatter) -- [x] Step 7: Create `src/tui/actions.ts` (all 8 action functions) -- [x] Step 8: Rewrite `src/tui/devnet-config-tui.ts` as thin orchestrator -- [x] Step 9: Verify build + tests pass - -## Current State - -Three files, ~2060 lines total: -- `src/devnet/config-editor.ts` (628 lines) — data layer, well structured -- `src/tui/devnet-config-metadata.ts` (213 lines) — metadata, fine as-is -- `src/tui/devnet-config-tui.ts` (1220 lines) — **needs refactoring** - -The TUI file has one 700-line god function (`runDevnetConfigTui`) with 10 closure -variables shared by 30+ inner functions, 3 duplicated dialog patterns, and 14 -repeated `if (dialogLock) return;` guards. - -## Target File Structure - -``` -src/tui/ - blessed-helpers.ts (~30 lines) - getListSelected(), type helpers - tui-state.ts (~70 lines) - TuiState interface + factory - dialogs.ts (~220 lines) - 3 dialog functions (input, confirm, select) - format.ts (~35 lines) - formatEntryLine() - actions.ts (~330 lines) - all 8 action functions - devnet-config-tui.ts (~150 lines) - layout, keybindings, main orchestrator - devnet-config-metadata.ts - UNCHANGED -``` - -Total: ~835 lines (down from 1220), no function over ~70 lines. - -## Shared Interfaces - -### TuiState (tui-state.ts) - -```typescript -import { Widgets } from 'blessed'; -import { DevnetConfigEditor, TomlEntry, TomlDocument } from '../devnet/config-editor'; - -export type FocusPane = 'files' | 'entries'; - -export interface TuiState { - editor: DevnetConfigEditor; - configPath: string; - documents: TomlDocument[]; - selectedDocumentIndex: number; - selectedEntryIndex: number; - focusPane: FocusPane; - hasUnsavedChanges: boolean; - didSave: boolean; - searchTerm: string; - statusMessage: string; - visibleEntries: TomlEntry[]; - dialogLock: boolean; -} - -export interface TuiWidgets { - screen: Widgets.Screen; - filesList: Widgets.ListElement; - entriesList: Widgets.ListElement; - statusBar: Widgets.BoxElement; -} - -export function createTuiState(editor, configPath): TuiState { ... } -``` - -### Action Function Signature - -Every action receives `(state, widgets)` and returns `Promise`. -Actions mutate `state` directly (it's a mutable bag). They call -`refreshUi(state, widgets)` at the end. - -### Dialog Functions - -Unchanged signatures — they take `screen` and return Promise. They are already -reasonably independent; we just consolidate them into one file and remove -duplicated boilerplate. - -## Step-by-Step Execution - -### Step 2: Add `setArrayValues()` to editor - -Fix the encapsulation leak where TUI directly `splice()`s editor internals. -Add a proper method to `DevnetConfigEditor`: - -```typescript -setArrayValues(documentId, pathParts, values: string[]): void -``` - -### Step 3: Create `blessed-helpers.ts` - -Extract the repeated `as unknown as { selected?: number }` pattern: - -```typescript -export function getListSelected(list: Widgets.ListElement): number -``` - -### Step 4: Create `tui-state.ts` - -Extract `TuiState`, `TuiWidgets`, `FocusPane` types and `createTuiState()`. - -### Step 5: Create `dialogs.ts` - -Move `waitForInput`, `waitForConfirm`, `waitForFixedArraySelection`, -`waitForArrayValue` into this file. No structural changes to the functions -themselves — just relocation + import cleanup. - -### Step 6: Create `format.ts` - -Move `formatEntryLine()` into its own file. - -### Step 7: Create `actions.ts` - -Extract all action logic from the god function into standalone functions: - -```typescript -export async function editCurrentEntry(state, widgets): Promise -export async function addEntry(state, widgets): Promise -export async function deleteEntry(state, widgets): Promise -export async function insertArrayEntry(state, widgets): Promise -export async function moveArrayEntry(state, widgets): Promise -export async function searchEntries(state, widgets): Promise -export function jumpSearchMatch(state, widgets, direction): void -export async function editFixedArraySelection(state, widgets, ...): Promise - -// Also extract these pure helpers: -export function resolveArrayTarget(entry): { arrayPath, suggestedIndex } | null -export function resolveFixedArrayTarget(entry): { arrayPath, spec } | null -export function parseNonNegativeInteger(value, fieldName): number -``` - -Each function takes `(state: TuiState, widgets: TuiWidgets, ...)` explicitly. - -### Step 8: Rewrite main `devnet-config-tui.ts` - -The main file becomes a thin orchestrator (~150 lines): -1. TTY check -2. Create screen + layout widgets -3. Create TuiState -4. `refreshUi()` function -5. `withDialogLock()` helper -6. `guardedKey()` helper — eliminates 14x `if (dialogLock) return;` -7. All key bindings (compact, using guardedKey) -8. List sync event handlers -9. Return `Promise` on screen destroy - -### Step 9: Verify - -- `npx tsc --noEmit` passes -- `npx jest tests/ --no-coverage` all pass -- No new lint errors - -## Key Design Decisions - -1. **State is a plain mutable object, not a class** — keeps it simple, - avoids getter/setter boilerplate. Actions mutate it directly. -2. **`refreshUi` stays in the main file** — it's the only function that - needs all widgets + state together. Actions call it via the widgets ref. -3. **Dialogs remain standalone functions** — they don't need state, only screen. -4. **No event emitter / pub-sub** — overkill for this scale. Direct function - calls are clearer. -5. **`devnet-config-metadata.ts` unchanged** — it's already clean. -6. **`devnet-config-editor.ts` gets one new method** — `setArrayValues()` to - fix the encapsulation leak. diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index d60dbb5..7d08b2b 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -61,6 +61,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: smartCSR: true, title: 'OffCKB Devnet Config Editor', fullUnicode: true, + terminal: 'xterm', }); const filesList = blessed.list({ From b1d876ebac75e0c6f02980a19bf2e69e78ff8483 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 14:51:49 +0800 Subject: [PATCH 19/24] fix(tui): preserve custom array values and add save-time config validation --- README.md | 14 +++--- src/devnet/config-editor.ts | 68 ++++++++++++++++++++++++++++++ src/tui/devnet-config-metadata.ts | 4 +- src/tui/dialogs.ts | 60 ++++++++++++++++++++++---- tests/devnet-config-editor.test.ts | 32 ++++++++++++++ 5 files changed, 162 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index cd11649..c67f272 100644 --- a/README.md +++ b/README.md @@ -284,7 +284,9 @@ offckb devnet config The editor supports full key browsing/editing for `ckb.toml` and `ckb-miner.toml`, including primitive value edits, object key add, array append/insert/move, search filter, and path delete. -Common shortcuts: `Enter` edit primitive, `a` add key/item, `i` insert array item, `m` move array item, `d` delete path, `/` search filter, `n`/`N` next/previous search match, `s` save, `q` quit. +Common shortcuts: `Enter` edit primitive, `a` add key/item, `i` insert array item, `m` move array item, `d` delete path, `/` search filter, `n`/`N` next/previous search match, `c` add custom value in fixed-array dialog (when allowed), `s` save, `q` quit. + +Note: saving rewrites `ckb.toml` / `ckb-miner.toml` into canonical TOML format; upstream comments and original formatting are not preserved after save. You can also update the same fields non-interactively (useful for scripts/CI): @@ -301,7 +303,7 @@ offckb node ``` 1. (Advanced) Locate your Devnet config folder for manual edits: - + ```sh offckb config list ``` @@ -317,12 +319,12 @@ Example result: } } ``` + Pay attention to the `devnet.configPath` and `devnet.dataPath`. - -1. `cd` into the `devnet.configPath` . Modify the config files as needed. See [Custom Devnet Setup](https://docs.nervos.org/docs/node/run-devnet-node#custom-devnet-setup) and [Configure CKB](https://github.com/nervosnetwork/ckb/blob/develop/docs/configure.md) for details. -1. After modifications, run `offckb clean -d` to remove the chain data if needed while keeping the updated config files. -1. Restart local blockchain by running `offckb node` +1. `cd` into the `devnet.configPath` . Modify the config files as needed. See [Custom Devnet Setup](https://docs.nervos.org/docs/node/run-devnet-node#custom-devnet-setup) and [Configure CKB](https://github.com/nervosnetwork/ckb/blob/develop/docs/configure.md) for details. +2. After modifications, run `offckb clean -d` to remove the chain data if needed while keeping the updated config files. +3. Restart local blockchain by running `offckb node` ## Config Setting diff --git a/src/devnet/config-editor.ts b/src/devnet/config-editor.ts index a98da13..1ac1ccd 100644 --- a/src/devnet/config-editor.ts +++ b/src/devnet/config-editor.ts @@ -286,6 +286,22 @@ function writeTomlFileAtomic(filePath: string, data: Record) { fs.renameSync(tempFilePath, filePath); } +function requirePath(target: Record, pathParts: string[], guard: (value: unknown) => value is T, message: string): T { + const value = getByPath(target, pathParts); + if (!guard(value)) { + throw new Error(message); + } + return value; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0; +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + export class DevnetConfigEditor { readonly configPath: string; readonly ckbTomlPath: string; @@ -595,7 +611,59 @@ export class DevnetConfigEditor { return nextValue; } + private validateBeforeSave(): void { + const ckb = this.documents.ckb.data as unknown as Record; + const miner = this.documents.miner.data as unknown as Record; + + const listenAddress = requirePath( + ckb, + ['rpc', 'listen_address'], + isNonEmptyString, + 'Invalid config: rpc.listen_address must be a non-empty string.', + ); + if (!validateHostPort(listenAddress)) { + throw new Error('Invalid config: rpc.listen_address must be in host:port format.'); + } + + const rpcModules = requirePath( + ckb, + ['rpc', 'modules'], + isStringArray, + 'Invalid config: rpc.modules must be an array of strings.', + ); + if (rpcModules.length === 0) { + throw new Error('Invalid config: rpc.modules must include at least one module.'); + } + if (rpcModules.some((moduleName) => moduleName.trim().length === 0)) { + throw new Error('Invalid config: rpc.modules must not contain empty module names.'); + } + + const supportProtocols = requirePath( + ckb, + ['network', 'support_protocols'], + isStringArray, + 'Invalid config: network.support_protocols must be an array of strings.', + ); + if (!supportProtocols.includes('Sync') || !supportProtocols.includes('Identify')) { + throw new Error('Invalid config: network.support_protocols must include both Sync and Identify.'); + } + if (supportProtocols.some((protocol) => protocol.trim().length === 0)) { + throw new Error('Invalid config: network.support_protocols must not contain empty protocol names.'); + } + + const minerRpcUrl = requirePath( + miner, + ['miner', 'client', 'rpc_url'], + isNonEmptyString, + 'Invalid config: miner.client.rpc_url must be a non-empty string.', + ); + if (!validateHttpUrl(minerRpcUrl)) { + throw new Error('Invalid config: miner.client.rpc_url must be a valid HTTP/HTTPS URL.'); + } + } + save(): void { + this.validateBeforeSave(); writeTomlFileAtomic(this.ckbTomlPath, this.documents.ckb.data as unknown as Record); writeTomlFileAtomic(this.minerTomlPath, this.documents.miner.data as unknown as Record); } diff --git a/src/tui/devnet-config-metadata.ts b/src/tui/devnet-config-metadata.ts index 42d135e..5fa300e 100644 --- a/src/tui/devnet-config-metadata.ts +++ b/src/tui/devnet-config-metadata.ts @@ -42,7 +42,7 @@ const CONFIG_DOCS: ConfigDocRule[] = [ { pathPattern: 'rpc.modules', doc: { - summary: 'Enabled RPC module names exposed by this node.', + summary: 'Enabled RPC modules; known defaults plus custom module names if supported.', source: 'CKB configure docs', }, }, @@ -91,7 +91,7 @@ const CONFIG_DOCS: ConfigDocRule[] = [ { pathPattern: 'network.support_protocols', doc: { - summary: 'Enabled CKB p2p protocol list.', + summary: 'Enabled CKB p2p protocols; must include Sync and Identify.', source: 'CKB configure docs / CKB protocol definitions', }, }, diff --git a/src/tui/dialogs.ts b/src/tui/dialogs.ts index 9156a9a..448faab 100644 --- a/src/tui/dialogs.ts +++ b/src/tui/dialogs.ts @@ -13,8 +13,12 @@ export async function waitForFixedArraySelection( currentValues: string[], ): Promise { return new Promise((resolve) => { + const knownOptions = [...spec.options]; + const customCurrentValues = currentValues.filter((value) => !knownOptions.includes(value)); + const optionList = [...knownOptions, ...customCurrentValues]; + const maxVisibleRows = 14; - const visibleRows = Math.min(Math.max(spec.options.length, 1), maxVisibleRows); + const visibleRows = Math.min(Math.max(optionList.length, 1), maxVisibleRows); const listHeight = visibleRows + 2; const dialogHeight = listHeight + 6; @@ -69,13 +73,14 @@ export async function waitForFixedArraySelection( width: '100%-4', height: 2, tags: true, - content: 'Space toggle Enter apply Esc cancel Ctrl/Alt+a all Ctrl/Alt+d none', + content: `Space toggle Enter apply Esc cancel Ctrl/Alt+a all Ctrl/Alt+d none${spec.allowCustom ? ' c add custom' : ''}`, }); const renderList = () => { - const items = spec.options.map((option) => { + const items = optionList.map((option) => { const checked = selectedValues.has(option) ? 'x' : ' '; - return `[${checked}] ${option}`; + const suffix = knownOptions.includes(option) ? '' : ' (custom)'; + return `[${checked}] ${option}${suffix}`; }); list.setItems(items); }; @@ -102,11 +107,11 @@ export async function waitForFixedArraySelection( const selectedOption = () => { const selectedIndex = getListSelected(list); - return spec.options[selectedIndex] ?? null; + return optionList[selectedIndex] ?? null; }; const applySelection = () => { - const values = spec.options.filter((option) => selectedValues.has(option)); + const values = optionList.filter((option) => selectedValues.has(option)); cleanup(values); }; @@ -128,7 +133,7 @@ export async function waitForFixedArraySelection( list.key(['space'], () => toggleSelectedOption()); list.key(['escape'], () => cleanup(null)); list.key(['C-a', 'A-a', 'M-a'], () => { - spec.options.forEach((option) => selectedValues.add(option)); + optionList.forEach((option) => selectedValues.add(option)); renderList(); screen.render(); }); @@ -140,7 +145,7 @@ export async function waitForFixedArraySelection( dialog.key(['escape'], () => cleanup(null)); dialog.key(['C-a', 'A-a', 'M-a'], () => { - spec.options.forEach((option) => selectedValues.add(option)); + optionList.forEach((option) => selectedValues.add(option)); renderList(); screen.render(); }); @@ -150,6 +155,45 @@ export async function waitForFixedArraySelection( screen.render(); }); + const addCustomValue = async () => { + if (!spec.allowCustom) return; + + const answer = await waitForInput(screen, `${title} (${spec.label})`, 'Custom value:', ''); + if (answer == null) { + list.focus(); + screen.render(); + return; + } + + const customValue = answer.trim(); + if (!customValue) { + list.focus(); + screen.render(); + return; + } + + if (!optionList.includes(customValue)) { + optionList.push(customValue); + } + selectedValues.add(customValue); + + renderList(); + const index = optionList.findIndex((option) => option === customValue); + if (index >= 0) { + list.select(index); + } + list.focus(); + screen.render(); + }; + + list.key(['c'], () => { + void addCustomValue(); + }); + + dialog.key(['c'], () => { + void addCustomValue(); + }); + list.focus(); screen.render(); }); diff --git a/tests/devnet-config-editor.test.ts b/tests/devnet-config-editor.test.ts index c4f39e7..b3bb147 100644 --- a/tests/devnet-config-editor.test.ts +++ b/tests/devnet-config-editor.test.ts @@ -21,10 +21,12 @@ function writeFixtureConfig(configPath: string) { listen_address: '0.0.0.0:8114', max_request_body_size: 10_485_760, enable_deprecated_rpc: false, + modules: ['Net', 'Pool', 'Miner', 'Chain', 'Stats', 'Subscription', 'Experiment', 'Debug', 'Indexer'], }, network: { max_peers: 125, bootnodes: ['node-a', 'node-b'], + support_protocols: ['Ping', 'Discovery', 'Identify', 'Sync'], }, }), 'utf8', @@ -183,4 +185,34 @@ describe('DevnetConfigEditor', () => { expect(ckbToml.network.bootnodes).toEqual(['node-b', 'node-a', 'node-x']); }); + + it('allows custom rpc modules and preserves them on save', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setArrayValues('ckb', ['rpc', 'modules'], ['Net', 'Indexer', 'CustomModuleX']); + editor.save(); + + const ckbToml = toml.parse(fs.readFileSync(path.join(configPath, 'ckb.toml'), 'utf8')) as unknown as Record< + string, + any + >; + + expect(ckbToml.rpc.modules).toEqual(['Net', 'Indexer', 'CustomModuleX']); + }); + + it('rejects saving when support_protocols misses mandatory protocols', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setArrayValues('ckb', ['network', 'support_protocols'], ['Ping', 'Discovery']); + + expect(() => editor.save()).toThrow('network.support_protocols must include both Sync and Identify'); + }); + + it('rejects saving when rpc.modules is empty', () => { + const editor = createDevnetConfigEditor(configPath); + + editor.setArrayValues('ckb', ['rpc', 'modules'], []); + + expect(() => editor.save()).toThrow('rpc.modules must include at least one module'); + }); }); From 8ad24277bd901ed120025b8d3ebaf966fa092244 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 15:22:38 +0800 Subject: [PATCH 20/24] chore(tui): help text --- src/devnet/config-editor.ts | 7 ++++++- src/tui/devnet-config-tui.ts | 13 +++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/devnet/config-editor.ts b/src/devnet/config-editor.ts index 1ac1ccd..5c280dc 100644 --- a/src/devnet/config-editor.ts +++ b/src/devnet/config-editor.ts @@ -286,7 +286,12 @@ function writeTomlFileAtomic(filePath: string, data: Record) { fs.renameSync(tempFilePath, filePath); } -function requirePath(target: Record, pathParts: string[], guard: (value: unknown) => value is T, message: string): T { +function requirePath( + target: Record, + pathParts: string[], + guard: (value: unknown) => value is T, + message: string, +): T { const value = getByPath(target, pathParts); if (!guard(value)) { throw new Error(message); diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 7d08b2b..f287b4f 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -1,4 +1,5 @@ import blessed from 'blessed'; +import path from 'path'; import { DevnetConfigEditor, TomlEntry } from '../devnet/config-editor'; import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; import { formatEntryLine, formatFixedArrayDetailLine } from './format'; @@ -97,7 +98,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: bottom: 0, left: 0, width: '100%', - height: '10%', + height: 4, border: 'line', tags: true, content: '', @@ -167,15 +168,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: } const dirtyText = state.hasUnsavedChanges ? '{yellow-fg}yes{/yellow-fg}' : '{green-fg}no{/green-fg}'; - const selectedEntry = state.visibleEntries[state.selectedEntryIndex]; - const keyDoc = selectedEntry != null ? getConfigDoc(selectedEntry.path) : null; - const docLine = keyDoc != null ? `${keyDoc.summary} (${keyDoc.source})` : 'No inline doc for this key yet.'; + const selectedFilePath = path.join(state.configPath, doc.title); statusBar.setContent( [ - `Path: ${state.configPath}`, - `File: ${doc.title} | Focus: ${state.focusPane} | Search: ${state.searchTerm || '(none)'} | Unsaved: ${dirtyText}`, - `Status: ${state.statusMessage}`, - `Doc: ${docLine}`, + `Path: ${selectedFilePath}`, + `Keys: Enter Edit | a Add | d Delete | i Insert | m Move | / Search | n/N Jump | s Save | q Quit | Unsaved: ${dirtyText}`, ].join('\n'), ); From ee2526f019077e5ccbfcbd1c0036dd9eddb306b1 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 15:52:42 +0800 Subject: [PATCH 21/24] feat(tui): add three-column devnet config editor with readonly reference pane --- README.md | 4 +- src/tui/actions.ts | 2 +- src/tui/devnet-config-tui.ts | 122 ++++++++++++++++++++++++++++++----- src/tui/tui-state.ts | 3 +- 4 files changed, 113 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index c67f272..a20ee15 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,9 @@ By default, OffCKB use a fixed Devnet config. You can customize it, for example offckb devnet config ``` -The editor supports full key browsing/editing for `ckb.toml` and `ckb-miner.toml`, including primitive value edits, object key add, array append/insert/move, search filter, and path delete. +The editor uses a three-column layout: first-column file switcher (`ckb.toml` / `ckb-miner.toml`), a middle primary editing pane, and a smaller right read-only reference pane that shows the full built-in template for the currently selected file. + +The left editing pane supports full key browsing/editing, including primitive value edits, object key add, array append/insert/move, search filter, and path delete. Common shortcuts: `Enter` edit primitive, `a` add key/item, `i` insert array item, `m` move array item, `d` delete path, `/` search filter, `n`/`N` next/previous search match, `c` add custom value in fixed-array dialog (when allowed), `s` save, `q` quit. diff --git a/src/tui/actions.ts b/src/tui/actions.ts index 8547c2d..073aa91 100644 --- a/src/tui/actions.ts +++ b/src/tui/actions.ts @@ -109,7 +109,7 @@ function currentEntry(ctx: ActionContext): TomlEntry | null { export async function editCurrentEntry(ctx: ActionContext): Promise { const { state, widgets, refreshUi } = ctx; - if (state.focusPane === 'files') { + if (state.focusPane !== 'entries') { state.focusPane = 'entries'; refreshUi(); return; diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index f287b4f..1b748dc 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -1,7 +1,8 @@ import blessed from 'blessed'; +import fs from 'node:fs'; import path from 'path'; import { DevnetConfigEditor, TomlEntry } from '../devnet/config-editor'; -import { getConfigDoc, getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; +import { getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; import { formatEntryLine, formatFixedArrayDetailLine } from './format'; import { createTuiState, TuiWidgets } from './tui-state'; import { getListSelected } from './blessed-helpers'; @@ -45,6 +46,53 @@ function getVisibleEntries(entries: TomlEntry[], searchTerm: string): TomlEntry[ }); } +function escapeBlessedTags(text: string): string { + return text.replace(/\{/g, '\\{').replace(/\}/g, '\\}'); +} + +function styleTomlReferenceLine(line: string): string { + const escapedLine = escapeBlessedTags(line); + const trimmed = line.trim(); + + if (trimmed.length === 0) { + return escapedLine; + } + + if (/^\[\[[^\]]+\]\]$/.test(trimmed)) { + return `{magenta-fg}{bold}${escapedLine}{/bold}{/magenta-fg}`; + } + + if (/^\[[^\]]+\]$/.test(trimmed)) { + return `{cyan-fg}{bold}${escapedLine}{/bold}{/cyan-fg}`; + } + + if (trimmed.startsWith('#')) { + return `{250-fg}${escapedLine}{/250-fg}`; + } + + const keyValueMatch = line.match(/^(\s*[^=\s][^=]*?\s*=\s*)(.*)$/); + if (keyValueMatch != null) { + const keyPart = escapeBlessedTags(keyValueMatch[1]); + const rawValuePart = keyValueMatch[2] ?? ''; + + const inlineCommentStart = rawValuePart.indexOf(' #'); + if (inlineCommentStart >= 0) { + const valuePart = escapeBlessedTags(rawValuePart.slice(0, inlineCommentStart)); + const commentPart = escapeBlessedTags(rawValuePart.slice(inlineCommentStart)); + return `{yellow-fg}${keyPart}{/yellow-fg}{green-fg}${valuePart}{/green-fg}{250-fg}${commentPart}{/250-fg}`; + } + + const valuePart = escapeBlessedTags(rawValuePart); + return `{yellow-fg}${keyPart}{/yellow-fg}{green-fg}${valuePart}{/green-fg}`; + } + + return escapedLine; +} + +function styleTomlReference(source: string): string { + return source.split('\n').map(styleTomlReferenceLine).join('\n'); +} + // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- @@ -70,8 +118,8 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: label: ' Files ', top: 0, left: 0, - width: '22%', - height: '90%', + width: '20%', + height: '100%-4', border: 'line', keys: true, vi: true, @@ -83,9 +131,9 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: parent: screen, label: ' Config ', top: 0, - left: '22%', - width: '78%', - height: '90%', + left: '20%', + width: '55%', + height: '100%-4', border: 'line', keys: true, vi: true, @@ -93,6 +141,24 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: tags: true, }); + const referenceBox = blessed.box({ + parent: screen, + label: ' Reference (Read-Only) ', + top: 0, + left: '75%', + width: '25%', + height: '100%-4', + border: 'line', + scrollable: true, + alwaysScroll: true, + keys: true, + vi: true, + mouse: true, + tags: true, + style: { border: { fg: 'gray' } }, + content: '', + }); + const statusBar = blessed.box({ parent: screen, bottom: 0, @@ -105,9 +171,23 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: style: { border: { fg: 'gray' } }, }); - const widgets: TuiWidgets = { screen, filesList, entriesList, statusBar }; + const widgets: TuiWidgets = { screen, filesList, entriesList, referenceBox, statusBar }; let renderedRows: EntryRenderRow[] = []; let entryToRowIndex: number[] = []; + const referenceTemplatesRoot = path.resolve(__dirname, '../../ckb/devnet'); + + const getReferenceTemplateTitle = (documentId: 'ckb' | 'miner') => + documentId === 'ckb' ? 'ckb.toml' : 'ckb-miner.toml'; + + const loadReferenceTemplate = (documentId: 'ckb' | 'miner'): string => { + const title = getReferenceTemplateTitle(documentId); + const filePath = path.join(referenceTemplatesRoot, title); + try { + return fs.readFileSync(filePath, 'utf-8'); + } catch (error) { + return `Failed to load reference template:\n${filePath}\n\n${(error as Error).message}`; + } + }; // ---- refresh ---- const refreshUi = () => { @@ -156,6 +236,10 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: filesList.style.border = { fg: state.focusPane === 'files' ? 'cyan' : 'gray' }; entriesList.style.border = { fg: state.focusPane === 'entries' ? 'cyan' : 'gray' }; + referenceBox.style.border = { fg: state.focusPane === 'reference' ? 'cyan' : 'gray' }; + + const referenceContent = styleTomlReference(loadReferenceTemplate(doc.id)); + referenceBox.setContent(referenceContent); if (state.visibleEntries.length === 0 || renderedRows.length === 0) { state.selectedEntryIndex = 0; @@ -178,8 +262,10 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: if (state.focusPane === 'files') { filesList.focus(); - } else { + } else if (state.focusPane === 'entries') { entriesList.focus(); + } else { + referenceBox.focus(); } screen.render(); @@ -202,6 +288,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: if (listIndex !== state.selectedDocumentIndex) { state.selectedDocumentIndex = listIndex; state.selectedEntryIndex = 0; + referenceBox.setScroll(0); state.statusMessage = `Switched to ${state.documents[state.selectedDocumentIndex].title}.`; refreshUi(); } @@ -241,6 +328,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: if (index == null) return; state.selectedDocumentIndex = index; state.selectedEntryIndex = 0; + referenceBox.setScroll(0); refreshUi(); }); @@ -276,7 +364,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: // ---- key bindings ---- guardedKey(['tab'], () => { - state.focusPane = state.focusPane === 'files' ? 'entries' : 'files'; + state.focusPane = state.focusPane === 'files' ? 'entries' : state.focusPane === 'entries' ? 'reference' : 'files'; refreshUi(); }); @@ -284,6 +372,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: if (state.focusPane === 'entries') { state.focusPane = 'files'; refreshUi(); + return; + } + if (state.focusPane === 'reference') { + state.focusPane = 'entries'; + refreshUi(); } }); @@ -291,6 +384,11 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: if (state.focusPane === 'files') { state.focusPane = 'entries'; refreshUi(); + return; + } + if (state.focusPane === 'entries') { + state.focusPane = 'reference'; + refreshUi(); } }); @@ -328,12 +426,6 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: guardedKey(['n'], () => jumpSearchMatch(ctx, 'next')); guardedKey(['N'], () => jumpSearchMatch(ctx, 'prev')); - filesList.key(['enter'], () => { - if (state.dialogLock) return; - state.focusPane = 'entries'; - refreshUi(); - }); - // ---- start ---- refreshUi(); diff --git a/src/tui/tui-state.ts b/src/tui/tui-state.ts index f524060..db85986 100644 --- a/src/tui/tui-state.ts +++ b/src/tui/tui-state.ts @@ -1,7 +1,7 @@ import { Widgets } from 'blessed'; import { DevnetConfigEditor, TomlDocument, TomlEntry } from '../devnet/config-editor'; -export type FocusPane = 'files' | 'entries'; +export type FocusPane = 'files' | 'entries' | 'reference'; export interface TuiState { readonly editor: DevnetConfigEditor; @@ -22,6 +22,7 @@ export interface TuiWidgets { screen: Widgets.Screen; filesList: Widgets.ListElement; entriesList: Widgets.ListElement; + referenceBox: Widgets.BoxElement; statusBar: Widgets.BoxElement; } From f37ee2e06dc14c0f56839aa3b97c3c07d8958e15 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 16:05:59 +0800 Subject: [PATCH 22/24] chore(tui) --- src/tui/devnet-config-tui.ts | 18 +- src/tui/devnet-reference-templates.ts | 228 ++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 16 deletions(-) create mode 100644 src/tui/devnet-reference-templates.ts diff --git a/src/tui/devnet-config-tui.ts b/src/tui/devnet-config-tui.ts index 1b748dc..523efe6 100644 --- a/src/tui/devnet-config-tui.ts +++ b/src/tui/devnet-config-tui.ts @@ -1,8 +1,8 @@ import blessed from 'blessed'; -import fs from 'node:fs'; import path from 'path'; import { DevnetConfigEditor, TomlEntry } from '../devnet/config-editor'; import { getFixedArraySpecFromEntryPath } from './devnet-config-metadata'; +import { getEmbeddedReferenceTemplate } from './devnet-reference-templates'; import { formatEntryLine, formatFixedArrayDetailLine } from './format'; import { createTuiState, TuiWidgets } from './tui-state'; import { getListSelected } from './blessed-helpers'; @@ -174,20 +174,6 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: const widgets: TuiWidgets = { screen, filesList, entriesList, referenceBox, statusBar }; let renderedRows: EntryRenderRow[] = []; let entryToRowIndex: number[] = []; - const referenceTemplatesRoot = path.resolve(__dirname, '../../ckb/devnet'); - - const getReferenceTemplateTitle = (documentId: 'ckb' | 'miner') => - documentId === 'ckb' ? 'ckb.toml' : 'ckb-miner.toml'; - - const loadReferenceTemplate = (documentId: 'ckb' | 'miner'): string => { - const title = getReferenceTemplateTitle(documentId); - const filePath = path.join(referenceTemplatesRoot, title); - try { - return fs.readFileSync(filePath, 'utf-8'); - } catch (error) { - return `Failed to load reference template:\n${filePath}\n\n${(error as Error).message}`; - } - }; // ---- refresh ---- const refreshUi = () => { @@ -238,7 +224,7 @@ export async function runDevnetConfigTui(editor: DevnetConfigEditor, configPath: entriesList.style.border = { fg: state.focusPane === 'entries' ? 'cyan' : 'gray' }; referenceBox.style.border = { fg: state.focusPane === 'reference' ? 'cyan' : 'gray' }; - const referenceContent = styleTomlReference(loadReferenceTemplate(doc.id)); + const referenceContent = styleTomlReference(getEmbeddedReferenceTemplate(doc.id)); referenceBox.setContent(referenceContent); if (state.visibleEntries.length === 0 || renderedRows.length === 0) { diff --git a/src/tui/devnet-reference-templates.ts b/src/tui/devnet-reference-templates.ts new file mode 100644 index 0000000..1133ae5 --- /dev/null +++ b/src/tui/devnet-reference-templates.ts @@ -0,0 +1,228 @@ +const CKB_REFERENCE_TEMPLATE = `# Config generated by \`ckb init --chain dev\` + +data_dir = "data" + +[chain] +# Choose the kind of chains to run, possible values: +# - { file = "specs/dev.toml" } +# - { bundled = "specs/testnet.toml" } +# - { bundled = "specs/mainnet.toml" } +spec = { file = "specs/dev.toml" } + +[logger] +filter = "warn,ckb-script=debug" +color = true +log_to_file = true +log_to_stdout = true + +[sentry] +# set to blank to disable sentry error collection +dsn = "" +# if you are willing to help us to improve, +# please leave a way to contact you when we have troubles to reproduce the errors. +# org_contact = "" + +# # **Experimental** Monitor memory changes. +# [memory_tracker] +# # Seconds between checking the process, 0 is disable, default is 0. +# interval = 600 + +[db] +# The capacity of RocksDB cache, which caches uncompressed data blocks, indexes and filters, default is 256MB. +# Rocksdb will automatically create and use an 32MB internal cache for each column family by default if you set this value to 0. +# To turning off cache, you need to set this value to 0 and set \`no_block_cache = true\` in the options_file, +# however, we strongly discourage this setting, it may lead to severe performance degradation. +cache_size = 268435456 + +# Provide an options file to tune RocksDB for your workload and your system configuration. +# More details can be found in [the official tuning guide](https://github.com/facebook/rocksdb/wiki/RocksDB-Tuning-Guide). +options_file = "default.db-options" + +[network] +listen_addresses = ["/ip4/0.0.0.0/tcp/8115"] +### Specify the public and routable network addresses +# public_addresses = [] + +# Node connects to nodes listed here to discovery other peers when there's no local stored peers. +# When chain.spec is changed, this usually should also be changed to the bootnodes in the new chain. +bootnodes = [] + +### Whitelist-only mode +# whitelist_only = false +### Whitelist peers connecting from the given IP addresses +# whitelist_peers = [] +### Enable \`SO_REUSEPORT\` feature to reuse port on Linux, not supported on other OS yet +# reuse_port_on_linux = true + +max_peers = 125 +max_outbound_peers = 8 +# 2 minutes +ping_interval_secs = 120 +# 20 minutes +ping_timeout_secs = 1200 +connect_outbound_interval_secs = 15 +# If set to true, try to register upnp +upnp = false +# If set to true, network service will add discovered local address to peer store, it's helpful for private net development +discovery_local_address = true +# If set to true, random cleanup when there are too many inbound nodes +# Ensure that itself can continue to serve as a bootnode node +bootnode_mode = false + +# Supported protocols list, only "Sync" and "Identify" are mandatory, others are optional +support_protocols = ["Ping", "Discovery", "Identify", "Feeler", "DisconnectMessage", "Sync", "Relay", "Time", "Alert", "LightClient", "Filter"] + +# [network.sync.header_map] +# memory_limit = "256MB" + +[rpc] +# By default RPC only binds to localhost, thus it only allows accessing from the same machine. +# +# Allowing arbitrary machines to access the JSON-RPC port is dangerous and strongly discouraged. +# Please strictly limit the access to only trusted machines. +listen_address = "0.0.0.0:8114" + +# Default is 10MiB = 10 * 1024 * 1024 +max_request_body_size = 10485760 + +# List of API modules: ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] +modules = ["Net", "Pool", "Miner", "Chain", "Stats", "Subscription", "Experiment", "Debug", "Indexer"] + +# By default RPC only binds to HTTP service, you can bind it to TCP and WebSocket. +# tcp_listen_address = "127.0.0.1:18114" +# ws_listen_address = "127.0.0.1:28114" +reject_ill_transactions = true + +# By default deprecated rpc methods are disabled. +enable_deprecated_rpc = false + +[tx_pool] +max_tx_pool_size = 180_000_000 # 180mb +min_fee_rate = 1_000 # Here fee_rate are calculated directly using size in units of shannons/KB +# min_rbf_rate > min_fee_rate means RBF is enabled +min_rbf_rate = 1_500 # Here fee_rate are calculated directly using size in units of shannons/KB +max_tx_verify_cycles = 70_000_000 +max_ancestors_count = 25 + +[store] +header_cache_size = 4096 +cell_data_cache_size = 128 +block_proposals_cache_size = 30 +block_tx_hashes_cache_size = 30 +block_uncles_cache_size = 30 + +# [notifier] +# # Execute command when the new tip block changes, first arg is block hash. +# new_block_notify_script = "your_new_block_notify_script.sh" +# # Execute command when node received an network alert, first arg is alert message string. +# network_alert_notify_script = "your_network_alert_notify_script.sh" + +# Set the lock script to protect mined CKB. +# +# CKB uses CS architecture for miner. Miner process (ckb miner) gets block +# template from the Node process (ckb run) via RPC. Thus the lock script is +# configured in ckb.toml instead of ckb-miner.toml, and the config takes effect +# after restarting Node process. +# +# The \`code_hash\` identifies different cryptography algorithm. Read the manual +# of the lock script provider about how to generate this config. +# +# CKB provides an secp256k1 implementation, it requires a hash on the +# compressed public key. The hash algorithm is blake2b, with personal +# "ckb-default-hash". The first 160 bits (20 bytes) are used as the only arg. +# +# You can use any tool you trust to generate a Bitcoin private key and public +# key pair, which can be used in CKB as well. CKB CLI provides the function for +# you to convert the public key into block assembler configuration parameters. +# +# Here is an example using ckb-cli to generate an account, this command will +# print the block assembler args(lock_arg) to screen: +# +# ckb-cli account new +# +# If you already have a raw secp256k1 private key, you can get the lock_arg by: +# +# ckb-cli util key-info --privkey-path +# +# The command \`ckb init\` also accepts options to generate the block assembler +# directly. See \`ckb init --help\` for details. +# +# ckb init +# +# secp256k1_blake160_sighash_all example: +# [block_assembler] +# code_hash = "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8" +# args = "ckb-cli util blake2b --prefix-160 " +# hash_type = "type" +# message = "A 0x-prefixed hex string" +# # +# # CKB will prepend the binary version to message, to identify the block miner client. (default true, false to disable it) +# use_binary_version_as_message_prefix = true +# # +# # Block assembler will notify new block template through http post to specified endpoints when update +# notify = ["http://127.0.0.1:8888"] +# # Or you may want use more flexible scripts, block template as arg. +# notify_scripts = ["{cmd} {blocktemplate}"] +# +# [indexer_v2] +# # Indexing the pending txs in the ckb tx-pool +# index_tx_pool = false + +# ckb miner / ckb faucet +# private key: 0x650e256211f5e0beee9084596aa2cb84d11eb033cced5e2d5b191593a9f9f1d4 +# private key path: accounts/ckb-miner-and-faucet.key +[block_assembler] +code_hash = "0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8" +args = "0xa1db2eef3f29f3ef6f86c8d2a0772c705c449f4a" +hash_type = "type" +message = "0x" +`; + +const MINER_REFERENCE_TEMPLATE = `# Config generated by \`ckb init --chain dev\` + +data_dir = "data" + +[chain] +# Choose the kind of chains to run, possible values: +# - { file = "specs/dev.toml" } +# - { bundled = "specs/testnet.toml" } +# - { bundled = "specs/mainnet.toml" } +spec = { file = "specs/dev.toml" } + +[logger] +filter = "warn,ckb-script=debug" +color = true +log_to_file = true +log_to_stdout = true + +[sentry] +# set to blank to disable sentry error collection +dsn = "" +# if you are willing to help us to improve, +# please leave a way to contact you when we have troubles to reproduce the errors. +# org_contact = "" + +# # **Experimental** Monitor memory changes. +# [memory_tracker] +# # Seconds between checking the process, 0 is disable, default is 0. +# interval = 600 + +[miner.client] +rpc_url = "http://ckb:8114/" +block_on_submit = true + +# block template polling interval in milliseconds +poll_interval = 1000 + +# enable listen notify mode +# listen = "127.0.0.1:8888" + +[[miner.workers]] +worker_type = "Dummy" +delay_type = "Constant" +value = 5000 +`; + +export function getEmbeddedReferenceTemplate(documentId: 'ckb' | 'miner'): string { + return documentId === 'ckb' ? CKB_REFERENCE_TEMPLATE : MINER_REFERENCE_TEMPLATE; +} From 48cf73607ec9978a69004499290a00ef321cd8ce Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 16:13:37 +0800 Subject: [PATCH 23/24] chore(tui) --- README.md | 2 + package.json | 4 +- src/cmd/devnet-config.ts | 9 ++++ tests/devnet-config-command.test.ts | 80 +++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 tests/devnet-config-command.test.ts diff --git a/README.md b/README.md index a20ee15..d575e6d 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,8 @@ offckb devnet config --set ckb.logger.filter=info offckb devnet config --set ckb.rpc.enable_deprecated_rpc=true --set miner.client.poll_interval=1500 ``` +If your terminal is non-interactive (no TTY, e.g. CI/remote pipeline), use `--set` mode directly instead of the full-screen editor. + 1. Save changes and restart devnet: ```sh diff --git a/package.json b/package.json index 366b3ec..e199324 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ }, "devDependencies": { "@types/adm-zip": "^0.5.5", - "@types/blessed": "^0.1.27", + "@types/blessed": "0.1.27", "@types/jest": "^30.0.0", "@types/node": "^20.17.24", "@types/node-fetch": "^2.6.11", @@ -81,7 +81,7 @@ "@inquirer/prompts": "^7.8.6", "@types/http-proxy": "^1.17.15", "adm-zip": "^0.5.10", - "blessed": "^0.1.81", + "blessed": "0.1.81", "chalk": "4.1.2", "child_process": "^1.0.2", "ckb-transaction-dumper": "^0.4.2", diff --git a/src/cmd/devnet-config.ts b/src/cmd/devnet-config.ts index 276f17e..cc8b5d3 100644 --- a/src/cmd/devnet-config.ts +++ b/src/cmd/devnet-config.ts @@ -53,6 +53,15 @@ export async function devnetConfig(options: DevnetConfigOptions = {}) { return; } + if (!process.stdin.isTTY || !process.stdout.isTTY) { + logger.error('Interactive devnet config editor requires a TTY terminal.'); + logger.info('Use non-interactive mode instead, e.g.:'); + logger.info(' offckb devnet config --set ckb.logger.filter=info'); + logger.info(' offckb devnet config --set miner.client.poll_interval=1500'); + process.exitCode = 1; + return; + } + const isSaved = await runDevnetConfigTui(editor, configPath); if (isSaved) { diff --git a/tests/devnet-config-command.test.ts b/tests/devnet-config-command.test.ts new file mode 100644 index 0000000..c976e9a --- /dev/null +++ b/tests/devnet-config-command.test.ts @@ -0,0 +1,80 @@ +import { devnetConfig } from '../src/cmd/devnet-config'; +import { createDevnetConfigEditor } from '../src/devnet/config-editor'; +import { runDevnetConfigTui } from '../src/tui/devnet-config-tui'; +import { logger } from '../src/util/logger'; + +jest.mock('../src/cfg/setting', () => ({ + readSettings: () => ({ + devnet: { + configPath: '/tmp/offckb-devnet-config', + }, + }), +})); + +jest.mock('../src/devnet/config-editor', () => ({ + createDevnetConfigEditor: jest.fn(), +})); + +jest.mock('../src/tui/devnet-config-tui', () => ({ + runDevnetConfigTui: jest.fn(), +})); + +jest.mock('../src/util/logger', () => ({ + logger: { + success: jest.fn(), + info: jest.fn(), + error: jest.fn(), + }, +})); + +describe('devnet config command fallback behavior', () => { + const originalStdinTty = Object.getOwnPropertyDescriptor(process.stdin, 'isTTY'); + const originalStdoutTty = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); + + beforeEach(() => { + jest.clearAllMocks(); + process.exitCode = undefined; + + (createDevnetConfigEditor as jest.Mock).mockReturnValue({ + setFieldValue: jest.fn(), + save: jest.fn(), + }); + + (runDevnetConfigTui as jest.Mock).mockResolvedValue(false); + }); + + afterEach(() => { + if (originalStdinTty != null) { + Object.defineProperty(process.stdin, 'isTTY', originalStdinTty); + } + if (originalStdoutTty != null) { + Object.defineProperty(process.stdout, 'isTTY', originalStdoutTty); + } + process.exitCode = undefined; + }); + + it('uses --set mode in non-TTY terminals without launching TUI', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + await devnetConfig({ set: ['ckb.logger.filter=info'] }); + + expect(runDevnetConfigTui).not.toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.success).toHaveBeenCalledWith('Devnet config updated at: /tmp/offckb-devnet-config'); + expect(process.exitCode).toBeUndefined(); + }); + + it('prints actionable fallback guidance when TTY is unavailable', async () => { + Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true }); + + await devnetConfig(); + + expect(runDevnetConfigTui).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith('Interactive devnet config editor requires a TTY terminal.'); + expect(logger.info).toHaveBeenCalledWith('Use non-interactive mode instead, e.g.:'); + expect(logger.info).toHaveBeenCalledWith(' offckb devnet config --set ckb.logger.filter=info'); + expect(process.exitCode).toBe(1); + }); +}); From 1a41c58eddf92b8bba13e5011be318d1109c24b7 Mon Sep 17 00:00:00 2001 From: RetricSu Date: Fri, 27 Feb 2026 16:17:09 +0800 Subject: [PATCH 24/24] re-generate pnpm-lock file --- pnpm-lock.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d474791..282e098 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,7 +24,7 @@ importers: specifier: ^0.5.10 version: 0.5.16 blessed: - specifier: ^0.1.81 + specifier: 0.1.81 version: 0.1.81 chalk: specifier: 4.1.2 @@ -61,7 +61,7 @@ importers: specifier: ^0.5.5 version: 0.5.7 '@types/blessed': - specifier: ^0.1.27 + specifier: 0.1.27 version: 0.1.27 '@types/jest': specifier: ^30.0.0