feat(tui): copy CLI equivalents to the clipboard

The CLI equivalent dialog could only be read. y, c or Enter now copies the command
(Esc closes without copying), the filter dialog gets Ctrl+Y for its command line, and
F9 > Settings has "Copy CLI commands to the clipboard automatically" (ui.copyCliCommands,
default off). The footer points at Shift+drag for terminal selection; mouse tracking is
left to the TUI-wide mouse setting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-11-01 05:38:53 -06:00
1 parent 536d0b37cd
commit e8f075addf
12 files changed
+125 -10

No files matched your search

+1
View File
@@ -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
+2
View File
@@ -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<string, ViewSettings>
}
+2
View File
@@ -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.
+2 -2
View File
@@ -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)
+14 -3
View File
@@ -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 <minutes>', 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') } })
+36
View File
@@ -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 (
<Frame title="CLI equivalent" width={width} height={lines.length + 4} screenWidth={sw} screenHeight={sh}>
{lines.map((l, i) => <Line key={i} text={` ${l}`} width={width - 2} fg={theme().fg} />)}
<Line text="" width={width - 2} />
<Line text={footer} width={width - 2} fg={theme().dim} />
</Frame>
)
}
}
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) {}
+9 -1
View File
@@ -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(<Line key="keys" text={inner >= 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(<Line key="count" text={this.countText()} width={inner} fg={this.count?.state === 'error' ? t.err : t.ok} />)
lines.push(<text flexShrink={0} wrapMode="none" key="gram" fg={t.acc}>{pad(` ${trunc(grammar || '(no criteria: every record)', inner - 2)}`, inner)}</text>)
lines.push(<text flexShrink={0} wrapMode="none" key="cli" fg={t.dim}>{pad(` ${trunc(cli, inner - 2)}`, inner)}</text>)
lines.push(<text flexShrink={0} wrapMode="none" key="cli" fg={t.dim}>{pad(` ${trunc(cli, inner - 14)}`, inner - 11)}{pad(' ^Y copies', 11)}</text>)
return <Frame title={`Filter ${this.meta.label}`} width={width} height={lines.length + 2} screenWidth={sw} screenHeight={sh} top={1}>{lines}</Frame>
}
}
+1 -1
View File
@@ -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()}`) },
]
}
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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())
+8 -1
View File
@@ -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))
}
+48
View File
@@ -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])
})