@brimveyn/aimux 1.23.0 → 1.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.23.0",
3
+ "version": "1.23.2",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.10.2",
69
+ "@brimveyn/aimux-config": "0.10.3",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -3,7 +3,7 @@ import type { AppState } from '../state/types'
3
3
  import type { SideEffectContext } from './side-effect-context'
4
4
 
5
5
  import { filterSettingRows } from '../settings/search'
6
- import { findSettingRow, getSectionRows } from '../settings/sections'
6
+ import { findSettingRow } from '../settings/sections'
7
7
  import { readRow, resetRow, settingsStore, writeRow } from '../settings/settings-store'
8
8
  import { dispatchGlobal } from '../state/dispatch-ref'
9
9
  import { toast } from '../state/toast-store'
@@ -30,14 +30,20 @@ function nextOptionValue(
30
30
  return options[(index + delta + size) % size]?.value
31
31
  }
32
32
 
33
+ /**
34
+ * One press, one step. Deliberately not a fraction of the range: every number
35
+ * row here crosses its own range in under sixty presses, because each declares
36
+ * a `step` scaled to what it measures — so a "drag the gauge" stride would be
37
+ * arithmetic wrapped around a number the row already got right.
38
+ */
33
39
  function nextNumberValue(
34
40
  row: Extract<SettingRow, { kind: 'number' }>,
35
41
  current: SettingValue,
36
42
  delta: 1 | -1
37
43
  ): number {
38
44
  const base = typeof current === 'number' ? current : row.min
39
- // Clamped, never wrapped: `-`/`+` name a direction, and holding + on a width
40
- // until it snaps to the narrowest is not what anyone meant.
45
+ // Clamped, never wrapped: a direction is a direction, and holding `l` on a
46
+ // width until it snaps to the narrowest is not what anyone meant.
41
47
  return Math.min(row.max, Math.max(row.min, round(base + delta * row.step)))
42
48
  }
43
49
 
@@ -55,6 +61,15 @@ function readCtx(state: AppState): SettingCtx {
55
61
  return { state, values: settingsStore.getState().values }
56
62
  }
57
63
 
64
+ /**
65
+ * The row under the cursor. Unfiltered, because the cursor counts every row on
66
+ * the screen — the same list `filterSettingRows` numbers its hits against.
67
+ */
68
+ function selectedRow(state: AppState): SettingRow | undefined {
69
+ if (state.focusMode !== 'settings') return undefined
70
+ return filterSettingRows(state.projects, null)[state.settings.rowIndex]?.row
71
+ }
72
+
58
73
  /**
59
74
  * One entry point for every kind of row. `delta` is the direction asked for
60
75
  * (`-`/`+`); without it the row advances the way activating it should — a toggle
@@ -62,9 +77,7 @@ function readCtx(state: AppState): SettingCtx {
62
77
  */
63
78
  export function changeSelectedSetting(runtime: SettingsContext, delta?: 1 | -1): void {
64
79
  const state = runtime.getState()
65
- if (state.focusMode !== 'settings' || state.settings.pane !== 'rows') return
66
-
67
- const row = getSectionRows(state.settings.sectionId, state.projects)[state.settings.rowIndex]
80
+ const row = selectedRow(state)
68
81
  if (!row || row.kind === 'info') return
69
82
 
70
83
  const ctx = readCtx(state)
@@ -102,9 +115,10 @@ export function changeSelectedSetting(runtime: SettingsContext, delta?: 1 | -1):
102
115
  }
103
116
 
104
117
  /**
105
- * Move the screen's cursor onto the setting the search had highlighted, wherever
106
- * it turned out to live. The list is recomputed from the same filter the picker
107
- * drew, so the index still means what it meant.
118
+ * Move the screen's cursor onto the setting the search had highlighted. The hit
119
+ * carries its position in the screen's list, so there is nothing to look up
120
+ * and the list is recomputed from the same filter the picker drew, so the index
121
+ * still means what it meant.
108
122
  */
109
123
  export function confirmSettingsSearch(runtime: SettingsContext): void {
110
124
  const state = runtime.getState()
@@ -112,15 +126,12 @@ export function confirmSettingsSearch(runtime: SettingsContext): void {
112
126
  const hit = filterSettingRows(state.projects, state.modal.editBuffer)[state.modal.selectedIndex]
113
127
  dispatchGlobal({ type: 'close-modal' })
114
128
  if (!hit) return
115
- dispatchGlobal({ sectionId: hit.sectionId, type: 'settings-select-section' })
116
129
  dispatchGlobal({ rowIndex: hit.rowIndex, type: 'settings-select-row' })
117
130
  }
118
131
 
119
132
  /** Puts the selected row back to what it would be if this screen had never run. */
120
133
  export function resetSelectedSetting(runtime: SettingsContext): void {
121
- const state = runtime.getState()
122
- if (state.focusMode !== 'settings' || state.settings.pane !== 'rows') return
123
- const row = getSectionRows(state.settings.sectionId, state.projects)[state.settings.rowIndex]
134
+ const row = selectedRow(runtime.getState())
124
135
  if (row) resetRow(row)
125
136
  }
126
137
 
@@ -7,17 +7,21 @@ export interface SettingSearchHit {
7
7
  row: SettingRow
8
8
  sectionId: string
9
9
  sectionLabel: string
10
- /** Index within its own section, which is what the cursor is expressed in. */
10
+ /**
11
+ * Position in the screen's list — counted across every section, matches or
12
+ * not, because the screen is one list and that is what its cursor holds. So a
13
+ * search result can be jumped to with the index it already carries.
14
+ */
11
15
  rowIndex: number
12
16
  }
13
17
 
14
18
  /**
15
- * Every setting the query matches, across every section. Ten sections is more
16
- * than anyone will scan, and this is the only list in the app you could not type
17
- * into to find something.
19
+ * Every setting the query matches, across every section. With no query it is
20
+ * the screen's own list, which is why both go through here: one filter, so the
21
+ * list you search and the list you scroll can never disagree.
18
22
  *
19
- * Shared with `getModalOptionCount` so the picker and the reducer that moves its
20
- * cursor count the same list — two filters would drift the moment one changed.
23
+ * Shared with `getModalOptionCount` too, so the picker and the reducer that
24
+ * moves its cursor count the same list.
21
25
  */
22
26
  export function filterSettingRows(
23
27
  projects: readonly ProjectRecord[],
@@ -25,12 +29,12 @@ export function filterSettingRows(
25
29
  ): SettingSearchHit[] {
26
30
  const needle = (query ?? '').trim().toLowerCase()
27
31
  const hits: SettingSearchHit[] = []
32
+ let rowIndex = -1
28
33
 
29
34
  for (const section of SETTING_SECTIONS) {
30
35
  const rows = sectionRows(section, projects)
31
- for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
32
- const row = rows[rowIndex]
33
- if (!row) continue
36
+ for (const row of rows) {
37
+ rowIndex++
34
38
  // The id is in the haystack deliberately: it is the key in `aimux.json`, so
35
39
  // someone who saw it in that file can search for it.
36
40
  const haystack = `${row.label} ${row.description ?? ''} ${row.id} ${section.label}`
@@ -44,8 +44,6 @@ export const ALL_SETTING_ROWS: readonly SettingRow[] = SETTING_SECTIONS.flatMap(
44
44
  Array.isArray(section.rows) ? section.rows : []
45
45
  )
46
46
 
47
- export const DEFAULT_SECTION_ID = SETTING_SECTIONS[0]?.id ?? 'about'
48
-
49
47
  export function getSection(sectionId: string): SettingSection | undefined {
50
48
  return SETTING_SECTIONS.find((section) => section.id === sectionId)
51
49
  }
@@ -70,18 +68,31 @@ export function sectionRowCount(
70
68
  return Array.isArray(section.rows) ? section.rows.length : section.rows(projects).length
71
69
  }
72
70
 
73
- /** Rows of the given section, or an empty list when the id is unknown. */
74
- export function getSectionRows(
75
- sectionId: string,
76
- projects: readonly ProjectRecord[]
77
- ): readonly SettingRow[] {
78
- const section = getSection(sectionId)
79
- return section ? sectionRows(section, projects) : []
71
+ /**
72
+ * How many rows the screen holds in total. The screen is one list, so this is
73
+ * what clamps its cursor — and like `sectionRowCount`, it counts without
74
+ * building, which is what keeps the reducer off the disk.
75
+ */
76
+ export function totalRowCount(projects: readonly ProjectRecord[]): number {
77
+ let total = 0
78
+ for (const section of SETTING_SECTIONS) total += sectionRowCount(section, projects)
79
+ return total
80
80
  }
81
81
 
82
- export function getSectionRowCount(sectionId: string, projects: readonly ProjectRecord[]): number {
83
- const section = getSection(sectionId)
84
- return section ? sectionRowCount(section, projects) : 0
82
+ /**
83
+ * The flat index of each section's first row, in screen order. What `}` and `{`
84
+ * jump between, and the reason they can: an empty section contributes no index,
85
+ * so the cursor never lands on a heading with nothing under it.
86
+ */
87
+ export function sectionStartIndexes(projects: readonly ProjectRecord[]): number[] {
88
+ const starts: number[] = []
89
+ let index = 0
90
+ for (const section of SETTING_SECTIONS) {
91
+ const count = sectionRowCount(section, projects)
92
+ if (count > 0) starts.push(index)
93
+ index += count
94
+ }
95
+ return starts
85
96
  }
86
97
 
87
98
  /** The row with this id, dynamic ones included. */
@@ -219,9 +219,8 @@ export type UIAction =
219
219
  export type SettingsAction =
220
220
  | { type: 'enter-settings' }
221
221
  | { type: 'exit-settings' }
222
- | { type: 'settings-focus-pane'; pane: 'nav' | 'rows' }
223
222
  | { type: 'settings-move-selection'; delta: -1 | 1 }
224
- | { type: 'settings-select-section'; sectionId: string }
223
+ | { type: 'settings-jump-section'; delta: -1 | 1 }
225
224
  | { type: 'settings-select-row'; rowIndex: number }
226
225
  | { type: 'open-settings-search' }
227
226
  | { type: 'open-setting-text-modal'; settingId: string; label: string; value: string }
@@ -1,10 +1,10 @@
1
1
  import type { AppAction } from '../actions'
2
2
  import type { AppState, SettingsUIState } from '../types'
3
3
 
4
- import { DEFAULT_SECTION_ID, getSectionRowCount, SETTING_SECTIONS } from '../../settings/sections'
4
+ import { sectionStartIndexes, totalRowCount } from '../../settings/sections'
5
5
 
6
6
  export function emptySettingsUI(): SettingsUIState {
7
- return { pane: 'nav', rowIndex: 0, sectionId: DEFAULT_SECTION_ID }
7
+ return { rowIndex: 0 }
8
8
  }
9
9
 
10
10
  function clamp(value: number, max: number): number {
@@ -12,68 +12,46 @@ function clamp(value: number, max: number): number {
12
12
  return Math.max(0, Math.min(max, value))
13
13
  }
14
14
 
15
- function withSettings(state: AppState, patch: Partial<SettingsUIState>): AppState {
16
- return { ...state, settings: { ...state.settings, ...patch } }
17
- }
18
-
19
- function moveSelection(state: AppState, delta: -1 | 1): AppState {
20
- const { pane, rowIndex, sectionId } = state.settings
21
-
22
- if (pane === 'nav') {
23
- const index = SETTING_SECTIONS.findIndex((section) => section.id === sectionId)
24
- const next = clamp((index === -1 ? 0 : index) + delta, SETTING_SECTIONS.length - 1)
25
- const nextId = SETTING_SECTIONS[next]?.id
26
- if (nextId == null || nextId === sectionId) return state
27
- // A new section has its own rows, so the row cursor from the old one means
28
- // nothing here.
29
- return withSettings(state, { rowIndex: 0, sectionId: nextId })
15
+ function withRowIndex(state: AppState, rowIndex: number): AppState {
16
+ return {
17
+ ...state,
18
+ settings: { ...state.settings, rowIndex: clamp(rowIndex, totalRowCount(state.projects) - 1) },
30
19
  }
20
+ }
31
21
 
32
- return withSettings(state, {
33
- rowIndex: clamp(rowIndex + delta, getSectionRowCount(sectionId, state.projects) - 1),
34
- })
22
+ /**
23
+ * The first row of the next section down, or of the one the cursor is already
24
+ * inside when going up and it is not on its first row — the paragraph motion `}`
25
+ * and `{` are named after, rather than a plain "section ± 1" that would skip the
26
+ * heading you were standing under.
27
+ */
28
+ function jumpSection(state: AppState, delta: -1 | 1): AppState {
29
+ const starts = sectionStartIndexes(state.projects)
30
+ const current = state.settings.rowIndex
31
+ const next =
32
+ delta === 1
33
+ ? starts.find((start) => start > current)
34
+ : starts.filter((start) => start < current).at(-1)
35
+ if (next === undefined) return state
36
+ return withRowIndex(state, next)
35
37
  }
36
38
 
37
39
  export function reduceSettingsState(state: AppState, action: AppAction): AppState | null {
38
40
  switch (action.type) {
39
41
  case 'enter-settings':
40
42
  if (state.focusMode === 'settings') return state
41
- // The section is remembered across a close/open within the session; the
42
- // cursor inside it is not reopening lands you at the top of it.
43
- return {
44
- ...state,
45
- focusMode: 'settings',
46
- settings: { ...state.settings, pane: 'nav', rowIndex: 0 },
47
- }
43
+ // Reopening lands at the top: the list is one column now, so the top of it
44
+ // is where the search and the first section both are.
45
+ return { ...state, focusMode: 'settings', settings: { ...state.settings, rowIndex: 0 } }
48
46
  case 'exit-settings':
49
47
  if (state.focusMode !== 'settings') return state
50
48
  return { ...state, focusMode: 'navigation' }
51
- case 'settings-focus-pane': {
52
- // A section with no rows has nothing to focus, so `l` stays put rather
53
- // than parking the cursor in an empty column.
54
- if (
55
- action.pane === 'rows' &&
56
- getSectionRowCount(state.settings.sectionId, state.projects) === 0
57
- ) {
58
- return state
59
- }
60
- return withSettings(state, { pane: action.pane })
61
- }
62
49
  case 'settings-move-selection':
63
- return moveSelection(state, action.delta)
64
- case 'settings-select-section':
65
- if (action.sectionId === state.settings.sectionId) {
66
- return withSettings(state, { pane: 'nav' })
67
- }
68
- return withSettings(state, { pane: 'nav', rowIndex: 0, sectionId: action.sectionId })
50
+ return withRowIndex(state, state.settings.rowIndex + action.delta)
51
+ case 'settings-jump-section':
52
+ return jumpSection(state, action.delta)
69
53
  case 'settings-select-row':
70
- return withSettings(state, {
71
- pane: 'rows',
72
- rowIndex: clamp(
73
- action.rowIndex,
74
- getSectionRowCount(state.settings.sectionId, state.projects) - 1
75
- ),
76
- })
54
+ return withRowIndex(state, action.rowIndex)
77
55
  default:
78
56
  return null
79
57
  }
@@ -734,9 +734,11 @@ export const EMPTY_MULTI_REPO_STATE: MultiRepoState = { prefixes: {}, repos: []
734
734
  * two of them.
735
735
  */
736
736
  export interface SettingsUIState {
737
- sectionId: string
738
- /** Which of the two columns has the keyboard: the section list, or its rows. */
739
- pane: 'nav' | 'rows'
737
+ /**
738
+ * Where the cursor is in the screen's one list, counted across every section.
739
+ * The sections are headings in that list, not a column you move into, so this
740
+ * is the whole of the screen's state.
741
+ */
740
742
  rowIndex: number
741
743
  }
742
744
 
@@ -5,6 +5,7 @@ import type { SettingRow, SettingValue } from '../../../settings/types'
5
5
  import { readRow, useSettingsStore } from '../../../settings/settings-store'
6
6
  import { useAppStore } from '../../../state/app-store'
7
7
  import { type ResolvedTuiTheme, useTheme } from '../../theme'
8
+ import { truncate } from '../../truncate'
8
9
 
9
10
  /** Filled means on. Both are one cell wide in a Latin-width terminal. */
10
11
  const ON = '●'
@@ -51,11 +52,17 @@ export function describeValue(row: SettingRow, value: SettingValue): string {
51
52
  }
52
53
  }
53
54
 
55
+ /**
56
+ * Text wears text tokens, the way it does on the stats screen — a value is not a
57
+ * status. The two exceptions earn it: a toggle whose colour *is* its state, and
58
+ * an action row, where the colour is the affordance saying it does something.
59
+ */
54
60
  function valueColor(row: SettingRow, value: SettingValue, t: ResolvedTuiTheme): string {
55
61
  // A toggle is read at a glance or not at all, so its state is the colour as much
56
62
  // as the glyph: lit when on, as quiet as the rest of the row when off.
57
63
  if (row.kind === 'toggle') return value === true ? t.success : t.textMuted
58
- return row.kind === 'info' ? t.textMuted : t.primary
64
+ if (row.kind === 'action') return t.primary
65
+ return row.kind === 'info' ? t.textMuted : t.text
59
66
  }
60
67
 
61
68
  /**
@@ -66,9 +73,21 @@ function valueColor(row: SettingRow, value: SettingValue, t: ResolvedTuiTheme):
66
73
  * renders nothing. A parent subscribing to the whole store for every row in the
67
74
  * list would repaint it at the rate the terminals print.
68
75
  */
69
- export const RowValue = memo(function RowValue({ row }: { row: SettingRow }) {
76
+ export const RowValue = memo(function RowValue({
77
+ maxWidth,
78
+ row,
79
+ }: {
80
+ /** Cropped rather than wrapped: a row that grows a line shifts the list. */
81
+ maxWidth?: number
82
+ row: SettingRow
83
+ }) {
70
84
  const t = useTheme()
71
85
  const values = useSettingsStore((s) => s.values)
72
86
  const value = useAppStore((s) => readRow(row, { state: s, values }))
73
- return <text fg={valueColor(row, value, t)}>{formatValue(row, value)}</text>
87
+ const text = formatValue(row, value)
88
+ return (
89
+ <text fg={valueColor(row, value, t)} wrapMode="none">
90
+ {maxWidth === undefined ? text : truncate(text, maxWidth)}
91
+ </text>
92
+ )
74
93
  })
@@ -0,0 +1,170 @@
1
+ import { memo, useCallback } from 'react'
2
+
3
+ import type { SettingRow } from '../../../settings/types'
4
+
5
+ import { readRow, storedRow, useSettingsStore } from '../../../settings/settings-store'
6
+ import { useAppStore } from '../../../state/app-store'
7
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
8
+ import { useTheme } from '../../theme'
9
+ import { truncate } from '../../truncate'
10
+ import { Rule } from '../stats/shared'
11
+ import { describeValue } from './row-value'
12
+
13
+ /**
14
+ * Everything the rows used to carry on a second line of their own, moved to the
15
+ * foot of the screen and shown for the selected row only.
16
+ *
17
+ * Three lines, always, whatever the row is: a footer that grows and shrinks
18
+ * moves the list above it, and a list that shifts while you walk down it is the
19
+ * thing this screen was rebuilt to stop doing.
20
+ */
21
+
22
+ /**
23
+ * `[key, what it does]`, so the keys can wear the theme and the prose cannot.
24
+ *
25
+ * Cut to what works on the row you are standing on: `←/→` does nothing to a
26
+ * text field, and a hint for a key that does nothing is worse than no hint.
27
+ */
28
+ function hintsFor(row: SettingRow | null): [string, string][] {
29
+ const hints: [string, string][] = []
30
+ if (row?.kind === 'number' || row?.kind === 'select' || row?.kind === 'toggle') {
31
+ hints.push(['←/→', 'adjust'])
32
+ }
33
+ if (row != null && row.kind !== 'info') {
34
+ hints.push(['↵', row.kind === 'number' || row.kind === 'text' ? 'type a value' : 'change'])
35
+ }
36
+ hints.push(['r', 'reset'], ['/', 'search'], ['} {', 'section'], ['esc', 'close'])
37
+ return hints
38
+ }
39
+
40
+ /**
41
+ * What the row itself does not say. Not its description — the row carries that
42
+ * under its own label now — but where its value comes from and when it takes
43
+ * effect, which belong to the row you are on rather than to the setting.
44
+ */
45
+ function notesFor(row: SettingRow, fromConfigFile: boolean): string {
46
+ if (fromConfigFile) return 'set in aimux.config.ts — comes back on restart'
47
+ return storedRow(row)?.restart === true ? 'applies on restart' : ''
48
+ }
49
+
50
+ /**
51
+ * The options of a select, spelled out with the current one lit. The row can
52
+ * only show the one it is on, and stepping through the rest to find out what
53
+ * they are is not reading, it is guessing.
54
+ */
55
+ function OptionStrip({ row }: { row: Extract<SettingRow, { kind: 'select' }> }) {
56
+ const t = useTheme()
57
+ const values = useSettingsStore((s) => s.values)
58
+ const current = useAppStore((s) => readRow(row, { state: s, values }))
59
+ return (
60
+ <box flexDirection="row" gap={1}>
61
+ {row.options.map((option) => (
62
+ <text
63
+ key={String(option.value)}
64
+ fg={option.value === current ? t.text : t.textMuted}
65
+ selectable={false}
66
+ wrapMode="none"
67
+ >
68
+ {option.value === current ? `[${option.label}]` : option.label}
69
+ </text>
70
+ ))}
71
+ </box>
72
+ )
73
+ }
74
+
75
+ /** What the second line says when the row is not one with options to list. */
76
+ function rangeOf(row: SettingRow): string {
77
+ if (row.kind === 'number') return `${String(row.min)} – ${String(row.max)}`
78
+ if (row.kind === 'toggle') return 'on · off'
79
+ return ''
80
+ }
81
+
82
+ /**
83
+ * The whole of a value the row could only show the start of. A command or a
84
+ * path is the one kind of value that does not fit its column, and it is also
85
+ * the one you most need to read before changing it.
86
+ */
87
+ function FullValue({ row }: { row: Extract<SettingRow, { kind: 'text' | 'action' | 'info' }> }) {
88
+ const t = useTheme()
89
+ const values = useSettingsStore((s) => s.values)
90
+ const value = useAppStore((s) => readRow(row, { state: s, values }))
91
+ return (
92
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
93
+ {String(value)}
94
+ </text>
95
+ )
96
+ }
97
+
98
+ export const SettingsFooter = memo(function SettingsFooter({
99
+ pad,
100
+ row,
101
+ width,
102
+ }: {
103
+ pad: number
104
+ row: SettingRow | null
105
+ width: number
106
+ }) {
107
+ const t = useTheme()
108
+ const fromConfigFile = useSettingsStore((s) => (row ? s.fromConfigFile.has(row.id) : false))
109
+ const touched = useSettingsStore((s) => (row ? s.touched.has(row.id) : false))
110
+ const defaultValue = useSettingsStore((s) => (row ? s.defaults[row.id] : undefined))
111
+
112
+ const handleClose = useCallback(() => dispatchGlobal({ type: 'exit-settings' }), [])
113
+
114
+ const notes = row ? notesFor(row, fromConfigFile) : ''
115
+ // Only once the row has been changed — `r` is otherwise a key you press to
116
+ // find out what it does.
117
+ const resetHint =
118
+ row && touched && defaultValue !== undefined
119
+ ? `default: ${describeValue(row, defaultValue)}`
120
+ : null
121
+
122
+ return (
123
+ <box flexDirection="column" flexShrink={0} paddingLeft={pad} paddingRight={pad}>
124
+ <Rule width={width} />
125
+ <box width={width} flexDirection="row" flexShrink={0}>
126
+ <box flexGrow={1} flexShrink={1}>
127
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
128
+ {truncate(notes, Math.max(0, width - (resetHint?.length ?? 0) - 2))}
129
+ </text>
130
+ </box>
131
+ {resetHint != null ? (
132
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
133
+ {resetHint}
134
+ </text>
135
+ ) : null}
136
+ </box>
137
+ <box height={1} flexShrink={0} overflow="hidden">
138
+ {row?.kind === 'select' ? <OptionStrip row={row} /> : null}
139
+ {row?.kind === 'text' || row?.kind === 'action' || row?.kind === 'info' ? (
140
+ <FullValue row={row} />
141
+ ) : null}
142
+ {row?.kind === 'number' || row?.kind === 'toggle' ? (
143
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
144
+ {rangeOf(row)}
145
+ </text>
146
+ ) : null}
147
+ </box>
148
+ {/* Clipped rather than squeezed: a row of hints allowed to shrink loses
149
+ the spaces *inside* the words first, and `↵change -/+step` is worse
150
+ than the same line with its tail cut off on a narrow terminal. */}
151
+ <box width={width} flexDirection="row" flexShrink={0} gap={2} overflow="hidden">
152
+ {hintsFor(row).map(([key, what]) => (
153
+ <box key={key} flexDirection="row" flexShrink={0}>
154
+ <text
155
+ fg={t.primary}
156
+ selectable={false}
157
+ wrapMode="none"
158
+ onMouseDown={key === 'esc' ? handleClose : undefined}
159
+ >
160
+ {key}
161
+ </text>
162
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
163
+ {` ${what}`}
164
+ </text>
165
+ </box>
166
+ ))}
167
+ </box>
168
+ </box>
169
+ )
170
+ })
@@ -2,53 +2,118 @@ import { memo } from 'react'
2
2
 
3
3
  import type { SettingRow } from '../../../settings/types'
4
4
 
5
- import { storedRow, useSettingsStore } from '../../../settings/settings-store'
5
+ import { readRow, useSettingsStore } from '../../../settings/settings-store'
6
+ import { useAppStore } from '../../../state/app-store'
6
7
  import { useTheme } from '../../theme'
8
+ import { truncate } from '../../truncate'
7
9
  import { ListItem } from '../primitives/list-item'
8
- import { describeValue, RowValue } from './row-value'
10
+ import { Bar } from '../stats/shared'
11
+ import { RowValue } from './row-value'
9
12
 
10
- /** Where the value comes from, on the left, out of the value column's way. */
11
- const CONFIG_FILE_MARK = '*'
12
- const TOUCHED_MARK = '~'
13
+ /**
14
+ * Where the value comes from, on the left, out of the value column's way. Both
15
+ * are legended by the headline row at the top of the screen, which counts them.
16
+ */
17
+ export const CONFIG_FILE_MARK = '*'
18
+ export const TOUCHED_MARK = '~'
19
+
20
+ /** The cursor, the mark, their spaces, and the row's own padding either side. */
21
+ const ROW_CHROME = 6
22
+ /** The column every value ends on, and the least the widest of them needs. */
23
+ const VALUE_WIDTH = 12
24
+ /**
25
+ * Uncapped, unlike the stats bars, which stop at 32.
26
+ *
27
+ * That cap is there because those bars share a scale and get compared to each
28
+ * other, and comparing two lengths gets harder as they grow. These do not: each
29
+ * one is a position inside its own row's range, read on its own. So it runs to
30
+ * the value column and the row has no gap in the middle of it.
31
+ */
32
+ const BAR_MAX = Infinity
33
+ /**
34
+ * Every label there is fits in this, so nothing on the screen is cropped when
35
+ * the column is wide enough to give it — which is what the column layout is
36
+ * sized around. (`Auto-rename minimum words` is the longest at 25.)
37
+ */
38
+ const LABEL_MAX = 28
13
39
 
14
40
  interface SettingsRowProps {
15
41
  row: SettingRow
16
42
  active: boolean
17
43
  index: number
44
+ /** The column this row stands in — it fills it, edge to edge. */
45
+ width: number
18
46
  onSelect: (index: number) => void
19
47
  }
20
48
 
49
+ /**
50
+ * The gauge on a row that has a floor and a ceiling.
51
+ *
52
+ * A bar is honest here for the same reason it is honest on a quota and dishonest
53
+ * on a headline number: `min` and `max` are a real scale, so the fraction means
54
+ * something. It is measured from `min`, not from zero — a delay that runs 60s to
55
+ * 3600s sitting at 60 is at the bottom of its range, not at 2% of it.
56
+ */
57
+ function RangeGauge({
58
+ row,
59
+ width,
60
+ }: {
61
+ row: Extract<SettingRow, { kind: 'number' }>
62
+ width: number
63
+ }) {
64
+ const t = useTheme()
65
+ const values = useSettingsStore((s) => s.values)
66
+ const value = useAppStore((s) => readRow(row, { state: s, values }))
67
+ const span = row.max - row.min
68
+ if (span <= 0) return null
69
+ return (
70
+ <Bar
71
+ color={t.primary}
72
+ max={span}
73
+ segments={width}
74
+ value={Math.max(0, (typeof value === 'number' ? value : row.min) - row.min)}
75
+ />
76
+ )
77
+ }
78
+
79
+ /**
80
+ * What it is, what it is set to, where that sits in its range, and what it does
81
+ * underneath. It can afford the second line because the screen shows one section
82
+ * at a time — fifty rows in one scroll could not, which is what made the old
83
+ * two-line row a wall rather than a paragraph.
84
+ *
85
+ * The same three-part geometry as a stats bar row: label on the left, the gauge
86
+ * in the middle, the column's right edge on the right. Every value on the screen
87
+ * therefore ends on one column, which is what makes a page of them read as a
88
+ * page rather than as rows that happen to sit above each other.
89
+ *
90
+ * Both columns at full strength, unlike a stats row, which mutes its label. On
91
+ * that screen the number is the content and the label only says which number it
92
+ * is; here you are scanning the labels to find the one you came for.
93
+ */
21
94
  export const SettingsRow = memo(function SettingsRow({
22
95
  active,
23
96
  index,
24
97
  onSelect,
25
98
  row,
99
+ width,
26
100
  }: SettingsRowProps) {
27
101
  const t = useTheme()
28
102
  const fromConfigFile = useSettingsStore((s) => s.fromConfigFile.has(row.id))
29
103
  const touched = useSettingsStore((s) => s.touched.has(row.id))
30
- const defaultValue = useSettingsStore((s) => s.defaults[row.id])
31
- const editable = row.kind !== 'info'
32
104
  // A blank when neither applies, never nothing: a marker that comes and goes
33
105
  // would shift the label of every row it is missing from.
34
106
  let mark = ' '
35
107
  if (fromConfigFile) mark = CONFIG_FILE_MARK
36
108
  else if (touched) mark = TOUCHED_MARK
37
109
 
38
- // Always, never only on the focused row: a row that grows a line when you land
39
- // on it shifts everything under it, so the list moves under the cursor the
40
- // whole way down. A taller list that holds still reads better than a compact
41
- // one that does not.
42
- const notes: string[] = []
43
- if (row.description != null && row.description !== '') notes.push(row.description)
44
- if (fromConfigFile) notes.push('set in aimux.config.ts comes back on restart')
45
- else if (storedRow(row)?.restart === true) notes.push('applies on restart')
46
- // Right-aligned on the same line rather than tacked onto the end of the
47
- // description: it is about the row, not about what the setting does, and the
48
- // value column above it is on that edge too. Only once the row has been
49
- // changed — `r` is otherwise a key you press to find out what it does.
50
- const resetHint =
51
- touched && defaultValue !== undefined ? `r resets to ${describeValue(row, defaultValue)}` : null
110
+ // The label gets first call on the column and the gauge takes what is left,
111
+ // not the other way round: a bar two cells shorter still reads as the same
112
+ // proportion, where `Transparent bac…` and `Transparent bak…` do not read as
113
+ // different settings.
114
+ const inner = Math.max(12, width - ROW_CHROME)
115
+ const labelWidth = Math.max(10, Math.min(LABEL_MAX, inner - VALUE_WIDTH))
116
+ const barWidth = Math.min(BAR_MAX, inner - labelWidth - VALUE_WIDTH - 1)
52
117
 
53
118
  return (
54
119
  <ListItem
@@ -57,22 +122,33 @@ export const SettingsRow = memo(function SettingsRow({
57
122
  index={index}
58
123
  onClickIndex={onSelect}
59
124
  leading={<text fg={fromConfigFile ? t.warning : t.secondary}>{mark}</text>}
60
- title={<text fg={editable ? t.text : t.textMuted}>{row.label}</text>}
61
125
  subtitle={
62
- notes.length > 0 || resetHint != null ? (
63
- <box flexDirection="row">
64
- <box flexGrow={1} flexShrink={1}>
65
- <text fg={t.textMuted}>{notes.join(' · ')}</text>
66
- </box>
67
- {resetHint != null ? (
68
- <text fg={t.textMuted} wrapMode="none">
69
- {resetHint}
70
- </text>
71
- ) : null}
126
+ row.description != null && row.description !== '' ? (
127
+ <box width={inner}>
128
+ <text fg={t.textMuted} selectable={false}>
129
+ {row.description}
130
+ </text>
72
131
  </box>
73
132
  ) : undefined
74
133
  }
75
- trailing={<RowValue row={row} />}
134
+ title={
135
+ <box width={inner} flexDirection="row" flexShrink={0}>
136
+ <box width={labelWidth} flexShrink={0}>
137
+ <text fg={row.kind === 'info' ? t.textMuted : t.text} wrapMode="none">
138
+ {truncate(row.label, labelWidth - 1)}
139
+ </text>
140
+ </box>
141
+ {row.kind === 'number' && barWidth > 0 ? <RangeGauge row={row} width={barWidth} /> : null}
142
+ {/* Grows into whatever the label and the gauge left, so the value ends
143
+ on the column's edge whether or not the row has a gauge. */}
144
+ <box flexGrow={1} flexDirection="row" justifyContent="flex-end">
145
+ <RowValue
146
+ row={row}
147
+ maxWidth={inner - labelWidth - (barWidth > 0 && row.kind === 'number' ? barWidth : 0)}
148
+ />
149
+ </box>
150
+ </box>
151
+ }
76
152
  />
77
153
  )
78
154
  })
@@ -0,0 +1,61 @@
1
+ import { memo, useCallback } from 'react'
2
+
3
+ import { dispatchGlobal } from '../../../state/dispatch-ref'
4
+ import { useTheme } from '../../theme'
5
+
6
+ const SEARCH_GLYPH = '\u{2315}'
7
+
8
+ /**
9
+ * The way into the search, drawn rather than remembered.
10
+ *
11
+ * `/` opened the picker long before this existed, and the footer said so — but a
12
+ * key you have to read a hint to know about is a key nobody presses. A field you
13
+ * can see and click is the only version of this that gets used, so the screen
14
+ * spends three rows on it and keeps it out of the scroll.
15
+ *
16
+ * It never holds text. Clicking it opens the picker, which is where the typing
17
+ * and the filtering actually happen; this is its handle.
18
+ */
19
+ export const SettingsSearchBar = memo(function SettingsSearchBar({ width }: { width: number }) {
20
+ const t = useTheme()
21
+ const handleOpen = useCallback(() => {
22
+ dispatchGlobal({ type: 'open-settings-search' })
23
+ }, [])
24
+
25
+ // Filled, bordered in the active colour, and the prompt at full strength: it
26
+ // is the one control on a read-mostly screen, and a control drawn like the
27
+ // text around it is a control nobody sees.
28
+ return (
29
+ <box
30
+ border
31
+ borderStyle="rounded"
32
+ borderColor={t.borderActive}
33
+ backgroundColor={t.backgroundElement}
34
+ width={width}
35
+ flexShrink={0}
36
+ flexDirection="row"
37
+ paddingLeft={1}
38
+ paddingRight={1}
39
+ onMouseDown={handleOpen}
40
+ >
41
+ <text fg={t.primary} selectable={false} wrapMode="none">
42
+ {`${SEARCH_GLYPH} `}
43
+ </text>
44
+ <box flexGrow={1} flexShrink={1}>
45
+ <text fg={t.text} selectable={false} wrapMode="none">
46
+ Search every setting…
47
+ </text>
48
+ </box>
49
+ {/* The key drawn as the cap you press, not as a letter in a sentence. */}
50
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
51
+ {'[ '}
52
+ </text>
53
+ <text fg={t.primary} selectable={false} wrapMode="none">
54
+ /
55
+ </text>
56
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
57
+ {' ]'}
58
+ </text>
59
+ </box>
60
+ )
61
+ })
@@ -3,7 +3,8 @@ import type { ScrollBoxRenderable } from '@opentui/core'
3
3
  import { useTerminalDimensions } from '@opentui/react'
4
4
  import { memo, useCallback, useMemo, useRef } from 'react'
5
5
 
6
- import { getSectionRows, SETTING_SECTIONS } from '../../../settings/sections'
6
+ import { filterSettingRows, type SettingSearchHit } from '../../../settings/search'
7
+ import { getSection } from '../../../settings/sections'
7
8
  import { useSettingsStore } from '../../../settings/settings-store'
8
9
  import { useAppStore } from '../../../state/app-store'
9
10
  import { clampBarWidth } from '../../../state/bars'
@@ -11,84 +12,144 @@ import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref
11
12
  import { useScrollActiveIntoView } from '../../hooks/use-scroll-active-into-view'
12
13
  import { useTheme } from '../../theme'
13
14
  import { ListItem } from '../primitives/list-item'
14
- import { SettingsRow } from './settings-row'
15
+ import { Muted, Rule, Section, TileRow } from '../stats/shared'
16
+ import { SettingsFooter } from './settings-footer'
17
+ import { CONFIG_FILE_MARK, SettingsRow, TOUCHED_MARK } from './settings-row'
18
+ import { SettingsSearchBar } from './settings-search-bar'
15
19
 
16
20
  const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
17
21
 
22
+ const SETTINGS_GLYPH = '\u{2699}'
23
+
24
+ /** One padding column either side, the same inset the stats pages sit in. */
25
+ const PAGE_PAD = 1
26
+
27
+ /** A section and the rows it owns, each keeping the index the cursor holds. */
28
+ interface Group {
29
+ sectionId: string
30
+ label: string
31
+ glyph: string
32
+ note: string
33
+ /** `[cursor index, hit]`, in screen order. */
34
+ rows: [number, SettingSearchHit][]
35
+ }
36
+
37
+ function groupBySection(hits: SettingSearchHit[]): Group[] {
38
+ const groups: Group[] = []
39
+ for (const [index, hit] of hits.entries()) {
40
+ const last = groups.at(-1)
41
+ if (last?.sectionId === hit.sectionId) {
42
+ last.rows.push([index, hit])
43
+ continue
44
+ }
45
+ const section = getSection(hit.sectionId)
46
+ groups.push({
47
+ glyph: section?.glyph ?? ' ',
48
+ label: hit.sectionLabel,
49
+ note: section?.description ?? '',
50
+ rows: [[index, hit]],
51
+ sectionId: hit.sectionId,
52
+ })
53
+ }
54
+ return groups
55
+ }
56
+
18
57
  /**
19
- * Share of the window the settings themselves are allowed. Without a cap the
20
- * value sits on the far edge of a wide terminal, a hundred columns from the label
21
- * it belongs to, and the pair stops reading as a pair.
58
+ * The settings screen.
59
+ *
60
+ * Same shape as the stats screen a nav column exactly where the left bar
61
+ * stands, so opening it does not shift the column under the cursor, and a
62
+ * bordered content pane where the terminal would be, showing one section at a
63
+ * time.
22
64
  *
23
- * A share of the window rather than a fixed column count, so it holds whatever
24
- * the window is; the floor is there for the narrow ones, where the pane is
25
- * smaller than the cap anyway and this stops binding.
65
+ * One section at a time, but still one cursor: the pane is a view of whatever
66
+ * section the cursor is standing in, so `j` off the end of a section simply
67
+ * lands in the next one and the pane follows. That is what lets a row afford
68
+ * three lines — the setting, what it does, and a gap — where fifty rows in one
69
+ * scroll could only afford one, and what keeps the nav column from being a
70
+ * place you have to move into and back out of.
26
71
  */
27
- const CONTENT_WIDTH_RATIO = 0.45
28
- const CONTENT_MIN_WIDTH = 56
29
-
30
72
  export const SettingsView = memo(function SettingsView() {
31
73
  const t = useTheme()
32
74
  // The cursor is all this component needs from the app state. Rows read their
33
75
  // own values, so nothing here re-renders at the rate the terminals print.
34
- const settings = useAppStore((s) => s.settings)
35
- // The section list stands exactly where the left bar stands, so opening the
36
- // screen does not shift the column under the cursor. Its configured width, not
37
- // `getBarWidth`: a hidden bar still says how wide the sidebar is.
38
- const navWidth = useAppStore((s) => clampBarWidth(s.bars.left.width))
76
+ const rowIndex = useAppStore((s) => s.settings.rowIndex)
39
77
  // Which rows exist can depend on the projects — Setup has one per project — and
40
78
  // building a Setup row reads its script off disk. So the list is rebuilt when
41
- // the section changes, when the projects change, and after any write; not on
42
- // every render, which at the rate this store changes would mean reading files
43
- // at the rate the terminals print.
79
+ // the projects change and after any write; not on every render, which at the
80
+ // rate this store changes would mean reading files at the rate the terminals
81
+ // print.
44
82
  const projects = useAppStore((s) => s.projects)
45
83
  const revision = useSettingsStore((s) => s.revision)
46
- const rows = useMemo(
47
- () => getSectionRows(settings.sectionId, projects),
84
+ const touchedCount = useSettingsStore((s) => s.touched.size)
85
+ const configCount = useSettingsStore((s) => s.fromConfigFile.size)
86
+ // The section list stands exactly where the left bar stands. Its configured
87
+ // width, not `getBarWidth`: a hidden bar still says how wide the sidebar is.
88
+ const navWidth = useAppStore((s) => clampBarWidth(s.bars.left.width))
89
+ const dimensions = useTerminalDimensions()
90
+ const hits = useMemo(
91
+ // No query: this is the whole list, and the same function the search filters
92
+ // with — so the index a hit carries is the index this cursor holds.
93
+ () => filterSettingRows(projects, null),
48
94
  // `revision` is an invalidation key, not an input: it says a value the builder
49
95
  // read from disk may have changed under it.
50
96
  // eslint-disable-next-line react-hooks/exhaustive-deps
51
- [settings.sectionId, projects, revision]
52
- )
53
- const dimensions = useTerminalDimensions()
54
- const contentMaxWidth = Math.max(
55
- CONTENT_MIN_WIDTH,
56
- Math.round(dimensions.width * CONTENT_WIDTH_RATIO)
97
+ [projects, revision]
57
98
  )
99
+ const groups = useMemo(() => groupBySection(hits), [hits])
58
100
  const scrollRef = useRef<ScrollBoxRenderable | null>(null)
101
+ const selected = hits[rowIndex]
102
+ const current = groups.find((group) => group.sectionId === selected?.sectionId)
59
103
 
60
104
  useScrollActiveIntoView({
61
- activeId: settings.pane === 'rows' ? (rows[settings.rowIndex]?.id ?? null) : null,
105
+ activeId: selected?.row.id ?? null,
62
106
  idPrefix: 'setting-row-',
63
107
  scrollRef,
64
108
  visible: true,
65
109
  })
66
110
 
67
- const handleSectionClick = useCallback((index: number) => {
68
- const sectionId = SETTING_SECTIONS[index]?.id
69
- if (sectionId == null) return
70
- dispatchGlobal({ sectionId, type: 'settings-select-section' })
71
- }, [])
72
-
73
111
  // One click selects the row and changes it — a checkbox you have to select
74
112
  // first and then click again is not a checkbox.
75
- const handleRowClick = useCallback((rowIndex: number) => {
76
- dispatchGlobal({ rowIndex, type: 'settings-select-row' })
113
+ const handleRowClick = useCallback((index: number) => {
114
+ dispatchGlobal({ rowIndex: index, type: 'settings-select-row' })
77
115
  runSideEffectGlobal({ type: 'activate-settings-row' })
78
116
  }, [])
79
117
 
118
+ const handleSectionClick = useCallback(
119
+ (index: number) => {
120
+ const first = groups[index]?.rows[0]?.[0]
121
+ if (first === undefined) return
122
+ dispatchGlobal({ rowIndex: first, type: 'settings-select-row' })
123
+ },
124
+ [groups]
125
+ )
126
+
80
127
  const handleClose = useCallback(() => {
81
128
  dispatchGlobal({ type: 'exit-settings' })
82
129
  }, [])
83
130
 
84
- const section = SETTING_SECTIONS.find((s) => s.id === settings.sectionId)
85
- // The glyph rides with the label: a section that could not be found has
86
- // neither, and the fallback title stands on its own.
87
- const sectionTitle = section === undefined ? 'Settings' : `${section.glyph} ${section.label}`
88
- const sectionNote = section?.description
131
+ // The headline row doubles as the legend for the two marks the rows wear —
132
+ // the counts are the only reason anyone would need to know what they mean.
133
+ const tiles = useMemo(
134
+ () =>
135
+ [
136
+ { glyph: SETTINGS_GLYPH, label: 'settings', value: String(hits.length) },
137
+ // A mark no row is wearing needs no legend, and a count of zero is not
138
+ // news — so the tile only exists while the mark does.
139
+ touchedCount === 0
140
+ ? null
141
+ : { glyph: TOUCHED_MARK, label: 'changed', value: String(touchedCount) },
142
+ configCount === 0
143
+ ? null
144
+ : { glyph: CONFIG_FILE_MARK, label: 'from config', value: String(configCount) },
145
+ ].filter((tile) => tile !== null),
146
+ [hits.length, touchedCount, configCount]
147
+ )
148
+
89
149
  // Same 1-cell seam the bar draws between itself and the terminal, so the two
90
150
  // views line up to the column.
91
151
  const navContentWidth = Math.max(1, navWidth - 1)
152
+ const usable = Math.max(24, dimensions.width - navWidth - 2 - PAGE_PAD * 2)
92
153
 
93
154
  return (
94
155
  <box flexDirection="row" flexGrow={1} overflow="hidden">
@@ -97,22 +158,23 @@ export const SettingsView = memo(function SettingsView() {
97
158
  <box paddingLeft={1} paddingRight={1}>
98
159
  <text fg={t.textMuted}>Settings</text>
99
160
  </box>
161
+ {/* A jump list, not a column you move into: it says which section the
162
+ cursor is in and takes it somewhere. Nothing here holds focus,
163
+ which is why the screen needs no notion of which pane has it. */}
100
164
  <box flexGrow={1} flexShrink={1} flexDirection="column" overflow="hidden">
101
- {SETTING_SECTIONS.map((section, index) => (
165
+ {groups.map((group, index) => (
102
166
  <ListItem
103
- key={section.id}
104
- active={section.id === settings.sectionId}
167
+ key={group.sectionId}
168
+ active={group.sectionId === current?.sectionId}
105
169
  index={index}
106
170
  onClickIndex={handleSectionClick}
107
171
  title={
108
- <text fg={section.id === settings.sectionId ? t.text : t.textMuted}>
109
- {`${section.glyph} ${section.label}`}
172
+ <text fg={group.sectionId === current?.sectionId ? t.text : t.textMuted}>
173
+ {`${group.glyph} ${group.label}`}
110
174
  </text>
111
175
  }
112
176
  trailing={
113
- section.id === settings.sectionId && settings.pane === 'nav' ? (
114
- <text fg={t.primary}>›</text>
115
- ) : undefined
177
+ group.sectionId === current?.sectionId ? <text fg={t.primary}>›</text> : undefined
116
178
  }
117
179
  />
118
180
  ))}
@@ -126,46 +188,67 @@ export const SettingsView = memo(function SettingsView() {
126
188
  <box width={1} flexShrink={0} backgroundColor={t.border} />
127
189
  </box>
128
190
  {/* The pane the terminal would be in, with the same border — the content
129
- keeps the inset it had, instead of jumping to the edge of the screen. */}
191
+ keeps the inset it had instead of jumping to the edge of the screen. */}
130
192
  <box
131
193
  border
132
- borderColor={settings.pane === 'rows' ? t.borderActive : t.border}
133
- title={sectionTitle}
194
+ borderColor={t.borderActive}
195
+ title={`${SETTINGS_GLYPH} Settings`}
134
196
  padding={0}
135
197
  flexDirection="column"
136
198
  flexGrow={1}
137
199
  backgroundColor={t.background}
138
200
  >
139
- <box flexDirection="column" flexGrow={1} flexShrink={1} maxWidth={contentMaxWidth}>
140
- {sectionNote != null && sectionNote !== '' ? (
141
- <box flexShrink={0} paddingLeft={1} paddingRight={1}>
142
- <text fg={t.textMuted}>{sectionNote}</text>
201
+ {/* Outside the scroll: the way to find a setting should not be a thing
202
+ you have to scroll back up to. */}
203
+ <box flexShrink={0} paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD} paddingTop={1}>
204
+ <SettingsSearchBar width={usable} />
205
+ </box>
206
+ <scrollbox
207
+ ref={scrollRef}
208
+ scrollY
209
+ flexGrow={1}
210
+ flexShrink={1}
211
+ contentOptions={COLUMN_CONTENT_OPTIONS}
212
+ >
213
+ <box flexDirection="column" paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD}>
214
+ <TileRow tiles={tiles} usable={usable} />
215
+ <box paddingTop={1} paddingBottom={1} flexShrink={0}>
216
+ <Rule width={usable} />
143
217
  </box>
144
- ) : null}
145
- <scrollbox
146
- ref={scrollRef}
147
- scrollY
148
- flexGrow={1}
149
- flexShrink={1}
150
- contentOptions={COLUMN_CONTENT_OPTIONS}
151
- >
152
- {rows.length === 0 ? (
153
- <box paddingLeft={1}>
154
- <text fg={t.textMuted}>Nothing to configure here yet.</text>
155
- </box>
218
+ {current === undefined ? (
219
+ <Muted>Nothing to configure here yet.</Muted>
156
220
  ) : (
157
- rows.map((row, index) => (
158
- <SettingsRow
159
- key={row.id}
160
- row={row}
161
- index={index}
162
- active={settings.pane === 'rows' && index === settings.rowIndex}
163
- onSelect={handleRowClick}
164
- />
165
- ))
221
+ <Section
222
+ glyph={current.glyph}
223
+ title={current.label}
224
+ // Pinned to the same right edge every note on the screen lands on.
225
+ note={String(current.rows.length)}
226
+ width={usable}
227
+ >
228
+ {current.note === '' ? null : (
229
+ <box width={usable} paddingBottom={1}>
230
+ <text fg={t.textMuted} selectable={false}>
231
+ {current.note}
232
+ </text>
233
+ </box>
234
+ )}
235
+ <box flexDirection="column" gap={1}>
236
+ {current.rows.map(([index, hit]) => (
237
+ <SettingsRow
238
+ key={hit.row.id}
239
+ row={hit.row}
240
+ index={index}
241
+ active={index === rowIndex}
242
+ onSelect={handleRowClick}
243
+ width={usable}
244
+ />
245
+ ))}
246
+ </box>
247
+ </Section>
166
248
  )}
167
- </scrollbox>
168
- </box>
249
+ </box>
250
+ </scrollbox>
251
+ <SettingsFooter row={selected?.row ?? null} width={usable} pad={PAGE_PAD} />
169
252
  </box>
170
253
  </box>
171
254
  )
@@ -6,7 +6,8 @@ import { truncate } from '../../truncate'
6
6
  import { type BarShape, buildChart, buildRuler, strideOf } from './chart'
7
7
 
8
8
  /**
9
- * The stats screen's building blocks.
9
+ * The building blocks of a full-screen view — the stats pages, and the settings
10
+ * screen, which is laid out against the same grid so the two read as one app.
10
11
  *
11
12
  * Every page is the same grid: a headline row of tiles, a rule, then sections
12
13
  * measured against the page's own width rather than against their content. A
@@ -418,6 +419,28 @@ export interface PageTile {
418
419
  value: string
419
420
  }
420
421
 
422
+ /**
423
+ * The headline row: equal slots across the page's width, the last one taking
424
+ * whatever the division left over so the row ends on the page's right edge
425
+ * rather than a column or two short.
426
+ */
427
+ export function TileRow({ tiles, usable }: { tiles: PageTile[]; usable: number }) {
428
+ const tileWidth = Math.floor(usable / Math.max(1, tiles.length))
429
+ return (
430
+ <box flexDirection="row" flexShrink={0}>
431
+ {tiles.map((tile, index) => (
432
+ <StatTile
433
+ key={tile.label}
434
+ glyph={tile.glyph}
435
+ label={tile.label}
436
+ value={tile.value}
437
+ width={index === tiles.length - 1 ? usable - tileWidth * (tiles.length - 1) : tileWidth}
438
+ />
439
+ ))}
440
+ </box>
441
+ )
442
+ }
443
+
421
444
  /**
422
445
  * Every page's frame: the headline row, the rule under it, and the padding they
423
446
  * both sit in.
@@ -436,23 +459,9 @@ export function StatsPage({
436
459
  tiles: PageTile[]
437
460
  usable: number
438
461
  }) {
439
- const tileWidth = Math.floor(usable / Math.max(1, tiles.length))
440
-
441
462
  return (
442
463
  <box flexDirection="column" paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD}>
443
- <box flexDirection="row" flexShrink={0}>
444
- {tiles.map((tile, index) => (
445
- <StatTile
446
- key={tile.label}
447
- glyph={tile.glyph}
448
- label={tile.label}
449
- value={tile.value}
450
- // The last slot takes whatever the division left over, so the row
451
- // ends on the page's right edge rather than a column or two short.
452
- width={index === tiles.length - 1 ? usable - tileWidth * (tiles.length - 1) : tileWidth}
453
- />
454
- ))}
455
- </box>
464
+ <TileRow tiles={tiles} usable={usable} />
456
465
  <box paddingTop={1} paddingBottom={1} flexShrink={0}>
457
466
  <Rule width={usable} />
458
467
  </box>