@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.
- package/package.json +2 -2
- package/src/input/keymap/key-chord.ts +10 -0
- package/src/input/keymap/key-format.ts +14 -1
- package/src/input/modes/handlers/shared.ts +48 -0
- package/src/state/actions.ts +2 -1
- package/src/state/reducers/modal-state.ts +24 -28
- package/src/state/text-cursor.ts +117 -0
- package/src/ui/breaking-update-screen.tsx +7 -1
- package/src/ui/components/layout/bar-footer.tsx +3 -5
- package/src/ui/components/layout/bar.tsx +41 -16
- package/src/ui/components/layout/sidebar/project-list.tsx +6 -16
- package/src/ui/components/layout/sidebar/workspace-row.tsx +6 -9
- package/src/ui/components/layout/status-bar.tsx +9 -3
- package/src/ui/components/layout/terminal-pane.tsx +18 -16
- package/src/ui/components/modals/app/help-modal.tsx +20 -15
- package/src/ui/components/modals/app/update-available-modal.tsx +3 -1
- package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
- package/src/ui/components/modals/projects/create-project-modal.tsx +9 -3
- package/src/ui/components/modals/projects/project-picker-modal.tsx +6 -6
- package/src/ui/components/modals/settings/settings-search-modal.tsx +19 -14
- package/src/ui/components/modals/shared/modal-shell.tsx +12 -2
- package/src/ui/components/modals/shared/picker.tsx +8 -4
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +7 -5
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +10 -12
- package/src/ui/components/modals/themes/theme-picker-modal.tsx +14 -10
- package/src/ui/components/modals/workspace/create-workspace-modal.tsx +19 -14
- package/src/ui/components/modals/workspace/workspace-move-modal.tsx +4 -2
- package/src/ui/components/overlays/context-menu/context-menu-overlay.tsx +10 -4
- package/src/ui/components/primitives/list-item.tsx +24 -2
- package/src/ui/components/primitives/surface.tsx +4 -1
- package/src/ui/components/settings/row-value.tsx +8 -1
- package/src/ui/components/settings/settings-footer.tsx +3 -2
- package/src/ui/components/settings/settings-row.tsx +3 -0
- package/src/ui/components/settings/settings-search-bar.tsx +4 -6
- package/src/ui/components/settings/settings-view.tsx +25 -21
- package/src/ui/components/stats/stats-view.tsx +4 -2
- 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.
|
|
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.
|
|
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
|
}
|
package/src/state/actions.ts
CHANGED
|
@@ -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?:
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
const current = state.modal.cursorPos ??
|
|
610
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 -
|
|
82
|
-
|
|
83
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|