@brimveyn/aimux 1.23.6 → 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/app-runtime/tab-actions.ts +7 -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/pty/command-registry.ts +35 -0
- package/src/state/actions.ts +2 -1
- package/src/state/project-persistence.ts +16 -1
- package/src/state/reducers/modal-state.ts +24 -28
- package/src/state/reducers/tab-state.ts +14 -3
- package/src/state/text-cursor.ts +117 -0
- package/src/ui/breaking-update-screen.tsx +7 -1
- package/src/ui/components/git/pane/pr-checks-panel.tsx +7 -2
- 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",
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getAssistantOption,
|
|
11
11
|
isCommandAvailable,
|
|
12
12
|
parseCommand,
|
|
13
|
+
stripInjectedSessionArgs,
|
|
13
14
|
} from '../pty/command-registry'
|
|
14
15
|
import { createTerminalBounds } from '../state/layout-resize'
|
|
15
16
|
import {
|
|
@@ -116,6 +117,11 @@ export function startTabSession(
|
|
|
116
117
|
ctx.startStartupGrace(tab.id, STARTUP_GRACE_MS)
|
|
117
118
|
|
|
118
119
|
const { args, executable } = parseCommand(tab.command)
|
|
120
|
+
// `tab.command` is not always the string this client wrote: a hydrate from the
|
|
121
|
+
// daemon (or a snapshot taken after one) hands back the whole argv of the last
|
|
122
|
+
// spawn, session flags included. Strip ours back out before adding this
|
|
123
|
+
// spawn's, or claude exits on the duplicate and takes the tab with it.
|
|
124
|
+
const baseArgs = stripInjectedSessionArgs(tab.assistant, ctx.state.customCommands, args)
|
|
119
125
|
// Ahead of `extraArgs` because that is where an initial prompt goes, and the
|
|
120
126
|
// prompt is positional — a flag after it would be read as part of it.
|
|
121
127
|
const sessionArgs =
|
|
@@ -134,7 +140,7 @@ export function startTabSession(
|
|
|
134
140
|
}
|
|
135
141
|
|
|
136
142
|
backend.createSession({
|
|
137
|
-
args: [...
|
|
143
|
+
args: [...baseArgs, ...sessionArgs, ...(extraArgs ?? [])],
|
|
138
144
|
assistant: tab.assistant,
|
|
139
145
|
autoRenameCandidate,
|
|
140
146
|
cols,
|
|
@@ -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
|
}
|
|
@@ -251,6 +251,41 @@ export function buildAssistantSessionArgs(
|
|
|
251
251
|
: option.session.buildSessionArgs(sessionId)
|
|
252
252
|
}
|
|
253
253
|
|
|
254
|
+
const SESSION_FLAG = /^(?:-r|--resume|--session-id)$/
|
|
255
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Drop the session flags an earlier spawn left inside `tab.command`.
|
|
259
|
+
*
|
|
260
|
+
* The daemon stores its tabs as `[command, ...args].join(' ')`, so a client that
|
|
261
|
+
* hydrated from it — or a snapshot written by one — carries this tab's own
|
|
262
|
+
* `--session-id <uuid>` in the string the next spawn parses. Re-spawning that
|
|
263
|
+
* verbatim is `Error: Session ID … is already in use`, and an exiting PTY takes
|
|
264
|
+
* the tab with it.
|
|
265
|
+
*
|
|
266
|
+
* Only a uuid-shaped value is ours, and only when the user's own custom command
|
|
267
|
+
* does not declare that flag — a `--resume` they wrote themselves is theirs to
|
|
268
|
+
* keep, and `buildAssistantSessionArgs` already stands aside for it.
|
|
269
|
+
*/
|
|
270
|
+
export function stripInjectedSessionArgs(
|
|
271
|
+
assistant: AssistantId,
|
|
272
|
+
customCommands: Record<string, string>,
|
|
273
|
+
args: string[]
|
|
274
|
+
): string[] {
|
|
275
|
+
const custom = customCommands[assistant] ?? ''
|
|
276
|
+
if (/(^|\s)(-r|--resume|--session-id|--continue|-c)(\s|$)/.test(custom)) return args
|
|
277
|
+
const kept: string[] = []
|
|
278
|
+
for (let i = 0; i < args.length; i++) {
|
|
279
|
+
const arg = args[i] as string
|
|
280
|
+
if (SESSION_FLAG.test(arg) && UUID.test(args[i + 1] ?? '')) {
|
|
281
|
+
i++
|
|
282
|
+
continue
|
|
283
|
+
}
|
|
284
|
+
kept.push(arg)
|
|
285
|
+
}
|
|
286
|
+
return kept
|
|
287
|
+
}
|
|
288
|
+
|
|
254
289
|
export function isCommandAvailable(command: string): boolean {
|
|
255
290
|
return Bun.which(command) !== null
|
|
256
291
|
}
|
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
|
|
@@ -106,6 +106,21 @@ function pruneOrphanedTabs(
|
|
|
106
106
|
)
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Recover a session id a snapshot lost. Until the client stopped adopting the
|
|
111
|
+
* daemon's tabs wholesale, `sessionId` was wiped on every attach while the
|
|
112
|
+
* daemon's argv echo left the uuid sitting in `command` — so the conversation
|
|
113
|
+
* these tabs own is still recoverable from the very string that broke them.
|
|
114
|
+
*
|
|
115
|
+
* ponytail: a migration, not a mechanism. Delete it once no snapshot in the
|
|
116
|
+
* wild predates the fix.
|
|
117
|
+
*/
|
|
118
|
+
function recoverSessionId(command: string): string | undefined {
|
|
119
|
+
return /(?:^|\s)(?:-r|--resume|--session-id)\s+([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\s|$)/i.exec(
|
|
120
|
+
command
|
|
121
|
+
)?.[1]
|
|
122
|
+
}
|
|
123
|
+
|
|
109
124
|
export function restoreTabsFromProject(
|
|
110
125
|
snapshot: ProjectSnapshotV1 | undefined,
|
|
111
126
|
options: RestoreOptions = {}
|
|
@@ -130,7 +145,7 @@ export function restoreTabsFromProject(
|
|
|
130
145
|
errorMessage: tab.errorMessage,
|
|
131
146
|
exitCode: tab.exitCode,
|
|
132
147
|
id: tab.id,
|
|
133
|
-
sessionId: tab.sessionId,
|
|
148
|
+
sessionId: tab.sessionId ?? recoverSessionId(tab.command),
|
|
134
149
|
status: forceDisconnected ? getDisconnectedStatus(tab.status) : tab.status,
|
|
135
150
|
terminalModes: tab.terminalModes,
|
|
136
151
|
title: tab.title,
|
|
@@ -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 &&
|
|
@@ -357,7 +357,18 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
357
357
|
state.currentProjectId != null && state.currentProjectId !== ''
|
|
358
358
|
? state.projects.find((s) => s.id === state.currentProjectId)
|
|
359
359
|
: undefined
|
|
360
|
-
|
|
360
|
+
// The daemon rebuilds `command` as `[command, ...args].join(' ')` and has
|
|
361
|
+
// no `sessionId` on the wire at all. Adopting its tabs wholesale bakes this
|
|
362
|
+
// spawn's `--session-id <uuid>` into the string the *next* spawn parses —
|
|
363
|
+
// claude exits on "Session ID … is already in use" and takes the tab with
|
|
364
|
+
// it — and drops the id the resume depends on. Both fields belong to the
|
|
365
|
+
// client, so keep what it already holds for the tabs it knows.
|
|
366
|
+
const ownedById = new Map(state.tabs.map((entry) => [entry.id, entry]))
|
|
367
|
+
const hydratedTabs = action.tabs.map((entry) => {
|
|
368
|
+
const owned = ownedById.get(entry.id)
|
|
369
|
+
return owned ? { ...entry, command: owned.command, sessionId: owned.sessionId } : entry
|
|
370
|
+
})
|
|
371
|
+
const visibleForWorkspace = filterTabsForActiveWorkspace(hydratedTabs, currentProject)
|
|
361
372
|
const visibleIds = new Set(visibleForWorkspace.map((t) => t.id))
|
|
362
373
|
const hydratedActiveTabId =
|
|
363
374
|
action.activeTabId != null &&
|
|
@@ -365,7 +376,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
365
376
|
visibleIds.has(action.activeTabId)
|
|
366
377
|
? action.activeTabId
|
|
367
378
|
: (visibleForWorkspace[0]?.id ?? null)
|
|
368
|
-
const tabIds = new Set(
|
|
379
|
+
const tabIds = new Set(hydratedTabs.map((t) => t.id))
|
|
369
380
|
|
|
370
381
|
// Restore from new multi-tree format or migrate from legacy single tree
|
|
371
382
|
const hydratedTrees: Record<string, LayoutNode> = {}
|
|
@@ -402,7 +413,7 @@ export function reduceTabState(state: AppState, action: AppAction): AppState | n
|
|
|
402
413
|
focusMode: 'navigation',
|
|
403
414
|
layoutTrees: hydratedTrees,
|
|
404
415
|
tabGroupMap: hydratedGroupMap,
|
|
405
|
-
tabs: normalizeGroupedTabOrder(
|
|
416
|
+
tabs: normalizeGroupedTabOrder(hydratedTabs, hydratedTrees, hydratedGroupMap),
|
|
406
417
|
},
|
|
407
418
|
hydratedActiveTabId,
|
|
408
419
|
{ onlyIfMissing: true }
|
|
@@ -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
|
)
|
|
@@ -291,9 +291,14 @@ export const PrChecksPanel = memo(function PrChecksPanel({
|
|
|
291
291
|
No checks
|
|
292
292
|
</text>
|
|
293
293
|
) : (
|
|
294
|
-
|
|
294
|
+
// GitHub can list the same workflow/name twice (a job that ran on
|
|
295
|
+
// both `push` and `pull_request`), so the pair is not a unique key
|
|
296
|
+
// and the duplicate crashes the reconciler. The rows hold no state
|
|
297
|
+
// and the list is the API's own order, so the index is the key.
|
|
298
|
+
checks.map((check, i) => (
|
|
295
299
|
<CheckRow
|
|
296
|
-
key
|
|
300
|
+
// oxlint-disable-next-line no-array-index-key
|
|
301
|
+
key={`${i}:${check.workflow}/${check.name}`}
|
|
297
302
|
bg={bg}
|
|
298
303
|
check={check}
|
|
299
304
|
showWorkflow={innerWidth >= WORKFLOW_MIN_WIDTH}
|
|
@@ -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}>
|