@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.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +19 -1
- 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 +3 -2
- package/src/state/reducers/modal-state.ts +24 -28
- package/src/state/text-cursor.ts +117 -0
- package/src/state/types.ts +1 -1
- package/src/state/workspace-delete-store.ts +32 -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 +22 -15
- 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/shared/workspace-delete-confirm.tsx +8 -7
- 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/overlays/workspace-deleting-overlay.tsx +29 -0
- 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/root.tsx +2 -0
- 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.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.
|
|
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
|
-
|
|
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
|
}
|
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
|
|
@@ -94,7 +95,7 @@ export type ModalAction =
|
|
|
94
95
|
projectId: string
|
|
95
96
|
workspaceId: string
|
|
96
97
|
workspaceLabel: string
|
|
97
|
-
reason
|
|
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
|
-
|
|
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
|
+
}
|
package/src/state/types.ts
CHANGED
|
@@ -648,7 +648,7 @@ export interface ModalWorkspaceDeleteConfirm extends ModalBase {
|
|
|
648
648
|
projectId: string
|
|
649
649
|
workspaceId: string
|
|
650
650
|
workspaceLabel: string
|
|
651
|
-
reason
|
|
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
|
|
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
|
)
|