@brimveyn/aimux 1.23.7 → 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 (37) hide show
  1. package/package.json +2 -2
  2. package/src/input/keymap/key-chord.ts +10 -0
  3. package/src/input/keymap/key-format.ts +14 -1
  4. package/src/input/modes/handlers/shared.ts +48 -0
  5. package/src/state/actions.ts +2 -1
  6. package/src/state/reducers/modal-state.ts +24 -28
  7. package/src/state/text-cursor.ts +117 -0
  8. package/src/ui/breaking-update-screen.tsx +7 -1
  9. package/src/ui/components/layout/bar-footer.tsx +3 -5
  10. package/src/ui/components/layout/bar.tsx +41 -16
  11. package/src/ui/components/layout/sidebar/project-list.tsx +6 -16
  12. package/src/ui/components/layout/sidebar/workspace-row.tsx +6 -9
  13. package/src/ui/components/layout/status-bar.tsx +9 -3
  14. package/src/ui/components/layout/terminal-pane.tsx +18 -16
  15. package/src/ui/components/modals/app/help-modal.tsx +20 -15
  16. package/src/ui/components/modals/app/update-available-modal.tsx +3 -1
  17. package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
  18. package/src/ui/components/modals/projects/create-project-modal.tsx +9 -3
  19. package/src/ui/components/modals/projects/project-picker-modal.tsx +6 -6
  20. package/src/ui/components/modals/settings/settings-search-modal.tsx +19 -14
  21. package/src/ui/components/modals/shared/modal-shell.tsx +12 -2
  22. package/src/ui/components/modals/shared/picker.tsx +8 -4
  23. package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +7 -5
  24. package/src/ui/components/modals/tabs/new-tab-modal.tsx +10 -12
  25. package/src/ui/components/modals/themes/theme-picker-modal.tsx +14 -10
  26. package/src/ui/components/modals/workspace/create-workspace-modal.tsx +19 -14
  27. package/src/ui/components/modals/workspace/workspace-move-modal.tsx +4 -2
  28. package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +10 -4
  29. package/src/ui/components/primitives/list-item.tsx +24 -2
  30. package/src/ui/components/primitives/surface.tsx +4 -1
  31. package/src/ui/components/settings/row-value.tsx +8 -1
  32. package/src/ui/components/settings/settings-footer.tsx +3 -2
  33. package/src/ui/components/settings/settings-row.tsx +3 -0
  34. package/src/ui/components/settings/settings-search-bar.tsx +4 -6
  35. package/src/ui/components/settings/settings-view.tsx +25 -21
  36. package/src/ui/components/stats/stats-view.tsx +4 -2
  37. package/src/ui/selection-ink.ts +20 -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(
@@ -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
  />