tui: F10 offers Quit, Reset (home screen) and Cancel; :reset and :home

Quit ends the program after the unsaved-form or running-process warning.
Reset closes every dialog and screen above Home after the same warning,
refreshes the metadata, and returns to Home with the Apps tree focused,
keeping the session and profile; also :reset, :home and the F9 command
center. Esc cancels. Test, smoke script and docs updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-10-31 18:31:28 -05:00
1 parent 99befbb2ef
commit 165c002d3b
6 files changed
+60 -15

No files matched your search

+1 -1
View File
@@ -50,7 +50,7 @@ Keys (the function-key bar is the truth; F1 or `?` lists them; every action is a
| F7 `n` | new (a child on a child list) | `x` `g` `v` | export, go to by key, view settings |
| F8 `d` | delete, asks | Ctrl+S | save a form or continue a process step |
| F9 | menu: every command, searchable | `:` | command line, history and completion |
| F10 | quit, asks | Ctrl+C | quit now |
| F10 | Quit, Reset to home, or Cancel (`:reset`) | Ctrl+C | quit now |
Things to try: `:table query person`, Enter on a row, PgDn through records, Esc (same row); F6, add a
condition, Tab to the grammar and type `AND (fi` then Tab; `:table create placeAddress` (country, state, city
+1 -1
View File
@@ -32,7 +32,7 @@ bun run packages/tui/src/main.tsx --profile sample # the same without the
| F7, `n` | new record (on a child list: a child of this record) | `s` | sort by the column under the cursor |
| F8, `d` | delete, after a question | `]` `[`, Ctrl+N Ctrl+P, PgDn PgUp in a record | next and previous record of the list it came from |
| F9 | the command center: every action, searchable | `b` | run a table process on the selection |
| F10, `:quit` | quit, after a question | `x`, `g`, `v` | export, go to by key, view settings |
| F10, `:quit` | Quit, Reset (home screen, metadata refreshed, session kept) or Cancel; `:reset` and `:home` reset | `x`, `g`, `v` | export, go to by key, view settings |
| `:` | command line (history ↑↓, Ctrl+R, Tab completes) | Ctrl+S | save a form, continue a process step |
| Ctrl+C | quit at once (the terminal is restored) | `c` | copy a record |
+2 -2
View File
@@ -57,8 +57,8 @@ expect 12-help 'Help (F1' 'Keys'
keys Escape T:: 'T:app SampleWidgetsDashboard' Enter
expect 13-dashboard 'Sample Widgets Dashboard'
keys F10
expect 14-quit-confirm 'Quit qqq?'
keys y
expect 14-quit-confirm 'Quit qqq?' 'Reset: go to the home screen'
keys Enter
sleep 1
echo "captures in $out"
[[ $fails == 0 ]] && echo "smoke: ok" || { echo "smoke: $fails failed"; exit 1; }
+4 -4
View File
@@ -36,12 +36,12 @@ const NOUNS: Record<string, string[]> = {
apps: [], whoami: [], search: [], 'help-search': [],
}
const TUI_COMMANDS = ['quit', 'home', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'shortcuts', 'lock', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'profile', 'server', 'signout', 'connection', 'open', 'record', 'app']
const TUI_COMMANDS = ['quit', 'home', 'reset', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'shortcuts', 'lock', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'profile', 'server', 'signout', 'connection', 'open', 'record', 'app']
/** 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 (grid)'], ['F7 n', 'new record'], ['F8 d', 'delete (asks)'], ['F9', 'menu: every command'], ['F10', 'quit (asks)'],
['F6 f', 'filter (grid)'], ['F7 n', 'new record'], ['F8 d', 'delete (asks)'], ['F9', 'menu: every command'], ['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'],
['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'],
@@ -183,7 +183,7 @@ export class Commands implements CommandsLike {
try {
switch (cmd) {
case 'quit': case 'q': case 'exit': return s.requestQuit()
case 'home': return s.home()
case 'home': case 'reset': return s.reset()
case 'back': return s.back()
case 'help': return this.help()
case 'menu': return this.menu()
@@ -427,7 +427,7 @@ export class Commands implements CommandsLike {
const out: MenuEntry[] = [...(s.screen?.menu?.() ?? [])]
out.push({ section: 'Context', label: 'Back', key: 'Esc', command: 'back', run: () => s.back() })
// Go to
out.push({ section: 'Go to', label: 'Home', command: 'home', run: () => s.home() })
out.push({ section: 'Go to', label: 'Reset: go to the home screen', key: 'F10', command: 'reset', detail: 'refreshes metadata, keeps the session', run: () => s.reset() })
out.push({ section: 'Go to', label: 'Go to anything…', key: 'F2 / .', command: 'goto', run: () => this.palette() })
for (const n of (meta?.appTree ?? []) as AppTreeNode[]) if (n.type === 'APP') out.push({ section: 'Go to', label: `App: ${n.label}`, command: `app ${n.name}`, run: () => openApp(s, n.name) })
for (const t of Object.values(meta?.tables ?? {}).filter((x) => !x.isHidden).sort((a, b) => a.label.localeCompare(b.label))) out.push({ section: 'Go to', label: `Table: ${t.label}`, command: `table query ${t.name}`, run: () => openTable(s, t.name) })
+38 -3
View File
@@ -14,7 +14,7 @@ import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
import { History, 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 { ConfirmOverlay, MessageOverlay } from './overlays/basic'
import { ChoiceOverlay, ConfirmOverlay, MessageOverlay } from './overlays/basic'
import { Extensions } from './extensions'
import { collaborationPresence, presenceSource, setPresenceSource } from './presence'
import { setAscii, setTheme, theme } from './theme'
@@ -314,11 +314,46 @@ export class AppStore {
// ---- quitting ----
/** F10: a small dialog with Quit, Reset (home screen) and Cancel. */
requestQuit(): void {
this.open(new ChoiceOverlay<'quit' | 'reset' | 'cancel'>(this, 'Quit qqq?', [
{ label: 'Quit', value: 'quit', detail: 'end the program' },
{ label: 'Reset: go to the home screen', value: 'reset', detail: 'keeps the session' },
{ label: 'Cancel', value: 'cancel', detail: 'Esc' },
], (choice) => {
if (choice === 'quit') this.guard('quit', () => this.quit())
else if (choice === 'reset') this.reset()
}, 'Enter chooses'))
}
/** Warns about unsaved forms or running processes before `then`. */
private guard(what: 'quit' | 'reset', then: () => void): void {
const dirty = this.routes.some((s) => s.dirty?.())
const busy = this.routes.some((s) => s.busy?.())
const warn = dirty ? ' A form has unsaved changes that will be lost.' : busy ? ' A process is still running; it continues on the server.' : ''
this.confirm(`Quit qqq?${warn}`, () => this.quit())
if (!dirty && !busy) return then()
const warn = dirty ? 'A form has unsaved changes that will be lost.' : 'A process is still running; it continues on the server.'
this.confirm(`${warn} ${what === 'quit' ? 'Quit' : 'Reset'} anyway?`, then)
}
/**
* Reset: closes dialogs and every screen above Home (after the same warning), refreshes the
* metadata, and returns to Home with the Apps tree focused. The session and profile stay.
*/
reset(): void {
this.guard('reset', () => {
this.overlays = []
while (this.routes.length > 1) this.routes.pop()?.dispose?.()
const done = () => {
this.sidebar = this.sidebarFactory?.(this) ?? this.sidebar
this.screen?.resume?.()
this.focus = this.showSidebar ? 'sidebar' : 'content'
this.flash('reset: home screen, metadata refreshed')
this.bump()
}
if (!this.client) return done()
this.client.clearCaches()
this.client.metaData(true).then((m) => { this.meta = m }).catch((e) => this.error(e)).finally(done)
})
}
quit(): void {
+14 -4
View File
@@ -100,10 +100,20 @@ test('the process wizard: step form, asynchronous job with progress, summary', a
expect(JSON.parse(String((step.body as { values: string }).values))).toEqual({ millis: '250' })
})
test('F10 asks before quitting and n keeps the TUI', async () => {
test('F10 offers Quit, Reset and Cancel; Reset returns to Home with the Apps tree focused', async () => {
h = await startTui()
const f = await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F10))
await command(h, 'table query person')
await waitFor(h, (x) => x.includes('30 records'))
let f = await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F10))
expect(f).toContain('Quit qqq?')
const after = await press(h, () => h!.setup.mockInput.pressKey('n'))
expect(after).not.toContain('Quit qqq?')
expect(f).toContain('Reset: go to the home screen')
f = await press(h, () => h!.setup.mockInput.pressEscape())
expect(f).not.toContain('Quit qqq?')
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F10))
await press(h, () => h!.setup.mockInput.pressArrow('down'))
await press(h, () => h!.setup.mockInput.pressEnter())
await waitFor(h, (x) => x.includes('reset: home screen'))
expect(h.store.routes.length).toBe(1)
expect(h.store.screen?.kind).toBe('home')
expect(h.store.focus).toBe('sidebar')
})