@brimveyn/aimux 1.23.6 → 1.24.0

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.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/tab-actions.ts +7 -1
  3. package/src/input/keymap/key-chord.ts +10 -0
  4. package/src/input/keymap/key-format.ts +14 -1
  5. package/src/input/modes/handlers/shared.ts +48 -0
  6. package/src/pty/command-registry.ts +35 -0
  7. package/src/state/actions.ts +2 -1
  8. package/src/state/project-persistence.ts +16 -1
  9. package/src/state/reducers/modal-state.ts +24 -28
  10. package/src/state/reducers/tab-state.ts +14 -3
  11. package/src/state/text-cursor.ts +117 -0
  12. package/src/ui/breaking-update-screen.tsx +7 -1
  13. package/src/ui/components/git/pane/pr-checks-panel.tsx +7 -2
  14. package/src/ui/components/layout/bar-footer.tsx +3 -5
  15. package/src/ui/components/layout/bar.tsx +41 -16
  16. package/src/ui/components/layout/sidebar/project-list.tsx +6 -16
  17. package/src/ui/components/layout/sidebar/workspace-row.tsx +6 -9
  18. package/src/ui/components/layout/status-bar.tsx +9 -3
  19. package/src/ui/components/layout/terminal-pane.tsx +18 -16
  20. package/src/ui/components/modals/app/help-modal.tsx +20 -15
  21. package/src/ui/components/modals/app/update-available-modal.tsx +3 -1
  22. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  23. package/src/ui/components/modals/projects/create-project-modal.tsx +9 -3
  24. package/src/ui/components/modals/projects/project-picker-modal.tsx +6 -6
  25. package/src/ui/components/modals/settings/settings-search-modal.tsx +19 -14
  26. package/src/ui/components/modals/shared/modal-shell.tsx +12 -2
  27. package/src/ui/components/modals/shared/picker.tsx +8 -4
  28. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +7 -5
  29. package/src/ui/components/modals/tabs/new-tab-modal.tsx +10 -12
  30. package/src/ui/components/modals/themes/theme-picker-modal.tsx +14 -10
  31. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +19 -14
  32. package/src/ui/components/modals/workspace/workspace-move-modal.tsx +4 -2
  33. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +10 -4
  34. package/src/ui/components/primitives/list-item.tsx +24 -2
  35. package/src/ui/components/primitives/surface.tsx +4 -1
  36. package/src/ui/components/settings/row-value.tsx +8 -1
  37. package/src/ui/components/settings/settings-footer.tsx +3 -2
  38. package/src/ui/components/settings/settings-row.tsx +3 -0
  39. package/src/ui/components/settings/settings-search-bar.tsx +4 -6
  40. package/src/ui/components/settings/settings-view.tsx +25 -21
  41. package/src/ui/components/stats/stats-view.tsx +4 -2
  42. package/src/ui/selection-ink.ts +20 -0
@@ -5,6 +5,7 @@ import type { AssistantId } from '../../../../state/types'
5
5
  import { getAllAssistantOptions, getAssistantOption } from '../../../../pty/command-registry'
6
6
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
7
7
  import { getNewTabAssistantOptions } from '../../../../state/selectors'
8
+ import { useSelectionInk } from '../../../selection-ink'
8
9
  import { useTheme } from '../../../theme'
9
10
  import { uiTokens } from '../../../ui-tokens'
10
11
  import { Form, TextField } from '../shared/form'
@@ -35,6 +36,7 @@ export function NewTabModal({
35
36
  selectedIndex,
36
37
  }: NewTabModalProps) {
37
38
  const t = useTheme()
39
+ const ink = useSelectionInk()
38
40
  const excludeTerminal = pendingPrompt != null && pendingPrompt.trim() !== ''
39
41
  const footerText =
40
42
  pendingPrompt == null
@@ -66,16 +68,16 @@ export function NewTabModal({
66
68
  dispatchGlobal({ assistantId: option.id, type: 'open-edit-custom-command' }),
67
69
  subtitle: (
68
70
  <box flexDirection="column">
69
- <text fg={t.textMuted}>{option.description}</text>
71
+ <text fg={active ? ink : t.textMuted}>{option.description}</text>
70
72
  {customCmd != null && customCmd !== '' ? (
71
- <text fg={t.primary}>{customCmd}</text>
73
+ <text fg={active ? ink : t.primary}>{customCmd}</text>
72
74
  ) : null}
73
75
  </box>
74
76
  ),
75
- title: <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
77
+ title: <text fg={active ? ink : t.textMuted}>{option.label}</text>,
76
78
  }
77
79
  }),
78
- [customCommands, filtered, selectedIndex, t]
80
+ [customCommands, filtered, ink, selectedIndex, t]
79
81
  )
80
82
 
81
83
  if (editingCommand !== null) {
@@ -86,20 +88,16 @@ export function NewTabModal({
86
88
  keybindsModeId="modal.new-tab.editing-command"
87
89
  width={uiTokens.modalWidth.md}
88
90
  >
89
- <TextField
90
- active
91
- description={<>blank to reset to default: {option.command}</>}
92
- value={editBuffer}
93
- cursorPos={cursorPos}
94
- placeholder={option.command}
95
- />
91
+ {/* No description: the placeholder is the default command, so the field
92
+ already shows what leaving it blank gets you. */}
93
+ <TextField active value={editBuffer} cursorPos={cursorPos} placeholder={option.command} />
96
94
  </Form>
97
95
  )
98
96
  }
99
97
 
100
98
  return (
101
99
  <Picker
102
- title="New tab: choose assistant"
100
+ title="New tab"
103
101
  keybindsModeId="modal.new-tab.command-edit"
104
102
  width={uiTokens.modalWidth.md}
105
103
  gap={1}
@@ -4,6 +4,7 @@ import type { ThemeId } from '../../../themes'
4
4
 
5
5
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
6
6
  import { filterThemeIds, themeDisplayName } from '../../../filter-themes'
7
+ import { useSelectionInk } from '../../../selection-ink'
7
8
  import { useMode, useTheme, useTransparent } from '../../../theme'
8
9
  import { uiTokens } from '../../../ui-tokens'
9
10
  import { Picker, type PickerItem } from '../shared/picker'
@@ -27,6 +28,7 @@ export function ThemePickerModal({
27
28
  selectedIndex,
28
29
  }: ThemePickerModalProps) {
29
30
  const t = useTheme()
31
+ const ink = useSelectionInk()
30
32
  const transparent = useTransparent()
31
33
  const mode = useMode()
32
34
  const filtered = useMemo(() => filterThemeIds(filter), [filter])
@@ -48,11 +50,11 @@ export function ThemePickerModal({
48
50
  dispatchGlobal({ type: 'close-modal' })
49
51
  runSideEffectGlobal({ action: 'confirm', type: 'apply-theme' })
50
52
  },
51
- title: <text fg={active ? t.text : t.textMuted}>{themeDisplayName(id)}</text>,
52
- trailing: isCurrent ? <text fg={t.primary}>current</text> : undefined,
53
+ title: <text fg={active ? ink : t.textMuted}>{themeDisplayName(id)}</text>,
54
+ trailing: isCurrent ? <text fg={active ? ink : t.primary}>current</text> : undefined,
53
55
  }
54
56
  }),
55
- [currentThemeId, effectiveIndex, filtered, t]
57
+ [currentThemeId, effectiveIndex, filtered, ink, t]
56
58
  )
57
59
 
58
60
  const handleHover = useCallback(
@@ -68,13 +70,15 @@ export function ThemePickerModal({
68
70
  filter={filter}
69
71
  cursorPos={cursorPos}
70
72
  footer={
71
- <box flexDirection="column" gap={0}>
72
- <text fg={t.textMuted}>
73
- {filtered.length === 0 ? '' : ` ${effectiveIndex + 1} / ${filtered.length}`}
74
- </text>
75
- <text fg={t.textMuted}>{` transparent: ${transparent ? 'on' : 'off'} (ctrl-t)`}</text>
76
- <text fg={t.textMuted}>{` mode: ${mode} (ctrl-l)`}</text>
77
- </box>
73
+ <text fg={t.textMuted}>
74
+ {[
75
+ filtered.length === 0 ? '' : `${effectiveIndex + 1}/${filtered.length}`,
76
+ `transparent ${transparent ? 'on' : 'off'} (ctrl-t)`,
77
+ `mode ${mode} (ctrl-l)`,
78
+ ]
79
+ .filter((part) => part !== '')
80
+ .join(' · ')}
81
+ </text>
78
82
  }
79
83
  items={items}
80
84
  selectedIndex={effectiveIndex}
@@ -4,6 +4,7 @@ import type { WorkspaceRecord } from '../../../../state/types'
4
4
 
5
5
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
6
6
  import { buildBaseRefOptions } from '../../../../state/selectors'
7
+ import { useSelectionInk } from '../../../selection-ink'
7
8
  import { useTheme } from '../../../theme'
8
9
  import { uiTokens } from '../../../ui-tokens'
9
10
  import { AutoComplete, Form, type FormOptionItem, TextField } from '../shared/form'
@@ -35,6 +36,7 @@ export function CreateWorkspaceModal({
35
36
  workspaces,
36
37
  }: CreateWorkspaceModalProps) {
37
38
  const t = useTheme()
39
+ const ink = useSelectionInk()
38
40
 
39
41
  const handleHover = useCallback(
40
42
  (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
@@ -45,24 +47,30 @@ export function CreateWorkspaceModal({
45
47
  () =>
46
48
  buildBaseRefOptions(workspaces, baseBranches, baseQuery).map((option) => ({
47
49
  key: option.ref,
48
- leading: (
49
- <text fg={option.kind === 'workspace' ? t.warning : t.textMuted}>
50
- {option.kind === 'workspace' ? '\u{e728}' : '\u{e702}'}
51
- </text>
52
- ),
50
+ leading: (active) => {
51
+ let fg = t.textMuted
52
+ if (active) fg = ink
53
+ else if (option.kind === 'workspace') fg = t.warning
54
+ return <text fg={fg}>{option.kind === 'workspace' ? '\u{e728}' : '\u{e702}'}</text>
55
+ },
53
56
  subtitle:
54
- option.kind === 'workspace' ? (
55
- <text fg={t.textMuted}>workspace: {option.detail}</text>
56
- ) : null,
57
- title: (active) => <text fg={active ? t.text : t.textMuted}>{option.label}</text>,
57
+ option.kind === 'workspace'
58
+ ? (active) => <text fg={active ? ink : t.textMuted}>workspace: {option.detail}</text>
59
+ : null,
60
+ title: (active) => <text fg={active ? ink : t.textMuted}>{option.label}</text>,
58
61
  })),
59
- [baseBranches, baseQuery, t, workspaces]
62
+ [baseBranches, baseQuery, ink, t, workspaces]
60
63
  )
61
64
 
62
65
  const baseActive = activeField === 'base'
63
66
  return (
67
+ // The title asks the question and the subtitle answers what happens with the
68
+ // answer, so the prompt field needs no label of its own — a heading, a
69
+ // question, a paragraph and a placeholder all saying the same thing was four
70
+ // lines of chrome above an empty box.
64
71
  <Form
65
72
  title="New workspace"
73
+ subtitle="What do you want to work on? Names the workspace and its branch, and is sent to the assistant."
66
74
  keybindsModeId="modal.create-workspace"
67
75
  width={uiTokens.modalWidth.xl}
68
76
  >
@@ -70,11 +78,9 @@ export function CreateWorkspaceModal({
70
78
  <box flexDirection="column">
71
79
  <TextField
72
80
  active={activeField === 'prompt'}
73
- label="What do you want to work on? (optional)"
74
- description="Sent to the assistant, and names the workspace and its branch. Leave empty for a bare workspace."
75
81
  value={prompt}
76
82
  cursorPos={activeField === 'prompt' ? cursorPos : undefined}
77
- placeholder="Describe the task, or leave empty..."
83
+ placeholder="Describe the task, or leave empty for a bare workspace..."
78
84
  minLines={PROMPT_LINES}
79
85
  />
80
86
  {branchError != null && branchError !== '' ? (
@@ -94,7 +100,6 @@ export function CreateWorkspaceModal({
94
100
  onHover={handleHover}
95
101
  emptyState={<text fg={t.textMuted}>No branches found</text>}
96
102
  />
97
- <text fg={t.textMuted}>Ctrl+Enter newline · Tab picks a base · Enter creates</text>
98
103
  </box>
99
104
  </Form>
100
105
  )
@@ -4,6 +4,7 @@ import type { ModalWorkspaceMove, WorkspaceRecord } from '../../../../state/type
4
4
 
5
5
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
6
6
  import { formatDivergence } from '../../../../state/project-workspaces'
7
+ import { useSelectionInk } from '../../../selection-ink'
7
8
  import { useTheme } from '../../../theme'
8
9
  import { uiTokens } from '../../../ui-tokens'
9
10
  import { ListItem } from '../../primitives/list-item'
@@ -34,6 +35,7 @@ export function WorkspaceMoveModal({
34
35
  workspaces,
35
36
  }: WorkspaceMoveModalProps) {
36
37
  const t = useTheme()
38
+ const ink = useSelectionInk()
37
39
  const source = useMemo(
38
40
  () => workspaces.find((w) => w.id === sourceWorkspaceId),
39
41
  [sourceWorkspaceId, workspaces]
@@ -106,11 +108,11 @@ export function WorkspaceMoveModal({
106
108
  onHoverIndex={handleSelectIndex}
107
109
  onClickIndex={handleSelectIndex}
108
110
  title={
109
- <text fg={active ? t.text : t.textMuted} wrapMode="none">
111
+ <text fg={active ? ink : t.textMuted} wrapMode="none">
110
112
  {label}
111
113
  {workspace.source === 'primary' ? ' (primary)' : ''}
112
114
  {ahead !== '' ? ` ${ahead}` : ''}
113
- {dirty ? <span fg={t.warning}> ●</span> : null}
115
+ {dirty ? <span fg={active ? ink : t.warning}> ●</span> : null}
114
116
  </text>
115
117
  }
116
118
  />
@@ -9,6 +9,7 @@ import {
9
9
  type ContextMenuState,
10
10
  subscribeContextMenu,
11
11
  } from '../../../context-menu/controller'
12
+ import { useSelectionInk } from '../../../selection-ink'
12
13
  import { useTheme, useTransparent } from '../../../theme'
13
14
  import { fillBorderedBoxInterior } from '../../../transparent-fill'
14
15
 
@@ -28,6 +29,7 @@ const MenuItem = memo(function MenuItem({
28
29
  width: number
29
30
  }) {
30
31
  const t = useTheme()
32
+ const ink = useSelectionInk()
31
33
  const handleMouseOver = useCallback(() => onHover(index), [index, onHover])
32
34
  const handleMouseDown = useCallback(
33
35
  (e: OtuiMouseEvent) => {
@@ -45,11 +47,11 @@ const MenuItem = memo(function MenuItem({
45
47
  flexShrink={0}
46
48
  paddingLeft={1}
47
49
  paddingRight={1}
48
- backgroundColor={active ? t.backgroundElement : undefined}
50
+ backgroundColor={active ? t.primary : undefined}
49
51
  onMouseOver={handleMouseOver}
50
52
  onMouseDown={handleMouseDown}
51
53
  >
52
- <text fg={active ? t.text : t.textMuted} selectable={false}>
54
+ <text fg={active ? ink : t.textMuted} selectable={false}>
53
55
  {label}
54
56
  </text>
55
57
  </box>
@@ -131,9 +133,13 @@ export function ContextMenuOverlay() {
131
133
  left={left}
132
134
  width={width}
133
135
  flexDirection="column"
134
- border
136
+ // backgroundElement, not backgroundPanel: this pops up over the bars as
137
+ // often as over the terminal, and the bars are panel — a panel menu on a
138
+ // panel bar has no edge. Transparent mode keeps the border for the same
139
+ // reason the modal does: there is no background there to stand on.
140
+ border={transparent}
135
141
  borderColor={t.border}
136
- backgroundColor={transparent ? 'transparent' : t.backgroundPanel}
142
+ backgroundColor={transparent ? 'transparent' : t.backgroundElement}
137
143
  renderAfter={transparent ? fillBorderedBoxInterior : undefined}
138
144
  onMouseDown={handleMenuMouseDown}
139
145
  >
@@ -1,13 +1,23 @@
1
1
  import { memo, type ReactNode, useMemo } from 'react'
2
2
 
3
+ import { useSelectionInk } from '../../selection-ink'
3
4
  import { useTheme } from '../../theme'
4
5
  import { Surface } from './surface'
5
6
 
6
7
  export type ListItemDirection = 'row' | 'column'
7
8
 
9
+ /**
10
+ * How the selected row is filled. `primary` by default — the accent fill every
11
+ * picker and nav list wants. `elevated` is for a row that carries a colour scale
12
+ * of its own (the settings rows and their gauges): an accent behind those would
13
+ * swallow the very thing the row is there to show.
14
+ */
15
+ export type ListItemFill = 'primary' | 'elevated'
16
+
8
17
  interface ListItemProps {
9
18
  active: boolean
10
19
  direction?: ListItemDirection
20
+ fill?: ListItemFill
11
21
  id?: string
12
22
  index?: number
13
23
  leading?: ReactNode
@@ -23,6 +33,7 @@ interface ListItemProps {
23
33
  export const ListItem = memo(function ListItem({
24
34
  active,
25
35
  direction = 'column',
36
+ fill = 'primary',
26
37
  id,
27
38
  index,
28
39
  leading,
@@ -35,7 +46,14 @@ export const ListItem = memo(function ListItem({
35
46
  trailing,
36
47
  }: ListItemProps) {
37
48
  const t = useTheme()
49
+ const ink = useSelectionInk()
38
50
  const isRow = direction === 'row'
51
+ const filled = active && fill === 'primary'
52
+ // The cursor glyph: the selection ink on a filled row, the accent on a row
53
+ // that only lifts, muted on the rest.
54
+ let markerFg = t.textMuted
55
+ if (filled) markerFg = ink
56
+ else if (active) markerFg = t.primary
39
57
  // Build per-item handlers from the stable index-based callbacks so callers can
40
58
  // avoid creating fresh closures inside their list `.map()`.
41
59
  const handleHover = useMemo<(() => void) | undefined>(() => {
@@ -51,7 +69,7 @@ export const ListItem = memo(function ListItem({
51
69
  <box flexDirection="row">
52
70
  {isRow ? null : (
53
71
  <>
54
- <text fg={active ? t.primary : t.textMuted}>{active ? '›' : '·'}</text>
72
+ <text fg={markerFg}>{active ? '›' : '·'}</text>
55
73
  <text> </text>
56
74
  </>
57
75
  )}
@@ -66,7 +84,11 @@ export const ListItem = memo(function ListItem({
66
84
  return (
67
85
  <box id={id} onMouseOver={handleHover} onMouseDown={handleClick}>
68
86
  {active ? (
69
- <Surface tone="selected" paddingLeft={isRow ? 2 : 1} paddingRight={isRow ? 2 : 1}>
87
+ <Surface
88
+ tone={fill === 'primary' ? 'selected' : 'elevated'}
89
+ paddingLeft={isRow ? 2 : 1}
90
+ paddingRight={isRow ? 2 : 1}
91
+ >
70
92
  {inner}
71
93
  </Surface>
72
94
  ) : (
@@ -10,8 +10,11 @@ function toneToToken(tone: SurfaceTone): TuiColorToken {
10
10
  switch (tone) {
11
11
  case 'elevated':
12
12
  return 'backgroundElement'
13
+ // Filled with the theme's own accent, not another step of grey: a
14
+ // selection is the one thing on the screen you always need to find without
15
+ // reading. Everything drawn on it uses `useSelectionInk`.
13
16
  case 'selected':
14
- return 'backgroundElement'
17
+ return 'primary'
15
18
  case 'input':
16
19
  return 'backgroundElement'
17
20
  case 'inputActive':
@@ -74,9 +74,16 @@ function valueColor(row: SettingRow, value: SettingValue, t: ResolvedTuiTheme):
74
74
  * list would repaint it at the rate the terminals print.
75
75
  */
76
76
  export const RowValue = memo(function RowValue({
77
+ fg,
77
78
  maxWidth,
78
79
  row,
79
80
  }: {
81
+ /**
82
+ * Overrides the value's own colour. A row filled with `primary` has room for
83
+ * one ink, so the search results hand theirs down rather than let a green
84
+ * toggle sit on the accent.
85
+ */
86
+ fg?: string
80
87
  /** Cropped rather than wrapped: a row that grows a line shifts the list. */
81
88
  maxWidth?: number
82
89
  row: SettingRow
@@ -86,7 +93,7 @@ export const RowValue = memo(function RowValue({
86
93
  const value = useAppStore((s) => readRow(row, { state: s, values }))
87
94
  const text = formatValue(row, value)
88
95
  return (
89
- <text fg={valueColor(row, value, t)} wrapMode="none">
96
+ <text fg={fg ?? valueColor(row, value, t)} wrapMode="none">
90
97
  {maxWidth === undefined ? text : truncate(text, maxWidth)}
91
98
  </text>
92
99
  )
@@ -7,7 +7,6 @@ import { useAppStore } from '../../../state/app-store'
7
7
  import { dispatchGlobal } from '../../../state/dispatch-ref'
8
8
  import { useTheme } from '../../theme'
9
9
  import { truncate } from '../../truncate'
10
- import { Rule } from '../stats/shared'
11
10
  import { describeValue } from './row-value'
12
11
 
13
12
  /**
@@ -121,7 +120,9 @@ export const SettingsFooter = memo(function SettingsFooter({
121
120
 
122
121
  return (
123
122
  <box flexDirection="column" flexShrink={0} paddingLeft={pad} paddingRight={pad}>
124
- <Rule width={width} />
123
+ {/* A blank row, not a rule: the footer is set off from the list by the gap,
124
+ the same way the sidebar's is. */}
125
+ <box height={1} flexShrink={0} />
125
126
  <box width={width} flexDirection="row" flexShrink={0}>
126
127
  <box flexGrow={1} flexShrink={1}>
127
128
  <text fg={t.textMuted} selectable={false} wrapMode="none">
@@ -119,6 +119,9 @@ export const SettingsRow = memo(function SettingsRow({
119
119
  <ListItem
120
120
  id={`setting-row-${row.id}`}
121
121
  active={active}
122
+ // Lifted, not filled: the gauge is drawn in `primary` and the value's
123
+ // colour is its state, both of which an accent fill would swallow.
124
+ fill="elevated"
122
125
  index={index}
123
126
  onClickIndex={onSelect}
124
127
  leading={<text fg={fromConfigFile ? t.warning : t.secondary}>{mark}</text>}
@@ -22,14 +22,12 @@ export const SettingsSearchBar = memo(function SettingsSearchBar({ width }: { wi
22
22
  dispatchGlobal({ type: 'open-settings-search' })
23
23
  }, [])
24
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.
25
+ // Filled, and the prompt at full strength: it is the one control on a
26
+ // read-mostly screen, and a control drawn like the text around it is a control
27
+ // nobody sees. The fill is enough to say so — the frame it used to wear cost
28
+ // two rows to repeat what the fill already said.
28
29
  return (
29
30
  <box
30
- border
31
- borderStyle="rounded"
32
- borderColor={t.borderActive}
33
31
  backgroundColor={t.backgroundElement}
34
32
  width={width}
35
33
  flexShrink={0}
@@ -10,9 +10,10 @@ import { useAppStore } from '../../../state/app-store'
10
10
  import { clampBarWidth } from '../../../state/bars'
11
11
  import { dispatchGlobal, runSideEffectGlobal } from '../../../state/dispatch-ref'
12
12
  import { useScrollActiveIntoView } from '../../hooks/use-scroll-active-into-view'
13
+ import { useSelectionInk } from '../../selection-ink'
13
14
  import { useTheme } from '../../theme'
14
15
  import { ListItem } from '../primitives/list-item'
15
- import { Muted, Rule, Section, TileRow } from '../stats/shared'
16
+ import { Muted, Section, TileRow } from '../stats/shared'
16
17
  import { SettingsFooter } from './settings-footer'
17
18
  import { CONFIG_FILE_MARK, SettingsRow, TOUCHED_MARK } from './settings-row'
18
19
  import { SettingsSearchBar } from './settings-search-bar'
@@ -23,6 +24,8 @@ const SETTINGS_GLYPH = '\u{2699}'
23
24
 
24
25
  /** One padding column either side, the same inset the stats pages sit in. */
25
26
  const PAGE_PAD = 1
27
+ /** Matches the bar's own gutter, so the nav and a bar line up to the column. */
28
+ const NAV_GUTTER = 2
26
29
 
27
30
  /** A section and the rows it owns, each keeping the index the cursor holds. */
28
31
  interface Group {
@@ -71,6 +74,7 @@ function groupBySection(hits: SettingSearchHit[]): Group[] {
71
74
  */
72
75
  export const SettingsView = memo(function SettingsView() {
73
76
  const t = useTheme()
77
+ const ink = useSelectionInk()
74
78
  // The cursor is all this component needs from the app state. Rows read their
75
79
  // own values, so nothing here re-renders at the rate the terminals print.
76
80
  const rowIndex = useAppStore((s) => s.settings.rowIndex)
@@ -146,14 +150,21 @@ export const SettingsView = memo(function SettingsView() {
146
150
  [hits.length, touchedCount, configCount]
147
151
  )
148
152
 
149
- // Same 1-cell seam the bar draws between itself and the terminal, so the two
153
+ // The same gutter the bar draws between itself and the terminal, so the two
150
154
  // views line up to the column.
151
- const navContentWidth = Math.max(1, navWidth - 1)
155
+ const navContentWidth = Math.max(1, navWidth - NAV_GUTTER)
152
156
  const usable = Math.max(24, dimensions.width - navWidth - 2 - PAGE_PAD * 2)
153
157
 
154
158
  return (
155
159
  <box flexDirection="row" flexGrow={1} overflow="hidden">
156
- <box width={navWidth} flexDirection="row" overflow="hidden" backgroundColor={t.background}>
160
+ {/* The nav is this screen's bar, so it wears the bar's surface: panel
161
+ against the page the content pane keeps. */}
162
+ <box
163
+ width={navWidth}
164
+ flexDirection="row"
165
+ overflow="hidden"
166
+ backgroundColor={t.backgroundPanel}
167
+ >
157
168
  <box width={navContentWidth} flexDirection="column" overflow="hidden">
158
169
  <box paddingLeft={1} paddingRight={1}>
159
170
  <text fg={t.textMuted}>Settings</text>
@@ -169,12 +180,12 @@ export const SettingsView = memo(function SettingsView() {
169
180
  index={index}
170
181
  onClickIndex={handleSectionClick}
171
182
  title={
172
- <text fg={group.sectionId === current?.sectionId ? t.text : t.textMuted}>
183
+ <text fg={group.sectionId === current?.sectionId ? ink : t.textMuted}>
173
184
  {`${group.glyph} ${group.label}`}
174
185
  </text>
175
186
  }
176
187
  trailing={
177
- group.sectionId === current?.sectionId ? <text fg={t.primary}>›</text> : undefined
188
+ group.sectionId === current?.sectionId ? <text fg={ink}>›</text> : undefined
178
189
  }
179
190
  />
180
191
  ))}
@@ -185,19 +196,13 @@ export const SettingsView = memo(function SettingsView() {
185
196
  </text>
186
197
  </box>
187
198
  </box>
188
- <box width={1} flexShrink={0} backgroundColor={t.border} />
199
+ <box width={NAV_GUTTER} flexShrink={0} backgroundColor={t.backgroundPanel} />
189
200
  </box>
190
- {/* The pane the terminal would be in, with the same borderthe content
191
- keeps the inset it had instead of jumping to the edge of the screen. */}
192
- <box
193
- border
194
- borderColor={t.borderActive}
195
- title={`${SETTINGS_GLYPH} Settings`}
196
- padding={0}
197
- flexDirection="column"
198
- flexGrow={1}
199
- backgroundColor={t.background}
200
- >
201
+ {/* The pane the terminal would be in, with the same insetpadding where
202
+ that one has padding, and no frame, for the same reason. The titled
203
+ border said "Settings" a third time, after the nav heading and the
204
+ section header. */}
205
+ <box padding={1} flexDirection="column" flexGrow={1} backgroundColor={t.background}>
201
206
  {/* Outside the scroll: the way to find a setting should not be a thing
202
207
  you have to scroll back up to. */}
203
208
  <box flexShrink={0} paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD} paddingTop={1}>
@@ -212,14 +217,13 @@ export const SettingsView = memo(function SettingsView() {
212
217
  >
213
218
  <box flexDirection="column" paddingLeft={PAGE_PAD} paddingRight={PAGE_PAD}>
214
219
  <TileRow tiles={tiles} usable={usable} />
215
- <box paddingTop={1} paddingBottom={1} flexShrink={0}>
216
- <Rule width={usable} />
217
- </box>
220
+ <box height={1} flexShrink={0} />
218
221
  {current === undefined ? (
219
222
  <Muted>Nothing to configure here yet.</Muted>
220
223
  ) : (
221
224
  <Section
222
225
  glyph={current.glyph}
226
+ rule={false}
223
227
  title={current.label}
224
228
  // Pinned to the same right edge every note on the screen lands on.
225
229
  note={String(current.rows.length)}
@@ -7,6 +7,7 @@ import { useAppStore } from '../../../state/app-store'
7
7
  import { clampBarWidth } from '../../../state/bars'
8
8
  import { dispatchGlobal } from '../../../state/dispatch-ref'
9
9
  import { STATS_PAGES, statsPageAt } from '../../../state/stats-pages'
10
+ import { useSelectionInk } from '../../selection-ink'
10
11
  import { useTheme } from '../../theme'
11
12
  import { ListItem } from '../primitives/list-item'
12
13
  import { AimuxPage } from './aimux-page'
@@ -26,6 +27,7 @@ const COLUMN_CONTENT_OPTIONS = { flexDirection: 'column' as const, gap: 0 }
26
27
  */
27
28
  export const StatsView = memo(function StatsView() {
28
29
  const t = useTheme()
30
+ const ink = useSelectionInk()
29
31
  const stats = useAppStore((s) => s.stats)
30
32
  const navWidth = useAppStore((s) => clampBarWidth(s.bars.left.width))
31
33
  const dimensions = useTerminalDimensions()
@@ -104,11 +106,11 @@ export const StatsView = memo(function StatsView() {
104
106
  index={index}
105
107
  onClickIndex={handlePageClick}
106
108
  title={
107
- <text fg={index === stats.pageIndex ? t.text : t.textMuted}>
109
+ <text fg={index === stats.pageIndex ? ink : t.textMuted}>
108
110
  {`${entry.glyph} ${entry.label}`}
109
111
  </text>
110
112
  }
111
- trailing={index === stats.pageIndex ? <text fg={t.primary}>›</text> : undefined}
113
+ trailing={index === stats.pageIndex ? <text fg={ink}>›</text> : undefined}
112
114
  />
113
115
  ))}
114
116
  </box>
@@ -0,0 +1,20 @@
1
+ import { useBaseTheme } from './theme'
2
+
3
+ /**
4
+ * The one legible colour on a selected list row.
5
+ *
6
+ * A selected row is filled with `primary`, so everything drawn on it has to read
7
+ * against that rather than against the modal behind it — the page background
8
+ * does, which is the same trick the status bar's accent tiles already use for
9
+ * their mode and version badges. Every tone the caller would otherwise use on
10
+ * that row (muted subtitle, warning mark, accent badge) collapses to this one:
11
+ * a fill that strong leaves room for exactly one ink.
12
+ *
13
+ * Always the opaque base, never the live theme. In transparent mode
14
+ * `t.background` resolves to 'transparent' and the text would be painted away,
15
+ * while the fill itself stays opaque — `primary` is not one of the chrome
16
+ * background tokens the transparent overlay rewrites.
17
+ */
18
+ export function useSelectionInk(): string {
19
+ return useBaseTheme().background
20
+ }