@brimveyn/aimux 1.12.2 → 1.13.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/README.md +3 -0
- package/package.json +4 -3
- package/src/app-runtime/auto-commit-driver.ts +8 -4
- package/src/app-runtime/auto-commit-ref.ts +1 -1
- package/src/app-runtime/backend-attach-runtime.ts +18 -2
- package/src/app-runtime/backend-runtime-events.ts +1 -1
- package/src/app-runtime/pty-write.ts +52 -0
- package/src/app-runtime/selection-scroll.ts +2 -2
- package/src/app-runtime/session-actions.ts +16 -4
- package/src/app-runtime/side-effects.ts +598 -102
- package/src/app-runtime/snippet-actions.ts +25 -11
- package/src/app-runtime/use-auto-commit-driver.ts +7 -4
- package/src/app-runtime/use-backend-runtime.ts +3 -3
- package/src/app-runtime/use-directory-search.ts +7 -5
- package/src/app-runtime/use-mouse-handlers.ts +11 -6
- package/src/app-runtime/use-pane-size-report.ts +1 -1
- package/src/app-runtime/use-renderer-bindings.ts +143 -8
- package/src/app.tsx +25 -9
- package/src/auto-commit/headless-commands.ts +4 -6
- package/src/daemon/daemon.ts +3 -3
- package/src/daemon/runtime-paths.ts +1 -1
- package/src/daemon/session-manager.ts +1 -1
- package/src/daemon/session-registry.ts +10 -3
- package/src/diff-parser/parse-patch-files.ts +18 -18
- package/src/git/command-queue.ts +1 -1
- package/src/git/divergence.ts +46 -0
- package/src/git/git-diff.ts +12 -5
- package/src/git/git-poller.ts +33 -11
- package/src/git/git-status.ts +14 -3
- package/src/git/move-worktree.ts +96 -0
- package/src/git/use-repo-discovery.ts +2 -1
- package/src/git/worktree-divergence-poller.ts +59 -0
- package/src/git/worktree.ts +99 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/describe-bindings.ts +27 -9
- package/src/input/keymap/key-chord.ts +4 -4
- package/src/input/keymap/key-format.ts +2 -2
- package/src/input/keymap/sequence-resolver.ts +1 -1
- package/src/input/keymap/trie.ts +1 -1
- package/src/input/modes/bridge.ts +7 -0
- package/src/input/modes/transitions.ts +2 -1
- package/src/input/modes/types.ts +13 -1
- package/src/input/raw-input-handler.ts +34 -1
- package/src/input/terminal-text-extraction.ts +9 -5
- package/src/integrations/claude-syntax-overlay.ts +8 -8
- package/src/integrations/claude-theme-sync.ts +12 -12
- package/src/ipc/protocol.ts +3 -3
- package/src/platform/clipboard.ts +18 -2
- package/src/platform/worktree-deps.ts +22 -0
- package/src/platform/worktree-paths.ts +67 -0
- package/src/profile-paths.ts +3 -3
- package/src/pty/assistant-status-detection-loop.ts +2 -2
- package/src/pty/assistant-status-detector.ts +15 -6
- package/src/pty/command-registry.ts +8 -1
- package/src/pty/pty-manager.ts +1 -1
- package/src/services/ai-usage/adapters/claude.ts +5 -5
- package/src/services/ai-usage/adapters/codex.ts +8 -8
- package/src/services/ai-usage/cache.ts +2 -2
- package/src/services/ai-usage/pace.ts +3 -6
- package/src/services/ai-usage/provider.ts +1 -1
- package/src/session-backend/bootstrap.ts +2 -2
- package/src/session-backend/local-session-backend.ts +11 -11
- package/src/session-backend/remote-session-backend.ts +3 -3
- package/src/session-backend/types.ts +2 -2
- package/src/snippets/expand-variables.ts +112 -0
- package/src/snippets/run-shell-var.ts +93 -0
- package/src/snippets/trigger-detector.ts +105 -0
- package/src/state/ai-usage-store.ts +1 -1
- package/src/state/git-tree.ts +8 -4
- package/src/state/layout-tree.ts +8 -5
- package/src/state/reducers/git-mode-state.ts +44 -14
- package/src/state/reducers/git-panel-state.ts +38 -2
- package/src/state/reducers/modal-state.ts +385 -24
- package/src/state/reducers/session-state.ts +60 -1
- package/src/state/reducers/tab-state.ts +151 -85
- package/src/state/selectors.ts +6 -4
- package/src/state/session-catalog.ts +20 -5
- package/src/state/session-persistence.ts +8 -4
- package/src/state/session-worktrees.ts +227 -0
- package/src/state/snippet-catalog.ts +65 -4
- package/src/state/store.ts +1 -0
- package/src/state/toast-store.ts +95 -0
- package/src/state/types.ts +108 -16
- package/src/state/validation.ts +47 -3
- package/src/terminal-manager/manager-client.ts +15 -13
- package/src/ui/components/git/diff-renderer/fold-strip.tsx +2 -1
- package/src/ui/components/git/diff-renderer/pierre-diff.tsx +1 -1
- package/src/ui/components/git/diff-renderer/prepare-diff.ts +2 -2
- package/src/ui/components/git/diff-renderer/split-view.tsx +6 -4
- package/src/ui/components/git/diff-renderer/stacked-view.tsx +6 -4
- package/src/ui/components/git/diff-renderer/use-diff-prefetch.ts +19 -6
- package/src/ui/components/git/diff-renderer/use-diff-preparation.ts +2 -2
- package/src/ui/components/git/git-panel.tsx +17 -10
- package/src/ui/components/git/git-view.tsx +79 -19
- package/src/ui/components/git/image-diff/image-diff-view.tsx +2 -2
- package/src/ui/components/git/image-diff/terminal-image-pane.tsx +5 -4
- package/src/ui/components/git/pane/git-pane-context-menu.ts +2 -2
- package/src/ui/components/git/pane/git-pane-widget.tsx +6 -4
- package/src/ui/components/layout/session-bar.tsx +3 -3
- package/src/ui/components/layout/sidebar/sidebar.tsx +162 -36
- package/src/ui/components/layout/sidebar/tab-item.tsx +17 -1
- package/src/ui/components/layout/sidebar/use-sidebar-auto-scroll.ts +2 -2
- package/src/ui/components/layout/sidebar/use-sidebar-branch.ts +1 -1
- package/src/ui/components/layout/split-layout.tsx +1 -1
- package/src/ui/components/layout/terminal-pane.tsx +43 -40
- package/src/ui/components/modals/app/ai-usage-modal.tsx +8 -5
- package/src/ui/components/modals/app/help-modal.tsx +4 -2
- package/src/ui/components/modals/git/git-commit-modal.tsx +2 -2
- package/src/ui/components/modals/sessions/create-session-modal.tsx +5 -1
- package/src/ui/components/modals/sessions/session-picker-modal.tsx +5 -4
- package/src/ui/components/modals/shared/form.tsx +4 -5
- package/src/ui/components/modals/shared/modal-shell.tsx +3 -3
- package/src/ui/components/modals/shared/picker.tsx +28 -5
- package/src/ui/components/modals/snippets/snippet-editor-modal.tsx +10 -6
- package/src/ui/components/modals/snippets/snippet-picker-modal.tsx +25 -6
- package/src/ui/components/modals/tabs/new-tab-modal.tsx +173 -5
- package/src/ui/components/modals/worktree/worktree-move-modal.tsx +94 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +3 -3
- package/src/ui/components/overlays/toast/toast-item.tsx +88 -0
- package/src/ui/components/overlays/toast/toast-position.ts +45 -0
- package/src/ui/components/overlays/toast/toast-stack.tsx +30 -0
- package/src/ui/components/overlays/toast/toast-viewport.tsx +24 -0
- package/src/ui/components/overlays/toast/use-toast-animation.ts +64 -0
- package/src/ui/components/primitives/input-field.tsx +1 -1
- package/src/ui/components/primitives/list-item.tsx +3 -3
- package/src/ui/filter-themes.ts +1 -1
- package/src/ui/root.tsx +65 -14
- package/src/ui/session-ordering.ts +2 -2
- package/src/ui/status-bar-model.ts +14 -7
- package/src/ui/terminal-graphics/capabilities.ts +1 -1
- package/src/ui/terminal-graphics/format-fallback.ts +7 -9
- package/src/ui/terminal-graphics/kitty.ts +2 -7
- package/src/update/version-check.ts +1 -1
|
@@ -56,16 +56,22 @@ export function describeBindings(
|
|
|
56
56
|
for (const binding of mode.bindings) {
|
|
57
57
|
const keysDisplay = formatNotationForDisplay(binding.keys, leaderChord)
|
|
58
58
|
|
|
59
|
-
if (options.mergeAlternativesByDescription) {
|
|
59
|
+
if (options.mergeAlternativesByDescription === true) {
|
|
60
60
|
const actionSignature = getActionSignature(binding.result)
|
|
61
|
-
const existingIndex =
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
const existingIndex =
|
|
62
|
+
binding.description != null && binding.description !== ''
|
|
63
|
+
? resultIndexByDescription.get(binding.description)
|
|
64
|
+
: resultIndexByAction.get(actionSignature)
|
|
64
65
|
|
|
65
66
|
if (existingIndex === undefined) {
|
|
66
|
-
if (
|
|
67
|
+
if (
|
|
68
|
+
options.withDescriptionOnly === true &&
|
|
69
|
+
!(binding.description != null && binding.description !== '')
|
|
70
|
+
)
|
|
71
|
+
continue
|
|
67
72
|
const nextIndex = result.length
|
|
68
|
-
if (binding.description
|
|
73
|
+
if (binding.description != null && binding.description !== '')
|
|
74
|
+
resultIndexByDescription.set(binding.description, nextIndex)
|
|
69
75
|
resultIndexByAction.set(actionSignature, nextIndex)
|
|
70
76
|
seenKeysDisplayByIndex.set(nextIndex, new Set([keysDisplay]))
|
|
71
77
|
result.push({
|
|
@@ -83,7 +89,11 @@ export function describeBindings(
|
|
|
83
89
|
seenKeysDisplay.add(keysDisplay)
|
|
84
90
|
const existing = result[existingIndex]
|
|
85
91
|
if (!existing) continue
|
|
86
|
-
if (
|
|
92
|
+
if (
|
|
93
|
+
(existing.description == null || existing.description === '') &&
|
|
94
|
+
binding.description != null &&
|
|
95
|
+
binding.description !== ''
|
|
96
|
+
) {
|
|
87
97
|
existing.description = binding.description
|
|
88
98
|
existing.group = binding.group
|
|
89
99
|
resultIndexByDescription.set(binding.description, existingIndex)
|
|
@@ -93,9 +103,17 @@ export function describeBindings(
|
|
|
93
103
|
continue
|
|
94
104
|
}
|
|
95
105
|
|
|
96
|
-
if (
|
|
106
|
+
if (
|
|
107
|
+
options.withDescriptionOnly === true &&
|
|
108
|
+
!(binding.description != null && binding.description !== '')
|
|
109
|
+
)
|
|
110
|
+
continue
|
|
97
111
|
|
|
98
|
-
if (
|
|
112
|
+
if (
|
|
113
|
+
options.dedupeByDescription === true &&
|
|
114
|
+
binding.description != null &&
|
|
115
|
+
binding.description !== ''
|
|
116
|
+
) {
|
|
99
117
|
if (seenDescriptions.has(binding.description)) continue
|
|
100
118
|
seenDescriptions.add(binding.description)
|
|
101
119
|
}
|
|
@@ -77,7 +77,7 @@ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[
|
|
|
77
77
|
|
|
78
78
|
// Handle <leader>
|
|
79
79
|
if (inner.toLowerCase() === 'leader') {
|
|
80
|
-
if (!leader) {
|
|
80
|
+
if (!(leader != null && leader !== '')) {
|
|
81
81
|
throw new Error('parseKeyNotation: <leader> used but no leader key configured')
|
|
82
82
|
}
|
|
83
83
|
chords.push(leader)
|
|
@@ -102,7 +102,7 @@ export function parseKeyNotation(notation: string, leader?: KeyChord): KeyChord[
|
|
|
102
102
|
function parseAngleBracketToken(inner: string): KeyChord {
|
|
103
103
|
// Check if it's a special name (no modifiers)
|
|
104
104
|
const specialInner = SPECIAL_NAMES[inner]
|
|
105
|
-
if (specialInner) {
|
|
105
|
+
if (specialInner != null && specialInner !== '') {
|
|
106
106
|
return specialInner
|
|
107
107
|
}
|
|
108
108
|
|
|
@@ -120,11 +120,11 @@ function parseAngleBracketToken(inner: string): KeyChord {
|
|
|
120
120
|
else if (mod === 'S') shift = true
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
let keyName = parts
|
|
123
|
+
let keyName = parts.at(-1) ?? ''
|
|
124
124
|
|
|
125
125
|
// Check if the key part is a special name
|
|
126
126
|
const specialKey = SPECIAL_NAMES[keyName]
|
|
127
|
-
if (specialKey) {
|
|
127
|
+
if (specialKey != null && specialKey !== '') {
|
|
128
128
|
keyName = specialKey
|
|
129
129
|
}
|
|
130
130
|
|
|
@@ -14,14 +14,14 @@ const SPECIAL_DISPLAY: Record<string, string> = {
|
|
|
14
14
|
|
|
15
15
|
function formatModifierKey(baseName: string): string {
|
|
16
16
|
const special = SPECIAL_DISPLAY[baseName]
|
|
17
|
-
if (special) return special
|
|
17
|
+
if (special != null && special !== '') return special
|
|
18
18
|
if (baseName.length === 1) return baseName.toUpperCase()
|
|
19
19
|
return baseName.charAt(0).toUpperCase() + baseName.slice(1)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
/** Convert a single canonical KeyChord back to a human-readable label. */
|
|
23
23
|
export function formatChord(chord: KeyChord, leader?: KeyChord): string {
|
|
24
|
-
if (leader && chord === leader) return '<leader>'
|
|
24
|
+
if (leader != null && leader !== '' && chord === leader) return '<leader>'
|
|
25
25
|
|
|
26
26
|
let ctrl = false
|
|
27
27
|
let meta = false
|
|
@@ -135,7 +135,7 @@ export class SequenceResolver {
|
|
|
135
135
|
}
|
|
136
136
|
|
|
137
137
|
private updateRepeatOnResolve(chord: KeyChord, binding: TrieBinding): void {
|
|
138
|
-
if (binding.repeatable) {
|
|
138
|
+
if (binding.repeatable === true) {
|
|
139
139
|
this.repeatTerminal = chord
|
|
140
140
|
this.repeatBinding = binding
|
|
141
141
|
} else {
|
package/src/input/keymap/trie.ts
CHANGED
|
@@ -52,7 +52,7 @@ export class KeyTrie {
|
|
|
52
52
|
remove(sequence: KeyChord[]): boolean {
|
|
53
53
|
if (sequence.length === 0) return false
|
|
54
54
|
|
|
55
|
-
const path:
|
|
55
|
+
const path: { node: TrieNode; chord: KeyChord }[] = []
|
|
56
56
|
let node = this.root
|
|
57
57
|
|
|
58
58
|
for (const chord of sequence) {
|
|
@@ -26,6 +26,7 @@ const COMMAND_EDIT_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
|
26
26
|
const MODAL_MODE_IDS: Partial<Record<SupportedModalType, ModeId>> = {
|
|
27
27
|
'ai-usage': 'modal.ai-usage',
|
|
28
28
|
'update-available': 'modal.update-available',
|
|
29
|
+
'worktree-move': 'modal.worktree-move',
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function deriveModeId(state: AppState): ModeId {
|
|
@@ -35,6 +36,12 @@ export function deriveModeId(state: AppState): ModeId {
|
|
|
35
36
|
return 'modal.help.filtering'
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
// Overlay on top of git mode without flipping focusMode (like help) — route
|
|
40
|
+
// input to the picker while it's open even though focus stays 'git'.
|
|
41
|
+
if (state.modal.type === 'worktree-move') {
|
|
42
|
+
return 'modal.worktree-move'
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
const directMode = DIRECT_FOCUS_MODE_IDS[state.focusMode]
|
|
39
46
|
if (directMode) {
|
|
40
47
|
return directMode
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { ModeId } from './types'
|
|
2
2
|
|
|
3
3
|
const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
4
|
-
'git-mode': ['navigation', 'modal.git-commit'],
|
|
4
|
+
'git-mode': ['navigation', 'modal.git-commit', 'modal.worktree-move'],
|
|
5
5
|
'modal.ai-usage': ['navigation', 'terminal-input'],
|
|
6
6
|
'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
|
|
7
7
|
'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
|
|
@@ -18,6 +18,7 @@ const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
|
18
18
|
'modal.split-picker': ['navigation', 'terminal-input'],
|
|
19
19
|
'modal.theme-picker.filtering': ['navigation'],
|
|
20
20
|
'modal.update-available': ['navigation'],
|
|
21
|
+
'modal.worktree-move': ['git-mode', 'navigation'],
|
|
21
22
|
'navigation': [
|
|
22
23
|
'terminal-input',
|
|
23
24
|
'modal.new-tab.command-edit',
|
package/src/input/modes/types.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { KeyEvent } from '@opentui/core'
|
|
2
2
|
|
|
3
|
+
import type { SplitDirection } from '../../state/layout-tree'
|
|
3
4
|
import type { AppAction, AppState, GitFileListMode, TabSession } from '../../state/types'
|
|
4
5
|
|
|
5
6
|
export type ModeId =
|
|
@@ -21,11 +22,13 @@ export type ModeId =
|
|
|
21
22
|
| 'modal.git-commit.confirm'
|
|
22
23
|
| 'modal.git-commit.generating'
|
|
23
24
|
| 'modal.update-available'
|
|
25
|
+
| 'modal.worktree-move'
|
|
24
26
|
| 'modal.ai-usage'
|
|
25
27
|
|
|
26
28
|
export type SideEffect =
|
|
27
29
|
| { type: 'quit'; state: AppState }
|
|
28
30
|
| { type: 'launch-selected-assistant' }
|
|
31
|
+
| { type: 'edit-selected-assistant' }
|
|
29
32
|
| { type: 'confirm-selected-session' }
|
|
30
33
|
| { type: 'delete-selected-session' }
|
|
31
34
|
| { type: 'open-rename-selected-session' }
|
|
@@ -45,7 +48,7 @@ export type SideEffect =
|
|
|
45
48
|
| { type: 'rename-session'; sessionId: string; name: string }
|
|
46
49
|
| {
|
|
47
50
|
type: 'split-pane'
|
|
48
|
-
direction:
|
|
51
|
+
direction: SplitDirection
|
|
49
52
|
sourceTabId?: string
|
|
50
53
|
}
|
|
51
54
|
| { type: 'confirm-split' }
|
|
@@ -66,9 +69,18 @@ export type SideEffect =
|
|
|
66
69
|
| { type: 'confirm-update-selection' }
|
|
67
70
|
| { type: 'switch-session-by-index'; index: number }
|
|
68
71
|
| { type: 'delete-session'; sessionId: string }
|
|
72
|
+
| { type: 'delete-worktree'; sessionId: string; worktreeId: string; force?: boolean }
|
|
73
|
+
| {
|
|
74
|
+
type: 'move-worktree'
|
|
75
|
+
sessionId: string
|
|
76
|
+
sourceWorktreeId: string
|
|
77
|
+
targetWorktreeId: string
|
|
78
|
+
deleteSource?: boolean
|
|
79
|
+
}
|
|
69
80
|
| { type: 'toggle-transparent' }
|
|
70
81
|
| { type: 'toggle-mode' }
|
|
71
82
|
| { type: 'open-file-in-editor'; path: string }
|
|
83
|
+
| { type: 'open-selected-snippet-source-in-editor' }
|
|
72
84
|
|
|
73
85
|
export interface KeyResult {
|
|
74
86
|
actions: AppAction[]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PtyWriteOptions } from '../app-runtime/pty-write'
|
|
2
|
+
import type { TriggerMatch } from '../snippets/trigger-detector'
|
|
2
3
|
import type { FocusMode } from '../state/types'
|
|
3
4
|
|
|
4
5
|
import { logInputDebug } from '../debug/input-log'
|
|
@@ -88,6 +89,20 @@ export function createRawInputHandler(deps: {
|
|
|
88
89
|
* Returns true if the chord was consumed by the keymap, false otherwise.
|
|
89
90
|
*/
|
|
90
91
|
handleTerminalShortcut: (chord: KeyChord) => boolean
|
|
92
|
+
/** True when the active tab is in alternate-screen mode (vim, less, htop, ...). */
|
|
93
|
+
getIsAlternateBuffer?: () => boolean
|
|
94
|
+
/** Feed a single char to the per-tab macro trigger detector. */
|
|
95
|
+
feedTrigger?: (tabId: string, char: string) => TriggerMatch | null
|
|
96
|
+
/** Expand a matched macro and inject it into the PTY (erase + paste + cursor). */
|
|
97
|
+
expandMacro?: (tabId: string, match: TriggerMatch) => void
|
|
98
|
+
/** Reset detector state when input boundaries change (paste start, mode toggle). */
|
|
99
|
+
resetTrigger?: (tabId: string) => void
|
|
100
|
+
/**
|
|
101
|
+
* Called for every keystroke immediately after a macro expansion: if the
|
|
102
|
+
* keystroke is a backspace, erase the entire expansion and return true.
|
|
103
|
+
* Any other keystroke clears the pending-undo state and returns false.
|
|
104
|
+
*/
|
|
105
|
+
tryConsumeMacroUndo?: (tabId: string, sequence: string) => boolean
|
|
91
106
|
}): (sequence: string) => boolean {
|
|
92
107
|
let bracketedPasteBuffer: string | null = null
|
|
93
108
|
|
|
@@ -143,6 +158,7 @@ export function createRawInputHandler(deps: {
|
|
|
143
158
|
sequencePreview: sequence.slice(0, 120),
|
|
144
159
|
tabId,
|
|
145
160
|
})
|
|
161
|
+
deps.resetTrigger?.(tabId)
|
|
146
162
|
if (!handleSequence(tabId, sequence.slice(0, startIndex))) {
|
|
147
163
|
return false
|
|
148
164
|
}
|
|
@@ -158,6 +174,23 @@ export function createRawInputHandler(deps: {
|
|
|
158
174
|
return handleSequence(tabId, afterStart.slice(endIndex + BRACKETED_PASTE_END.length))
|
|
159
175
|
}
|
|
160
176
|
|
|
177
|
+
if (deps.tryConsumeMacroUndo?.(tabId, sequence) ?? false) {
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (
|
|
182
|
+
sequence.length === 1 &&
|
|
183
|
+
deps.feedTrigger &&
|
|
184
|
+
deps.expandMacro &&
|
|
185
|
+
!(deps.getIsAlternateBuffer?.() ?? false)
|
|
186
|
+
) {
|
|
187
|
+
const match = deps.feedTrigger(tabId, sequence)
|
|
188
|
+
if (match) {
|
|
189
|
+
deps.expandMacro(tabId, match)
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
161
194
|
if (handleTerminalShortcut(sequence)) {
|
|
162
195
|
return true
|
|
163
196
|
}
|
|
@@ -191,7 +224,7 @@ export function createRawInputHandler(deps: {
|
|
|
191
224
|
}
|
|
192
225
|
|
|
193
226
|
const activeTabId = deps.getActiveTabId()
|
|
194
|
-
if (!activeTabId) {
|
|
227
|
+
if (!(activeTabId != null && activeTabId !== '')) {
|
|
195
228
|
return false
|
|
196
229
|
}
|
|
197
230
|
|
|
@@ -17,11 +17,15 @@ export function getLineText(line: TerminalLine): string {
|
|
|
17
17
|
*/
|
|
18
18
|
export function extractStreamText(
|
|
19
19
|
lines: TerminalLine[],
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
startRowArg: number,
|
|
21
|
+
startColArg: number,
|
|
22
|
+
endRowArg: number,
|
|
23
|
+
endColArg: number
|
|
24
24
|
): string {
|
|
25
|
+
let startRow = startRowArg
|
|
26
|
+
let startCol = startColArg
|
|
27
|
+
let endRow = endRowArg
|
|
28
|
+
let endCol = endColArg
|
|
25
29
|
if (startRow > endRow || (startRow === endRow && startCol > endCol)) {
|
|
26
30
|
;[startRow, endRow] = [endRow, startRow]
|
|
27
31
|
;[startCol, endCol] = [endCol, startCol]
|
|
@@ -60,7 +64,7 @@ export function getWordAtColumn(
|
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
const ch = lineText[column]
|
|
63
|
-
if (
|
|
67
|
+
if (ch == null || ch === '' || !/\S/.test(ch)) {
|
|
64
68
|
return { endCol: column, startCol: column, text: '' }
|
|
65
69
|
}
|
|
66
70
|
|
|
@@ -103,10 +103,10 @@ export async function warmClaudeSyntaxOverlay(): Promise<void> {
|
|
|
103
103
|
warmedHighlighter = h
|
|
104
104
|
const themeName = await ensureActiveShikiTheme(h)
|
|
105
105
|
activeThemeName = themeName
|
|
106
|
-
await Promise.all(PRELOAD_LANGS.map((lang) => ensureShikiLang(h, lang)))
|
|
106
|
+
await Promise.all(PRELOAD_LANGS.map(async (lang) => ensureShikiLang(h, lang)))
|
|
107
107
|
ready = true
|
|
108
|
-
} catch (
|
|
109
|
-
logDebug('claude-syntax-overlay:warm-failed', { err: String(
|
|
108
|
+
} catch (error) {
|
|
109
|
+
logDebug('claude-syntax-overlay:warm-failed', { err: String(error) })
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
@@ -150,7 +150,7 @@ function lineText(line: TerminalLine): string {
|
|
|
150
150
|
function dominantBg(line: TerminalLine): string | undefined {
|
|
151
151
|
const counts = new Map<string, number>()
|
|
152
152
|
for (const span of line.spans) {
|
|
153
|
-
if (!span.bg) continue
|
|
153
|
+
if (!(span.bg != null && span.bg !== '')) continue
|
|
154
154
|
if (span.text.trim().length === 0) continue
|
|
155
155
|
counts.set(span.bg, (counts.get(span.bg) ?? 0) + span.text.length)
|
|
156
156
|
}
|
|
@@ -166,7 +166,7 @@ function dominantBg(line: TerminalLine): string | undefined {
|
|
|
166
166
|
}
|
|
167
167
|
|
|
168
168
|
function tokenize(code: string, lang: string): ShikiToken[][] {
|
|
169
|
-
if (!ready ||
|
|
169
|
+
if (!ready || activeThemeName == null || activeThemeName === '' || !warmedHighlighter) return []
|
|
170
170
|
try {
|
|
171
171
|
/* eslint-disable typescript-eslint/no-explicit-any */
|
|
172
172
|
return warmedHighlighter.codeToTokens(code, {
|
|
@@ -210,7 +210,7 @@ function buildSpans(
|
|
|
210
210
|
if (!tok.content) continue
|
|
211
211
|
const fs = tok.fontStyle ?? 0
|
|
212
212
|
const tokColor = tok.color?.toLowerCase()
|
|
213
|
-
const fg = tokColor && accents.has(tokColor) ? tok.color : fallbackFg
|
|
213
|
+
const fg = tokColor != null && tokColor !== '' && accents.has(tokColor) ? tok.color : fallbackFg
|
|
214
214
|
spans.push({
|
|
215
215
|
bg,
|
|
216
216
|
bold: (fs & 2) !== 0 || undefined,
|
|
@@ -381,7 +381,7 @@ function processLines(
|
|
|
381
381
|
accents: Set<string>,
|
|
382
382
|
targetWidth: number
|
|
383
383
|
): TerminalLine[] {
|
|
384
|
-
const out = lines
|
|
384
|
+
const out = [...lines]
|
|
385
385
|
let block: BlockContext | null = null
|
|
386
386
|
|
|
387
387
|
for (let i = 0; i < out.length; i += 1) {
|
|
@@ -393,7 +393,7 @@ function processLines(
|
|
|
393
393
|
const headerMatch = text.match(TOOL_HEADER_RE)
|
|
394
394
|
if (headerMatch) {
|
|
395
395
|
const lang = inferLangFromPath(headerMatch[1] ?? '')
|
|
396
|
-
if (lang) tabLang.set(tabId, lang)
|
|
396
|
+
if (lang != null && lang !== '') tabLang.set(tabId, lang)
|
|
397
397
|
}
|
|
398
398
|
|
|
399
399
|
const prefixMatch = text.match(PREFIX_RE)
|
|
@@ -37,13 +37,13 @@ function writeAtomic(target: string, contents: string): void {
|
|
|
37
37
|
writeFileSync(tmp, contents, 'utf8')
|
|
38
38
|
try {
|
|
39
39
|
renameSync(tmp, target)
|
|
40
|
-
} catch (
|
|
40
|
+
} catch (error) {
|
|
41
41
|
try {
|
|
42
42
|
unlinkSync(tmp)
|
|
43
43
|
} catch {
|
|
44
44
|
/* ignore */
|
|
45
45
|
}
|
|
46
|
-
throw
|
|
46
|
+
throw error
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -60,8 +60,8 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
|
|
|
60
60
|
let theme: ClaudeThemeFile
|
|
61
61
|
try {
|
|
62
62
|
theme = resolveClaudeTheme(resolved, mode)
|
|
63
|
-
} catch (
|
|
64
|
-
logSyncWarn('resolve-failed', { err: String(
|
|
63
|
+
} catch (error) {
|
|
64
|
+
logSyncWarn('resolve-failed', { err: String(error) })
|
|
65
65
|
return
|
|
66
66
|
}
|
|
67
67
|
|
|
@@ -69,8 +69,8 @@ export function syncClaudeTheme(resolved: ResolvedTuiTheme, mode: ThemeMode): vo
|
|
|
69
69
|
try {
|
|
70
70
|
mkdirSync(join(claudeDir(), 'themes'), { recursive: true })
|
|
71
71
|
writeAtomic(target, `${JSON.stringify(theme, null, 2)}\n`)
|
|
72
|
-
} catch (
|
|
73
|
-
logSyncWarn('write-failed', { err: String(
|
|
72
|
+
} catch (error) {
|
|
73
|
+
logSyncWarn('write-failed', { err: String(error), path: target })
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -86,8 +86,8 @@ export function ensureClaudeSettingsThemePref(): void {
|
|
|
86
86
|
let raw: string
|
|
87
87
|
try {
|
|
88
88
|
raw = readFileSync(target, 'utf8')
|
|
89
|
-
} catch (
|
|
90
|
-
logSyncWarn('settings-read-failed', { err: String(
|
|
89
|
+
} catch (error) {
|
|
90
|
+
logSyncWarn('settings-read-failed', { err: String(error), path: target })
|
|
91
91
|
return
|
|
92
92
|
}
|
|
93
93
|
if (raw.trim().length > 0) {
|
|
@@ -98,8 +98,8 @@ export function ensureClaudeSettingsThemePref(): void {
|
|
|
98
98
|
return
|
|
99
99
|
}
|
|
100
100
|
parsed = json as Record<string, unknown>
|
|
101
|
-
} catch (
|
|
102
|
-
logSyncWarn('settings-parse-failed', { err: String(
|
|
101
|
+
} catch (error) {
|
|
102
|
+
logSyncWarn('settings-parse-failed', { err: String(error), path: target })
|
|
103
103
|
return
|
|
104
104
|
}
|
|
105
105
|
}
|
|
@@ -112,7 +112,7 @@ export function ensureClaudeSettingsThemePref(): void {
|
|
|
112
112
|
try {
|
|
113
113
|
mkdirSync(claudeDir(), { recursive: true })
|
|
114
114
|
writeAtomic(target, `${JSON.stringify(parsed, null, 2)}\n`)
|
|
115
|
-
} catch (
|
|
116
|
-
logSyncWarn('settings-write-failed', { err: String(
|
|
115
|
+
} catch (error) {
|
|
116
|
+
logSyncWarn('settings-write-failed', { err: String(error), path: target })
|
|
117
117
|
}
|
|
118
118
|
}
|
package/src/ipc/protocol.ts
CHANGED
|
@@ -10,8 +10,8 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
import { isWorkspaceSnapshotV1 } from '../state/validation'
|
|
12
12
|
|
|
13
|
-
export const IPC_PROTOCOL_MIN_VERSION =
|
|
14
|
-
export const IPC_PROTOCOL_VERSION =
|
|
13
|
+
export const IPC_PROTOCOL_MIN_VERSION = 8
|
|
14
|
+
export const IPC_PROTOCOL_VERSION = 8
|
|
15
15
|
|
|
16
16
|
export interface ProtocolHelloRequest {
|
|
17
17
|
minVersion: number
|
|
@@ -43,7 +43,7 @@ export interface AttachResult {
|
|
|
43
43
|
* right state immediately, without the per-event race where a separate
|
|
44
44
|
* `sessionStatus` event could arrive before the session was in state.
|
|
45
45
|
*/
|
|
46
|
-
initialSessionStatuses:
|
|
46
|
+
initialSessionStatuses: { sessionId: string; status: SessionStatus }[]
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export type ClientRequest =
|
|
@@ -1,13 +1,29 @@
|
|
|
1
1
|
import { logDebug } from '../debug/input-log'
|
|
2
|
+
import { toast } from '../state/toast-store'
|
|
2
3
|
|
|
3
4
|
export function copyToSystemClipboard(text: string): void {
|
|
4
5
|
try {
|
|
5
6
|
const proc = Bun.spawn(['pbcopy'], { stdin: 'pipe' })
|
|
6
|
-
proc.stdin.write(text)
|
|
7
|
-
proc.stdin.end()
|
|
7
|
+
void proc.stdin.write(text)
|
|
8
|
+
void proc.stdin.end()
|
|
8
9
|
} catch (error) {
|
|
9
10
|
logDebug('platform.clipboard.copyError', {
|
|
10
11
|
error: error instanceof Error ? error.message : String(error),
|
|
11
12
|
})
|
|
13
|
+
toast.error('Copy failed')
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function readFromSystemClipboard(): Promise<string> {
|
|
18
|
+
try {
|
|
19
|
+
const proc = Bun.spawn(['pbpaste'], { stdout: 'pipe' })
|
|
20
|
+
const text = await new Response(proc.stdout).text()
|
|
21
|
+
await proc.exited
|
|
22
|
+
return text
|
|
23
|
+
} catch (error) {
|
|
24
|
+
logDebug('platform.clipboard.readError', {
|
|
25
|
+
error: error instanceof Error ? error.message : String(error),
|
|
26
|
+
})
|
|
27
|
+
return ''
|
|
12
28
|
}
|
|
13
29
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { realpath, symlink } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Symlink the source checkout's `node_modules` into a freshly created worktree
|
|
7
|
+
* so a JS project's dependencies don't need reinstalling. No-op when the source
|
|
8
|
+
* isn't a JS project, has no installed `node_modules`, or the worktree already
|
|
9
|
+
* has one. Returns true when a symlink was created.
|
|
10
|
+
*/
|
|
11
|
+
export async function linkNodeModules(sourceDir: string, worktreePath: string): Promise<boolean> {
|
|
12
|
+
if (!existsSync(join(sourceDir, 'package.json'))) return false
|
|
13
|
+
const sourceModules = join(sourceDir, 'node_modules')
|
|
14
|
+
if (!existsSync(sourceModules)) return false
|
|
15
|
+
const targetModules = join(worktreePath, 'node_modules')
|
|
16
|
+
if (existsSync(targetModules)) return false
|
|
17
|
+
// Resolve through any existing symlink so the new link points at the real
|
|
18
|
+
// dependency store rather than chaining through another worktree.
|
|
19
|
+
const resolved = await realpath(sourceModules)
|
|
20
|
+
await symlink(resolved, targetModules, 'dir')
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { lstat, mkdir, realpath } from 'node:fs/promises'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const DEFAULT_WORKTREE_ROOT = '/tmp/aimux-wt'
|
|
6
|
+
const MAX_SLUG_LENGTH = 24
|
|
7
|
+
|
|
8
|
+
export function getAimuxWorktreeRoot(): string {
|
|
9
|
+
const root = process.env.AIMUX_WORKTREE_ROOT
|
|
10
|
+
return root != null && root !== '' ? root : DEFAULT_WORKTREE_ROOT
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sanitizePathSegment(input: string, maxLength = MAX_SLUG_LENGTH): string {
|
|
14
|
+
const sanitized = input
|
|
15
|
+
.trim()
|
|
16
|
+
.replaceAll(/[^A-Za-z0-9._-]+/g, '-')
|
|
17
|
+
.replaceAll(/\.\.+/g, '-')
|
|
18
|
+
.replaceAll(/^-+|-+$/g, '')
|
|
19
|
+
return (sanitized || 'worktree').slice(0, maxLength).replaceAll(/[-_.]+$/g, '') || 'worktree'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function shortHash(input: string, length = 8): string {
|
|
23
|
+
return createHash('sha1').update(input).digest('hex').slice(0, length)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function makeWorktreePath({
|
|
27
|
+
repoRoot,
|
|
28
|
+
worktreeId,
|
|
29
|
+
worktreeName,
|
|
30
|
+
}: {
|
|
31
|
+
repoRoot: string
|
|
32
|
+
worktreeName: string
|
|
33
|
+
worktreeId: string
|
|
34
|
+
}): string {
|
|
35
|
+
const repoKey = `r-${shortHash(resolve(repoRoot))}`
|
|
36
|
+
const idSuffix = shortHash(worktreeId, 5)
|
|
37
|
+
const slug = `${sanitizePathSegment(worktreeName)}-${idSuffix}`
|
|
38
|
+
return join(getAimuxWorktreeRoot(), repoKey, slug)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isInsideAimuxWorktreeRoot(path: string): boolean {
|
|
42
|
+
const normalizeTmp = (value: string) => value.replace(/^\/private\/tmp(?=\/|$)/, '/tmp')
|
|
43
|
+
const root = `${normalizeTmp(resolve(getAimuxWorktreeRoot()))}/`
|
|
44
|
+
const target = `${normalizeTmp(resolve(path))}/`
|
|
45
|
+
return target.startsWith(root)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function ensureAimuxWorktreeRoot(): Promise<string> {
|
|
49
|
+
const root = getAimuxWorktreeRoot()
|
|
50
|
+
await mkdir(root, { recursive: true })
|
|
51
|
+
const stat = await lstat(root)
|
|
52
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
53
|
+
throw new Error(`unsafe Aimux worktree root: ${root}`)
|
|
54
|
+
}
|
|
55
|
+
return root
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function assertSafeAimuxWorktreePath(path: string): Promise<void> {
|
|
59
|
+
const root = await ensureAimuxWorktreeRoot()
|
|
60
|
+
if (!isInsideAimuxWorktreeRoot(path)) {
|
|
61
|
+
throw new Error(`refusing worktree path outside Aimux temp root: ${path}`)
|
|
62
|
+
}
|
|
63
|
+
const realRoot = await realpath(root)
|
|
64
|
+
const realParent = await realpath(resolve(path, '..'))
|
|
65
|
+
if (`${realParent}/`.startsWith(`${realRoot}/`)) return
|
|
66
|
+
throw new Error(`unsafe Aimux worktree parent: ${realParent}`)
|
|
67
|
+
}
|
package/src/profile-paths.ts
CHANGED
|
@@ -4,14 +4,14 @@ export const DEFAULT_PROFILE = 'default'
|
|
|
4
4
|
|
|
5
5
|
function sanitizeProfile(profile: string): string {
|
|
6
6
|
const trimmed = profile.trim().toLowerCase()
|
|
7
|
-
const normalized = trimmed.
|
|
8
|
-
const collapsed = normalized.
|
|
7
|
+
const normalized = trimmed.replaceAll(/[^a-z0-9._-]+/g, '-')
|
|
8
|
+
const collapsed = normalized.replaceAll(/-+/g, '-').replaceAll(/^-|-$/g, '')
|
|
9
9
|
return collapsed || DEFAULT_PROFILE
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
export function getProfileName(): string {
|
|
13
13
|
const configured = process.env.AIMUX_PROFILE ?? process.env.AIMUX_RUNTIME_PROFILE
|
|
14
|
-
if (!configured) {
|
|
14
|
+
if (!(configured != null && configured !== '')) {
|
|
15
15
|
return DEFAULT_PROFILE
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -58,9 +58,9 @@ export interface StatusDetectionLoopHandle {
|
|
|
58
58
|
/** Last session flags, for on-attach replay. */
|
|
59
59
|
getSessionStatus: (sessionId: string) => SessionStatus | undefined
|
|
60
60
|
/** Snapshot of every known session's flags. */
|
|
61
|
-
snapshotSessions: () =>
|
|
61
|
+
snapshotSessions: () => { sessionId: string; status: SessionStatus }[]
|
|
62
62
|
/** Snapshot of every known tab's status plus its session. */
|
|
63
|
-
snapshotTabs: () =>
|
|
63
|
+
snapshotTabs: () => { tabId: string; sessionId: string; status: TabActivity }[]
|
|
64
64
|
/**
|
|
65
65
|
* Synchronously run classification for a single session. Used on client
|
|
66
66
|
* attach so the replay snapshot is populated *before* the client reads it,
|