diff --git a/CHANGELOG.md b/CHANGELOG.md index c794676..4922c62 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ candidate's, plus the changes since it listed first. ### Added: the terminal UI (`qqq ui`) +- **The "CLI equivalent" dialog can be copied**: `y`, `c` or Enter copies the command to the clipboard (Esc closes without copying), the filter dialog has Ctrl+Y for its command, Shift+drag still selects with the terminal, and F9 › Settings › "Copy CLI commands to the clipboard automatically" (`ui.copyCliCommands`, default off) copies as soon as the dialog opens. - **A classic full-screen UI** in the same binary and on the same profiles and sign-ins as the CLI: function-key bar (F1 help to F10 quit, with Esc+digit for terminals that take the F-keys), PgUp/PgDn everywhere, Esc back with the screen below keeping its place, a `:` command line that speaks the CLI diff --git a/packages/core/src/session/profiles.ts b/packages/core/src/session/profiles.ts index 3ca250e..3c83b36 100644 --- a/packages/core/src/session/profiles.ts +++ b/packages/core/src/session/profiles.ts @@ -27,6 +27,8 @@ export interface UiSettings { theme?: string /** Record view: data rows of each child list (default 5); a table's view settings override it. */ childRows?: number + /** The terminal UI copies a CLI equivalent to the clipboard as soon as it shows it (default off). */ + copyCliCommands?: boolean /** Keyed by screen, e.g. `table:person`, `record:person`, `app:peopleApp`. */ views?: Record } diff --git a/packages/tui/README.md b/packages/tui/README.md index 2322b43..61560f5 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -179,6 +179,8 @@ Theme (for this profile or for all), or `--theme`; the option wins over the prof global choice. `--ascii` replaces box drawing and symbols. Colours come only from the tokens in `src/theme.ts`. +The CLI equivalent dialog copies its command with `y` (or Enter); F9 › Settings can copy it automatically, and Shift+drag selects it with the terminal. + F9 › Settings also holds single-key shortcuts on/off (default: the server preference `keyboard.singleKeyShortcuts`), the download and upload folders, the PIN and idle lock, and the terminal check. diff --git a/packages/tui/src/boot.ts b/packages/tui/src/boot.ts index 1f6441f..7a4c39d 100644 --- a/packages/tui/src/boot.ts +++ b/packages/tui/src/boot.ts @@ -5,7 +5,7 @@ /** Builds the store for a run: settings from options, config and environment; sign-in; the first route. */ -import { readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core' +import { readConfig, readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core' import { installCommands } from './commands' import { SignInScreen, type SignInDeps } from './screens/signin' import { Sidebar } from './screens/sidebar' @@ -69,7 +69,7 @@ export function boot(options: BootOptions): AppStore { else note = `unknown theme "${args.theme}"; themes: ${Object.keys(THEMES).join(', ')}` } const theme = (args.theme && THEMES[args.theme] ? args.theme : undefined) ?? readTheme(target?.name) ?? DEFAULT_THEME - const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse, singleKeys: true }, quit: options.quit, build: options.build, target, timers: options.timers }) + const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse, singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === true }, quit: options.quit, build: options.build, target, timers: options.timers }) let secrets: SecretStore | undefined const secretsFn = options.secrets ?? (() => (secrets ??= defaultSecretStore())) setSecretStoreFactory(secretsFn) diff --git a/packages/tui/src/commands.ts b/packages/tui/src/commands.ts index 1236127..e1e3683 100644 --- a/packages/tui/src/commands.ts +++ b/packages/tui/src/commands.ts @@ -11,7 +11,7 @@ import { runCli } from '@qrun-io/qqq-cli/program' import { THEMES } from './theme' -import { metaHelp, processHelp, tableHelp, applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core' +import { metaHelp, processHelp, tableHelp, applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeConfig, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core' import type { AppTreeNode } from './client' import { openApp, openForm, openProcess, openRecord, openTable } from './nav' import { ChoiceOverlay, PromptOverlay } from './overlays/basic' @@ -44,10 +44,10 @@ const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions /** Global keys for the help overlay. */ export const GLOBAL_KEYS: Array<[string, string]> = [ ['F1 ?', 'help (this)'], ['F2 / .', 'go to anything'], ['F3 Enter', 'view / open'], ['F4 e', 'edit'], ['F5 r', 'refresh'], - ['F6 f', 'filter: field, operator, value rows; F2 grammar'], ['F7 n', 'new record'], ['F8 d', 'delete (asks)'], ['F9', 'menu: every command'], ['Esc 1..0, Ctrl+1..0', 'F1..F10 when the terminal takes F-keys'], ['F10', 'quit, reset or cancel'], + ['F6 f', 'filter: field, operator, value rows; F2 grammar; Ctrl+Y copies its qqq command'], ['F7 n', 'new record'], ['F8 d', 'delete (asks)'], ['F9', 'menu: every command'], ['Esc 1..0, Ctrl+1..0', 'F1..F10 when the terminal takes F-keys'], ['F10', 'quit, reset or cancel'], ['Esc', 'back, cancel'], ['Tab', 'Apps tree / content'], ['↑↓ PgUp PgDn', 'move, page'], ['Ctrl+F Ctrl+B', 'page (always works)'], ['Space S+Space', 'page lists'], ['Home End', 'first, last'], ['← →', 'columns, tree'], ['a', 'actions here'], ['A D', 'audits, developer'], ['s', 'sort by column'], ['Space (grid)', 'select row'], ['b', 'run a process on selection'], ['x', 'export'], ['g', 'go to by key'], ['v', 'view settings'], - ['] [', 'next / previous record'], ['Ctrl+S', 'save a form'], [':', 'command line'], ['Ctrl+K', 'palette'], ['Ctrl+C', 'quit now'], + ['] [', 'next / previous record'], ['Ctrl+S', 'save a form'], [':', 'command line'], ['Ctrl+K', 'palette'], ['y (CLI equivalent)', 'copy the qqq command to the clipboard'], ['Ctrl+C', 'quit now'], ] export class Commands implements CommandsLike { @@ -412,6 +412,16 @@ export class Commands implements CommandsLike { } } + setCopyCli(on: boolean): void { + const s = this.store + s.settings.copyCli = on + const config = readConfig() + config.ui = { ...config.ui, copyCliCommands: on } + writeConfig(config) + s.bump() + s.flash(`CLI commands ${on ? 'are copied to the clipboard automatically' : 'are not copied automatically'}`) + } + setPin(): void { const s = this.store const ask = () => s.open(new PromptOverlay(s, 'Set a screen-lock PIN', 'At least 4 characters. Only a salted hash is kept in ~/.config/qqq/config.json.', (pin) => { @@ -617,6 +627,7 @@ export class Commands implements CommandsLike { out.push({ section: 'Settings', label: `Idle timeout: ${idle ? `${idle} min` : 'off'}`, command: 'idle ', run: () => s.open(new PromptOverlay(s, 'Screen saver after idle minutes', '0 turns it off.', (v) => void this.run(`idle ${v}`), String(idle))) }) out.push({ section: 'Settings', label: hasPin() ? 'Change PIN' : 'Set PIN', command: 'pin', run: () => this.setPin() }) if (hasPin()) out.push({ section: 'Settings', label: 'Remove PIN', command: 'pin remove', run: () => this.removePin() }) + out.push({ section: 'Settings', label: `Copy CLI commands to the clipboard automatically: ${s.settings.copyCli ? 'on' : 'off'}`, run: () => this.setCopyCli(!s.settings.copyCli) }) const fp = readFilePreferences() out.push({ section: 'Settings', label: `Download folder: ${fp.downloadDir ?? '(last used, else ~/Downloads)'}`, run: () => chooseFolder(s, 'Default download folder', startDirForSave(fp), (dir) => { writeFilePreferences({ downloadDir: dir }); s.flash(`downloads go to ${dir}`) }) }) out.push({ section: 'Settings', label: `Downloads: ${fp.downloadMode === 'silent' ? 'save silently to the download folder' : 'ask every time'}`, run: () => { writeFilePreferences({ downloadMode: fp.downloadMode === 'silent' ? 'ask' : 'silent' }); s.flash('download mode changed') } }) diff --git a/packages/tui/src/overlays/basic.tsx b/packages/tui/src/overlays/basic.tsx index 10ff680..6aada94 100644 --- a/packages/tui/src/overlays/basic.tsx +++ b/packages/tui/src/overlays/basic.tsx @@ -55,6 +55,42 @@ export class MessageOverlay implements Overlay { } } +/** The "CLI equivalent" dialog: y, c or Enter copies the command; Esc closes without copying. */ +export class CliCommandOverlay implements Overlay { + readonly id = overlayId('cli') + private note?: string + constructor(private store: AppStore, readonly command: string) { + if (store.settings.copyCli && this.copy()) this.note = 'copied to the clipboard' + } + private copy(): boolean { + const how = this.store.copyText(this.command) + if (!how) this.note = 'no clipboard here: select the text with Shift+drag' + else this.note = `copied (${how})` + return !!how + } + onKey(key: Key): void { + const ch = charOf(key).toLowerCase() + if (key.name !== 'escape' && !this.note && (ch === 'y' || ch === 'c' || key.name === 'return')) { + this.copy() + this.store.bump() + return + } + this.store.closeOverlay(this) + } + render(sw: number, sh: number) { + const width = Math.min(sw - 4, Math.max(54, Math.min(100, this.command.length + 4))) + const lines = wrap(this.command, width - 4).slice(0, sh - 8) + const footer = this.note ? ` ${this.note} · any key closes` : ' y copies · Shift+drag selects text · Esc closes' + return ( + + {lines.map((l, i) => )} + + + + ) + } +} + export class ConfirmOverlay implements Overlay { readonly id = overlayId('confirm') constructor(private store: AppStore, readonly text: string, private onYes: () => void, private onNo?: () => void, readonly defaultYes = false) {} diff --git a/packages/tui/src/overlays/filter.tsx b/packages/tui/src/overlays/filter.tsx index d58032e..d485657 100644 --- a/packages/tui/src/overlays/filter.tsx +++ b/packages/tui/src/overlays/filter.tsx @@ -988,6 +988,9 @@ export class FilterOverlay implements Overlay { this.store.open(new ConfirmOverlay(this.store, 'Discard the changes to the filter?', () => this.close())) } + /** The qqq command the footer shows (set by render); ^Y copies it. */ + private cliText?: string + onKey(key: Key): void { if (this.mode === 'guided' && this.editing) return this.editKey(key) if (key.name === 'escape') { @@ -998,6 +1001,10 @@ export class FilterOverlay implements Overlay { return this.cancel() } if (key.name === 'f10' || (key.ctrl && key.name === 's')) return this.apply() + if (key.ctrl && key.name === 'y' && this.cliText) { + const how = this.store.copyText(this.cliText) + return this.store.flash(how ? `copied the command (${how})` : 'no clipboard here', how ? 'info' : 'error') + } if (key.name === 'f2') return this.mode === 'guided' ? this.toAdvanced() : this.toGuided() if (this.mode === 'guided') return this.guidedKey(key) return this.advancedKey(key) @@ -1290,9 +1297,10 @@ export class FilterOverlay implements Overlay { lines.push(= 96 ? ' Tab complete · ↑↓ choose (history when empty) · ^R history · F2 guided · Enter apply · Esc cancel' : ' Tab complete · ↑↓ choose · ^R history · F2 guided · Enter apply · Esc'} width={inner} fg={t.dim} />) } const cli = `qqq table query ${this.meta.name}${grammar ? ` -g ${shellQuote(grammar)}` : ''}${this.sort ? ` -o ${this.sort.asc ? '' : '-'}${this.sort.field}` : ''}` + this.cliText = cli lines.push() lines.push({pad(` ${trunc(grammar || '(no criteria: every record)', inner - 2)}`, inner)}) - lines.push({pad(` ${trunc(cli, inner - 2)}`, inner)}) + lines.push({pad(` ${trunc(cli, inner - 14)}`, inner - 11)}{pad(' ^Y copies', 11)}) return {lines} } } diff --git a/packages/tui/src/screens/process.tsx b/packages/tui/src/screens/process.tsx index c2a91bd..cc0c787 100644 --- a/packages/tui/src/screens/process.tsx +++ b/packages/tui/src/screens/process.tsx @@ -405,7 +405,7 @@ export class ProcessScreen implements Screen { return [ { section: 'Context', label: 'Cancel the process', key: 'Esc', disabled: finished ? 'finished' : undefined, run: () => this.cancel() }, { section: 'Context', label: 'Run it again', key: 'F5 r', disabled: finished ? undefined : 'still running', run: () => this.start() }, - { section: 'Context', label: 'Copy as a CLI command', detail: `qqq ${this.cliEquivalent()}`, run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') }, + { section: 'Context', label: 'Copy as a CLI command', detail: `qqq ${this.cliEquivalent()}`, run: () => this.store.showCli(`qqq ${this.cliEquivalent()}`) }, ] } diff --git a/packages/tui/src/screens/record.tsx b/packages/tui/src/screens/record.tsx index a58675d..4903770 100644 --- a/packages/tui/src/screens/record.tsx +++ b/packages/tui/src/screens/record.tsx @@ -642,7 +642,7 @@ export class RecordScreen implements Screen { { section: 'Context', label: 'Refresh', key: 'F5 r', run: () => this.refresh() }, ...this.shareEntry(), { section: 'Context', label: 'View settings (columns, label width, child list rows)', key: 'v', run: () => { const o = this.viewSettings(); if (o) this.store.open(o) } }, - { section: 'Context', label: 'Copy as a CLI command', detail: `qqq ${this.cliEquivalent()}`, run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') }, + { section: 'Context', label: 'Copy as a CLI command', detail: `qqq ${this.cliEquivalent()}`, run: () => this.store.showCli(`qqq ${this.cliEquivalent()}`) }, ] for (const group of this.groups) { const child = this.childMeta.get(group.table) diff --git a/packages/tui/src/screens/table.tsx b/packages/tui/src/screens/table.tsx index 4e45cdf..696a335 100644 --- a/packages/tui/src/screens/table.tsx +++ b/packages/tui/src/screens/table.tsx @@ -673,7 +673,7 @@ export class TableScreen implements Screen { { section: 'Context', label: 'Go to a record by key', key: 'g', run: () => this.gotoKey() }, { section: 'Context', label: 'Refresh', key: 'F5 r', run: () => this.refresh() }, { section: 'Context', label: 'View settings (columns, widths, stripes, density)', key: 'v', command: 'view', run: () => { const o = this.viewSettings(); if (o) this.store.open(o) } }, - { section: 'Context', label: 'Copy as a CLI command', detail: this.cliEquivalent(), run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') }, + { section: 'Context', label: 'Copy as a CLI command', detail: this.cliEquivalent(), run: () => this.store.showCli(`qqq ${this.cliEquivalent()}`) }, ] for (const f of ['csv', 'xlsx', 'json', 'tsv']) out.push({ section: 'Context', label: `Export as ${f}`, key: f === 'csv' ? 'x' : undefined, command: `export ${f}`, run: () => this.exportAs(f) }) out.push(...this.bulkEntries()) diff --git a/packages/tui/src/store.ts b/packages/tui/src/store.ts index 1928b93..e2eccc1 100644 --- a/packages/tui/src/store.ts +++ b/packages/tui/src/store.ts @@ -14,7 +14,7 @@ import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client' import { History, MetaDataIndex, addRecent, currentCredential, readIdleMinutes, type MetaDataDocument, type RecentItem } from '@qrun-io/qqq-core' import { TUI_CHANNEL, TuiClient, type VersionInfo } from './client' import { charOf, isPrintable, type Key } from './keys' -import { ChoiceOverlay, ConfirmOverlay, MessageOverlay } from './overlays/basic' +import { ChoiceOverlay, ConfirmOverlay, CliCommandOverlay, MessageOverlay } from './overlays/basic' import { Extensions } from './extensions' import { collaborationPresence, presenceSource, setPresenceSource } from './presence' import { setAscii, setTheme, theme } from './theme' @@ -28,6 +28,8 @@ export interface Settings { mouse: boolean /** Single-key shortcuts (s, v, n, e …); the server's keyboard.singleKeyShortcuts preference turns them off. */ singleKeys: boolean + /** Copy CLI equivalents to the clipboard automatically (ui.copyCliCommands; default off). */ + copyCli?: boolean } export interface Status { @@ -221,6 +223,11 @@ export class AppStore { this.bump() } + /** The CLI equivalent dialog; copies at once when the preference is on. */ + showCli(command: string): void { + this.open(new CliCommandOverlay(this, command)) + } + message(text: string, kind: 'info' | 'error' = 'info', title?: string): void { this.open(new MessageOverlay(this, text, kind, title)) } diff --git a/packages/tui/test/cli-overlay.test.tsx b/packages/tui/test/cli-overlay.test.tsx new file mode 100644 index 0000000..b3fd18d --- /dev/null +++ b/packages/tui/test/cli-overlay.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright 2026 QRun.IO, Inc. + * Licensed under the Apache License, Version 2.0 (see LICENSE). + */ + +import { expect, test } from 'bun:test' +import { CliCommandOverlay } from '../src/overlays/basic' + +function fakeStore(copyCli: boolean) { + const copied: string[] = [] + const closed: unknown[] = [] + const store = { + settings: { copyCli }, + copyText: (t: string) => { copied.push(t); return 'pbcopy' }, + closeOverlay: (o: unknown) => closed.push(o), + bump: () => undefined, + } + return { store, copied, closed } +} +const key = (name: string, sequence = name) => ({ name, sequence, ctrl: false, meta: false, shift: false }) as never +const text = (o: CliCommandOverlay) => JSON.stringify(o.render(100, 30)) + +test('y copies, shows copied, and the next key closes', () => { + const { store, copied, closed } = fakeStore(false) + const o = new CliCommandOverlay(store as never, 'qqq table query petSpecies') + expect(text(o)).toContain('y copies · Shift+drag selects text') + o.onKey(key('y')) + expect(copied).toEqual(['qqq table query petSpecies']) + expect(text(o)).toContain('copied (pbcopy) · any key closes') + expect(closed).toEqual([]) + o.onKey(key('x')) + expect(closed).toEqual([o]) +}) + +test('the preference copies on open', () => { + const { store, copied } = fakeStore(true) + const o = new CliCommandOverlay(store as never, 'qqq table query petSpecies') + expect(copied).toEqual(['qqq table query petSpecies']) + expect(text(o)).toContain('copied to the clipboard · any key closes') +}) + +test('Esc closes without copying', () => { + const { store, copied, closed } = fakeStore(false) + const o = new CliCommandOverlay(store as never, 'qqq table query petSpecies') + o.onKey(key('escape')) + expect(copied).toEqual([]) + expect(closed).toEqual([o]) +})