@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.23.7",
3
+ "version": "1.24.1",
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.9",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -13,6 +13,7 @@ import { allLeafIds, getGroupIdForTab } from '../state/layout-tree'
13
13
  import { saveCurrentProject } from '../state/project-save'
14
14
  import { getActiveWorkspace, getActiveWorkspacePath } from '../state/project-workspaces'
15
15
  import { toast } from '../state/toast-store'
16
+ import { beginWorkspaceDelete, endWorkspaceDelete } from '../state/workspace-delete-store'
16
17
  import { filterThemeIds } from '../ui/filter-themes'
17
18
  import { scrollGitDiff } from '../ui/git-view-controls'
18
19
  import { applyTheme, getCurrentMode, getTransparent, setMode, setTransparent } from '../ui/theme'
@@ -306,6 +307,16 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
306
307
  return
307
308
  }
308
309
  case 'delete-workspace': {
310
+ const deleting = state.projects
311
+ .find((entry) => entry.id === effect.projectId)
312
+ ?.workspaces?.find((entry) => entry.id === effect.workspaceId)
313
+ // Marked before the enqueue, not inside the git op: the queue is why a
314
+ // delete can sit there doing nothing visible, which is the whole thing
315
+ // this indicator exists to answer.
316
+ beginWorkspaceDelete(
317
+ effect.workspaceId,
318
+ deleting?.branch ?? deleting?.name ?? 'this workspace'
319
+ )
309
320
  void (async () => {
310
321
  try {
311
322
  await enqueueGitOp(async () =>
@@ -333,11 +344,18 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
333
344
  closeTabs: effect.closeTabs === true,
334
345
  force: true,
335
346
  projectId: effect.projectId,
336
- reason: message,
347
+ // Git's refusal, condensed to the one line that changes the answer.
348
+ // The raw message is a paragraph of plumbing nobody reads before
349
+ // pressing y, and the dialog is not where it gets debugged.
350
+ reason: /active assistant tabs/i.test(message)
351
+ ? 'Its assistant tabs will be closed.'
352
+ : 'Deleting anyway discards uncommitted work.',
337
353
  type: 'open-workspace-delete-confirm',
338
354
  workspaceId: effect.workspaceId,
339
355
  workspaceLabel: workspace?.branch ?? workspace?.name ?? 'this workspace',
340
356
  })
357
+ } finally {
358
+ endWorkspaceDelete(effect.workspaceId)
341
359
  }
342
360
  })()
343
361
  return
@@ -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
@@ -94,7 +95,7 @@ export type ModalAction =
94
95
  projectId: string
95
96
  workspaceId: string
96
97
  workspaceLabel: string
97
- reason: string
98
+ reason?: string
98
99
  closeTabs: boolean
99
100
  force: boolean
100
101
  }
@@ -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
+ }
@@ -648,7 +648,7 @@ export interface ModalWorkspaceDeleteConfirm extends ModalBase {
648
648
  projectId: string
649
649
  workspaceId: string
650
650
  workspaceLabel: string
651
- reason: string
651
+ reason?: string
652
652
  closeTabs: boolean
653
653
  /** Whether confirming force-deletes — true only after a recoverable failure. */
654
654
  force: boolean
@@ -0,0 +1,32 @@
1
+ import { useStore } from 'zustand'
2
+ import { createStore } from 'zustand/vanilla'
3
+
4
+ /**
5
+ * Workspace deletes in flight, keyed by workspace id, valued by the label to
6
+ * show for them. Its own store rather than a slice of `AppState`: nothing here
7
+ * survives the operation, no reducer decides anything about it, and the side
8
+ * effect that owns the git work can set it without a dispatch round-trip.
9
+ */
10
+ interface WorkspaceDeleteState {
11
+ deleting: Record<string, string>
12
+ }
13
+
14
+ const workspaceDeleteStore = createStore<WorkspaceDeleteState>(() => ({ deleting: {} }))
15
+
16
+ export function beginWorkspaceDelete(workspaceId: string, label: string): void {
17
+ workspaceDeleteStore.setState((state) => ({
18
+ deleting: { ...state.deleting, [workspaceId]: label },
19
+ }))
20
+ }
21
+
22
+ export function endWorkspaceDelete(workspaceId: string): void {
23
+ workspaceDeleteStore.setState((state) => {
24
+ const deleting = { ...state.deleting }
25
+ delete deleting[workspaceId]
26
+ return { deleting }
27
+ })
28
+ }
29
+
30
+ export function useWorkspaceDeleteStore<T>(selector: (state: WorkspaceDeleteState) => T): T {
31
+ return useStore(workspaceDeleteStore, selector)
32
+ }
@@ -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
  )