@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.23.7",
3
+ "version": "1.24.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.10.7",
69
+ "@brimveyn/aimux-config": "0.10.8",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -11,8 +11,11 @@ export type KeyChord = string
11
11
  const SPECIAL_NAMES: Record<string, string> = {
12
12
  BS: 'backspace',
13
13
  CR: 'return',
14
+ Del: 'delete',
14
15
  Down: 'down',
16
+ End: 'end',
15
17
  Esc: 'escape',
18
+ Home: 'home',
16
19
  Left: 'left',
17
20
  Right: 'right',
18
21
  Space: 'space',
@@ -38,6 +41,12 @@ export function keyInputToChord(key: KeyInput): KeyChord {
38
41
  let prefix = ''
39
42
  if (ctrl) prefix += 'C-'
40
43
  if (meta) prefix += 'M-'
44
+ // Only for named keys. On a single character shift is already in the character
45
+ // itself (handled above), so an `S-` there would be a chord nothing produces.
46
+ // Whether Shift+Enter arrives at all is the terminal's call — it needs the
47
+ // kitty keyboard protocol or modifyOtherThanKeys to report the modifier, and
48
+ // where it does not the Ctrl+Enter binding beside it still fires.
49
+ if (shift && baseName.length > 1) prefix += 'S-'
41
50
 
42
51
  return `${prefix}${baseName}`
43
52
  }
@@ -136,6 +145,7 @@ function parseAngleBracketToken(inner: string): KeyChord {
136
145
  let prefix = ''
137
146
  if (ctrl) prefix += 'C-'
138
147
  if (meta) prefix += 'M-'
148
+ if (shift && keyName.length > 1) prefix += 'S-'
139
149
 
140
150
  return `${prefix}${keyName}`
141
151
  }
@@ -2,8 +2,11 @@ import { type KeyChord, parseKeyNotation } from './key-chord'
2
2
 
3
3
  const SPECIAL_DISPLAY: Record<string, string> = {
4
4
  backspace: 'Backspace',
5
+ delete: 'Del',
5
6
  down: '↓',
7
+ end: 'End',
6
8
  escape: 'Esc',
9
+ home: 'Home',
7
10
  left: '←',
8
11
  return: 'Enter',
9
12
  right: '→',
@@ -25,6 +28,10 @@ export function formatChord(chord: KeyChord, leader?: KeyChord): string {
25
28
 
26
29
  let ctrl = false
27
30
  let meta = false
31
+ // Shift rides as an `S-` prefix on named keys (`S-return`), and as an
32
+ // uppercase letter on the rest. Both end up as "Shift+…" here; without this
33
+ // the help overlay printed the raw chord.
34
+ let shift = false
28
35
  let rest = chord
29
36
 
30
37
  while (true) {
@@ -38,10 +45,15 @@ export function formatChord(chord: KeyChord, leader?: KeyChord): string {
38
45
  rest = rest.slice(2)
39
46
  continue
40
47
  }
48
+ if (rest.startsWith('S-')) {
49
+ shift = true
50
+ rest = rest.slice(2)
51
+ continue
52
+ }
41
53
  break
42
54
  }
43
55
 
44
- if (!ctrl && !meta) {
56
+ if (!ctrl && !meta && !shift) {
45
57
  if (rest.length === 1 && /^[A-Z]$/.test(rest)) {
46
58
  return `Shift+${rest}`
47
59
  }
@@ -51,6 +63,7 @@ export function formatChord(chord: KeyChord, leader?: KeyChord): string {
51
63
  const parts: string[] = []
52
64
  if (ctrl) parts.push('Ctrl')
53
65
  if (meta) parts.push('Alt')
66
+ if (shift) parts.push('Shift')
54
67
  parts.push(formatModifierKey(rest))
55
68
  return parts.join('+')
56
69
  }
@@ -40,11 +40,59 @@ export function handleModalSelectionKeys(
40
40
  return null
41
41
  }
42
42
 
43
+ /**
44
+ * Cursor motion for a key no binding claimed.
45
+ *
46
+ * Every modal that accepts typing is a `passthrough()` mode, and this is the one
47
+ * function they all fall through to — so a motion added here reaches every text
48
+ * field in the app at once, without a binding per modal. The modals that do bind
49
+ * an arrow (the update prompt's row of buttons, the git and settings screens)
50
+ * resolve it before it ever gets here.
51
+ */
52
+ function handleCursorMotion(key: KeyInput): KeyResult | null {
53
+ const word = key.ctrl || key.meta
54
+ switch (key.name) {
55
+ case 'left':
56
+ return result([
57
+ word
58
+ ? { to: 'word-left', type: 'move-modal-cursor' }
59
+ : { delta: -1, type: 'move-modal-cursor' },
60
+ ])
61
+ case 'right':
62
+ return result([
63
+ word
64
+ ? { to: 'word-right', type: 'move-modal-cursor' }
65
+ : { delta: 1, type: 'move-modal-cursor' },
66
+ ])
67
+ case 'home':
68
+ return result([{ to: 'home', type: 'move-modal-cursor' }])
69
+ case 'end':
70
+ return result([{ to: 'end', type: 'move-modal-cursor' }])
71
+ // Only reached in a modal that leaves Up/Down unbound — the ones that drive a
72
+ // list with them hand their own action a `moveModalCursorInField` fallback.
73
+ case 'up':
74
+ return result([{ to: 'line-up', type: 'move-modal-cursor' }])
75
+ case 'down':
76
+ return result([{ to: 'line-down', type: 'move-modal-cursor' }])
77
+ default:
78
+ return null
79
+ }
80
+ }
81
+
43
82
  export function handleTextInput(key: KeyInput): KeyResult | null {
83
+ const motion = handleCursorMotion(key)
84
+ if (motion) return motion
85
+
44
86
  if (key.name === 'backspace') {
45
87
  return result([{ char: '\b', type: 'update-command-edit' }])
46
88
  }
47
89
 
90
+ // DEL, the counterpart to backspace: both are commands rather than text
91
+ // because neither can arrive as a typed character.
92
+ if (key.name === 'delete') {
93
+ return result([{ char: '\x7f', type: 'update-command-edit' }])
94
+ }
95
+
48
96
  if (key.name === 'space') {
49
97
  return result([{ char: ' ', type: 'update-command-edit' }])
50
98
  }
@@ -7,6 +7,7 @@
7
7
  import type { ModeId } from '@brimveyn/aimux-config'
8
8
 
9
9
  import type { LayoutNode, SplitDirection } from './layout-tree'
10
+ import type { CursorTarget } from './text-cursor'
10
11
  import type {
11
12
  AssistantId,
12
13
  BarSide,
@@ -32,7 +33,7 @@ import type {
32
33
  } from './types'
33
34
 
34
35
  export type ModalAction =
35
- | { type: 'move-modal-cursor'; delta?: number; to?: 'home' | 'end' }
36
+ | { type: 'move-modal-cursor'; delta?: number; to?: CursorTarget }
36
37
  | {
37
38
  type: 'open-new-tab-modal'
38
39
  pendingWorkspace?: PendingWorkspaceLaunch
@@ -16,6 +16,7 @@ import {
16
16
  filterSnippets,
17
17
  getNewTabAssistantOptions,
18
18
  } from '../selectors'
19
+ import { applyEdit, moveCursor } from '../text-cursor'
19
20
  import { reduceGitCommitModalState } from './git-commit-modal-state'
20
21
 
21
22
  function emptyModal() {
@@ -106,6 +107,19 @@ function getCreateWorkspaceFieldValue(
106
107
  return field === 'prompt' ? modal.prompt : modal.baseQuery
107
108
  }
108
109
 
110
+ /**
111
+ * The buffer a cursor motion applies to. Every modal but one keeps the field it
112
+ * is editing in `editBuffer` — the multi-field ones (snippet editor, commit
113
+ * message) swap it on Tab — so that is the default. Create-workspace is the
114
+ * exception: its two fields are named, and neither is `editBuffer`.
115
+ */
116
+ function getModalCursorText(state: AppState): string | null {
117
+ if (state.modal.type === 'create-workspace') {
118
+ return getCreateWorkspaceFieldValue(state.modal, state.modal.activeField)
119
+ }
120
+ return state.modal.editBuffer
121
+ }
122
+
109
123
  export function reduceModalState(state: AppState, action: AppAction): AppState | null {
110
124
  // The one modal with a stage machine of its own, in its own file.
111
125
  const gitCommit = reduceGitCommitModalState(state, action)
@@ -604,14 +618,10 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
604
618
  return { ...state, modal: { ...state.modal, selectedIndex: clamped } }
605
619
  }
606
620
  case 'move-modal-cursor': {
607
- if (state.modal.editBuffer === null) return state
608
- const len = state.modal.editBuffer.length
609
- const current = state.modal.cursorPos ?? len
610
- let next = current
611
- if (action.to === 'home') next = 0
612
- else if (action.to === 'end') next = len
613
- else if (typeof action.delta === 'number') next = current + action.delta
614
- next = clampCursor(next, len)
621
+ const text = getModalCursorText(state)
622
+ if (text === null) return state
623
+ const current = clampCursor(state.modal.cursorPos ?? text.length, text.length)
624
+ const next = moveCursor(text, current, action)
615
625
  if (next === current) return state
616
626
  return { ...state, modal: { ...state.modal, cursorPos: next } }
617
627
  }
@@ -656,16 +666,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
656
666
  const field = state.modal.activeField
657
667
  const current = getCreateWorkspaceFieldValue(state.modal, field)
658
668
  const at = clampCursor(state.modal.cursorPos ?? current.length, current.length)
659
- let text: string
660
- let pos: number
661
- if (action.char === '\b') {
662
- if (at === 0) return state
663
- text = current.slice(0, at - 1) + current.slice(at)
664
- pos = at - 1
665
- } else {
666
- text = current.slice(0, at) + action.char + current.slice(at)
667
- pos = at + action.char.length
668
- }
669
+ const edit = applyEdit(current, at, action.char)
670
+ if (edit === null) return state
671
+ const { pos, text } = edit
669
672
  if (field === 'prompt') {
670
673
  return {
671
674
  ...state,
@@ -690,16 +693,9 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
690
693
  }
691
694
  const buffer = state.modal.editBuffer
692
695
  const cursor = clampCursor(state.modal.cursorPos ?? buffer.length, buffer.length)
693
- let nextBuffer: string
694
- let nextCursor: number
695
- if (action.char === '\b') {
696
- if (cursor === 0) return state
697
- nextBuffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor)
698
- nextCursor = cursor - 1
699
- } else {
700
- nextBuffer = buffer.slice(0, cursor) + action.char + buffer.slice(cursor)
701
- nextCursor = cursor + action.char.length
702
- }
696
+ const edit = applyEdit(buffer, cursor, action.char)
697
+ if (edit === null) return state
698
+ const { pos: nextCursor, text: nextBuffer } = edit
703
699
  const isNewTabEditing = state.modal.type === 'new-tab' && state.modal.editingCommand !== null
704
700
  const resetIndex =
705
701
  !isNewTabEditing &&
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Cursor motion inside a modal's text buffer.
3
+ *
4
+ * Pure string maths, kept out of the reducer so it can be read and tested on its
5
+ * own — the reducer only decides *which* buffer a motion applies to.
6
+ *
7
+ * ponytail: lines are `\n`-delimited, not visually wrapped. A paragraph that the
8
+ * input box soft-wraps over three rows is one line here, so Up/Down step over
9
+ * the whole paragraph. Fixing that means the motion needs the box's width, which
10
+ * lives in the renderer, not the store — worth doing only if the wrapped fields
11
+ * ever get long enough for it to bite.
12
+ */
13
+
14
+ /** Where a motion wants to go. `home`/`end` are line-wise, so they do the right thing in a one-line field too. */
15
+ export type CursorTarget = 'end' | 'home' | 'line-down' | 'line-up' | 'word-left' | 'word-right'
16
+
17
+ function clamp(value: number, max: number): number {
18
+ return Math.max(0, Math.min(max, value))
19
+ }
20
+
21
+ /**
22
+ * The `[start, end)` of the line `at` sits on. `end` is the newline's index (or
23
+ * the buffer's length on the last line), so it is where `End` parks.
24
+ */
25
+ function lineBounds(text: string, at: number): { end: number; start: number } {
26
+ // `lastIndexOf` clamps a negative `fromIndex` to 0, which would find a leading
27
+ // newline and put the start one past it — so column 0 is answered directly.
28
+ const start = at === 0 ? 0 : text.lastIndexOf('\n', at - 1) + 1
29
+ const newline = text.indexOf('\n', at)
30
+ return { end: newline === -1 ? text.length : newline, start }
31
+ }
32
+
33
+ const isWordChar = (ch: string | undefined): boolean => ch !== undefined && /\S/.test(ch)
34
+
35
+ /** Left edge of the word before the cursor: skip the gap, then the word. */
36
+ function wordLeft(text: string, at: number): number {
37
+ let i = at
38
+ while (i > 0 && !isWordChar(text[i - 1])) i--
39
+ while (i > 0 && isWordChar(text[i - 1])) i--
40
+ return i
41
+ }
42
+
43
+ /** Right edge of the word after the cursor, by the same rule. */
44
+ function wordRight(text: string, at: number): number {
45
+ let i = at
46
+ while (i < text.length && !isWordChar(text[i])) i++
47
+ while (i < text.length && isWordChar(text[i])) i++
48
+ return i
49
+ }
50
+
51
+ /** The same column on the line above, or as near to it as that line reaches. */
52
+ function lineUp(text: string, at: number): number {
53
+ const { start } = lineBounds(text, at)
54
+ // Already on the first line: Up parks at its start rather than doing nothing,
55
+ // which is what every editor does with it.
56
+ if (start === 0) return 0
57
+ const previous = lineBounds(text, start - 1)
58
+ return Math.min(previous.start + (at - start), previous.end)
59
+ }
60
+
61
+ /** The same column on the line below. */
62
+ function lineDown(text: string, at: number): number {
63
+ const { end, start } = lineBounds(text, at)
64
+ if (end === text.length) return text.length
65
+ const next = lineBounds(text, end + 1)
66
+ return Math.min(next.start + (at - start), next.end)
67
+ }
68
+
69
+ /** Resolve one motion against `text`, clamped to it. */
70
+ export function moveCursor(
71
+ text: string,
72
+ at: number,
73
+ motion: { delta?: number; to?: CursorTarget }
74
+ ): number {
75
+ const from = clamp(at, text.length)
76
+ if (motion.to === undefined) {
77
+ return clamp(from + (motion.delta ?? 0), text.length)
78
+ }
79
+ switch (motion.to) {
80
+ case 'home':
81
+ return lineBounds(text, from).start
82
+ case 'end':
83
+ return lineBounds(text, from).end
84
+ case 'word-left':
85
+ return wordLeft(text, from)
86
+ case 'word-right':
87
+ return wordRight(text, from)
88
+ case 'line-up':
89
+ return lineUp(text, from)
90
+ case 'line-down':
91
+ return lineDown(text, from)
92
+ default:
93
+ motion.to satisfies never
94
+ return from
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Apply one edit at the cursor. `\b` deletes behind it, `\x7f` in front of it —
100
+ * neither can arrive as typed text, which is why they double as the two commands.
101
+ */
102
+ export function applyEdit(
103
+ text: string,
104
+ at: number,
105
+ char: string
106
+ ): { pos: number; text: string } | null {
107
+ const from = clamp(at, text.length)
108
+ if (char === '\b') {
109
+ if (from === 0) return null
110
+ return { pos: from - 1, text: text.slice(0, from - 1) + text.slice(from) }
111
+ }
112
+ if (char === '\x7f') {
113
+ if (from === text.length) return null
114
+ return { pos: from, text: text.slice(0, from) + text.slice(from + 1) }
115
+ }
116
+ return { pos: from + char.length, text: text.slice(0, from) + char + text.slice(from) }
117
+ }
@@ -2,6 +2,7 @@ import { useKeyboard } from '@opentui/react'
2
2
 
3
3
  import { ModalShell } from './components/modals/shared/modal-shell'
4
4
  import { ListItem } from './components/primitives/list-item'
5
+ import { useSelectionInk } from './selection-ink'
5
6
  import { uiTokens } from './ui-tokens'
6
7
 
7
8
  interface BreakingUpdateScreenProps {
@@ -9,6 +10,7 @@ interface BreakingUpdateScreenProps {
9
10
  }
10
11
 
11
12
  export function BreakingUpdateScreen({ onConfirm }: BreakingUpdateScreenProps) {
13
+ const ink = useSelectionInk()
12
14
  useKeyboard((key) => {
13
15
  if (key.name === 'return') {
14
16
  key.preventDefault()
@@ -24,7 +26,11 @@ export function BreakingUpdateScreen({ onConfirm }: BreakingUpdateScreenProps) {
24
26
  <text>The terminal manager must be restarted.</text>
25
27
  <text>You will lose your current terminal states.</text>
26
28
  </box>
27
- <ListItem active direction="row" title={<text>OK — Restart terminal manager</text>} />
29
+ <ListItem
30
+ active
31
+ direction="row"
32
+ title={<text fg={ink}>OK — Restart terminal manager</text>}
33
+ />
28
34
  </box>
29
35
  </ModalShell>
30
36
  )
@@ -53,11 +53,9 @@ export function BarFooter({ contentWidth }: { contentWidth: number }) {
53
53
 
54
54
  return (
55
55
  <box flexDirection="column" flexShrink={0}>
56
- <box flexShrink={0}>
57
- <text fg={t.border} selectable={false} wrapMode="none">
58
- {'─'.repeat(Math.max(1, contentWidth))}
59
- </text>
60
- </box>
56
+ {/* A blank row, not a rule: the footer is set off from the last widget by
57
+ the gap alone. */}
58
+ <box height={1} flexShrink={0} />
61
59
  {/* Each entry is its own <text>, with a spacer box between them: one string
62
60
  holding both would make the whole footer a single click target. */}
63
61
  <box flexDirection="row" flexShrink={0} paddingLeft={1} paddingRight={1}>
@@ -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