Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
512d597934 | ||
|
|
60a48e7d5d |
No files matched your search
@@ -20,6 +20,19 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
|
||||
- Kernel: `parseMarkdown`, `markdownText`, `answerDocument` and `linkTargetKey` (render/markdown.ts): the
|
||||
assistant's markdown as blocks with numbered links, one per target (a filter on the primary key is
|
||||
that record; JSON and base64 filters compare by content).
|
||||
- **`qqq ui`: the Shift bank.** Shift+F1…F10 are a second row of function keys: Keys (the `?`
|
||||
reference), Servers, SQL (SQL Runner), Bulk, Metadata (refresh), Views (saved views), Copy (into a new
|
||||
record), Export, History and Assistant (the same toggle as F12 and Ctrl+G). Like the primary bar they follow the
|
||||
screen: keys that do not apply are dimmed and say why when pressed. The assignments are one table bound to
|
||||
named actions (`assistant.toggle` and the rest), so other keys can bind the same actions.
|
||||
- **The F-key bar in two rows** (primary, then the Shift row marked `⇧`) on terminals of 30 lines or more;
|
||||
`:fkeys one|two|auto` and F9 › Settings › F-key bar choose (saved as `ui.fkeyBar`). In one-row mode the
|
||||
Shift row replaces the primary row for 2 seconds after a Shift+F press. Clicks on the Shift row run its
|
||||
keys; `?` lists the bank.
|
||||
- **One decoder for modified function keys** across terminals: xterm modifier forms (tmux, iTerm2, Windows
|
||||
Terminal), the kitty keyboard protocol, the legacy `ESC [25~`…`ESC [34~` numbering (VT220, rxvt and
|
||||
the Linux console, macOS Terminal.app; `QQQ_FKEYS` overrides the detection) and rxvt's `$ ^ @`
|
||||
suffixes. Ctrl+F-keys are decoded too (no bindings yet).
|
||||
|
||||
- **Save as PDF…** is the first entry of the UI's printer picker (record `p`, grid Ctrl+P, `:print`; `:print pdf`
|
||||
saves straight away): the server renders the same record sheet, table list or page (printing
|
||||
@@ -45,6 +58,9 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
|
||||
Links), never their URL-encoded `/app/...?filter=` targets; the tool results' links are listed only
|
||||
when the text does not already show the same target, so `db_performance` no longer appears three times.
|
||||
Exact duplicate paragraphs are shown once; the timing and token line stays, dimmed.
|
||||
- **Shift+F3 and the other modified F3s** (`ESC [1;2R`) no longer vanish: OpenTUI took them for cursor
|
||||
position reports. They are rewritten on the input stream into `ESC [13;2~` outside the startup
|
||||
cursor-query window.
|
||||
|
||||
## [1.0.0] - 2026-11-01
|
||||
|
||||
|
||||
@@ -217,6 +217,8 @@ qqq ui --theme light # high-contrast (defau
|
||||
- **The function-key bar** at the bottom is always right for the current screen: F1 Help, F2 Search,
|
||||
F3 View, F4 Edit, F5 Refresh, F6 Filter, F7 New, F8 Delete, F9 Menu, F10 Quit. When the terminal takes
|
||||
the F-keys, Esc then a digit (or Ctrl+digit) stands for F1 to F10.
|
||||
**Shift+F1…F10** are a second bank (Keys, Servers, SQL, Bulk, Metadata, Views, Copy, Export, History,
|
||||
Assistant), shown as a second bar row from 30 lines (`:fkeys one|two|auto`).
|
||||
- **F2** is Search: records across tables (quick search), the main way into the data. **`/`** (or `.`, Ctrl+K)
|
||||
is the find-anything palette: any app, table, process, widget, report, record or command, searched as you
|
||||
type, fast on instances with thousands of tables. **Ctrl+O** switches servers, **Ctrl+P** prints what you see.
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface UiSettings {
|
||||
childRows?: number
|
||||
/** The terminal UI copies a CLI equivalent to the clipboard as soon as it shows it (default off). */
|
||||
copyCliCommands?: boolean
|
||||
/** The terminal UI's function-key bar: one row, two rows (with the Shift bank), or auto (two from 30 lines). */
|
||||
fkeyBar?: 'one' | 'two' | 'auto'
|
||||
/** Date entry: display order, clock, week start, picker behaviour (forms/dates.ts). */
|
||||
dates?: import('../forms/dates.js').DateSettings
|
||||
/** Keyed by screen, e.g. `table:person`, `record:person`, `app:peopleApp`. */
|
||||
|
||||
@@ -55,6 +55,47 @@ shared with the CLI. `QQQ_UI_NO_QUIT=1` is for the soak test only.
|
||||
| `t` | tags of a record (collaboration extension) | Esc then 1..0, Ctrl+1..0 | F1..F10 when the terminal takes the F-keys |
|
||||
| Ctrl+P, `p`, PrtSc | print what you see: record sheet, table list (all rows or this page), or the screen (printing extension; else save as a file) | | |
|
||||
|
||||
### Shift bank (Shift+F1…F10)
|
||||
|
||||
A second row of ten function keys, like Norton Commander's Shift bar. Keys that do not apply on the screen
|
||||
are dimmed; pressing one says why.
|
||||
|
||||
| Key | Label | Does |
|
||||
|---|---|---|
|
||||
| Shift+F1 | Keys | the keyboard reference (as `?`) |
|
||||
| Shift+F2 | Servers | the Servers dialog (as Ctrl+O) |
|
||||
| Shift+F3 | SQL | the SQL Runner (sql-workbench extension) |
|
||||
| Shift+F4 | Bulk | bulk edit, bulk delete and processes on the grid selection (as `b`) |
|
||||
| Shift+F5 | Metadata | refresh the metadata from the server |
|
||||
| Shift+F6 | Views | saved views of the grid |
|
||||
| Shift+F7 | Copy | copy the record, or the grid row under the cursor, into a new record (as `c`) |
|
||||
| Shift+F8 | Export | export the grid to a file (as `x`) |
|
||||
| Shift+F9 | History | the History screen (as `:history`) |
|
||||
| Shift+F10 | Assistant | show or hide the Assistant panel (the same action as F12 and Ctrl+G; assistant extension) |
|
||||
|
||||
The bar shows the Shift row under the primary one (prefixed `⇧`) when the terminal has 30 lines or more.
|
||||
`:fkeys one|two|auto` or F9 › Settings › F-key bar chooses: **auto** (two rows from 30 lines), **two** rows
|
||||
always, or **one** row, where the Shift row takes the primary row's place for 2 seconds after any Shift+F
|
||||
press (a terminal cannot report Shift being pressed and released on its own). A click on the Shift row runs
|
||||
that key. The assignments are one table (`src/keys/shift-bank.ts`), each naming an action in
|
||||
`src/keys/actions.ts`.
|
||||
|
||||
What terminals send for Shift+F1…F12 is decoded in one place (`src/keys/fkeys.ts`):
|
||||
|
||||
| Terminal | Shift+F1…F12 | Notes |
|
||||
|---|---|---|
|
||||
| tmux (any outer terminal) | `ESC [1;2P` … `ESC [1;2S`, `ESC [15;2~` … `ESC [24;2~` | verified live; tmux re-encodes the keys it knows |
|
||||
| iTerm2, Windows Terminal, xterm, VTE, Konsole, Alacritty | the same xterm forms | kitty-protocol terminals (kitty, WezTerm, Ghostty, iTerm2 with CSI u) send `ESC [13;2~` for F3 and may send `ESC [57364;2u`…; both read |
|
||||
| macOS Terminal.app | Shift+F5…F12 are `ESC [25~` … `ESC [34~` | Shift+F1…F4 have no code of their own by default: add them in Settings › Profiles › Keyboard (`\033[1;2P` …) or use the F9 menu |
|
||||
| rxvt, urxvt, Linux console | Shift+F3…F10 are `ESC [25~` … `ESC [34~`, Shift+F11/F12 `ESC [23$` / `ESC [24$` | Shift+F1/F2 send the same as F11/F12 there |
|
||||
|
||||
Shift+F3 in the xterm form (`ESC [1;2R`) looks like a cursor position report, which OpenTUI drops; it and
|
||||
the legacy forms are rewritten on the input stream into the canonical `ESC [n;2~` (never in the first
|
||||
1.5 s after start or a resume, while the terminal answers OpenTUI's cursor queries). The legacy numbering is
|
||||
picked from `TERM_PROGRAM` and `TERM`; `QQQ_FKEYS=vt220|rxvt|apple|modern` overrides it (vt220: `ESC [25~`
|
||||
is Shift+F1; modern: legacy numbers are ignored, the default in tmux, iTerm2, Windows Terminal and kitty).
|
||||
Ctrl+F1…F12 are decoded too but have no bindings yet: Ctrl+F5 acts as F5.
|
||||
|
||||
Keys on one screen:
|
||||
|
||||
| Screen | Keys |
|
||||
@@ -591,6 +632,8 @@ before any program sees it. `qqq ui --check` (and the quick check at startup) fi
|
||||
(System Settings › Keyboard), or hold fn.
|
||||
- Whatever the terminal does, Ctrl+F / Ctrl+B page and the F9 menu and `:` line reach every action; Esc then
|
||||
a digit (or Ctrl+digit) stands for F1..F10.
|
||||
- **Shift+F-keys** (the Shift bank): see [Shift bank](#shift-bank-shiftf1f10) for what each terminal sends;
|
||||
`QQQ_FKEYS` overrides the legacy numbering.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -74,9 +74,10 @@ expect 07-record 'record 2 of'
|
||||
keys Escape
|
||||
expect 08-back 'row 2 '
|
||||
right "$((hx + 1))" "$((hy + 4))"
|
||||
expect 09-actions 'row 3 ' 'Actions'
|
||||
expect 09-actions 'Actions'
|
||||
keys Escape
|
||||
cap 09b-before-drag
|
||||
# the grid's status line can sit under the dialog (the F-key bar takes two rows from 30 lines): checked after it closes
|
||||
expect 09b-before-drag 'row 3 '
|
||||
read -r x y < <(at 09b-before-drag 'First Name')
|
||||
read -r lx _ < <(at 09b-before-drag 'Last Name')
|
||||
drag "$((lx - 1))" "$y" "$((lx + 6))"
|
||||
@@ -89,6 +90,12 @@ expect 11b-footer ' · ping ' # the flash from the resize has faded
|
||||
read -r x y < <(at 11b-footer ' · ping ')
|
||||
click 2 "$y"
|
||||
expect 12-footer-servers 'Servers'
|
||||
# the Shift row of the F-key bar (two rows from 30 lines): a click on Shift+F2 opens the same dialog
|
||||
keys Escape
|
||||
cap 12c-bar
|
||||
read -r x y < <(at 12c-bar '2Servers')
|
||||
click "$((x + 1))" "$y"
|
||||
expect 12d-shift-servers 'Servers (Ctrl+O)'
|
||||
keys Escape F9 T:mouse
|
||||
expect 12b-menu 'Mouse: on'
|
||||
read -r x y < <(at 12b-menu 'Mouse: on')
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Component, useEffect, useReducer, type ReactNode } from 'react'
|
||||
import { FKeyZone, PaneZone, StatusZone, useMouseDispatcher } from './mouse/app-zones'
|
||||
import { MouseCapture, MouseLayer, MouseRoot } from './mouse/zone'
|
||||
import { presenceText, usePresence } from './presence'
|
||||
import { ShiftRow, barRows } from './keys/shift-row'
|
||||
import type { AppStore } from './store'
|
||||
import { breadcrumbs } from './breadcrumbs'
|
||||
|
||||
@@ -132,7 +133,9 @@ export function App({ store }: { store: AppStore }) {
|
||||
const t = theme()
|
||||
const w = Math.max(20, width)
|
||||
const h = Math.max(8, height)
|
||||
const bodyHeight = h - 3
|
||||
// the F-key bar: the primary row, and the Shift row under it with two rows (keys/shift-row.tsx)
|
||||
const rows = barRows(store, h)
|
||||
const bodyHeight = h - 2 - rows
|
||||
const assistant = store.assistantLayout()
|
||||
const full = assistant?.mode === 'full'
|
||||
const sidebarWidth = store.showSidebar && !full ? (w >= 160 ? 32 : 28) : 0
|
||||
@@ -156,7 +159,8 @@ export function App({ store }: { store: AppStore }) {
|
||||
{assistantWidth > 0 && store.assistant && <ErrorBoundary resetKey={store.assistant.id} width={assistantWidth} label="Assistant">{store.assistant.render(assistantWidth, bodyHeight, store.focus === 'assistant' && !top)}</ErrorBoundary>}
|
||||
</box>
|
||||
<StatusLine store={store} width={w} />
|
||||
<FKeyBar store={store} width={w} />
|
||||
{rows === 1 && store.shiftRowShown() ? <ShiftRow store={store} width={w} slots={store.fkeys().length} /> : <FKeyBar store={store} width={w} />}
|
||||
{rows === 2 && <ShiftRow store={store} width={w} slots={store.fkeys().length} />}
|
||||
{store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)}
|
||||
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
|
||||
<MouseCapture width={w} height={h} />
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { readConfig, readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
|
||||
import { installCommands } from './commands'
|
||||
import { readMouse } from './mouse/settings'
|
||||
import { readFKeyBar } from './keys/shift-bank'
|
||||
import { SignInScreen, type SignInDeps } from './screens/signin'
|
||||
import { Sidebar } from './screens/sidebar'
|
||||
import { AppStore, setSecretStoreFactory, type Target } from './store'
|
||||
@@ -72,7 +73,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 ?? readMouse(), singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === 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 ?? readMouse(), singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === true, fkeyBar: readFKeyBar() }, quit: options.quit, build: options.build, target, timers: options.timers })
|
||||
let secrets: SecretStore | undefined
|
||||
const secretsFn = options.secrets ?? (() => (secrets ??= defaultSecretStore()))
|
||||
setSecretStoreFactory(secretsFn)
|
||||
|
||||
@@ -39,6 +39,7 @@ import { withPrintEntry } from './overlays/actions'
|
||||
import { openHistory } from './history/screen'
|
||||
import { searchLines } from './history/reverse-search'
|
||||
import { changeEvent, commandEvent, history } from './history/record'
|
||||
import { FKEY_BAR_MODES, describeFKeyBar, runShiftKey, setFKeyBar, shiftBankHelp, type FKeyBarMode } from './keys/shift-bank'
|
||||
|
||||
const NOUNS: Record<string, string[]> = {
|
||||
table: ['list', 'describe', 'query', 'count', 'get', 'create', 'update', 'delete', 'export'],
|
||||
@@ -53,7 +54,7 @@ const NOUNS: Record<string, string[]> = {
|
||||
apps: [], whoami: [], search: [], 'help-search': [],
|
||||
}
|
||||
|
||||
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history']
|
||||
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
|
||||
|
||||
/** One line on each TUI command, for the `:` line's completion popup. */
|
||||
export const TUI_COMMAND_HELP: Record<string, string> = {
|
||||
@@ -63,7 +64,7 @@ export const TUI_COMMAND_HELP: Record<string, string> = {
|
||||
shortcuts: 'single-key shortcuts on or off', lock: 'lock the screen', pin: 'set or remove the lock PIN', idle: 'screen saver after idle minutes', refresh: 'reload this screen',
|
||||
filter: 'filter the grid (grammar)', export: 'export the grid or result', jobs: 'your jobs', printjobs: 'print jobs', print: 'print what you see (or :print <printer>)', profile: 'switch profile', servers: 'saved servers (Ctrl+O)',
|
||||
history: 'everything you did (CLI and TUI)', server: 'connect to a server URL', signout: 'sign out', connection: 'connection details', open: 'open a table', record: 'open a record by key', app: 'open an app',
|
||||
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'show or hide the Assistant panel (F12, Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode',
|
||||
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'show or hide the Assistant panel (F12, Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode', fkeys: 'F-key bar: one row, two rows (with the Shift bank) or auto',
|
||||
}
|
||||
|
||||
/** CLI commands the `:` line does not offer (they make no sense inside the TUI). */
|
||||
@@ -95,7 +96,10 @@ export class Commands implements CommandsLike {
|
||||
// keys only: the screen's own key rows and shortcuts from its actions (label and key), never the actions themselves
|
||||
const panelKeys = s.focus === 'assistant' && s.assistantOpen && s.assistant ? s.assistant.helpKeys() : []
|
||||
const screenKeys: KeyRow[] = [...panelKeys, ...(screen?.helpKeys?.() ?? []), ...(screen?.menu?.() ?? []).filter((e) => e.key && !e.disabled).map((e): KeyRow => [e.key!, e.label.replace(/…$/, '').replace(/\s*\([^)]*\d[^)]*\)$/, '')])]
|
||||
s.open(new HelpOverlay(s, helpKeyGroups(screen?.kind, s.singleKeys, screenKeys), topicHelp))
|
||||
const help = new HelpOverlay(s, helpKeyGroups(screen?.kind, s.singleKeys, screenKeys), topicHelp)
|
||||
// the Shift bank (Shift+F1…F10) after the screen's help
|
||||
help.after = [{ title: 'Shift bank (Shift+F1…F10; the second row of the F-key bar)', keys: shiftBankHelp(s) }]
|
||||
s.open(help)
|
||||
// help for this screen: the kernel's help entries for the screen's roles; in help-authoring mode
|
||||
// (:helpkeys) every slot shows its key, with or without content, so authors see where help goes
|
||||
const authoring = s.helpKeys
|
||||
@@ -230,6 +234,10 @@ export class Commands implements CommandsLike {
|
||||
}
|
||||
|
||||
/** The Servers dialog: saved profiles, probed in the background; Enter switches. */
|
||||
shiftKey(n: number): void {
|
||||
runShiftKey(this.store, n)
|
||||
}
|
||||
|
||||
servers(): void {
|
||||
const s = this.store
|
||||
if (s.overlays.some((o) => o instanceof ServersOverlay)) return
|
||||
@@ -418,6 +426,12 @@ export class Commands implements CommandsLike {
|
||||
case 'doctor': case 'check': return this.doctor()
|
||||
case 'profile': return rest[0] ? this.switchProfile(rest[0]) : this.chooseProfile()
|
||||
case 'servers': return this.servers()
|
||||
case 'fkeys': {
|
||||
const mode = rest[0] as FKeyBarMode | undefined
|
||||
if (!mode) return s.flash(`F-key bar: ${describeFKeyBar(s.settings.fkeyBar ?? 'auto')} · :fkeys one|two|auto`)
|
||||
if (!FKEY_BAR_MODES.includes(mode)) return fail('fkeys one|two|auto')
|
||||
return setFKeyBar(s, mode)
|
||||
}
|
||||
case 'server': return rest[0] ? this.switchServer(rest[0]) : fail('server <url>')
|
||||
case 'signout': return this.signOut()
|
||||
case 'connection': return this.connection()
|
||||
@@ -749,6 +763,7 @@ export class Commands implements CommandsLike {
|
||||
out.push({ section: 'Settings', label: `Single-key shortcuts: ${s.settings.singleKeys ? 'on' : 'off'}`, command: `shortcuts ${s.settings.singleKeys ? 'off' : 'on'}`, run: () => this.setShortcuts(!s.settings.singleKeys) })
|
||||
out.push({ section: 'Settings', label: `Linear layout: ${s.settings.linear ? 'on' : 'off'}`, command: `linear ${s.settings.linear ? 'off' : 'on'}`, run: () => { s.settings.linear = !s.settings.linear; s.bump() } })
|
||||
out.push({ id: 'settings:mouse', section: 'Settings', label: `Mouse: ${s.settings.mouse ? 'on' : 'off'}`, command: `mouse ${s.settings.mouse ? 'off' : 'on'}`, detail: s.settings.mouse ? 'off restores plain terminal text selection' : undefined, run: () => setMouseSetting(s, !s.settings.mouse) })
|
||||
out.push({ id: 'settings:fkeys', section: 'Settings', label: `F-key bar: ${s.settings.fkeyBar ?? 'auto'}`, command: 'fkeys one|two|auto', detail: describeFKeyBar(s.settings.fkeyBar ?? 'auto'), run: () => s.open(new ChoiceOverlay<FKeyBarMode>(s, 'F-key bar', FKEY_BAR_MODES.map((m) => ({ label: m, value: m, detail: describeFKeyBar(m) })), (m) => setFKeyBar(s, m), 'Enter chooses')) })
|
||||
out.push({ section: 'Settings', label: `ASCII glyphs: ${s.settings.ascii ? 'on' : 'off'}`, command: `ascii ${s.settings.ascii ? 'off' : 'on'}`, run: () => s.setAscii(!s.settings.ascii) })
|
||||
out.push({ section: 'Settings', label: 'Screen saver / lock', command: 'lock', run: () => this.lock() })
|
||||
let idle = 0
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Named actions that keys bind to: the Shift bank (shift-bank.ts) binds by name, and so can any other
|
||||
* key (F12 and Ctrl+G bind `assistant.toggle`). Each action says whether it applies on the current
|
||||
* screen; one that does not says why, and the bar dims its key.
|
||||
*/
|
||||
|
||||
import { openForm, permits } from '../nav'
|
||||
import { openHistory } from '../history/screen'
|
||||
import { openSqlRunner, sqlWorkbenchInstalled } from '../sql/entry'
|
||||
import type { AppStore } from '../store'
|
||||
import type { MenuEntry } from '../types'
|
||||
|
||||
export interface NamedAction {
|
||||
/** Why it does not apply here; undefined when it does. */
|
||||
unavailable(store: AppStore): string | undefined
|
||||
run(store: AppStore): void
|
||||
}
|
||||
|
||||
/** The parts of the table grid the actions use (screens/table.tsx). */
|
||||
interface GridLike {
|
||||
kind: string
|
||||
tableName: string
|
||||
meta?: unknown
|
||||
currentRecord?: unknown
|
||||
keyOf?(record: unknown): string | undefined
|
||||
bulkActions?(): void
|
||||
bulkEntries?(): MenuEntry[]
|
||||
tableProcesses?(): unknown[]
|
||||
savedViews?(): void
|
||||
exportDialog?(): void
|
||||
variant?: unknown
|
||||
}
|
||||
|
||||
/** The parts of the record view the actions use (screens/record.tsx). */
|
||||
interface RecordLike {
|
||||
kind: string
|
||||
tableName: string
|
||||
primaryKey?: string
|
||||
meta?: unknown
|
||||
tableVariant?: string
|
||||
}
|
||||
|
||||
function grid(store: AppStore): GridLike | undefined {
|
||||
const s = store.screen as unknown as GridLike | undefined
|
||||
return s?.kind === 'table' ? s : undefined
|
||||
}
|
||||
|
||||
function record(store: AppStore): RecordLike | undefined {
|
||||
const s = store.screen as unknown as RecordLike | undefined
|
||||
return s?.kind === 'record' ? s : undefined
|
||||
}
|
||||
|
||||
/** The record to copy: the record on screen, or the grid row under the cursor. */
|
||||
function copySource(store: AppStore): { table: string; key: string; meta: unknown; tableVariant?: string } | undefined {
|
||||
const r = record(store)
|
||||
if (r?.primaryKey !== undefined) return { table: r.tableName, key: r.primaryKey, meta: r.meta, tableVariant: r.tableVariant }
|
||||
const g = grid(store)
|
||||
const key = g?.keyOf?.(g.currentRecord)
|
||||
return g && key !== undefined ? { table: g.tableName, key, meta: g.meta } : undefined
|
||||
}
|
||||
|
||||
export const ACTIONS = {
|
||||
'help.keys': {
|
||||
unavailable: () => undefined,
|
||||
run: (s) => s.commands().help(),
|
||||
},
|
||||
servers: {
|
||||
unavailable: () => undefined,
|
||||
run: (s) => s.commands().servers(),
|
||||
},
|
||||
'sql.open': {
|
||||
unavailable: (s) => sqlWorkbenchInstalled(s) ? undefined : 'needs the sql-workbench extension',
|
||||
run: (s) => openSqlRunner(s),
|
||||
},
|
||||
'grid.bulk': {
|
||||
unavailable: (s) => {
|
||||
const g = grid(s)
|
||||
if (!g) return 'bulk actions work on a table grid'
|
||||
return (g.bulkEntries?.().length ?? 0) + (g.tableProcesses?.().length ?? 0) ? undefined : 'this table has no bulk actions or processes'
|
||||
},
|
||||
run: (s) => grid(s)?.bulkActions?.(),
|
||||
},
|
||||
'meta.refresh': {
|
||||
unavailable: (s) => s.client ? undefined : 'not connected',
|
||||
run: (s) => s.run(s.client!.metaData(true), (m) => {
|
||||
s.setMeta(m)
|
||||
s.client!.clearCaches()
|
||||
s.flash('metadata refreshed')
|
||||
}),
|
||||
},
|
||||
'grid.views': {
|
||||
unavailable: (s) => grid(s) ? undefined : 'saved views work on a table grid',
|
||||
run: (s) => grid(s)?.savedViews?.(),
|
||||
},
|
||||
'record.copy': {
|
||||
unavailable: (s) => {
|
||||
const src = copySource(s)
|
||||
if (!src) return 'copy works on a record or a grid row'
|
||||
return permits(src.meta as never, 'insert') ? undefined : 'copying needs permission to create records'
|
||||
},
|
||||
run: (s) => {
|
||||
const src = copySource(s)
|
||||
if (src) openForm(s, src.table, { mode: 'copy', primaryKey: src.key, ...(src.tableVariant ? { tableVariant: src.tableVariant } : {}) })
|
||||
},
|
||||
},
|
||||
export: {
|
||||
unavailable: (s) => grid(s) ? undefined : 'export works on a table grid',
|
||||
run: (s) => grid(s)?.exportDialog?.(),
|
||||
},
|
||||
history: {
|
||||
unavailable: () => undefined,
|
||||
run: (s) => openHistory(s),
|
||||
},
|
||||
'assistant.toggle': {
|
||||
unavailable: (s) => s.extensions.has('assistant') ? undefined : 'needs the assistant extension',
|
||||
run: (s) => s.toggleAssistant(),
|
||||
},
|
||||
} satisfies Record<string, NamedAction>
|
||||
|
||||
export type ActionName = keyof typeof ACTIONS
|
||||
|
||||
/** Runs a named action, or says why it does not apply here. */
|
||||
export function runAction(store: AppStore, name: ActionName, label: string = name): void {
|
||||
const action: NamedAction = ACTIONS[name]
|
||||
const why = action.unavailable(store)
|
||||
if (why) return store.flash(`${label}: ${why}`)
|
||||
action.run(store)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The one decoder for function keys with modifiers (Shift+F1…F12, Ctrl+F-keys, Alt), across the
|
||||
* forms terminals send:
|
||||
*
|
||||
* - xterm modifier forms: `CSI 1;m P|Q|R|S` (F1–F4), `SS3 m P…S`, `CSI n;m ~` (F5–F12; also
|
||||
* `CSI 11..14;m ~` and kitty's `CSI 13;m ~` for F3), m = 1 + shift + 2·alt + 4·ctrl
|
||||
* (tmux, iTerm2, Windows Terminal, xterm, VTE, Konsole, Alacritty);
|
||||
* - the kitty keyboard protocol: the same forms with `:event` sub-parameters, and `CSI 57364..57375;m u`;
|
||||
* - legacy numbered forms with no modifier parameter, which mean different keys per terminal:
|
||||
* `CSI 25~ … CSI 34~` is Shift+F1…F8 with the VT220 numbering (F13 = Shift+F1), Shift+F3…F10 on
|
||||
* rxvt and the Linux console (F11 = Shift+F1), Shift+F5…F12 on macOS Terminal.app;
|
||||
* - rxvt's suffixes: `CSI n $` Shift, `CSI n ^` Ctrl, `CSI n @` Ctrl+Shift;
|
||||
* - the Linux console's `CSI [ A…E` (F1–F5).
|
||||
*
|
||||
* OpenTUI 0.8 reads the xterm and kitty forms itself, except Shift+F3 (`CSI 1;2R`) and the other
|
||||
* modified F3s: they look like a cursor position report, so OpenTUI takes them as one and drops them.
|
||||
* It reads none of the legacy or rxvt forms. `rewriteFKeyInput` turns exactly those into the
|
||||
* canonical `CSI n;m ~` form before OpenTUI sees the bytes (stdin-rewrite.ts), so every terminal
|
||||
* ends up as one OpenTUI key event (name `f3`, shift true), which `bankKey` names `shift+f3`.
|
||||
*/
|
||||
|
||||
import type { Key } from '../keys'
|
||||
|
||||
/** A function key with its modifiers. */
|
||||
export interface FKeyChord {
|
||||
/** 1..12 */
|
||||
n: number
|
||||
shift: boolean
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
/** A kitty key-release event (nothing to do). */
|
||||
release?: boolean
|
||||
}
|
||||
|
||||
/** Which sequence family a chord was read from. */
|
||||
export type FKeyFamily = 'ss3' | 'csi-letter' | 'csi-f3-cpr' | 'csi-tilde' | 'kitty-u' | 'legacy-number' | 'rxvt-suffix' | 'linux-console'
|
||||
|
||||
/**
|
||||
* How a terminal numbers its legacy shifted F-keys (`CSI 25~` and up): `vt220` (F13 = Shift+F1),
|
||||
* `rxvt` (rxvt, urxvt, the Linux console: F11 = Shift+F1), `apple` (macOS Terminal.app: Shift+F5 is
|
||||
* `CSI 25~`), `modern` (the terminal only sends the xterm forms; legacy numbers mean nothing).
|
||||
*/
|
||||
export type LegacyProfile = 'vt220' | 'rxvt' | 'apple' | 'modern'
|
||||
|
||||
export const LEGACY_PROFILES: LegacyProfile[] = ['vt220', 'rxvt', 'apple', 'modern']
|
||||
|
||||
/** The `CSI n ~` number of F1..F12. */
|
||||
const TILDE_CODE = [0, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 23, 24]
|
||||
const TILDE_TO_N: Record<number, number> = Object.fromEntries(TILDE_CODE.map((code, n) => [code, n]).filter(([, n]) => n > 0))
|
||||
const LETTER_TO_N: Record<string, number> = { P: 1, Q: 2, R: 3, S: 4 }
|
||||
const LINUX_TO_N: Record<string, number> = { A: 1, B: 2, C: 3, D: 4, E: 5 }
|
||||
/** kitty's private-use code points for F1..F12 (F13 and up are real keys of their own). */
|
||||
const KITTY_F1 = 57364
|
||||
|
||||
/** Legacy numbers (no modifier) that stand for Shift+F-keys, per profile. */
|
||||
const LEGACY_SHIFT: Record<LegacyProfile, Record<number, number>> = {
|
||||
vt220: { 25: 1, 26: 2, 28: 3, 29: 4, 31: 5, 32: 6, 33: 7, 34: 8 },
|
||||
rxvt: { 25: 3, 26: 4, 28: 5, 29: 6, 31: 7, 32: 8, 33: 9, 34: 10 },
|
||||
apple: { 25: 5, 26: 6, 28: 7, 29: 8, 31: 9, 32: 10, 33: 11, 34: 12 },
|
||||
modern: {},
|
||||
}
|
||||
|
||||
/** The profile for this terminal: QQQ_FKEYS overrides; otherwise from TERM_PROGRAM, TERM and friends. */
|
||||
export function legacyProfile(env: NodeJS.ProcessEnv = process.env): LegacyProfile {
|
||||
const forced = env.QQQ_FKEYS?.trim().toLowerCase()
|
||||
if (forced && (LEGACY_PROFILES as string[]).includes(forced)) return forced as LegacyProfile
|
||||
const program = env.TERM_PROGRAM ?? ''
|
||||
const term = env.TERM ?? ''
|
||||
if (program === 'Apple_Terminal') return 'apple'
|
||||
// tmux re-encodes the keys it knows in the xterm forms; Windows Terminal sends CSI 29~ for the Menu key
|
||||
if (env.TMUX || env.WT_SESSION || /^(iTerm\.app|WezTerm|ghostty|vscode|tmux)$/.test(program) || /kitty|ghostty|alacritty|foot/.test(term)) return 'modern'
|
||||
if (/^(rxvt|linux)/.test(term)) return 'rxvt'
|
||||
return 'vt220'
|
||||
}
|
||||
|
||||
function modifiers(m: number): Pick<FKeyChord, 'shift' | 'ctrl' | 'alt'> {
|
||||
const bits = Math.max(0, m - 1)
|
||||
return { shift: Boolean(bits & 1), alt: Boolean(bits & 2), ctrl: Boolean(bits & 4) }
|
||||
}
|
||||
|
||||
/** `m` or `m:event` → modifier number and whether it is a kitty release. */
|
||||
function modParam(p: string | undefined): { m: number; release: boolean } {
|
||||
if (!p) return { m: 1, release: false }
|
||||
const [m, event] = p.split(':')
|
||||
return { m: Number(m) || 1, release: event === '3' }
|
||||
}
|
||||
|
||||
function chord(n: number, m: number, release = false, alt = false): FKeyChord | undefined {
|
||||
if (!(n >= 1 && n <= 12)) return undefined
|
||||
const mods = modifiers(m)
|
||||
const out: FKeyChord = { n, ...mods, alt: mods.alt || alt }
|
||||
if (release) out.release = true
|
||||
return out
|
||||
}
|
||||
|
||||
/** Reads one whole sequence; undefined when it is not a function key. */
|
||||
export function decodeFKey(seq: string, profile: LegacyProfile = 'vt220'): { chord: FKeyChord; family: FKeyFamily } | undefined {
|
||||
// an extra ESC in front is Alt (meta) on terminals that send it that way
|
||||
let s = seq
|
||||
let alt = false
|
||||
if (s.startsWith('\x1b\x1b')) {
|
||||
alt = true
|
||||
s = s.slice(1)
|
||||
}
|
||||
let m: RegExpExecArray | null
|
||||
if ((m = /^\x1bO(\d*)([PQRS])$/.exec(s))) {
|
||||
const c = chord(LETTER_TO_N[m[2]!]!, Number(m[1]) || 1, false, alt)
|
||||
return c && { chord: c, family: 'ss3' }
|
||||
}
|
||||
if ((m = /^\x1b\[(?:1;)?(\d+(?::\d+)?)?([PQRS])$/.exec(s))) {
|
||||
const { m: mod, release } = modParam(m[1])
|
||||
const c = chord(LETTER_TO_N[m[2]!]!, mod, release, alt)
|
||||
return c && { chord: c, family: m[2] === 'R' ? 'csi-f3-cpr' : 'csi-letter' }
|
||||
}
|
||||
if ((m = /^\x1b\[(\d+)(?:;(\d+(?::\d+)?))?~$/.exec(s))) {
|
||||
const code = Number(m[1])
|
||||
const { m: mod, release } = modParam(m[2])
|
||||
if (TILDE_TO_N[code]) {
|
||||
const c = chord(TILDE_TO_N[code]!, mod, release, alt)
|
||||
return c && { chord: c, family: 'csi-tilde' }
|
||||
}
|
||||
const shifted = LEGACY_SHIFT[profile][code]
|
||||
if (shifted && !m[2]) {
|
||||
const c = chord(shifted, 2, false, alt)
|
||||
return c && { chord: c, family: 'legacy-number' }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if ((m = /^\x1b\[(\d+)([$^@])$/.exec(s))) {
|
||||
// rxvt: $ Shift, ^ Ctrl, @ Ctrl+Shift; 25..34 are the shifted F3..F10 there
|
||||
const code = Number(m[1])
|
||||
const suffix = m[2]!
|
||||
const base = TILDE_TO_N[code] ?? LEGACY_SHIFT.rxvt[code]
|
||||
if (!base) return undefined
|
||||
const shift = suffix !== '^' || LEGACY_SHIFT.rxvt[code] !== undefined
|
||||
const ctrl = suffix !== '$'
|
||||
const c = chord(base, 1 + (shift ? 1 : 0) + (ctrl ? 4 : 0), false, alt)
|
||||
return c && { chord: c, family: 'rxvt-suffix' }
|
||||
}
|
||||
if ((m = /^\x1b\[(\d+)(?:;(\d+(?::\d+)?))?u$/.exec(s))) {
|
||||
const { m: mod, release } = modParam(m[2])
|
||||
const c = chord(Number(m[1]) - KITTY_F1 + 1, mod, release, alt)
|
||||
return c && { chord: c, family: 'kitty-u' }
|
||||
}
|
||||
if ((m = /^\x1b\[\[([A-E])$/.exec(s))) {
|
||||
const c = chord(LINUX_TO_N[m[1]!]!, 1, false, alt)
|
||||
return c && { chord: c, family: 'linux-console' }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** `shift+f2`, `ctrl+f5`, `ctrl+shift+f1`, `alt+f3`, `f4`. */
|
||||
export function chordName(c: FKeyChord): string {
|
||||
return `${c.ctrl ? 'ctrl+' : ''}${c.alt ? 'alt+' : ''}${c.shift ? 'shift+' : ''}f${c.n}`
|
||||
}
|
||||
|
||||
/** The canonical xterm form OpenTUI reads for every modifier: `CSI n;m ~`. */
|
||||
export function canonicalSequence(c: FKeyChord): string {
|
||||
const m = 1 + (c.shift ? 1 : 0) + (c.alt ? 2 : 0) + (c.ctrl ? 4 : 0)
|
||||
return `\x1b[${TILDE_CODE[c.n]}${m > 1 ? `;${m}` : ''}~`
|
||||
}
|
||||
|
||||
/** Families OpenTUI 0.8 does not read (or reads as something else); these are rewritten. */
|
||||
const NEEDS_REWRITE = new Set<FKeyFamily>(['csi-f3-cpr', 'legacy-number', 'rxvt-suffix'])
|
||||
|
||||
/**
|
||||
* Rewrites the function-key sequences OpenTUI cannot read into the canonical form, leaving every
|
||||
* other byte alone. `cpr` false leaves `CSI 1;m R` alone: during startup (and after a resume) those
|
||||
* bytes are real cursor position reports that OpenTUI's capability probe is waiting for. A chunk
|
||||
* inside a bracketed paste is never touched.
|
||||
*/
|
||||
export function rewriteFKeyInput(chunk: string, profile: LegacyProfile, options: { cpr: boolean }): string {
|
||||
if (!chunk.includes('\x1b') || chunk.includes('\x1b[200~')) return chunk
|
||||
return chunk.replace(/\x1b{1,2}\[(?:1;\d+R|\d+[~$^@])/g, (seq) => {
|
||||
if (seq.endsWith('R') && !options.cpr) return seq
|
||||
const found = decodeFKey(seq, profile)
|
||||
if (!found || !NEEDS_REWRITE.has(found.family)) return seq
|
||||
// Shift+F3 and friends only with a modifier: CSI 1;1R is always a cursor report
|
||||
if (found.family === 'csi-f3-cpr' && !found.chord.shift && !found.chord.ctrl && !found.chord.alt) return seq
|
||||
return canonicalSequence(found.chord)
|
||||
})
|
||||
}
|
||||
|
||||
/** The chord of a key event from OpenTUI (or a test), when it is a function key. */
|
||||
export function fkeyChord(key: Key, profile: LegacyProfile = 'vt220'): FKeyChord | undefined {
|
||||
const named = /^f(\d{1,2})$/.exec(key.name)
|
||||
if (named) {
|
||||
const n = Number(named[1])
|
||||
return n >= 1 && n <= 12 ? { n, shift: Boolean(key.shift), ctrl: Boolean(key.ctrl), alt: Boolean(key.meta) } : undefined
|
||||
}
|
||||
if (key.name && key.name !== 'menu') return undefined
|
||||
return key.sequence?.startsWith('\x1b') ? decodeFKey(key.sequence, profile)?.chord : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the store dispatches: Shift+F1…F12 (no Ctrl, no Alt) become `shift+f1`…`shift+f12` (the
|
||||
* Shift bank); everything else is left as it is (Ctrl+F5 still acts as F5: no Ctrl bank yet).
|
||||
*/
|
||||
export function bankKey(key: Key, profile: LegacyProfile = 'vt220'): Key {
|
||||
const c = fkeyChord(key, profile)
|
||||
if (!c || c.release || !c.shift || c.ctrl || c.alt) return key
|
||||
return { name: `shift+f${c.n}`, sequence: key.sequence ?? '', shift: true }
|
||||
}
|
||||
|
||||
/** The Shift bank number of a dispatched key (`shift+f2` → 2), or undefined. */
|
||||
export function shiftNumber(key: Key): number | undefined {
|
||||
const m = /^shift\+f(\d{1,2})$/.exec(key.name)
|
||||
return m ? Number(m[1]) : undefined
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Shift bank: Shift+F1…F10, a second row of ten function keys (like Norton Commander's Shift
|
||||
* bar). The assignments are this one table; each key names an action (actions.ts), so changing a key
|
||||
* is changing a row here. Like the primary bar, a key that does not apply on the screen is dimmed
|
||||
* and pressing it says why.
|
||||
*/
|
||||
|
||||
import { readConfig, writeConfig } from '@qrun-io/qqq-core'
|
||||
import type { AppStore } from '../store'
|
||||
import { ACTIONS, runAction, type ActionName, type NamedAction } from './actions'
|
||||
|
||||
export interface ShiftKey {
|
||||
n: number
|
||||
/** The bar label (and the help's). */
|
||||
label: string
|
||||
/** At most 5 characters, for narrow terminals. */
|
||||
short: string
|
||||
action: ActionName
|
||||
/** What it does, for the `?` help. */
|
||||
help: string
|
||||
}
|
||||
|
||||
export const SHIFT_BANK: ShiftKey[] = [
|
||||
{ n: 1, label: 'Keys', short: 'Keys', action: 'help.keys', help: 'Keys: this reference (as ?)' },
|
||||
{ n: 2, label: 'Servers', short: 'Servr', action: 'servers', help: 'Servers dialog (as Ctrl+O)' },
|
||||
{ n: 3, label: 'SQL', short: 'SQL', action: 'sql.open', help: 'SQL Runner' },
|
||||
{ n: 4, label: 'Bulk', short: 'Bulk', action: 'grid.bulk', help: 'Bulk actions on the grid (as b)' },
|
||||
{ n: 5, label: 'Metadata', short: 'Meta', action: 'meta.refresh', help: 'Refresh the metadata' },
|
||||
{ n: 6, label: 'Views', short: 'Views', action: 'grid.views', help: 'Saved views of the grid' },
|
||||
{ n: 7, label: 'Copy', short: 'Copy', action: 'record.copy', help: 'Copy into a new record (as c)' },
|
||||
{ n: 8, label: 'Export', short: 'Exprt', action: 'export', help: 'Export the grid (as x)' },
|
||||
{ n: 9, label: 'History', short: 'Hist', action: 'history', help: 'History screen' },
|
||||
{ n: 10, label: 'Assistant', short: 'Asst', action: 'assistant.toggle', help: 'Assistant panel (as F12, Ctrl+G)' },
|
||||
]
|
||||
|
||||
/** The bank key for a number, if bound. */
|
||||
export function shiftKey(n: number): ShiftKey | undefined {
|
||||
return SHIFT_BANK.find((k) => k.n === n)
|
||||
}
|
||||
|
||||
/** One slot of the Shift row as drawn: a label, or null where the key does nothing here (dimmed). */
|
||||
export interface ShiftSlot {
|
||||
n: number
|
||||
label: string | null
|
||||
global: string
|
||||
short: string
|
||||
why?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The Shift row for the current screen, `slots` keys wide (the primary row's count, so both rows line
|
||||
* up; unbound keys are blank).
|
||||
*/
|
||||
export function shiftSlots(store: AppStore, slots = 10): ShiftSlot[] {
|
||||
const out: ShiftSlot[] = []
|
||||
for (let n = 1; n <= Math.max(10, slots); n++) {
|
||||
const k = shiftKey(n)
|
||||
if (!k) {
|
||||
out.push({ n, label: null, global: '', short: '' })
|
||||
continue
|
||||
}
|
||||
const action: NamedAction = ACTIONS[k.action]
|
||||
let why: string | undefined
|
||||
try {
|
||||
why = action.unavailable(store)
|
||||
} catch {
|
||||
why = 'not available'
|
||||
}
|
||||
out.push({ n, label: why ? null : k.label, global: k.label, short: k.short, why })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Shift+Fn pressed: runs its action (or says why not) and, in one-row mode, shows the Shift row for a moment. */
|
||||
export function runShiftKey(store: AppStore, n: number): void {
|
||||
store.showShiftRow(SHIFT_ROW_MS)
|
||||
const k = shiftKey(n)
|
||||
if (!k) return store.flash(`Shift+F${n} does nothing`)
|
||||
runAction(store, k.action, `Shift+F${n} ${k.label}`)
|
||||
}
|
||||
|
||||
/** The keys for the `?` help's Shift bank group. */
|
||||
export function shiftBankHelp(store: AppStore): Array<[string, string]> {
|
||||
return shiftSlots(store).filter((s) => shiftKey(s.n)).map((s) => {
|
||||
const k = shiftKey(s.n)!
|
||||
return [`Shift+F${s.n}`, `${k.help}${s.why ? ' · not here' : ''}`]
|
||||
})
|
||||
}
|
||||
|
||||
// ---- the F-key bar setting ----
|
||||
|
||||
export type FKeyBarMode = 'one' | 'two' | 'auto'
|
||||
export const FKEY_BAR_MODES: FKeyBarMode[] = ['auto', 'one', 'two']
|
||||
/** From this height `auto` draws both rows. */
|
||||
export const TWO_ROW_MIN_HEIGHT = 30
|
||||
/** How long one-row mode shows the Shift row after a Shift+F press. */
|
||||
export const SHIFT_ROW_MS = 2000
|
||||
|
||||
export function readFKeyBar(env: NodeJS.ProcessEnv = process.env): FKeyBarMode {
|
||||
try {
|
||||
const v = readConfig(env).ui?.fkeyBar
|
||||
return v === 'one' || v === 'two' ? v : 'auto'
|
||||
} catch {
|
||||
return 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFKeyBar(mode: FKeyBarMode, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const config = readConfig(env)
|
||||
config.ui = { ...config.ui, fkeyBar: mode }
|
||||
writeConfig(config, env)
|
||||
}
|
||||
|
||||
/** Settings › F-key bar and `:fkeys one|two|auto`: applies now and saves it. */
|
||||
export function setFKeyBar(store: AppStore, mode: FKeyBarMode): void {
|
||||
store.settings.fkeyBar = mode
|
||||
store.bump()
|
||||
try {
|
||||
writeFKeyBar(mode)
|
||||
} catch (e) {
|
||||
return store.error(e)
|
||||
}
|
||||
store.flash(`F-key bar: ${describeFKeyBar(mode)} (saved)`)
|
||||
}
|
||||
|
||||
export function describeFKeyBar(mode: FKeyBarMode): string {
|
||||
return mode === 'one' ? 'one row (Shift row for 2 s after Shift+F)' : mode === 'two' ? 'two rows' : `auto (two rows from ${TWO_ROW_MIN_HEIGHT} lines)`
|
||||
}
|
||||
|
||||
/** How many rows the bar takes at this terminal height. */
|
||||
export function fkeyBarRows(mode: FKeyBarMode | undefined, height: number): 1 | 2 {
|
||||
if (mode === 'two') return 2
|
||||
if (mode === 'one') return 1
|
||||
return height >= TWO_ROW_MIN_HEIGHT ? 2 : 1
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Shift row of the F-key bar: under the primary row when the bar has two rows, or in its place
|
||||
* for a moment after a Shift+F press in one-row mode. It uses the primary row's arithmetic (mouse/hit.ts
|
||||
* fkeyAt), so the keys line up and a click hits the key drawn there; the first number carries the ⇧.
|
||||
*/
|
||||
|
||||
import type { AppStore } from '../store'
|
||||
import { fkeyAt } from '../mouse/hit'
|
||||
import { key } from '../mouse/keys'
|
||||
import { Zone } from '../mouse/zone'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import { pad } from '../util/text'
|
||||
import { fkeyBarRows, shiftSlots, type ShiftSlot } from './shift-bank'
|
||||
|
||||
/** The cells of each slot, as the primary row lays them out: max(7, width / count), the last takes the rest. */
|
||||
export function slotWidths(width: number, count: number): number[] {
|
||||
const cell = Math.max(7, Math.floor(width / count))
|
||||
const out: number[] = []
|
||||
let used = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
const w = i === count - 1 ? Math.max(0, width - used) : cell
|
||||
out.push(w)
|
||||
used += w
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The text of each slot (number, then the label padded to the slot), clipped to the width. */
|
||||
export function shiftRowCells(slots: ShiftSlot[], width: number, ascii = false): Array<{ n: number; num: string; label: string; active: boolean }> {
|
||||
const widths = slotWidths(width, slots.length)
|
||||
const narrow = width < 100
|
||||
return slots.map((s, i) => {
|
||||
const w = widths[i]!
|
||||
const num = i === 0 ? `${ascii ? 'S' : '⇧'}${s.n}` : String(s.n).padStart(2)
|
||||
const base = s.label ?? s.global
|
||||
const label = narrow || base.length > w - 2 ? s.short : base
|
||||
return { n: s.n, num: num.slice(0, w), label: pad(label, Math.max(0, w - 2)).slice(0, Math.max(0, w - 2)), active: s.label !== null }
|
||||
})
|
||||
}
|
||||
|
||||
export function ShiftRow({ store, width, slots }: { store: AppStore; width: number; slots: number }) {
|
||||
const t = theme()
|
||||
const shift = shiftSlots(store, slots)
|
||||
const cells = shiftRowCells(shift, width, glyphs().border !== undefined)
|
||||
return (
|
||||
<Zone global onClick={(e) => {
|
||||
const n = fkeyAt(e.x, width, shift.map((s) => s.n))
|
||||
if (n !== undefined) store.handleKey(key(`shift+f${n}`, { shift: true }))
|
||||
}}>
|
||||
<text flexShrink={0} wrapMode="none">
|
||||
{cells.map((c) => (
|
||||
<span key={c.n}>
|
||||
<span fg={t.keyNum}>{c.num}</span>
|
||||
<span fg={c.active ? t.keyLabelFg : t.dim} bg={c.active ? t.keyLabelBg : undefined}>{c.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</text>
|
||||
</Zone>
|
||||
)
|
||||
}
|
||||
|
||||
/** How many rows the bar takes now (the body is that much shorter). */
|
||||
export function barRows(store: AppStore, height: number): 1 | 2 {
|
||||
return fkeyBarRows(store.settings.fkeyBar, height)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Feeds the renderer canonical function-key sequences: every `data` chunk of the input stream goes
|
||||
* through rewriteFKeyInput before any listener (OpenTUI's stdin parser) sees it. OpenTUI's own input
|
||||
* handlers run after its parser, which already took Shift+F3 for a cursor report, so the bytes are
|
||||
* rewritten on the stream itself.
|
||||
*/
|
||||
|
||||
import { rewriteFKeyInput, type LegacyProfile } from './fkeys'
|
||||
|
||||
/** Cursor reports are expected for this long after start and after a resume (the capability probe). */
|
||||
export const CPR_QUIET_MS = 1500
|
||||
|
||||
export interface FKeyRewrite {
|
||||
/** Call after renderer.resume(): the probe runs again, so CSI 1;m R is a cursor report for a while. */
|
||||
quiet(): void
|
||||
/** Restores the stream. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function installFKeyRewrite(stream: NodeJS.EventEmitter, profile: LegacyProfile, clock: () => number = Date.now): FKeyRewrite {
|
||||
const original = stream.emit
|
||||
let quietUntil = clock() + CPR_QUIET_MS
|
||||
const patched = function (this: NodeJS.EventEmitter, event: string | symbol, ...args: unknown[]): boolean {
|
||||
if (event === 'data' && args.length) {
|
||||
const chunk = args[0]
|
||||
const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('latin1') : undefined
|
||||
if (text !== undefined && text.includes('\x1b')) {
|
||||
const out = rewriteFKeyInput(text, profile, { cpr: clock() >= quietUntil })
|
||||
if (out !== text) args[0] = typeof chunk === 'string' ? out : Buffer.from(out, 'latin1')
|
||||
}
|
||||
}
|
||||
return original.call(this, event, ...args)
|
||||
}
|
||||
stream.emit = patched as typeof stream.emit
|
||||
return {
|
||||
quiet: () => { quietUntil = clock() + CPR_QUIET_MS },
|
||||
dispose: () => { if (stream.emit === patched) stream.emit = original },
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { App } from './app'
|
||||
import { boot, parseArgs } from './boot'
|
||||
import { runDoctor } from './doctor'
|
||||
import { isShutdownNoise, quietShutdownNoise } from './exit'
|
||||
import { legacyProfile } from './keys/fkeys'
|
||||
import { installFKeyRewrite } from './keys/stdin-rewrite'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.check === 'full') {
|
||||
@@ -49,6 +51,10 @@ const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
})
|
||||
|
||||
// Shift+F3 (CSI 1;2R, which OpenTUI takes for a cursor report) and the legacy and rxvt Shift/Ctrl+F
|
||||
// forms reach OpenTUI as the canonical CSI n;m ~ (keys/fkeys.ts)
|
||||
const fkeyRewrite = installFKeyRewrite(process.stdin, legacyProfile())
|
||||
|
||||
let destroyed = false
|
||||
function restore(): void {
|
||||
if (destroyed) return
|
||||
@@ -93,7 +99,10 @@ store.editExternal = async (text: string) => {
|
||||
spawnSync(editor, [file], { stdio: 'inherit', shell: process.platform === 'win32' })
|
||||
return readFileSync(file, 'utf8')
|
||||
} finally {
|
||||
// the resume probes the terminal again: its cursor reports are not Shift+F3
|
||||
fkeyRewrite.quiet()
|
||||
await renderer.resume()
|
||||
fkeyRewrite.quiet()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,9 @@ export class HelpOverlay implements Overlay {
|
||||
readonly id = overlayId('help')
|
||||
top = 0
|
||||
|
||||
/** Key groups shown after the screen's help (the Shift bank), before the mouse. */
|
||||
after: KeyGroup[] = []
|
||||
|
||||
constructor(private store: AppStore, readonly groups: KeyGroup[], readonly topicHelp: string[], readonly pointer = HELP_POINTER) {}
|
||||
|
||||
onKey(key: Key): void {
|
||||
@@ -190,6 +193,7 @@ export class HelpOverlay implements Overlay {
|
||||
body.push({ text: ' About this screen', fg: t.acc })
|
||||
for (const p of this.topicHelp) for (const l of wrap(p, inner - 4)) body.push({ text: ` ${l}` })
|
||||
}
|
||||
if (this.after.length) this.keyLines(this.after, body, half)
|
||||
if (this.store.settings.mouse) this.keyLines([MOUSE_KEYS], body, half)
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -14,6 +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 { bankKey, legacyProfile, shiftNumber, type LegacyProfile } from './keys/fkeys'
|
||||
import { ChoiceOverlay, ConfirmOverlay, CliCommandOverlay, MessageOverlay } from './overlays/basic'
|
||||
import { Extensions } from './extensions'
|
||||
import { PrintTracker, printScreen } from './printing'
|
||||
@@ -37,6 +38,8 @@ export interface Settings {
|
||||
singleKeys: boolean
|
||||
/** Copy CLI equivalents to the clipboard automatically (ui.copyCliCommands; default off). */
|
||||
copyCli?: boolean
|
||||
/** The F-key bar: one row, two rows (primary and Shift), or auto (two from 30 lines); keys/shift-bank.ts. */
|
||||
fkeyBar?: 'one' | 'two' | 'auto'
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
@@ -118,6 +121,11 @@ export class AppStore {
|
||||
/** Copies text to the clipboard (set by main); returns how it was copied, or undefined when there is no clipboard. */
|
||||
copyText: (text: string) => string | undefined = () => undefined
|
||||
sidebar?: SidebarLike
|
||||
/** How this terminal numbers its legacy shifted F-keys (keys/fkeys.ts; QQQ_FKEYS overrides). */
|
||||
fkeyProfile: LegacyProfile = legacyProfile()
|
||||
/** One-row bar: the Shift row is shown instead of the primary row until this time (after a Shift+F press). */
|
||||
shiftRowUntil = 0
|
||||
private shiftRowTimer?: ReturnType<typeof setTimeout>
|
||||
private listeners = new Set<Listener>()
|
||||
private timers: Array<ReturnType<typeof setInterval>> = []
|
||||
private streamAbort?: AbortController
|
||||
@@ -363,6 +371,8 @@ export class AppStore {
|
||||
// F-key fallbacks for terminals that take the F-keys: Esc then a digit (arrives as Alt+digit) or Ctrl+digit; 0 is F10
|
||||
const digit = /^[0-9]$/.test(raw.name) ? raw.name : /^[0-9]$/.test(raw.sequence?.slice(-1) ?? '') && raw.meta ? raw.sequence.slice(-1) : undefined
|
||||
if (digit !== undefined && (raw.meta || raw.ctrl)) key = { name: `f${digit === '0' ? 10 : digit}`, sequence: '' }
|
||||
// Shift+F1…F12 (any terminal's form, keys/fkeys.ts) are the Shift bank: shift+f1…shift+f12
|
||||
key = bankKey(key, this.fkeyProfile)
|
||||
try {
|
||||
this.dispatch(key)
|
||||
} catch (e) {
|
||||
@@ -399,6 +409,8 @@ export class AppStore {
|
||||
// Ctrl+P prints what you see on every screen; Print Screen too when the terminal passes it on
|
||||
// (kitty keyboard protocol); F8 prints where the screen has no Delete for it
|
||||
if ((key.ctrl && key.name === 'p') || key.name === 'printscreen' || (key.name === 'f8' && this.fkeys().find((k) => k.n === 8)?.label === 'Print')) return printScreen(this)
|
||||
const shifted = shiftNumber(key)
|
||||
if (shifted !== undefined) return this.commands().shiftKey(shifted)
|
||||
if (key.name === 'f1') return this.commands().help()
|
||||
if (this.backslash && charOf(key) === '?') {
|
||||
this.backslash = false
|
||||
@@ -517,6 +529,8 @@ export class AppStore {
|
||||
dispose(): void {
|
||||
for (const t of this.timers) clearInterval(t)
|
||||
this.timers = []
|
||||
if (this.shiftRowTimer) clearTimeout(this.shiftRowTimer)
|
||||
this.shiftRowTimer = undefined
|
||||
this.clockTimer = undefined
|
||||
this.streamAbort?.abort()
|
||||
this.printTracker.dispose()
|
||||
@@ -659,6 +673,22 @@ export class AppStore {
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** One-row bar: shows the Shift row for `ms` (keys/shift-bank.ts SHIFT_ROW_MS), then the primary row again. */
|
||||
showShiftRow(ms = 2000): void {
|
||||
this.shiftRowUntil = Date.now() + ms
|
||||
if (this.shiftRowTimer) clearTimeout(this.shiftRowTimer)
|
||||
this.shiftRowTimer = setTimeout(() => {
|
||||
this.shiftRowTimer = undefined
|
||||
this.bump()
|
||||
}, ms + 10)
|
||||
;(this.shiftRowTimer as { unref?: () => void }).unref?.()
|
||||
}
|
||||
|
||||
/** Whether one-row mode shows the Shift row now. */
|
||||
shiftRowShown(): boolean {
|
||||
return Date.now() < this.shiftRowUntil
|
||||
}
|
||||
|
||||
/** The function-key bar for the current context; from 120 columns also 11 (reserved, blank) and 12 (the Assistant). */
|
||||
fkeys(): Array<{ n: number; label: string | null; global: string }> {
|
||||
const defaults: Record<number, string> = { 1: 'Help', 2: 'Search', 3: 'View', 4: 'Edit', 5: 'Refresh', 6: 'Filter', 7: 'New', 8: 'Delete', 9: 'Menu', 10: 'Quit' }
|
||||
@@ -704,6 +734,8 @@ export interface CommandsLike {
|
||||
menu(): void
|
||||
/** The Servers dialog (Ctrl+O). */
|
||||
servers(): void
|
||||
/** Shift+Fn: the Shift bank's key n (keys/shift-bank.ts). */
|
||||
shiftKey(n: number): void
|
||||
commandLine(initial?: string): void
|
||||
viewSettings(): void
|
||||
lock(): void
|
||||
|
||||
@@ -271,7 +271,8 @@ test('F-key bar: 11 and 12 from 120 columns, 12Ask lit with the extension, a cli
|
||||
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
|
||||
expect(keys.find((k) => k.n === 12)?.label).toBe('Ask')
|
||||
expect(keys.find((k) => k.n === 11)?.label).toBeNull()
|
||||
const bar = h.frame().split('\n').filter((l) => l.trim()).pop()!
|
||||
// the primary row (from 30 lines the Shift row is drawn under it)
|
||||
const bar = h.frame().split('\n').find((l) => l.includes(' 1Help'))!
|
||||
expect(bar).toContain('12Ask')
|
||||
expect(bar).toMatch(/11\s+12Ask/)
|
||||
expect([...bar].length).toBeLessThanOrEqual(120)
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The function-key decoder (keys/fkeys.ts), per sequence family, and the stdin rewrite into what OpenTUI reads. */
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseKeypress } from '@opentui/core'
|
||||
import { bankKey, canonicalSequence, chordName, decodeFKey, fkeyChord, legacyProfile, rewriteFKeyInput, shiftNumber, type FKeyChord, type LegacyProfile } from '../src/keys/fkeys'
|
||||
import { CPR_QUIET_MS, installFKeyRewrite } from '../src/keys/stdin-rewrite'
|
||||
|
||||
const E = '\x1b'
|
||||
const name = (seq: string, profile: LegacyProfile = 'vt220') => {
|
||||
const d = decodeFKey(seq, profile)
|
||||
return d ? chordName(d.chord) : undefined
|
||||
}
|
||||
const family = (seq: string, profile: LegacyProfile = 'vt220') => decodeFKey(seq, profile)?.family
|
||||
|
||||
/** What OpenTUI makes of a sequence after the rewrite (kitty parsing on, as the renderer runs). */
|
||||
function opentui(seq: string, profile: LegacyProfile = 'vt220'): string | undefined {
|
||||
const k = parseKeypress(rewriteFKeyInput(seq, profile, { cpr: true }), { useKittyKeyboard: true })
|
||||
if (!k?.name) return undefined
|
||||
return `${k.ctrl ? 'ctrl+' : ''}${k.meta ? 'alt+' : ''}${k.shift ? 'shift+' : ''}${k.name}`
|
||||
}
|
||||
|
||||
describe('xterm modifier forms (tmux, iTerm2, Windows Terminal, xterm, VTE)', () => {
|
||||
const shift = ['1;2P', '1;2Q', '1;2R', '1;2S', '15;2~', '17;2~', '18;2~', '19;2~', '20;2~', '21;2~', '23;2~', '24;2~']
|
||||
test('Shift+F1…F12', () => {
|
||||
shift.forEach((s, i) => expect(name(`${E}[${s}`)).toBe(`shift+f${i + 1}`))
|
||||
expect(family(`${E}[1;2P`)).toBe('csi-letter')
|
||||
expect(family(`${E}[1;2R`)).toBe('csi-f3-cpr')
|
||||
expect(family(`${E}[15;2~`)).toBe('csi-tilde')
|
||||
})
|
||||
test('SS3 with a modifier and the plain F1–F4', () => {
|
||||
expect(name(`${E}O2P`)).toBe('shift+f1')
|
||||
expect(name(`${E}O2S`)).toBe('shift+f4')
|
||||
expect(name(`${E}OQ`)).toBe('f2')
|
||||
expect(family(`${E}O2R`)).toBe('ss3')
|
||||
})
|
||||
test('CSI 11..14 forms (VT220, PuTTY) with and without modifiers', () => {
|
||||
expect(name(`${E}[11~`)).toBe('f1')
|
||||
expect(name(`${E}[12;2~`)).toBe('shift+f2')
|
||||
expect(name(`${E}[14;2~`)).toBe('shift+f4')
|
||||
})
|
||||
test('Ctrl, Alt and combinations come for free', () => {
|
||||
expect(name(`${E}[1;5P`)).toBe('ctrl+f1')
|
||||
expect(name(`${E}[15;5~`)).toBe('ctrl+f5')
|
||||
expect(name(`${E}[24;5~`)).toBe('ctrl+f12')
|
||||
expect(name(`${E}[1;6P`)).toBe('ctrl+shift+f1')
|
||||
expect(name(`${E}[20;3~`)).toBe('alt+f9')
|
||||
expect(name(`${E}${E}[1;2P`)).toBe('alt+shift+f1')
|
||||
expect(name(`${E}[1;5R`)).toBe('ctrl+f3')
|
||||
})
|
||||
test('OpenTUI reads all of them after the rewrite, Shift+F3 included', () => {
|
||||
shift.forEach((s, i) => expect(opentui(`${E}[${s}`)).toBe(`shift+f${i + 1}`))
|
||||
expect(opentui(`${E}[1;5R`)).toBe('ctrl+f3')
|
||||
// without the rewrite OpenTUI drops Shift+F3: it looks like a cursor position report
|
||||
expect(parseKeypress(`${E}[1;2R`, { useKittyKeyboard: true })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('kitty keyboard protocol', () => {
|
||||
test('F3 is CSI 13 ~; event sub-parameters; the CSI u code points', () => {
|
||||
expect(name(`${E}[13;2~`)).toBe('shift+f3')
|
||||
expect(name(`${E}[15;2:2~`)).toBe('shift+f5')
|
||||
expect(decodeFKey(`${E}[1;2:3Q`)!.chord).toEqual({ n: 2, shift: true, ctrl: false, alt: false, release: true })
|
||||
expect(name(`${E}[57364;2u`)).toBe('shift+f1')
|
||||
expect(name(`${E}[57375;2u`)).toBe('shift+f12')
|
||||
expect(family(`${E}[57365;2u`)).toBe('kitty-u')
|
||||
// F13 and up are keys of their own (macOS keyboards have them)
|
||||
expect(decodeFKey(`${E}[57376;2u`)).toBeUndefined()
|
||||
})
|
||||
test('OpenTUI reads the kitty forms itself (no rewrite)', () => {
|
||||
expect(rewriteFKeyInput(`${E}[13;2~`, 'vt220', { cpr: true })).toBe(`${E}[13;2~`)
|
||||
expect(opentui(`${E}[13;2~`)).toBe('shift+f3')
|
||||
expect(opentui(`${E}[57364;2u`)).toBe('shift+f1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy numbered forms, per terminal profile', () => {
|
||||
const codes = ['25~', '26~', '28~', '29~', '31~', '32~', '33~', '34~']
|
||||
test('vt220 (F13 = Shift+F1): CSI 25~…34~ are Shift+F1…F8', () => {
|
||||
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'vt220')).toBe(`shift+f${i + 1}`))
|
||||
expect(family(`${E}[25~`)).toBe('legacy-number')
|
||||
})
|
||||
test('rxvt and the Linux console (F11 = Shift+F1): Shift+F3…F10', () => {
|
||||
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'rxvt')).toBe(`shift+f${i + 3}`))
|
||||
// CSI 23~ / 24~ stay F11 / F12 everywhere (rxvt's Shift+F1 / F2 cannot be told apart from them)
|
||||
expect(name(`${E}[23~`, 'rxvt')).toBe('f11')
|
||||
})
|
||||
test('macOS Terminal.app: Shift+F5…F12', () => {
|
||||
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'apple')).toBe(`shift+f${i + 5}`))
|
||||
})
|
||||
test('modern terminals: legacy numbers mean nothing (Windows Terminal sends CSI 29~ for the Menu key)', () => {
|
||||
for (const c of codes) expect(decodeFKey(`${E}[${c}`, 'modern')).toBeUndefined()
|
||||
expect(rewriteFKeyInput(`${E}[29~`, 'modern', { cpr: true })).toBe(`${E}[29~`)
|
||||
})
|
||||
test('the rewrite gives OpenTUI the canonical form', () => {
|
||||
expect(opentui(`${E}[25~`, 'vt220')).toBe('shift+f1')
|
||||
expect(opentui(`${E}[26~`, 'vt220')).toBe('shift+f2')
|
||||
expect(opentui(`${E}[33~`, 'apple')).toBe('shift+f11')
|
||||
expect(opentui(`${E}[34~`, 'rxvt')).toBe('shift+f10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rxvt suffixes and the Linux console', () => {
|
||||
test('$ Shift, ^ Ctrl, @ Ctrl+Shift', () => {
|
||||
expect(name(`${E}[23$`)).toBe('shift+f11')
|
||||
expect(name(`${E}[24$`)).toBe('shift+f12')
|
||||
expect(name(`${E}[11^`)).toBe('ctrl+f1')
|
||||
expect(name(`${E}[15^`)).toBe('ctrl+f5')
|
||||
expect(name(`${E}[24^`)).toBe('ctrl+f12')
|
||||
expect(name(`${E}[25^`)).toBe('ctrl+shift+f3')
|
||||
expect(name(`${E}[23@`)).toBe('ctrl+shift+f11')
|
||||
expect(family(`${E}[15^`)).toBe('rxvt-suffix')
|
||||
expect(opentui(`${E}[23$`)).toBe('shift+f11')
|
||||
expect(opentui(`${E}[15^`)).toBe('ctrl+f5')
|
||||
})
|
||||
test('editing keys with the same suffixes are not function keys', () => {
|
||||
expect(decodeFKey(`${E}[2$`)).toBeUndefined()
|
||||
expect(decodeFKey(`${E}[5^`)).toBeUndefined()
|
||||
expect(rewriteFKeyInput(`${E}[3^`, 'rxvt', { cpr: true })).toBe(`${E}[3^`)
|
||||
})
|
||||
test('Linux console CSI [ A…E are F1…F5', () => {
|
||||
expect(name(`${E}[[A`)).toBe('f1')
|
||||
expect(name(`${E}[[E`)).toBe('f5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the rewrite', () => {
|
||||
test('leaves cursor reports alone during the probe, and CSI 1;1R always', () => {
|
||||
expect(rewriteFKeyInput(`${E}[1;2R`, 'vt220', { cpr: false })).toBe(`${E}[1;2R`)
|
||||
expect(rewriteFKeyInput(`${E}[1;1R`, 'vt220', { cpr: true })).toBe(`${E}[1;1R`)
|
||||
expect(rewriteFKeyInput(`${E}[12;40R`, 'vt220', { cpr: true })).toBe(`${E}[12;40R`)
|
||||
expect(rewriteFKeyInput(`${E}[1;2R`, 'vt220', { cpr: true })).toBe(`${E}[13;2~`)
|
||||
})
|
||||
test('rewrites inside a chunk, keeps text and other sequences, never touches a paste', () => {
|
||||
expect(rewriteFKeyInput(`ab${E}[25~${E}[A${E}[1;2R`, 'vt220', { cpr: true })).toBe(`ab${E}[11;2~${E}[A${E}[13;2~`)
|
||||
expect(rewriteFKeyInput(`${E}[200~${E}[25~${E}[201~`, 'vt220', { cpr: true })).toBe(`${E}[200~${E}[25~${E}[201~`)
|
||||
expect(rewriteFKeyInput('plain', 'vt220', { cpr: true })).toBe('plain')
|
||||
})
|
||||
test('canonical sequences', () => {
|
||||
const c = (n: number, mods: Partial<FKeyChord> = {}): FKeyChord => ({ n, shift: false, ctrl: false, alt: false, ...mods })
|
||||
expect(canonicalSequence(c(1, { shift: true }))).toBe(`${E}[11;2~`)
|
||||
expect(canonicalSequence(c(3, { ctrl: true }))).toBe(`${E}[13;5~`)
|
||||
expect(canonicalSequence(c(12))).toBe(`${E}[24~`)
|
||||
})
|
||||
test('on the stream: Buffer chunks before any listener; quiet after a resume; dispose restores', () => {
|
||||
const stream = new EventEmitter()
|
||||
const seen: string[] = []
|
||||
stream.on('data', (d: Buffer | string) => seen.push(Buffer.isBuffer(d) ? d.toString('latin1') : d))
|
||||
let now = 1000
|
||||
const r = installFKeyRewrite(stream, 'vt220', () => now)
|
||||
stream.emit('data', Buffer.from(`${E}[1;2R`))
|
||||
stream.emit('data', Buffer.from(`${E}[25~`))
|
||||
now += CPR_QUIET_MS
|
||||
stream.emit('data', Buffer.from(`${E}[1;2R`))
|
||||
r.quiet()
|
||||
stream.emit('data', `${E}[1;2R`)
|
||||
stream.emit('data', Buffer.from('é', 'utf8'))
|
||||
r.dispose()
|
||||
stream.emit('data', `${E}[25~`)
|
||||
expect(seen).toEqual([`${E}[1;2R`, `${E}[11;2~`, `${E}[13;2~`, `${E}[1;2R`, Buffer.from('é', 'utf8').toString('latin1'), `${E}[25~`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('key events and the profile', () => {
|
||||
test('fkeyChord and bankKey: Shift (only) becomes the Shift bank; Ctrl+F keeps acting as F', () => {
|
||||
expect(bankKey({ name: 'f2', sequence: `${E}[1;2Q`, shift: true }).name).toBe('shift+f2')
|
||||
expect(bankKey({ name: 'f2', sequence: `${E}OQ` }).name).toBe('f2')
|
||||
expect(bankKey({ name: 'f5', sequence: '', ctrl: true }).name).toBe('f5')
|
||||
expect(bankKey({ name: 'f1', sequence: '', ctrl: true, shift: true }).name).toBe('f1')
|
||||
expect(bankKey({ name: 'f9', sequence: '', meta: true, shift: true }).name).toBe('f9')
|
||||
// a sequence OpenTUI left unnamed is decoded from its bytes
|
||||
expect(bankKey({ name: '', sequence: `${E}[26~` }, 'vt220').name).toBe('shift+f2')
|
||||
expect(bankKey({ name: 'menu', sequence: `${E}[29~` }, 'modern').name).toBe('menu')
|
||||
expect(fkeyChord({ name: 'f13', sequence: '' })).toBeUndefined()
|
||||
expect(fkeyChord({ name: 'x', sequence: 'x' })).toBeUndefined()
|
||||
expect(shiftNumber({ name: 'shift+f10', sequence: '' })).toBe(10)
|
||||
expect(shiftNumber({ name: 'f10', sequence: '' })).toBeUndefined()
|
||||
})
|
||||
test('legacyProfile from the environment', () => {
|
||||
expect(legacyProfile({ TERM_PROGRAM: 'Apple_Terminal', TERM: 'xterm-256color' })).toBe('apple')
|
||||
expect(legacyProfile({ TERM_PROGRAM: 'iTerm.app', TERM: 'xterm-256color' })).toBe('modern')
|
||||
expect(legacyProfile({ WT_SESSION: 'x', TERM: 'xterm-256color' })).toBe('modern')
|
||||
expect(legacyProfile({ TMUX: '/tmp/x', TERM: 'tmux-256color', TERM_PROGRAM: 'tmux' })).toBe('modern')
|
||||
expect(legacyProfile({ TERM: 'rxvt-unicode-256color' })).toBe('rxvt')
|
||||
expect(legacyProfile({ TERM: 'linux' })).toBe('rxvt')
|
||||
expect(legacyProfile({ TERM: 'xterm' })).toBe('vt220')
|
||||
expect(legacyProfile({ TERM: 'xterm', QQQ_FKEYS: 'apple' })).toBe('apple')
|
||||
expect(legacyProfile({ TERM: 'xterm', QQQ_FKEYS: 'bogus' })).toBe('vt220')
|
||||
})
|
||||
})
|
||||
@@ -101,8 +101,9 @@ test('F-key bar: a click presses the key; the footer connection opens the menu a
|
||||
expect(h.store.overlays.length).toBe(1)
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
// the footer is the row above the F-key bar; its first cells are the server and profile: the Servers dialog
|
||||
await press(h, () => mouse().click(2, 30 - 2))
|
||||
// the footer is the row above the F-key bar (two rows from 30 lines: primary, then Shift); its
|
||||
// first cells are the server and profile: the Servers dialog
|
||||
await press(h, () => mouse().click(2, 30 - 3))
|
||||
await waitFor(h, () => h!.store.overlays.length === 1)
|
||||
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The Shift bank (Shift+F1…F10): the table, the bar in one and two rows, dispatch, the mouse, help. */
|
||||
|
||||
import { afterEach, beforeAll, expect, test } from 'bun:test'
|
||||
import { testRender } from '@opentui/react/test-utils'
|
||||
import { readConfig } from '@qrun-io/qqq-core'
|
||||
import { App } from '../src/app'
|
||||
import { boot, parseArgs } from '../src/boot'
|
||||
import { ACTIONS } from '../src/keys/actions'
|
||||
import { SHIFT_BANK, fkeyBarRows, shiftSlots } from '../src/keys/shift-bank'
|
||||
import { shiftRowCells, slotWidths } from '../src/keys/shift-row'
|
||||
import { CPR_QUIET_MS, installFKeyRewrite } from '../src/keys/stdin-rewrite'
|
||||
import { fkeyAt } from '../src/mouse/hit'
|
||||
import { SqlRunnerScreen } from '../src/sql/screen'
|
||||
import { fakeFetch, type FakeLog } from './fake-server'
|
||||
import { sqlFakeFetch } from './fake-sql'
|
||||
import { command, isolate, press, sessionSecrets, startTui, waitFor, type Harness } from './helpers'
|
||||
|
||||
let h: Harness | undefined
|
||||
|
||||
beforeAll(() => isolate())
|
||||
afterEach(() => {
|
||||
h?.store.dispose()
|
||||
h?.setup.renderer.destroy()
|
||||
h = undefined
|
||||
})
|
||||
|
||||
const E = '\x1b'
|
||||
/** Raw bytes into the terminal, as a terminal would send them. */
|
||||
const raw = (seq: string) => press(h!, () => h!.setup.mockInput.pressKeys([seq]))
|
||||
const lines = (frame: string) => frame.replace(/\n$/, '').split('\n')
|
||||
const fits = (frame: string, width: number) => lines(frame).every((l) => [...l].length <= width)
|
||||
const last = (frame: string, n = 1) => lines(frame).slice(-n)
|
||||
|
||||
async function start(width: number, height: number, fkeyBar?: 'one' | 'two' | 'auto', fetch?: typeof globalThis.fetch): Promise<Harness> {
|
||||
const log: FakeLog = { requests: [] }
|
||||
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: sessionSecrets, fetch: fetch ?? fakeFetch(log) })
|
||||
if (fkeyBar) store.settings.fkeyBar = fkeyBar
|
||||
const setup = await testRender(<App store={store} />, { width, height })
|
||||
const harness: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
|
||||
store.captureScreen = () => setup.captureCharFrame()
|
||||
await waitFor(harness, () => harness.store.screen?.kind === 'home' && Boolean(harness.store.meta))
|
||||
return harness
|
||||
}
|
||||
|
||||
/** Changes the bar mode and repaints. */
|
||||
async function mode(m: 'one' | 'two' | 'auto'): Promise<string> {
|
||||
h!.store.settings.fkeyBar = m
|
||||
h!.store.bump()
|
||||
return press(h!, () => undefined)
|
||||
}
|
||||
|
||||
/** The fake server with the assistant extension (enough for the panel to open). */
|
||||
function assistantFetch(): typeof fetch {
|
||||
const base = fakeFetch({ requests: [] })
|
||||
const json = (d: unknown) => new Response(JSON.stringify(d), { headers: { 'content-type': 'application/json' } })
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const path = new URL(new Request(input as RequestInfo, init).url).pathname.replace('/qqq/v1', '')
|
||||
if (path === '/version') return json({ apiVersion: '1.9.0', features: ['metaData', 'tables', 'processes'], extensions: [{ name: 'assistant', version: '1.7.0', pathPrefix: '/assistant' }] })
|
||||
if (path === '/assistant/config') return json({ enabled: true, model: 'm', profile: { displayName: 'Assistant' } })
|
||||
return base(input, init)
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
// ---- the table ----
|
||||
|
||||
test('the bank is one table of ten keys, each bound to a named action, with short labels for 80 columns', () => {
|
||||
expect(SHIFT_BANK.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
expect(SHIFT_BANK.map((k) => k.label)).toEqual(['Keys', 'Servers', 'SQL', 'Bulk', 'Metadata', 'Views', 'Copy', 'Export', 'History', 'Assistant'])
|
||||
for (const k of SHIFT_BANK) {
|
||||
expect(ACTIONS[k.action]).toBeDefined()
|
||||
expect(k.short.length).toBeLessThanOrEqual(5)
|
||||
}
|
||||
// the assistant toggle is the named action F12 and Ctrl+G bind too
|
||||
expect(SHIFT_BANK.find((k) => k.n === 10)!.action).toBe('assistant.toggle')
|
||||
})
|
||||
|
||||
test('rows: auto is two from 30 lines; one and two are fixed', () => {
|
||||
expect(fkeyBarRows('auto', 29)).toBe(1)
|
||||
expect(fkeyBarRows('auto', 30)).toBe(2)
|
||||
expect(fkeyBarRows(undefined, 40)).toBe(2)
|
||||
expect(fkeyBarRows('one', 40)).toBe(1)
|
||||
expect(fkeyBarRows('two', 24)).toBe(2)
|
||||
})
|
||||
|
||||
test('row cells: the primary arithmetic, ⇧ on the first number, short labels below 100 columns, clipped', () => {
|
||||
expect(slotWidths(80, 10)).toEqual([8, 8, 8, 8, 8, 8, 8, 8, 8, 8])
|
||||
expect(slotWidths(120, 12).reduce((a, b) => a + b, 0)).toBe(120)
|
||||
const slots = SHIFT_BANK.map((k) => ({ n: k.n, label: k.label, global: k.label, short: k.short }))
|
||||
const narrow = shiftRowCells(slots, 80)
|
||||
expect(narrow[0]!.num).toBe('⇧1')
|
||||
expect(narrow[1]!.label.trim()).toBe('Servr')
|
||||
expect(narrow.map((c) => c.num + c.label).join('').length).toBe(80)
|
||||
expect(shiftRowCells(slots, 120)[4]!.label.trim()).toBe('Metadata')
|
||||
expect(shiftRowCells(slots, 80, true)[0]!.num).toBe('S1')
|
||||
// twelve slots (when the primary row has 11 and 12) line up; 11 and 12 are blank
|
||||
expect(shiftRowCells([...slots, { n: 11, label: null, global: '', short: '' }, { n: 12, label: null, global: '', short: '' }], 120).length).toBe(12)
|
||||
})
|
||||
|
||||
// ---- the bar ----
|
||||
|
||||
test('80×24 auto: one row; a Shift+F press shows the Shift row for 2 s, then the primary row again', async () => {
|
||||
h = await start(80, 24)
|
||||
let f = h.frame()
|
||||
expect(fits(f, 80)).toBe(true)
|
||||
expect(last(f)[0]).toContain(' 1Help')
|
||||
expect(f).not.toContain('⇧1')
|
||||
// Shift+F2 (xterm form): the Servers dialog; the bar swaps to the Shift row
|
||||
await raw(`${E}[1;2Q`)
|
||||
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
|
||||
f = h.frame()
|
||||
expect(last(f)[0]).toStartWith('⇧1Keys')
|
||||
expect(last(f)[0]).toContain('2Servr')
|
||||
expect(last(f)[0]).toContain('9Hist')
|
||||
expect(fits(f, 80)).toBe(true)
|
||||
// after the 2 s the primary row is back
|
||||
h.store.shiftRowUntil = Date.now() - 1
|
||||
h.store.bump()
|
||||
f = await press(h, () => undefined)
|
||||
expect(last(f)[0]).toContain(' 1Help')
|
||||
})
|
||||
|
||||
test('80×24 two rows: primary then Shift, the footer above them, nothing wider than 80', async () => {
|
||||
h = await start(80, 24, 'two')
|
||||
const f = h.frame()
|
||||
expect(fits(f, 80)).toBe(true)
|
||||
const [primary, shift] = last(f, 2)
|
||||
expect(primary).toContain(' 1Help')
|
||||
expect(primary).toContain('10Quit')
|
||||
expect(shift).toStartWith('⇧1Keys')
|
||||
expect(shift).toContain('10Asst')
|
||||
expect(lines(f).length).toBe(24)
|
||||
})
|
||||
|
||||
test('120×40 auto: two rows with full labels; one-row mode keeps the primary row', async () => {
|
||||
h = await start(120, 40)
|
||||
let f = h.frame()
|
||||
expect(fits(f, 120)).toBe(true)
|
||||
const [primary, shift] = last(f, 2)
|
||||
expect(primary).toContain(' 1Help')
|
||||
expect(shift).toStartWith('⇧1Keys')
|
||||
for (const label of ['2Servers', '3SQL', '4Bulk', '5Metadata', '6Views', '7Copy', '8Export', '9History', '10Asst']) expect(shift).toContain(label)
|
||||
// the primary row has 11 and 12 from 120 columns: the Shift row lines up with it (11 and 12 unbound)
|
||||
expect(shift).toMatch(/11\s+12\s*$/)
|
||||
expect(shift.indexOf(' 9History')).toBe(primary.indexOf(' 9Menu'))
|
||||
expect(lines(f).length).toBe(40)
|
||||
f = await mode('one')
|
||||
expect(last(f)[0]).toContain(' 1Help')
|
||||
expect(f).not.toContain('⇧1Keys')
|
||||
expect(lines(f).length).toBe(40)
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F9', { shift: true }))
|
||||
f = await waitFor(h, () => h!.store.screen?.kind === 'history')
|
||||
expect(last(f)[0]).toStartWith('⇧1Keys')
|
||||
})
|
||||
|
||||
test('context: keys that do not apply are dimmed and say why; on a grid Bulk, Views, Copy and Export apply', async () => {
|
||||
h = await start(120, 40)
|
||||
const home = Object.fromEntries(shiftSlots(h.store).map((s) => [s.global, s.why]))
|
||||
expect(home.Keys).toBeUndefined()
|
||||
expect(home.Servers).toBeUndefined()
|
||||
expect(home.SQL).toContain('sql-workbench')
|
||||
expect(home.Bulk).toContain('table grid')
|
||||
expect(home.Views).toContain('table grid')
|
||||
expect(home.Copy).toBeDefined()
|
||||
expect(home.Export).toContain('table grid')
|
||||
expect(home.History).toBeUndefined()
|
||||
expect(home.Assistant).toContain('assistant extension')
|
||||
// Shift+F3 in the xterm form without the stdin rewrite (main.tsx installs it): OpenTUI takes it for a cursor report
|
||||
await raw(`${E}[1;2R`)
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
|
||||
expect(h.store.flashText?.text).toContain('Shift+F10 Assistant: needs the assistant extension')
|
||||
expect(h.store.assistantOpen).toBe(false)
|
||||
await command(h, 'table query person')
|
||||
await waitFor(h, (f) => f.includes('First1 '))
|
||||
const grid = Object.fromEntries(shiftSlots(h.store).map((s) => [s.global, s.why]))
|
||||
expect(grid.Views).toBeUndefined()
|
||||
expect(grid.Export).toBeUndefined()
|
||||
// person has no TABLE_INSERT capability in the fake: Copy is dimmed and says so
|
||||
expect(grid.Copy).toBe('copying needs permission to create records')
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F7', { shift: true }))
|
||||
expect(h.store.flashText?.text).toBe('Shift+F7 Copy: copying needs permission to create records')
|
||||
expect(h.store.screen?.kind).toBe('table')
|
||||
})
|
||||
|
||||
// ---- dispatch ----
|
||||
|
||||
test('dispatch on a grid: Shift+F8 export, Shift+F6 saved views, Shift+F4 bulk, Shift+F7 copy, Shift+F5 metadata, Shift+F1 keys', async () => {
|
||||
h = await start(120, 40)
|
||||
await command(h, 'table query person')
|
||||
await waitFor(h, (f) => f.includes('First1 '))
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F8', { shift: true }))
|
||||
expect((h.store.overlays[0] as unknown as { title: string }).title).toBe('Export to a file')
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F6', { shift: true }))
|
||||
await waitFor(h, (f) => f.includes('Saved views'))
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
h.store.overlays = []
|
||||
await raw(`${E}[1;2S`) // Shift+F4
|
||||
const bulk = h.store.overlays[0] as unknown as { title?: string } | undefined
|
||||
if (bulk) expect(bulk.title).toContain('Run on')
|
||||
else expect(h.store.flashText?.text).toContain('Shift+F4 Bulk')
|
||||
h.store.overlays = []
|
||||
// with permission to create records, Shift+F7 copies the row under the cursor into a new-record form
|
||||
const meta = (h.store.screen as unknown as { meta: { capabilities: string[] } }).meta
|
||||
meta.capabilities = [...meta.capabilities, 'TABLE_INSERT']
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F7', { shift: true }))
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'form')
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'table')
|
||||
const before = h.log.requests.filter((r) => r.path === '/metaData').length
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F5', { shift: true }))
|
||||
await waitFor(h, () => h!.store.flashText?.text === 'metadata refreshed')
|
||||
expect(h.log.requests.filter((r) => r.path === '/metaData').length).toBe(before + 1)
|
||||
await raw(`${E}[1;2P`) // Shift+F1
|
||||
expect(h.store.overlays[0]!.constructor.name).toBe('HelpOverlay')
|
||||
})
|
||||
|
||||
test('plain F-keys are unchanged; Ctrl+F5 still refreshes like F5; Shift+F11 does nothing', async () => {
|
||||
h = await start(120, 40)
|
||||
await raw(`${E}OQ`) // F2
|
||||
expect(h.store.screen?.kind).toBe('search')
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F11', { shift: true }))
|
||||
expect(h.store.flashText?.text).toContain('Shift+F11 does nothing')
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F5', { ctrl: true }))
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
})
|
||||
|
||||
test('the rewrite on the terminal input: Shift+F3 (CSI 1;2R) opens the SQL Runner; legacy CSI 26~ is Shift+F2', async () => {
|
||||
const log: FakeLog = { requests: [] }
|
||||
h = await start(120, 40, undefined, sqlFakeFetch(log, true, { executions: [], notebooks: [], rowsCalls: [] }))
|
||||
let now = 0
|
||||
const rewrite = installFKeyRewrite(h.setup.renderer.stdin, 'vt220', () => now)
|
||||
// during the startup probe CSI 1;2R is a cursor report: nothing happens
|
||||
await raw(`${E}[1;2R`)
|
||||
expect(h.store.screen?.kind).toBe('home')
|
||||
// past it, the same bytes are Shift+F3
|
||||
now += CPR_QUIET_MS
|
||||
try {
|
||||
expect(shiftSlots(h.store).find((s) => s.n === 3)!.why).toBeUndefined()
|
||||
await raw(`${E}[1;2R`)
|
||||
await waitFor(h, () => h!.store.screen instanceof SqlRunnerScreen)
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
h.store.overlays = []
|
||||
await raw(`${E}[26~`)
|
||||
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
|
||||
} finally {
|
||||
rewrite.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('Shift+F10 toggles the Assistant (the same named action as Ctrl+G)', async () => {
|
||||
h = await start(120, 40, undefined, assistantFetch())
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
|
||||
expect(h.store.assistantOpen).toBe(true)
|
||||
expect(h.store.focus).toBe('assistant')
|
||||
// the panel has the focus: the same key goes on toggling (Shift+F keys are global)
|
||||
await press(h, () => h!.setup.mockInput.pressKey('F10', { shift: true }))
|
||||
expect(h.store.focus === 'content' || !h.store.assistantOpen).toBe(true)
|
||||
})
|
||||
|
||||
// ---- the mouse ----
|
||||
|
||||
test('mouse: a click on the Shift row runs that key; in one-row mode while the Shift row shows, too', async () => {
|
||||
h = await start(120, 40)
|
||||
const y = 39
|
||||
const shiftLine = last(h.frame())[0]!
|
||||
const x = shiftLine.indexOf('2Servers') + 2
|
||||
// the Shift row has as many slots as the primary row (12 from 120 columns)
|
||||
expect(fkeyAt(x, 120, h.store.fkeys().map((k) => k.n))).toBe(2)
|
||||
await press(h, () => h!.setup.mockMouse.click(x, y))
|
||||
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
h.store.overlays = []
|
||||
// the primary row above still presses F1
|
||||
await press(h, () => h!.setup.mockMouse.click(3, y - 1))
|
||||
expect(h.store.overlays[0]!.constructor.name).toBe('HelpOverlay')
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
h.store.overlays = []
|
||||
h.store.showShiftRow()
|
||||
const f = await mode('one')
|
||||
const row = last(f)[0]!
|
||||
expect(row).toStartWith('⇧1Keys')
|
||||
await press(h, () => h!.setup.mockMouse.click(row.indexOf('9History') + 2, 39))
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'history')
|
||||
})
|
||||
|
||||
// ---- help and settings ----
|
||||
|
||||
test('? lists the Shift bank; :fkeys and Settings › F-key bar switch and save the mode', async () => {
|
||||
h = await start(120, 40)
|
||||
h.store.commands().help()
|
||||
const help = h.store.overlays[0] as unknown as { after: Array<{ title: string; keys: Array<[string, string]> }> }
|
||||
expect(help.after[0]!.title).toStartWith('Shift bank')
|
||||
expect(help.after[0]!.keys.map(([k]) => k)).toEqual(SHIFT_BANK.map((k) => `Shift+F${k.n}`))
|
||||
expect(help.after[0]!.keys[2]![1]).toContain('not here')
|
||||
h.store.overlays = []
|
||||
await command(h, 'fkeys one')
|
||||
expect(h.store.settings.fkeyBar).toBe('one')
|
||||
expect(readConfig().ui?.fkeyBar).toBe('one')
|
||||
const f = await press(h, () => undefined)
|
||||
expect(f).not.toContain('⇧1Keys')
|
||||
await command(h, 'fkeys sideways')
|
||||
expect(h.store.flashText?.text).toContain('fkeys one|two|auto')
|
||||
const entry = (h.store.commandsImpl!(h.store) as unknown as { entries(t: string): Array<{ id?: string; label: string; run?: () => void }> }).entries('').find((e) => e.id === 'settings:fkeys')
|
||||
expect(entry?.label).toBe('F-key bar: one')
|
||||
entry!.run!()
|
||||
expect((h.store.overlays[0] as unknown as { title: string }).title).toBe('F-key bar')
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
// (the : line keeps a failed command for editing, so this one runs directly)
|
||||
await press(h, () => h!.store.commands().run('fkeys auto'))
|
||||
expect(readConfig().ui?.fkeyBar).toBe('auto')
|
||||
})
|
||||
Reference in new issue
Block a user