diff --git a/CHANGELOG.md b/CHANGELOG.md
index 76a84e9..a771e3b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -368,7 +368,10 @@ in parentheses mark the changes made after the candidate.
- The UI's record view keeps its layout when paging record to record: each child list is a box of fixed
height (5 data rows by default, padded when shorter, scrolled inside with "rows 1-5 of 12" when longer),
empty lists still draw their box, fields and columns are placed from the metadata, and the row count is a
- per-table view setting (`v`, `childRows`) with a default in `ui.childRows`. An association a section's
+ per-table view setting (`v`, `childRows`) with a default in `ui.childRows`. The `v` dialog is a settings
+ form like the grid's View settings: Columns cycles auto, 1, 2; Label width (8-60) and Child list rows (1-50) take typed
+ digits or +/- and Backspace returns them to auto or the profile default; one Collaboration panel row cycles auto, on,
+ off; `r` resets with a confirm; `*` marks values changed from the default An association a section's
`childRecordList` widget lists shows once, in the widget's place and under its label (0f7f536).
- The UI's filter dialog reads escaped quotes like core's grammar (213c605); before the redesign a
true/false quick filter could be set to true but not changed from the quick row (067d97c).
diff --git a/packages/tui/README.md b/packages/tui/README.md
index 12e7ac0..e7d27a9 100644
--- a/packages/tui/README.md
+++ b/packages/tui/README.md
@@ -249,9 +249,12 @@ on a list, PgDn/PgUp scroll it a box at a time and Down/Up (`j`/`k`) a row at it
still step records; Enter opens the child under the cursor. A section whose `childRecordList` widget lists
an association (by `manageAssociationName` or the association's `joinName`, as the Next UI binds it) shows
that association there, once, under the section's label and with the widget's columns; other associations
-follow the sections, and a child's own child lists come last. The row count is a per-table view setting
-(`v`, saved as `childRows` in `ui.views.record:
`), defaulting to `ui.childRows` of the profile, else
-the global `ui.childRows`, else 5.
+follow the sections, and a child's own child lists come last. The record view settings (`v`) are a form
+like the grid's View settings, saved per table in `ui.views.record:`: Columns (auto, 1, 2 with Space or
+←→), Label width (`labelWidth`, 8 to 60: type digits, `+`/`-` or ←→ step by one, Backspace returns to auto),
+Child list rows (`childRows`, 1 to 50, the same keys; Backspace returns to the default, `ui.childRows` of the
+profile, else the global `ui.childRows`, else 5), the Collaboration panel when the extension is present, and
+Reset to defaults (`r`, with a confirm). `*` marks a value changed from the default; Enter saves, Esc cancels.
Nothing is drawn outside its container: every line is cut to the cells it has (measured in terminal cells,
so wide characters count double) and a cut ends in `…`; dialogs never extend past the screen, and key help
@@ -288,7 +291,7 @@ fit, timeline and the follow state go first, the viewers and the tags last. On t
more a Collaboration panel on the right (36 columns) shows the tags, the latest two comments (author, age,
first line), the open tasks and the next due date, the files, the followers and everyone viewing now with
what they are doing; the strip is then the keys only. The panel is a record view setting (`v`:
-Collaboration panel auto, on or off; on shows it whenever the record keeps 60 columns). Presence changes
+Collaboration panel row: auto, on or off; on shows it whenever the record keeps 60 columns). Presence changes
redraw the strip and the panel live. Every strip segment and panel row is a click zone that presses its key
(`t` `C` `P` `K` `T` `W`; the panel's heading lists everything, a viewer shows who is here). `a` and F9 list
comments (list, add, reply), follow or stop following, attachments (list, download, upload, delete), the
diff --git a/packages/tui/src/overlays/record-view-settings.tsx b/packages/tui/src/overlays/record-view-settings.tsx
new file mode 100644
index 0000000..49ab4da
--- /dev/null
+++ b/packages/tui/src/overlays/record-view-settings.tsx
@@ -0,0 +1,178 @@
+/*
+ * Copyright 2026 QRun.IO, Inc.
+ * Licensed under the Apache License, Version 2.0 (see LICENSE).
+ */
+
+import { readViewSettings, writeViewSettings, type ViewSettings } from '@qrun-io/qqq-core'
+import type { Key } from '../keys'
+import { charOf } from '../keys'
+import type { AppStore } from '../store'
+import { theme } from '../theme'
+import type { Overlay } from '../types'
+import { KEY_SEP, fitKeyLine, pad, type KeyToken } from '../util/text'
+import { overlayId } from './basic'
+import { Frame, Line } from './frame'
+import { pickRow } from '../mouse/keys'
+import { Zone } from '../mouse/zone'
+
+export const LABEL_WIDTH_RANGE = { min: 8, max: 60 }
+export const CHILD_ROWS_RANGE = { min: 1, max: 50 }
+
+/** The dialog's key help; a higher `drop` goes first when the width cannot fit all of it in three lines. */
+export const RECORD_VIEW_SETTINGS_KEYS: KeyToken[] = [
+ { text: 'Space or ←→ changes', drop: 0 },
+ { text: 'digits type a number', drop: 1 },
+ { text: '+/- step', drop: 1 },
+ { text: 'Backspace default', drop: 2 },
+ { text: '* changed from the default', drop: 3 },
+ { text: 'r reset to defaults', drop: 2 },
+ { text: 'Enter saves for this table', drop: 0 },
+ { text: 'Esc cancels', drop: 0 },
+]
+
+type Kind = 'columns' | 'labelWidth' | 'childRows' | 'collabPanel' | 'reset'
+const COLUMNS: Array<1 | 2 | undefined> = [undefined, 1, 2]
+const PANELS: Array<'auto' | 'on' | 'off'> = ['auto', 'on', 'off']
+
+/** Clamps a number into a range (rounded to a whole number). */
+export function clampTo(n: number, range: { min: number; max: number }): number {
+ return Math.max(range.min, Math.min(range.max, Math.round(n)))
+}
+
+/**
+ * How a record is shown, saved per table (`record:`): one or two columns of fields, the label width,
+ * the rows of each child list and the collaboration panel. Numbers take typed digits and +/- steps.
+ */
+export class RecordViewSettingsOverlay implements Overlay {
+ readonly id = overlayId('recordview')
+ view: ViewSettings
+ row = 0
+ /** Digits typed into the selected number row, committed (clamped) when the row changes or the dialog saves. */
+ typed: string | undefined
+
+ /**
+ * @param autoLabelWidth what the label width is when unset (stepping starts there)
+ * @param defaultChildRows the profile's child list rows
+ */
+ constructor(private store: AppStore, readonly tableLabel: string, readonly key: string, private autoLabelWidth: number, readonly defaultChildRows: number, readonly collab: boolean, private onDone: () => void) {
+ this.view = { ...readViewSettings(key) }
+ }
+
+ kinds(): Kind[] {
+ return ['columns', 'labelWidth', 'childRows', ...(this.collab ? (['collabPanel'] as const) : []), 'reset']
+ }
+
+ kind(): Kind {
+ return this.kinds()[this.row]!
+ }
+
+ /** What each row shows, whether it differs from the default, and what it does. */
+ rows(): Array<{ kind: Kind; label: string; value: string; changed: boolean; help: string }> {
+ const typedFor = (k: Kind) => (this.typed !== undefined && this.kind() === k ? `${this.typed}_` : undefined)
+ const v = this.view
+ const all = {
+ columns: { label: 'Columns', value: v.recordColumns ? String(v.recordColumns) : 'auto', changed: v.recordColumns !== undefined, help: 'columns of fields · auto uses two when there is room · Space or ←→ changes' },
+ labelWidth: { label: 'Label width', value: typedFor('labelWidth') ?? (v.labelWidth !== undefined ? String(v.labelWidth) : 'auto'), changed: v.labelWidth !== undefined, help: `cells for field labels (${LABEL_WIDTH_RANGE.min}-${LABEL_WIDTH_RANGE.max}) · type digits or +/- · Backspace for auto` },
+ childRows: { label: 'Child list rows', value: typedFor('childRows') ?? (v.childRows !== undefined ? String(v.childRows) : `profile default (${this.defaultChildRows})`), changed: v.childRows !== undefined, help: `data rows in each child list (${CHILD_ROWS_RANGE.min}-${CHILD_ROWS_RANGE.max}) · type digits or +/- · Backspace for the profile default` },
+ collabPanel: { label: 'Collaboration panel', value: v.collabPanel === 'on' || v.collabPanel === 'off' ? v.collabPanel : 'auto (140+ columns)', changed: v.collabPanel === 'on' || v.collabPanel === 'off', help: 'the side panel of tags, comments, tasks and who is here · Space or ←→ changes' },
+ reset: { label: 'Reset to defaults', value: '', changed: false, help: 'clears every record view setting of this table · Space or r asks first' },
+ }
+ return this.kinds().map((kind) => ({ kind, ...all[kind] }))
+ }
+
+ /** Applies typed digits to the setting they belong to. */
+ commit(): void {
+ if (this.typed === undefined) return
+ const k = this.kind()
+ const text = this.typed
+ this.typed = undefined
+ if (text === '') return
+ if (k === 'labelWidth') this.view.labelWidth = clampTo(Number(text), LABEL_WIDTH_RANGE)
+ if (k === 'childRows') this.view.childRows = clampTo(Number(text), CHILD_ROWS_RANGE)
+ }
+
+ move(row: number): void {
+ this.commit()
+ this.row = Math.max(0, Math.min(this.kinds().length - 1, row))
+ }
+
+ /** Space and the arrows: cycle a choice, or step a number by one. */
+ change(delta: number): void {
+ this.commit()
+ const k = this.kind()
+ if (k === 'columns') this.view.recordColumns = cycle(COLUMNS, this.view.recordColumns, delta)
+ else if (k === 'collabPanel') {
+ const next = cycle(PANELS, this.view.collabPanel ?? 'auto', delta)
+ if (next === 'auto') delete this.view.collabPanel
+ else this.view.collabPanel = next
+ } else if (k === 'labelWidth') this.view.labelWidth = clampTo((this.view.labelWidth ?? this.autoLabelWidth) + delta, LABEL_WIDTH_RANGE)
+ else if (k === 'childRows') this.view.childRows = clampTo((this.view.childRows ?? this.defaultChildRows) + delta, CHILD_ROWS_RANGE)
+ else if (k === 'reset' && delta) this.askReset()
+ }
+
+ askReset(): void {
+ this.store.confirm(`Reset the record view settings of ${this.tableLabel}?`, () => {
+ writeViewSettings(this.key, undefined)
+ this.store.closeOverlay(this)
+ this.onDone()
+ })
+ }
+
+ save(): void {
+ this.commit()
+ writeViewSettings(this.key, { ...this.view })
+ this.store.closeOverlay(this)
+ this.onDone()
+ }
+
+ onKey(key: Key): void {
+ const ch = charOf(key)
+ const k = this.kind()
+ const numeric = k === 'labelWidth' || k === 'childRows'
+ if (key.name === 'escape') return this.store.closeOverlay(this)
+ if (key.name === 'return' || (key.ctrl && key.name === 's')) return this.save()
+ if (key.name === 'down') return this.move(this.row + 1)
+ if (key.name === 'up') return this.move(this.row - 1)
+ if (key.name === 'right' || key.name === 'space') return this.change(1)
+ if (key.name === 'left') return this.change(-1)
+ if (numeric && ch && /^[0-9]$/.test(ch)) {
+ const next = (this.typed ?? '') + ch
+ this.typed = next.replace(/^0+(?=\d)/, '').slice(0, 2)
+ } else if (numeric && key.name === 'backspace') {
+ if (this.typed) this.typed = this.typed.slice(0, -1)
+ else {
+ this.typed = undefined
+ if (k === 'labelWidth') delete this.view.labelWidth
+ else delete this.view.childRows
+ }
+ } else if (numeric && ch === '+') this.change(1)
+ else if (numeric && ch === '-') this.change(-1)
+ else if (ch === 'r') this.askReset()
+ }
+
+ render(sw: number, sh: number) {
+ const t = theme()
+ const width = Math.min(sw - 4, 72)
+ const inner = width - 2
+ const rows = this.rows()
+ const keys = fitKeyLine(RECORD_VIEW_SETTINGS_KEYS, inner - 2, 3)
+ // border 2 + blank 1 + help 1 + key lines
+ const chrome = 4 + keys.length
+ return (
+
+ {rows.map((r, i) => {
+ const sel = i === this.row
+ return { this.move(i) }, () => { this.move(i); this.change(1) })}>{pad(` ${r.changed ? '*' : ' '}${pad(r.label, 22)} ${r.value}`, inner)}
+ })}
+
+
+ {keys.map((line, i) => )}
+
+ )
+ }
+}
+
+function cycle(list: T[], current: T, delta: number): T {
+ const i = Math.max(0, list.indexOf(current))
+ return list[(i + (delta < 0 ? -1 : 1) + list.length) % list.length]!
+}
diff --git a/packages/tui/src/screens/record.tsx b/packages/tui/src/screens/record.tsx
index 996e67c..6629f69 100644
--- a/packages/tui/src/screens/record.tsx
+++ b/packages/tui/src/screens/record.tsx
@@ -21,6 +21,7 @@ import type { Key } from '../keys'
import { charOf } from '../keys'
import { openForm, openProcess, openRecord, permits } from '../nav'
import { ChoiceOverlay } from '../overlays/basic'
+import { RecordViewSettingsOverlay } from '../overlays/record-view-settings'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, MenuEntry, Overlay, Screen } from '../types'
@@ -322,7 +323,7 @@ export class RecordScreen implements Screen {
}
const inner = this.listWidth()
const view = this.view()
- const labelWidth = view.labelWidth ?? Math.min(28, Math.max(8, ...Object.values({ ...meta.fields, ...Object.fromEntries((meta.sections ?? []).flatMap((x) => x.fieldNames ?? []).filter((n) => this.fullMeta?.fields[n]).map((n) => [n, this.fullMeta!.fields[n]!])) }).map((f) => f.label.length + 1)))
+ const labelWidth = view.labelWidth ?? this.autoLabelWidth()
rows.push({ text: `${rec.recordLabel ?? this.primaryKey}${this.cursor ? ` (${this.cursor.describe()})` : ''}`, fg: t.fg, bold: true })
// collaboration (tags, comments, who is here) lives in the strip above the key line and the side panel, not here
const tables: Record = Object.fromEntries([...this.childMeta].map(([k, v]) => [k, v as unknown as TableMetaLike]))
@@ -808,33 +809,15 @@ export class RecordScreen implements Screen {
return out
}
+ /** The label width when none is saved: the longest label of the shown fields, 8 to 28 cells. */
+ autoLabelWidth(): number {
+ const meta = this.meta
+ if (!meta) return 20
+ return Math.min(28, Math.max(8, ...Object.values({ ...meta.fields, ...Object.fromEntries((meta.sections ?? []).flatMap((x) => x.fieldNames ?? []).filter((n) => this.fullMeta?.fields[n]).map((n) => [n, this.fullMeta!.fields[n]!])) }).map((f) => f.label.length + 1)))
+ }
+
viewSettings(): Overlay | undefined {
- const key = `record:${this.tableName}`
- const v = readViewSettings(key)
- const choices = [
- { label: 'Automatic columns (two when there is room)', value: 'auto' },
- { label: 'One column', value: '1' },
- { label: 'Two columns', value: '2' },
- { label: 'Label width: narrower (-4)', value: 'narrow' },
- { label: 'Label width: wider (+4)', value: 'wide' },
- ...[3, 5, 8, 10, 15].map((n) => ({ label: `Child list rows: ${n}${v.childRows === n ? ' (current)' : ''}`, value: `rows:${n}` })),
- { label: `Child list rows: profile default (${readChildRows(this.store.status.profile || undefined)})`, value: 'rows:default' },
- // the side panel only exists with the collaboration extension
- ...(this.store.extensions.has('collaboration') ? (['auto', 'on', 'off'] as const).map((p) => ({ label: `Collaboration panel: ${p}${p === 'auto' ? ' (140 columns or more)' : ''}${(v.collabPanel ?? 'auto') === p ? ' (current)' : ''}`, value: `panel:${p}` })) : []),
- { label: 'Reset record view settings', value: 'reset' },
- ]
- return new ChoiceOverlay(this.store, `Record view settings · ${this.meta?.label ?? this.tableName}`, choices, (value) => {
- const next = { ...v }
- if (value === 'auto') delete next.recordColumns
- else if (value === '1' || value === '2') next.recordColumns = Number(value) as 1 | 2
- else if (value === 'rows:default') delete next.childRows
- else if (value === 'panel:auto') delete next.collabPanel
- else if (value.startsWith('panel:')) next.collabPanel = value.slice(6) as 'on' | 'off'
- else if (value.startsWith('rows:')) next.childRows = Number(value.slice(5))
- else if (value === 'narrow' || value === 'wide') next.labelWidth = Math.max(6, Math.min(40, (v.labelWidth ?? 20) + (value === 'wide' ? 4 : -4)))
- writeViewSettings(key, value === 'reset' ? undefined : next)
- this.build()
- })
+ return new RecordViewSettingsOverlay(this.store, this.meta?.label ?? this.tableName, `record:${this.tableName}`, this.autoLabelWidth(), readChildRows(this.store.status.profile || undefined), this.store.extensions.has('collaboration'), () => this.build())
}
/** The whole value of the field(s) on the row under the cursor, wrapped in a dialog (field rows cut values with …). */
diff --git a/packages/tui/test/enterprise.test.tsx b/packages/tui/test/enterprise.test.tsx
index fb8bb01..7ec141e 100644
--- a/packages/tui/test/enterprise.test.tsx
+++ b/packages/tui/test/enterprise.test.tsx
@@ -14,6 +14,7 @@ import { RecordCollaboration } from '../src/collab'
import type { RecordScreen } from '../src/screens/record'
import { readViewSettings } from '@qrun-io/qqq-core'
import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab } from './fake-collab'
+import type { RecordViewSettingsOverlay } from '../src/overlays/record-view-settings'
import { fakeFetch } from './fake-server'
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
@@ -214,13 +215,18 @@ test('record view at 160 columns: the collaboration panel (tags, latest comments
await waitFor(h, () => h!.store.overlays.length > 0)
await escape()
// the view setting (v): off hides the panel at 160 columns, the full strip comes back, and it is saved
- h.store.open(screen.viewSettings()!)
- await type('panel: off')
+ const settings = screen.viewSettings() as RecordViewSettingsOverlay
+ h.store.open(settings)
+ settings.row = settings.kinds().indexOf('collabPanel')
+ await press(h, () => h!.setup.mockInput.typeText(' '))
+ await press(h, () => h!.setup.mockInput.typeText(' '))
await enter()
f = await waitFor(h, (x) => x.includes('Tags: rush, vip (t) · 1 comment (C)') && !x.includes('Viewing now'))
expect(readViewSettings('record:person').collabPanel).toBe('off')
- h.store.open(screen.viewSettings()!)
- await type('panel: auto')
+ const again = screen.viewSettings() as RecordViewSettingsOverlay
+ h.store.open(again)
+ again.row = again.kinds().indexOf('collabPanel')
+ await press(h, () => h!.setup.mockInput.typeText(' '))
await enter()
await waitFor(h, (x) => x.includes('Viewing now'))
expect(readViewSettings('record:person').collabPanel).toBeUndefined()