@brimveyn/aimux 1.23.7 → 1.24.1

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 (43) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +19 -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/state/actions.ts +3 -2
  7. package/src/state/reducers/modal-state.ts +24 -28
  8. package/src/state/text-cursor.ts +117 -0
  9. package/src/state/types.ts +1 -1
  10. package/src/state/workspace-delete-store.ts +32 -0
  11. package/src/ui/breaking-update-screen.tsx +7 -1
  12. package/src/ui/components/layout/bar-footer.tsx +3 -5
  13. package/src/ui/components/layout/bar.tsx +41 -16
  14. package/src/ui/components/layout/sidebar/project-list.tsx +6 -16
  15. package/src/ui/components/layout/sidebar/workspace-row.tsx +22 -15
  16. package/src/ui/components/layout/status-bar.tsx +9 -3
  17. package/src/ui/components/layout/terminal-pane.tsx +18 -16
  18. package/src/ui/components/modals/app/help-modal.tsx +20 -15
  19. package/src/ui/components/modals/app/update-available-modal.tsx +3 -1
  20. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  21. package/src/ui/components/modals/projects/create-project-modal.tsx +9 -3
  22. package/src/ui/components/modals/projects/project-picker-modal.tsx +6 -6
  23. package/src/ui/components/modals/settings/settings-search-modal.tsx +19 -14
  24. package/src/ui/components/modals/shared/modal-shell.tsx +12 -2
  25. package/src/ui/components/modals/shared/picker.tsx +8 -4
  26. package/src/ui/components/modals/shared/workspace-delete-confirm.tsx +8 -7
  27. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +7 -5
  28. package/src/ui/components/modals/tabs/new-tab-modal.tsx +10 -12
  29. package/src/ui/components/modals/themes/theme-picker-modal.tsx +14 -10
  30. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +19 -14
  31. package/src/ui/components/modals/workspace/workspace-move-modal.tsx +4 -2
  32. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +10 -4
  33. package/src/ui/components/overlays/workspace-deleting-overlay.tsx +29 -0
  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/root.tsx +2 -0
  43. package/src/ui/selection-ink.ts +20 -0
@@ -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(
@@ -9,6 +9,7 @@ import { dispatchGlobal, runSideEffectGlobal } from '../../../../state/dispatch-
9
9
  import { formatDiffStat } from '../../../../state/project-workspaces'
10
10
  // eslint-disable-next-line no-duplicate-imports
11
11
  import { IDLE_WORKSPACE_ACTIVITY } from '../../../../state/types'
12
+ import { useWorkspaceDeleteStore } from '../../../../state/workspace-delete-store'
12
13
  import { useActivitySprite } from '../../../hooks/use-activity-sprite'
13
14
  import { useBusySpinner } from '../../../hooks/use-busy-spinner'
14
15
  // eslint-disable-next-line no-duplicate-imports
@@ -33,14 +34,11 @@ interface WorkspaceRowProps {
33
34
  projectIndex: number
34
35
  /** True when this row is the active cursor item. */
35
36
  isActiveItem: boolean
36
- /** True when this row's project is the current project (selection scope). */
37
- inCurrentGroup: boolean
38
37
  contentWidth: number
39
38
  }
40
39
 
41
40
  export const WorkspaceRow = memo(function WorkspaceRow({
42
41
  contentWidth,
43
- inCurrentGroup,
44
42
  isActiveItem,
45
43
  project,
46
44
  projectIndex,
@@ -59,9 +57,13 @@ export const WorkspaceRow = memo(function WorkspaceRow({
59
57
  // own: one shape is what keeps a cell from resolving to the wrong image.
60
58
  const hasBranch = workspace.branch != null && workspace.branch !== ''
61
59
  const sprite = useActivitySprite(hasBranch ? spriteStateFor(activity) : null)
60
+ // A delete takes seconds of git work with the row still on screen. The branch
61
+ // line is where it says so — the name above it is what you are looking for,
62
+ // and the branch under it is the one fact the delete is about to take away.
63
+ const isDeleting = useWorkspaceDeleteStore((s) => s.deleting[workspace.id] !== undefined)
62
64
  // Only the working case animates, so the timer is off for every other row —
63
65
  // and off entirely when a sprite is drawing this row instead.
64
- const spinner = useBusySpinner(activity.working && sprite === null)
66
+ const spinner = useBusySpinner(isDeleting || (activity.working && sprite === null))
65
67
  // A primitive, so the selector stays referentially stable across renders.
66
68
  const hasSleepingTabs = useAppStore((s) =>
67
69
  s.tabs.some((tab) => tab.workspaceId === workspace.id && tab.hibernated === true)
@@ -124,7 +126,6 @@ export const WorkspaceRow = memo(function WorkspaceRow({
124
126
  closeTabs: true,
125
127
  force: false,
126
128
  projectId: project.id,
127
- reason: 'Its assistant tabs will be closed and the worktree removed.',
128
129
  type: 'open-workspace-delete-confirm',
129
130
  workspaceId: workspace.id,
130
131
  workspaceLabel: workspace.branch ?? workspace.name,
@@ -134,12 +135,12 @@ export const WorkspaceRow = memo(function WorkspaceRow({
134
135
  return entries
135
136
  }, [project.id, workspace.branch, workspace.id, workspace.name, workspace.source])
136
137
 
137
- let bgColor: string | undefined
138
- if (isActiveItem) {
139
- bgColor = base.backgroundElement
140
- } else if (inCurrentGroup) {
141
- bgColor = base.backgroundPanel
142
- }
138
+ // No band for "this row's project is the current one": the bar is a single
139
+ // backgroundPanel surface now and backgroundElement is spoken for by the
140
+ // cursor row, which leaves no third tone that works in every theme. The
141
+ // cursor row one step off the panel, plus its accent bar — is what says
142
+ // where you are; the group it sits in follows from that.
143
+ const bgColor = isActiveItem ? base.backgroundElement : undefined
143
144
  // The cursor: a full-height accent bar down the left of the row, both lines
144
145
  // of it. The background alone is one step of grey and gets lost among rows
145
146
  // that carry colour of their own; a bar the height of the row is found
@@ -246,7 +247,7 @@ export const WorkspaceRow = memo(function WorkspaceRow({
246
247
  </text>
247
248
  ) : null}
248
249
  </box>
249
- {branchLabel == null ? null : (
250
+ {branchLabel == null && !isDeleting ? null : (
250
251
  <box flexDirection="row" alignItems="center">
251
252
  <text fg={t.primary} selectable={false} wrapMode="none">
252
253
  {cursorGlyph}
@@ -257,9 +258,15 @@ export const WorkspaceRow = memo(function WorkspaceRow({
257
258
  <text fg={statusColor} selectable={false} wrapMode="none">
258
259
  {sprite?.glyphs[1] ?? ' '}
259
260
  </text>
260
- <text fg={t.textMuted} selectable={false} wrapMode="none">
261
- {'\u{e702}'} {branchLabel}
262
- </text>
261
+ {isDeleting ? (
262
+ <text fg={t.error} selectable={false} wrapMode="none">
263
+ {spinner} Deleting…
264
+ </text>
265
+ ) : (
266
+ <text fg={t.textMuted} selectable={false} wrapMode="none">
267
+ {'\u{e702}'} {branchLabel}
268
+ </text>
269
+ )}
263
270
  </box>
264
271
  )}
265
272
  </ContextMenuBox>
@@ -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>
@@ -6,7 +6,11 @@ import { Form } from './form'
6
6
 
7
7
  interface WorkspaceDeleteConfirmProps {
8
8
  keybindsModeId: ModeId
9
- reason: string
9
+ /**
10
+ * One short line naming what confirming costs beyond the delete itself.
11
+ * Omitted for the ordinary case, where the title already said everything.
12
+ */
13
+ reason?: string
10
14
  workspaceLabel: string
11
15
  }
12
16
 
@@ -24,16 +28,13 @@ export function WorkspaceDeleteConfirm({
24
28
  return (
25
29
  <Form
26
30
  title="Delete workspace?"
31
+ subtitle={workspaceLabel}
27
32
  keybindsModeId={keybindsModeId}
28
33
  width={uiTokens.modalWidth.md}
34
+ // Destructive confirmation — the one place §7 keeps an inline key hint.
29
35
  footer={<text fg={t.textMuted}>Enter / y to delete · Esc / n to cancel</text>}
30
36
  >
31
- <box flexDirection="column" gap={1}>
32
- <text fg={t.text}>
33
- Delete <strong>{workspaceLabel}</strong>?
34
- </text>
35
- <text fg={t.warning}>{reason}</text>
36
- </box>
37
+ {reason == null || reason === '' ? null : <text fg={t.textMuted}>{reason}</text>}
37
38
  </Form>
38
39
  )
39
40
  }
@@ -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(