@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 +2 -2
- package/src/app-runtime/settings-actions.ts +24 -13
- package/src/settings/search.ts +13 -9
- package/src/settings/sections/index.ts +23 -12
- package/src/state/actions.ts +1 -2
- package/src/state/reducers/settings-state.ts +29 -51
- package/src/state/types.ts +5 -3
- package/src/ui/components/settings/row-value.tsx +22 -3
- package/src/ui/components/settings/settings-footer.tsx +170 -0
- package/src/ui/components/settings/settings-row.tsx +109 -33
- package/src/ui/components/settings/settings-search-bar.tsx +61 -0
- package/src/ui/components/settings/settings-view.tsx +162 -79
- package/src/ui/components/stats/shared.tsx +25 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brimveyn/aimux",
|
|
3
|
-
"version": "1.23.
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|
106
|
-
*
|
|
107
|
-
*
|
|
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
|
|
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
|
|
package/src/settings/search.ts
CHANGED
|
@@ -7,17 +7,21 @@ export interface SettingSearchHit {
|
|
|
7
7
|
row: SettingRow
|
|
8
8
|
sectionId: string
|
|
9
9
|
sectionLabel: string
|
|
10
|
-
/**
|
|
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.
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
* cursor count the same list
|
|
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 (
|
|
32
|
-
|
|
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
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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. */
|
package/src/state/actions.ts
CHANGED
|
@@ -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-
|
|
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 {
|
|
4
|
+
import { sectionStartIndexes, totalRowCount } from '../../settings/sections'
|
|
5
5
|
|
|
6
6
|
export function emptySettingsUI(): SettingsUIState {
|
|
7
|
-
return {
|
|
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
|
|
16
|
-
return {
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
//
|
|
42
|
-
//
|
|
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
|
|
64
|
-
case 'settings-
|
|
65
|
-
|
|
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
|
|
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
|
}
|
package/src/state/types.ts
CHANGED
|
@@ -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
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
10
|
+
import { Bar } from '../stats/shared'
|
|
11
|
+
import { RowValue } from './row-value'
|
|
9
12
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
63
|
-
<box
|
|
64
|
-
<
|
|
65
|
-
|
|
66
|
-
</
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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
|
-
*
|
|
24
|
-
* the
|
|
25
|
-
*
|
|
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
|
|
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
|
|
42
|
-
//
|
|
43
|
-
//
|
|
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
|
|
47
|
-
|
|
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
|
-
[
|
|
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:
|
|
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((
|
|
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
|
-
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
{
|
|
165
|
+
{groups.map((group, index) => (
|
|
102
166
|
<ListItem
|
|
103
|
-
key={
|
|
104
|
-
active={
|
|
167
|
+
key={group.sectionId}
|
|
168
|
+
active={group.sectionId === current?.sectionId}
|
|
105
169
|
index={index}
|
|
106
170
|
onClickIndex={handleSectionClick}
|
|
107
171
|
title={
|
|
108
|
-
<text fg={
|
|
109
|
-
{`${
|
|
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
|
-
|
|
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
|
|
191
|
+
keeps the inset it had instead of jumping to the edge of the screen. */}
|
|
130
192
|
<box
|
|
131
193
|
border
|
|
132
|
-
borderColor={
|
|
133
|
-
title={
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
</
|
|
168
|
-
</
|
|
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
|
|
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
|
-
<
|
|
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>
|