@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
@@ -20,6 +20,12 @@ export interface BarBoundaryResizeInfo {
20
20
  totalSize: number
21
21
  }
22
22
 
23
+ /**
24
+ * Columns between the widgets and the terminal: one to grab for a resize, one so
25
+ * the content is not flush against the edge. Both grab.
26
+ */
27
+ const GUTTER = 2
28
+
23
29
  interface BarProps {
24
30
  side: BarSide
25
31
  onResizeDrag?: (event: OtuiMouseEvent) => boolean
@@ -28,8 +34,6 @@ interface BarProps {
28
34
  onBoundaryResizeStart?: (info: BarBoundaryResizeInfo) => void
29
35
  }
30
36
 
31
- const RESIZE_HANDLE = '─'
32
-
33
37
  /**
34
38
  * One edge bar hosting a vertical stack of widgets. Both bars are this
35
39
  * component; the only asymmetry is which side the resize handle sits on.
@@ -78,11 +82,21 @@ export function Bar({
78
82
  if (width === 0) return null
79
83
 
80
84
  const visible = visibleWidgets(bar)
81
- const contentWidth = Math.max(1, width - 1)
82
-
83
- // Drag handle on the side facing the terminal.
85
+ const contentWidth = Math.max(1, width - GUTTER)
86
+
87
+ // The gutter on the side facing the terminal: the resize grip and the widgets'
88
+ // inset from the terminal are the same two columns — the padding is not dead
89
+ // space, both cells start a resize. Painted with the bar, not in the page
90
+ // colour: a page-coloured strip here left a seam between the bar and the tab
91
+ // bar above it, which are the same panel. What separates the bar from the
92
+ // terminal is the terminal's own background, nothing drawn.
84
93
  const edge = (
85
- <box width={1} flexShrink={0} backgroundColor={t.border} onMouseDown={handleEdgeMouseDown} />
94
+ <box
95
+ width={GUTTER}
96
+ flexShrink={0}
97
+ backgroundColor={t.backgroundPanel}
98
+ onMouseDown={handleEdgeMouseDown}
99
+ />
86
100
  )
87
101
 
88
102
  const body = (
@@ -93,7 +107,6 @@ export function Bar({
93
107
  bodyRef={bodyRef}
94
108
  contentWidth={contentWidth}
95
109
  grow={widget.grow}
96
- handleColor={t.border}
97
110
  index={index}
98
111
  isLast={index === visible.length - 1}
99
112
  onBoundaryResizeStart={onBoundaryResizeStart}
@@ -109,7 +122,7 @@ export function Bar({
109
122
  width={width}
110
123
  padding={0}
111
124
  flexDirection="row"
112
- backgroundColor={t.background}
125
+ backgroundColor={t.backgroundPanel}
113
126
  gap={0}
114
127
  overflow="hidden"
115
128
  rightClickMenu={buildBarContextMenu(bars, side)}
@@ -118,7 +131,17 @@ export function Bar({
118
131
  onMouseUp={handleMouseUp}
119
132
  >
120
133
  {side === 'right' ? edge : null}
121
- <box width={contentWidth} flexGrow={1} flexDirection="column" overflow="hidden">
134
+ {/* The bar is one surface, top to bottom: widgets, the gaps between them,
135
+ the footer and the gutter all share it, and it runs straight into the
136
+ tab bar above. Painting each widget separately left the gaps and the
137
+ footer showing the page colour through, which read as seams. */}
138
+ <box
139
+ width={contentWidth}
140
+ flexGrow={1}
141
+ flexDirection="column"
142
+ overflow="hidden"
143
+ backgroundColor={t.backgroundPanel}
144
+ >
122
145
  {body}
123
146
  {side === 'left' ? <BarFooter contentWidth={contentWidth} /> : null}
124
147
  </box>
@@ -131,7 +154,6 @@ function BarWidgetSlot({
131
154
  bodyRef,
132
155
  contentWidth,
133
156
  grow,
134
- handleColor,
135
157
  index,
136
158
  isLast,
137
159
  onBoundaryResizeStart,
@@ -141,7 +163,6 @@ function BarWidgetSlot({
141
163
  bodyRef: React.RefObject<BoxRenderable | null>
142
164
  contentWidth: number
143
165
  grow: number
144
- handleColor: string
145
166
  index: number
146
167
  isLast: boolean
147
168
  side: BarSide
@@ -181,12 +202,16 @@ function BarWidgetSlot({
181
202
  >
182
203
  {render(contentWidth)}
183
204
  </ContextMenuBox>
205
+ {/* The boundary between two widgets: a blank grabbable row, no rule drawn
206
+ in it. Widgets read as separate because of the gap, the same way
207
+ opencode separates its surfaces. */}
184
208
  {isLast ? null : (
185
- <box minHeight={1} flexShrink={0} onMouseDown={handleBoundaryMouseDown}>
186
- <text fg={handleColor} selectable={false}>
187
- {RESIZE_HANDLE.repeat(Math.max(1, contentWidth))}
188
- </text>
189
- </box>
209
+ <box
210
+ minHeight={1}
211
+ width={contentWidth}
212
+ flexShrink={0}
213
+ onMouseDown={handleBoundaryMouseDown}
214
+ />
190
215
  )}
191
216
  </>
192
217
  )
@@ -234,7 +234,6 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
234
234
  <ProjectRow
235
235
  key={`ws:${project.id}`}
236
236
  project={project}
237
- inCurrentGroup={isCurrentProject}
238
237
  projectIndex={projectIndex}
239
238
  dragging={draggingId === project.id}
240
239
  contentWidth={contentWidth}
@@ -249,7 +248,6 @@ export function ProjectList({ contentWidth }: ProjectListProps) {
249
248
  workspace={workspace}
250
249
  projectIndex={projectIndex}
251
250
  isActiveItem={isCurrentProject && workspace.id === activeWorkspaceId}
252
- inCurrentGroup={isCurrentProject}
253
251
  contentWidth={contentWidth}
254
252
  />
255
253
  )
@@ -300,13 +298,6 @@ function DropGap({ active, contentWidth, index, setGapRef }: DropGapProps) {
300
298
 
301
299
  interface ProjectRowProps {
302
300
  project: ProjectRecord
303
- /**
304
- * True when this row belongs to the current project (selection scope).
305
- *
306
- * There is deliberately no `isActiveItem`: the cursor lives on workspace
307
- * rows, and this row is the heading they sit under.
308
- */
309
- inCurrentGroup: boolean
310
301
  /** 1-based index in the visible order, so the "+" can switch projects first. */
311
302
  projectIndex: number
312
303
  dragging: boolean
@@ -318,7 +309,6 @@ interface ProjectRowProps {
318
309
  const ProjectRow = memo(function ProjectRow({
319
310
  contentWidth,
320
311
  dragging,
321
- inCurrentGroup,
322
312
  onDragStart,
323
313
  project,
324
314
  projectIndex,
@@ -329,12 +319,12 @@ const ProjectRow = memo(function ProjectRow({
329
319
  const base = useBaseTheme()
330
320
  // Only the drag highlight is "selected"-strength here. A heading that lights
331
321
  // up like a cursor row is what made the project look like a workspace.
332
- let bgColor: string | undefined
333
- if (dragging) {
334
- bgColor = base.backgroundElement
335
- } else if (inCurrentGroup) {
336
- bgColor = base.backgroundPanel
337
- }
322
+ // No band for "this row's project is the current one": the bar is a single
323
+ // backgroundPanel surface now and backgroundElement is spoken for by the
324
+ // cursor row, which leaves no third tone that works in every theme. The
325
+ // cursor row one step off the panel, plus its accent bar — is what says
326
+ // where you are; the group it sits in follows from that.
327
+ const bgColor = dragging ? base.backgroundElement : undefined
338
328
  const currentProjectId = useAppStore((s) => s.currentProjectId)
339
329
 
340
330
  const handleMouseDown = useCallback(
@@ -33,14 +33,11 @@ interface WorkspaceRowProps {
33
33
  projectIndex: number
34
34
  /** True when this row is the active cursor item. */
35
35
  isActiveItem: boolean
36
- /** True when this row's project is the current project (selection scope). */
37
- inCurrentGroup: boolean
38
36
  contentWidth: number
39
37
  }
40
38
 
41
39
  export const WorkspaceRow = memo(function WorkspaceRow({
42
40
  contentWidth,
43
- inCurrentGroup,
44
41
  isActiveItem,
45
42
  project,
46
43
  projectIndex,
@@ -134,12 +131,12 @@ export const WorkspaceRow = memo(function WorkspaceRow({
134
131
  return entries
135
132
  }, [project.id, workspace.branch, workspace.id, workspace.name, workspace.source])
136
133
 
137
- let bgColor: string | undefined
138
- if (isActiveItem) {
139
- bgColor = base.backgroundElement
140
- } else if (inCurrentGroup) {
141
- bgColor = base.backgroundPanel
142
- }
134
+ // No band for "this row's project is the current one": the bar is a single
135
+ // backgroundPanel surface now and backgroundElement is spoken for by the
136
+ // cursor row, which leaves no third tone that works in every theme. The
137
+ // cursor row one step off the panel, plus its accent bar — is what says
138
+ // where you are; the group it sits in follows from that.
139
+ const bgColor = isActiveItem ? base.backgroundElement : undefined
143
140
  // The cursor: a full-height accent bar down the left of the row, both lines
144
141
  // of it. The background alone is one step of grey and gets lost among rows
145
142
  // that carry colour of their own; a bar the height of the row is found
@@ -129,9 +129,15 @@ export function StatusBar() {
129
129
 
130
130
  const glyphs = SEPARATOR_GLYPHS[getStatusBarSeparator()]
131
131
 
132
- const tileB = t.backgroundElement
133
- const tileFiller = t.backgroundPanel
134
- const tileX = t.backgroundElement
132
+ // The bar's own band is backgroundElement, not backgroundPanel: the chrome
133
+ // directly above it — the tab bar and both widget bars — is panel, and with no
134
+ // rules left anywhere the status bar merged into it. Its two mid tiles drop to
135
+ // panel to keep a step between tile and filler. Which of the three tones is
136
+ // lighter is a theme's business (catppuccin recesses, aimux lifts); all that
137
+ // matters is that they are three different tokens.
138
+ const tileB = t.backgroundPanel
139
+ const tileFiller = t.backgroundElement
140
+ const tileX = t.backgroundPanel
135
141
  const tileY = modeColor
136
142
 
137
143
  const hasB = model.projectSegments.length > 0
@@ -44,12 +44,6 @@ interface TerminalPaneProps {
44
44
  junctionEdges?: JunctionEdges
45
45
  }
46
46
 
47
- function getBorderColor(isActive: boolean, focusMode: TerminalPaneProps['focusMode']): string {
48
- const t = getCurrentTheme()
49
- if (!isActive) return t.border
50
- return focusMode === 'terminal-input' ? t.accent : t.primary
51
- }
52
-
53
47
  function renderSpan(span: TerminalSpan, key: string, softCursor: boolean): ReactNode {
54
48
  let node: ReactNode = span.text
55
49
 
@@ -177,15 +171,19 @@ const NOOP = (): void => {}
177
171
  // wraps) a wheel event bumps _scrollY, and opentui's onResize never re-clamps it,
178
172
  // so the offset sticks for the renderable's life: the viewport shifts up, dead
179
173
  // rows appear at the bottom, and the prompt scrolls off-screen after `clear`.
180
- // Pin the offset to 0 and disable the built-in wheel handler outright (it also
181
- // drives horizontal scroll once wrapMode is "none"). The event still bubbles to
182
- // forwardScrollEvent, so local scrollback / mouse-forwarding keep working, and
183
- // selection is unaffected (it's driven by the renderer, not handleScroll).
184
- // handleScroll is protected; reach it via Reflect, mirroring the scrollY reset
185
- // already used in TerminalPane.
174
+ //
175
+ // Kill the built-in wheel handler at its dispatch point: processMouseEvent calls
176
+ // onMouseEvent() unconditionally after any React mouse listener, and
177
+ // TextBufferRenderable.onMouseEvent() unconditionally calls handleScroll() for a
178
+ // scroll event. Overriding onMouseEvent (rather than only handleScroll) is a
179
+ // single deterministic no-op standing between every wheel step and _scrollY, so
180
+ // the drift can no longer slip in on a path the later reset misses. Selection is
181
+ // unaffected: it is driven by the renderer (startSelection/updateSelection), not
182
+ // by onMouseEvent, which exists only to scroll. Both are protected; reach them
183
+ // via Reflect, mirroring the scrollY reset already used elsewhere in the pane.
186
184
  const pinTerminalScroll = (node: TextRenderable | null): void => {
187
185
  if (!node) return
188
- Reflect.set(node, 'handleScroll', NOOP)
186
+ Reflect.set(node, 'onMouseEvent', NOOP)
189
187
  if (node.scrollY !== 0) node.scrollY = 0
190
188
  }
191
189
 
@@ -498,9 +496,13 @@ export function TerminalPane({
498
496
  return (
499
497
  <box flexDirection="column" flexGrow={1} gap={0}>
500
498
  <ContextMenuBox
501
- border
502
- borderColor={getBorderColor(paneIsActive, focusMode)}
503
- padding={0}
499
+ // No frame, in any state — not around the pane, not between two split
500
+ // panes, and nothing that colours itself by focus mode. What was the
501
+ // border is now plain padding: same one-cell inset, so the geometry
502
+ // PANE_BORDER describes and the resize/hit-test gutter both still hold,
503
+ // but the cells are painted in the editor background like everything
504
+ // else. Surfaces are told apart by their background alone.
505
+ padding={1}
504
506
  flexDirection="column"
505
507
  flexGrow={1}
506
508
  backgroundColor={editorBg}
@@ -5,6 +5,7 @@ import { useCallback, useLayoutEffect, useMemo } from 'react'
5
5
  import { collectHelpEntries, HELP_MODE_LABELS } from '../../../../input/keymap/help-entries'
6
6
  import { dispatchGlobal } from '../../../../state/dispatch-ref'
7
7
  import { useKeymap } from '../../../keymap-context'
8
+ import { useSelectionInk } from '../../../selection-ink'
8
9
  import { useTheme } from '../../../theme'
9
10
  import { uiTokens } from '../../../ui-tokens'
10
11
  import { Picker, type PickerItem } from '../shared/picker'
@@ -24,6 +25,7 @@ function matchesFilter(needle: string, ...fields: (string | undefined)[]): boole
24
25
 
25
26
  export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModalProps) {
26
27
  const t = useTheme()
28
+ const ink = useSelectionInk()
27
29
  const config = useKeymap()
28
30
  const allEntries = useMemo(() => collectHelpEntries(config), [config])
29
31
  const scoped = useMemo(
@@ -50,21 +52,24 @@ export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModal
50
52
 
51
53
  const items = useMemo<PickerItem[]>(
52
54
  () =>
53
- filtered.map((entry, index) => ({
54
- group: entry.modeLabel,
55
- key: `${entry.mode}::${entry.keysDisplay}::${entry.description ?? ''}::${index}`,
56
- title: (
57
- <text fg={t.textMuted} wrapMode="none">
58
- {entry.description ?? ''}
59
- </text>
60
- ),
61
- trailing: (
62
- <text fg={t.textMuted} wrapMode="none">
63
- {entry.keysDisplay}
64
- </text>
65
- ),
66
- })),
67
- [filtered, t]
55
+ filtered.map((entry, index) => {
56
+ const active = index === selectedIndex
57
+ return {
58
+ group: entry.modeLabel,
59
+ key: `${entry.mode}::${entry.keysDisplay}::${entry.description ?? ''}::${index}`,
60
+ title: (
61
+ <text fg={active ? ink : t.textMuted} wrapMode="none">
62
+ {entry.description ?? ''}
63
+ </text>
64
+ ),
65
+ trailing: (
66
+ <text fg={active ? ink : t.textMuted} wrapMode="none">
67
+ {entry.keysDisplay}
68
+ </text>
69
+ ),
70
+ }
71
+ }),
72
+ [filtered, ink, selectedIndex, t]
68
73
  )
69
74
 
70
75
  const handleHover = useCallback(
@@ -1,3 +1,4 @@
1
+ import { useSelectionInk } from '../../../selection-ink'
1
2
  import { useTheme } from '../../../theme'
2
3
  import { uiTokens } from '../../../ui-tokens'
3
4
  import { ListItem } from '../../primitives/list-item'
@@ -20,6 +21,7 @@ export function UpdateAvailableModal({
20
21
  selectedIndex,
21
22
  }: UpdateAvailableModalProps) {
22
23
  const t = useTheme()
24
+ const ink = useSelectionInk()
23
25
  return (
24
26
  <ModalShell
25
27
  title="Update available"
@@ -36,7 +38,7 @@ export function UpdateAvailableModal({
36
38
  key={option.label}
37
39
  active={active}
38
40
  direction="row"
39
- title={<text fg={active ? t.text : t.textMuted}>{option.label}</text>}
41
+ title={<text fg={active ? ink : t.textMuted}>{option.label}</text>}
40
42
  />
41
43
  )
42
44
  })}
@@ -172,9 +172,9 @@ export function GitCommitModal({
172
172
 
173
173
  {isConfirm || isGenerating || !isAutoCommitEnabled() ? null : (
174
174
  <box flexDirection="row" gap={1} marginTop={1} alignItems="center">
175
+ {/* Filled, not framed — the same button the setup widget uses. */}
175
176
  <box
176
- border
177
- borderColor={t.border}
177
+ backgroundColor={t.backgroundElement}
178
178
  paddingLeft={1}
179
179
  paddingRight={1}
180
180
  flexDirection="row"
@@ -3,6 +3,7 @@ import { useMemo } from 'react'
3
3
  import type { DirectoryResult } from '../../../../state/types'
4
4
 
5
5
  import { abbreviatePath } from '../../../path-format'
6
+ import { useSelectionInk } from '../../../selection-ink'
6
7
  import { getCurrentTheme, useTheme } from '../../../theme'
7
8
  import { uiTokens } from '../../../ui-tokens'
8
9
  import { AutoComplete, Form, type FormOptionItem, TextField } from '../shared/form'
@@ -40,6 +41,7 @@ export function CreateProjectModal({
40
41
  selectedIndex,
41
42
  }: CreateProjectModalProps) {
42
43
  const t = useTheme()
44
+ const ink = useSelectionInk()
43
45
  const dirActive = activeField === 'directory'
44
46
  const nameActive = activeField === 'name'
45
47
 
@@ -47,12 +49,16 @@ export function CreateProjectModal({
47
49
  () =>
48
50
  results.map((result) => ({
49
51
  key: result.path,
50
- leading: <text fg={getDirectoryResultColor(result)}>{getDirectoryResultIcon(result)}</text>,
52
+ leading: (active) => (
53
+ <text fg={active ? ink : getDirectoryResultColor(result)}>
54
+ {getDirectoryResultIcon(result)}
55
+ </text>
56
+ ),
51
57
  title: (active) => (
52
- <text fg={active ? t.text : t.textMuted}>{abbreviatePath(result.path)}</text>
58
+ <text fg={active ? ink : t.textMuted}>{abbreviatePath(result.path)}</text>
53
59
  ),
54
60
  })),
55
- [results, t]
61
+ [ink, results, t]
56
62
  )
57
63
 
58
64
  return (
@@ -6,6 +6,7 @@ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-
6
6
  import { filterProjects } from '../../../../state/selectors'
7
7
  import { abbreviatePath } from '../../../path-format'
8
8
  import { orderProjectsForDisplay } from '../../../project-ordering'
9
+ import { useSelectionInk } from '../../../selection-ink'
9
10
  import { useTheme } from '../../../theme'
10
11
  import { uiTokens } from '../../../ui-tokens'
11
12
  import { Picker, type PickerItem } from '../shared/picker'
@@ -44,6 +45,7 @@ export function ProjectPickerModal({
44
45
  selectedIndex,
45
46
  }: ProjectPickerModalProps) {
46
47
  const t = useTheme()
48
+ const ink = useSelectionInk()
47
49
  const ordered = useMemo(() => orderProjectsForDisplay(projects), [projects])
48
50
  const filtered = useMemo(() => filterProjects(ordered, filter), [filter, ordered])
49
51
  const hasFilter = !!(filter != null && filter !== '')
@@ -59,10 +61,10 @@ export function ProjectPickerModal({
59
61
  onDelete: () => runSideEffectGlobal({ type: 'delete-selected-project' }),
60
62
  subtitle:
61
63
  project.projectPath != null && project.projectPath !== '' ? (
62
- <text fg={t.textMuted}>{abbreviatePath(project.projectPath)}</text>
64
+ <text fg={active ? ink : t.textMuted}>{abbreviatePath(project.projectPath)}</text>
63
65
  ) : undefined,
64
66
  title: (
65
- <text fg={active ? t.text : t.textMuted}>
67
+ <text fg={active ? ink : t.textMuted}>
66
68
  {formatProjectLine(project, currentProjectId, currentTabCount, displayIndex)}
67
69
  </text>
68
70
  ),
@@ -72,13 +74,11 @@ export function ProjectPickerModal({
72
74
  key: '__create-new__',
73
75
  onClick: () => runSideEffectGlobal({ type: 'confirm-selected-project' }),
74
76
  title: (
75
- <text fg={selectedIndex === filtered.length ? t.text : t.textMuted}>
76
- Create new project
77
- </text>
77
+ <text fg={selectedIndex === filtered.length ? ink : t.textMuted}>Create new project</text>
78
78
  ),
79
79
  }
80
80
  return [...projectItems, createNewItem]
81
- }, [currentProjectId, currentTabCount, filtered, ordered, selectedIndex, t])
81
+ }, [currentProjectId, currentTabCount, filtered, ink, ordered, selectedIndex, t])
82
82
 
83
83
  const handleHover = useCallback(
84
84
  (index: number) => dispatchGlobal({ index, type: 'set-modal-selection-index' }),
@@ -3,6 +3,7 @@ import { useCallback, useMemo } from 'react'
3
3
  import { filterSettingRows } from '../../../../settings/search'
4
4
  import { useAppStore } from '../../../../state/app-store'
5
5
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
6
+ import { useSelectionInk } from '../../../selection-ink'
6
7
  import { useTheme } from '../../../theme'
7
8
  import { uiTokens } from '../../../ui-tokens'
8
9
  import { RowValue } from '../../settings/row-value'
@@ -28,25 +29,29 @@ export function SettingsSearchModal({
28
29
  selectedIndex,
29
30
  }: SettingsSearchModalProps) {
30
31
  const t = useTheme()
32
+ const ink = useSelectionInk()
31
33
  const projects = useAppStore((s) => s.projects)
32
34
  const hits = useMemo(() => filterSettingRows(projects, filter), [projects, filter])
33
35
 
34
36
  const items = useMemo<PickerItem[]>(
35
37
  () =>
36
- hits.map((hit) => ({
37
- group: hit.sectionLabel,
38
- key: hit.row.id,
39
- // Acts on the selection, which the hover just set — the same two-step
40
- // every other picker's rows use.
41
- onClick: () => runSideEffectGlobal({ type: 'confirm-settings-search' }),
42
- subtitle:
43
- hit.row.description != null && hit.row.description !== '' ? (
44
- <text fg={t.textMuted}>{hit.row.description}</text>
45
- ) : undefined,
46
- title: <text fg={t.text}>{hit.row.label}</text>,
47
- trailing: <RowValue row={hit.row} />,
48
- })),
49
- [hits, t]
38
+ hits.map((hit, index) => {
39
+ const active = index === selectedIndex
40
+ return {
41
+ group: hit.sectionLabel,
42
+ key: hit.row.id,
43
+ // Acts on the selection, which the hover just set — the same two-step
44
+ // every other picker's rows use.
45
+ onClick: () => runSideEffectGlobal({ type: 'confirm-settings-search' }),
46
+ subtitle:
47
+ hit.row.description != null && hit.row.description !== '' ? (
48
+ <text fg={active ? ink : t.textMuted}>{hit.row.description}</text>
49
+ ) : undefined,
50
+ title: <text fg={active ? ink : t.text}>{hit.row.label}</text>,
51
+ trailing: <RowValue fg={active ? ink : undefined} row={hit.row} />,
52
+ }
53
+ }),
54
+ [hits, ink, selectedIndex, t]
50
55
  )
51
56
 
52
57
  const handleHover = useCallback(
@@ -37,11 +37,21 @@ export function ModalShell({
37
37
  justifyContent="center"
38
38
  alignItems="center"
39
39
  >
40
+ {/* No frame, like everything else — the panel background is what lifts the
41
+ modal off the terminal, and the extra column either side is what makes
42
+ it read as a card rather than a box drawn on the screen.
43
+
44
+ Transparent mode is the one exception that keeps the border: there is
45
+ no background there to lift anything, so without a rule the modal has
46
+ no edge at all. */}
40
47
  <box
41
- border
48
+ border={transparent}
42
49
  borderColor={t.border}
43
50
  backgroundColor={bg}
44
- padding={1}
51
+ paddingTop={1}
52
+ paddingBottom={1}
53
+ paddingLeft={2}
54
+ paddingRight={2}
45
55
  width={width}
46
56
  renderAfter={transparent ? fillBorderedBoxInterior : undefined}
47
57
  >
@@ -4,6 +4,7 @@ import type { MouseEvent as OtuiMouseEvent, ScrollBoxRenderable } from '@opentui
4
4
  import { useTerminalDimensions } from '@opentui/react'
5
5
  import { type ReactNode, useCallback, useLayoutEffect, useMemo, useRef } from 'react'
6
6
 
7
+ import { useSelectionInk } from '../../../selection-ink'
7
8
  import { useTheme } from '../../../theme'
8
9
  import { BareInput } from '../../primitives/bare-input'
9
10
  import { ListItem } from '../../primitives/list-item'
@@ -48,7 +49,10 @@ function PickerItemCtas({
48
49
  onDelete?: () => void
49
50
  onWorkspace?: () => void
50
51
  }) {
51
- const t = useTheme()
52
+ // These only ever render on the selected row, which is filled with `primary` —
53
+ // so they wear its ink, not the primary/warning/error they would carry
54
+ // anywhere else. `[edit]` in primary on primary is nothing at all.
55
+ const ink = useSelectionInk()
52
56
  const handleEdit = useCallback(
53
57
  (event: OtuiMouseEvent) => {
54
58
  event.stopPropagation()
@@ -74,17 +78,17 @@ function PickerItemCtas({
74
78
  <box flexDirection="row" gap={1}>
75
79
  {onEdit ? (
76
80
  <box onMouseDown={handleEdit}>
77
- <text fg={t.primary}>[edit]</text>
81
+ <text fg={ink}>[edit]</text>
78
82
  </box>
79
83
  ) : null}
80
84
  {onWorkspace ? (
81
85
  <box onMouseDown={handleWorkspace}>
82
- <text fg={t.warning}>[WT]</text>
86
+ <text fg={ink}>[WT]</text>
83
87
  </box>
84
88
  ) : null}
85
89
  {onDelete ? (
86
90
  <box onMouseDown={handleDelete}>
87
- <text fg={t.error}>[del]</text>
91
+ <text fg={ink}>[del]</text>
88
92
  </box>
89
93
  ) : null}
90
94
  </box>
@@ -5,6 +5,7 @@ import type { SnippetRecord } from '../../../../state/types'
5
5
  import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-ref'
6
6
  import { filterSnippets } from '../../../../state/selectors'
7
7
  import { isConfigSnippetId } from '../../../../state/snippet-catalog'
8
+ import { useSelectionInk } from '../../../selection-ink'
8
9
  import { useTheme } from '../../../theme'
9
10
  import { uiTokens } from '../../../ui-tokens'
10
11
  import { Picker, type PickerItem } from '../shared/picker'
@@ -33,6 +34,7 @@ export function SnippetPickerModal({
33
34
  snippets,
34
35
  }: SnippetPickerModalProps) {
35
36
  const t = useTheme()
37
+ const ink = useSelectionInk()
36
38
  const filtered = useMemo(() => filterSnippets(snippets, filter), [filter, snippets])
37
39
 
38
40
  const items = useMemo<PickerItem[]>(
@@ -52,21 +54,21 @@ export function SnippetPickerModal({
52
54
  onEdit: fromConfig
53
55
  ? undefined
54
56
  : () => runSideEffectGlobal({ type: 'edit-selected-snippet' }),
55
- subtitle: <text fg={t.textMuted}>{truncateContent(snippet.content)}</text>,
57
+ subtitle: <text fg={active ? ink : t.textMuted}>{truncateContent(snippet.content)}</text>,
56
58
  title: (
57
59
  <box flexDirection="row">
58
- <text fg={active ? t.text : t.textMuted}>
60
+ <text fg={active ? ink : t.textMuted}>
59
61
  <strong>{snippet.name}</strong>
60
62
  </text>
61
63
  {snippet.trigger != null && snippet.trigger !== '' ? (
62
- <text fg={t.textMuted}>{` :${snippet.trigger}`}</text>
64
+ <text fg={active ? ink : t.textMuted}>{` :${snippet.trigger}`}</text>
63
65
  ) : null}
64
- {fromConfig ? <text fg={t.textMuted}>{' [config]'}</text> : null}
66
+ {fromConfig ? <text fg={active ? ink : t.textMuted}>{' [config]'}</text> : null}
65
67
  </box>
66
68
  ),
67
69
  }
68
70
  }),
69
- [filtered, selectedIndex, t]
71
+ [filtered, ink, selectedIndex, t]
70
72
  )
71
73
 
72
74
  const handleHover = useCallback(