@brimveyn/aimux 1.4.0 → 1.5.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 +142 -176
- package/package.json +7 -3
- package/src/app-runtime/use-terminal-resize.ts +112 -32
- package/src/app.tsx +20 -3
- package/src/config.ts +53 -17
- package/src/daemon/session-manager.ts +11 -4
- package/src/daemon/session-registry.ts +15 -4
- package/src/index.tsx +6 -1
- package/src/input/keymap/build-handlers.ts +1 -0
- package/src/input/keymap/help-entries.ts +44 -0
- package/src/input/keymap/keymap-ref.ts +11 -0
- package/src/input/keymap/sequence-resolver.ts +35 -2
- package/src/input/keymap/trie.ts +1 -0
- package/src/input/modes/bridge.ts +1 -1
- package/src/input/modes/handlers/shared.ts +0 -16
- package/src/input/modes/transitions.ts +4 -4
- package/src/input/modes/types.ts +1 -1
- package/src/platform/daemon-control.ts +0 -8
- package/src/pty/pty-manager.ts +127 -11
- package/src/restart-terminal-manager.ts +44 -0
- package/src/session-backend/local-session-backend.ts +15 -4
- package/src/session-backend/remote-session-backend.ts +13 -2
- package/src/session-backend/types.ts +13 -2
- package/src/state/reducers/git-panel-state.ts +35 -8
- package/src/state/reducers/modal-state.ts +48 -3
- package/src/state/selectors.ts +1 -5
- package/src/state/session-persistence.ts +1 -20
- package/src/state/store.ts +38 -5
- package/src/state/types.ts +27 -13
- package/src/state/validation.ts +0 -2
- package/src/state/workspace-save.ts +6 -2
- package/src/ui/components/create-session-modal.tsx +5 -3
- package/src/ui/components/git-commit-modal.tsx +1 -3
- package/src/ui/components/git-pane-widget.tsx +46 -0
- package/src/ui/components/git-panel.tsx +72 -25
- package/src/ui/components/help-modal.tsx +156 -42
- package/src/ui/components/modal-keybinds-overlay.tsx +39 -0
- package/src/ui/components/modal-shell.tsx +15 -3
- package/src/ui/components/new-tab-modal.tsx +9 -10
- package/src/ui/components/pending-chord-overlay.tsx +2 -1
- package/src/ui/components/session-name-modal.tsx +1 -3
- package/src/ui/components/session-picker-modal.tsx +1 -3
- package/src/ui/components/sidebar.tsx +24 -50
- package/src/ui/components/snippet-editor-modal.tsx +1 -3
- package/src/ui/components/snippet-picker-modal.tsx +1 -3
- package/src/ui/components/status-bar.tsx +0 -4
- package/src/ui/components/terminal-pane.tsx +17 -12
- package/src/ui/components/theme-picker-modal.tsx +6 -3
- package/src/ui/components/update-available-modal.tsx +7 -4
- package/src/ui/keymap-context.ts +1 -6
- package/src/ui/root.tsx +29 -1
- package/src/ui/status-bar-model.ts +0 -5
- package/src/ui/directory-search.ts +0 -1
package/src/state/types.ts
CHANGED
|
@@ -6,13 +6,7 @@ export type TabStatus = 'starting' | 'running' | 'disconnected' | 'exited' | 'er
|
|
|
6
6
|
|
|
7
7
|
export type TabActivity = 'busy' | 'idle'
|
|
8
8
|
|
|
9
|
-
export type FocusMode =
|
|
10
|
-
| 'navigation'
|
|
11
|
-
| 'terminal-input'
|
|
12
|
-
| 'modal'
|
|
13
|
-
| 'command-edit'
|
|
14
|
-
| 'layout'
|
|
15
|
-
| 'git'
|
|
9
|
+
export type FocusMode = 'navigation' | 'terminal-input' | 'modal' | 'command-edit' | 'git'
|
|
16
10
|
|
|
17
11
|
export type ModalType =
|
|
18
12
|
| 'new-tab'
|
|
@@ -89,8 +83,6 @@ export interface WorkspaceSnapshotV1 {
|
|
|
89
83
|
sidebar: {
|
|
90
84
|
visible: boolean
|
|
91
85
|
width: number
|
|
92
|
-
gitPanelVisible?: boolean
|
|
93
|
-
gitPanelRatio?: number
|
|
94
86
|
}
|
|
95
87
|
tabs: PersistedTabSnapshot[]
|
|
96
88
|
layoutTree?: import('./layout-tree').LayoutNode
|
|
@@ -136,8 +128,24 @@ export interface SidebarState {
|
|
|
136
128
|
width: number
|
|
137
129
|
minWidth: number
|
|
138
130
|
maxWidth: number
|
|
139
|
-
|
|
140
|
-
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type GitPaneMode = 'embedded' | 'pane'
|
|
134
|
+
export type GitPanePosition = 'top' | 'bottom' | 'left' | 'right'
|
|
135
|
+
|
|
136
|
+
export type GitPanePathConfig =
|
|
137
|
+
| { enabled: false }
|
|
138
|
+
| { enabled: true; pathFn?: (path: string) => string }
|
|
139
|
+
|
|
140
|
+
export type GitPaneDiffCountConfig = { enabled: boolean }
|
|
141
|
+
|
|
142
|
+
export interface GitPaneState {
|
|
143
|
+
visible: boolean
|
|
144
|
+
mode: GitPaneMode
|
|
145
|
+
position: GitPanePosition
|
|
146
|
+
ratio: number
|
|
147
|
+
path: GitPanePathConfig
|
|
148
|
+
diffCount: GitPaneDiffCountConfig
|
|
141
149
|
}
|
|
142
150
|
|
|
143
151
|
export type GitFileStatus = 'M' | 'A' | 'D' | 'R' | 'C' | 'U' | '?'
|
|
@@ -222,6 +230,7 @@ export interface ModalThemePicker extends ModalBase {
|
|
|
222
230
|
|
|
223
231
|
export interface ModalHelp extends ModalBase {
|
|
224
232
|
type: 'help'
|
|
233
|
+
entryCount: number
|
|
225
234
|
}
|
|
226
235
|
|
|
227
236
|
export interface ModalSplitPicker extends ModalBase {
|
|
@@ -300,6 +309,7 @@ export interface AppState {
|
|
|
300
309
|
snippets: SnippetRecord[]
|
|
301
310
|
focusMode: FocusMode
|
|
302
311
|
sidebar: SidebarState
|
|
312
|
+
gitPane: GitPaneState
|
|
303
313
|
modal: ModalState
|
|
304
314
|
layout: LayoutState
|
|
305
315
|
customCommands: Record<AssistantId, string>
|
|
@@ -332,6 +342,8 @@ export type ModalAction =
|
|
|
332
342
|
| { type: 'open-snippet-picker' }
|
|
333
343
|
| { type: 'open-snippet-editor'; snippetId?: string }
|
|
334
344
|
| { type: 'begin-snippet-filter' }
|
|
345
|
+
| { type: 'begin-help-filter' }
|
|
346
|
+
| { type: 'set-help-entry-count'; count: number }
|
|
335
347
|
| { type: 'open-theme-picker' }
|
|
336
348
|
| { type: 'open-update-available-modal'; currentVersion: string; latestVersion: string }
|
|
337
349
|
|
|
@@ -407,8 +419,10 @@ export type UIAction =
|
|
|
407
419
|
| { type: 'resize-sidebar'; delta: number }
|
|
408
420
|
| { type: 'set-focus-mode'; focusMode: FocusMode }
|
|
409
421
|
| { type: 'set-terminal-size'; cols: number; rows: number }
|
|
410
|
-
| { type: 'toggle-git-
|
|
411
|
-
| { type: 'resize-git-
|
|
422
|
+
| { type: 'toggle-git-pane' }
|
|
423
|
+
| { type: 'resize-git-pane'; delta: number }
|
|
424
|
+
| { type: 'set-git-pane-mode'; mode: GitPaneMode }
|
|
425
|
+
| { type: 'set-git-pane-position'; position: GitPanePosition }
|
|
412
426
|
| { type: 'set-pending-chords'; chords: string[] | null }
|
|
413
427
|
| { type: 'toggle-session-bar' }
|
|
414
428
|
| { type: 'set-session-bar-position'; position: SessionBarPosition }
|
package/src/state/validation.ts
CHANGED
|
@@ -114,8 +114,6 @@ export function isWorkspaceSnapshotV1(value: unknown): value is WorkspaceSnapsho
|
|
|
114
114
|
isObjectRecord(value.sidebar) &&
|
|
115
115
|
isBoolean(value.sidebar.visible) &&
|
|
116
116
|
isFiniteNumber(value.sidebar.width) &&
|
|
117
|
-
(value.sidebar.gitPanelVisible === undefined || isBoolean(value.sidebar.gitPanelVisible)) &&
|
|
118
|
-
(value.sidebar.gitPanelRatio === undefined || isFiniteNumber(value.sidebar.gitPanelRatio)) &&
|
|
119
117
|
Array.isArray(value.tabs) &&
|
|
120
118
|
value.tabs.every(
|
|
121
119
|
(tab) =>
|
|
@@ -24,8 +24,12 @@ export function saveCurrentWorkspace(state: AppState): void {
|
|
|
24
24
|
saveConfig({
|
|
25
25
|
...loadConfig(),
|
|
26
26
|
customCommands: state.customCommands,
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
gitPane: {
|
|
28
|
+
mode: state.gitPane.mode,
|
|
29
|
+
position: state.gitPane.position,
|
|
30
|
+
ratio: state.gitPane.ratio,
|
|
31
|
+
visible: state.gitPane.visible,
|
|
32
|
+
},
|
|
29
33
|
sessionBarPosition: state.sessionBar.position,
|
|
30
34
|
sessionBarVisible: state.sessionBar.visible,
|
|
31
35
|
})
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { DirectoryResult } from '../../state/types'
|
|
2
2
|
|
|
3
|
-
import { useModalHelp } from '../keymap-context'
|
|
4
3
|
import { abbreviatePath } from '../path-format'
|
|
5
4
|
import { theme } from '../theme'
|
|
6
5
|
import { uiTokens } from '../ui-tokens'
|
|
@@ -51,10 +50,13 @@ export function CreateSessionModal({
|
|
|
51
50
|
}: CreateSessionModalProps) {
|
|
52
51
|
const dirActive = activeField === 'directory'
|
|
53
52
|
const nameActive = activeField === 'name'
|
|
54
|
-
const help = useModalHelp('modal.create-session')
|
|
55
53
|
|
|
56
54
|
return (
|
|
57
|
-
<ModalShell
|
|
55
|
+
<ModalShell
|
|
56
|
+
title="Create session"
|
|
57
|
+
keybindsModeId="modal.create-session"
|
|
58
|
+
width={uiTokens.modalWidth.xl}
|
|
59
|
+
>
|
|
58
60
|
<box flexDirection="column">
|
|
59
61
|
<text fg={dirActive ? theme.text : theme.textMuted}>Search projects</text>
|
|
60
62
|
<InputField
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { useModalHelp } from '../keymap-context'
|
|
2
1
|
import { theme } from '../theme'
|
|
3
2
|
import { uiTokens } from '../ui-tokens'
|
|
4
3
|
import { InputField } from './input-field'
|
|
@@ -14,10 +13,9 @@ interface GitCommitModalProps {
|
|
|
14
13
|
export function GitCommitModal({ activeField, body, cursorPos, title }: GitCommitModalProps) {
|
|
15
14
|
const titleActive = activeField === 'title'
|
|
16
15
|
const bodyActive = activeField === 'body'
|
|
17
|
-
const help = useModalHelp('modal.git-commit')
|
|
18
16
|
|
|
19
17
|
return (
|
|
20
|
-
<ModalShell title="Commit"
|
|
18
|
+
<ModalShell title="Commit" keybindsModeId="modal.git-commit" width={uiTokens.modalWidth.xl}>
|
|
21
19
|
<box flexDirection="column">
|
|
22
20
|
<text fg={titleActive ? theme.text : theme.textMuted}>Title</text>
|
|
23
21
|
<InputField
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { memo, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { GitPanelState } from '../../state/types'
|
|
4
|
+
|
|
5
|
+
import { useGitPanelPolling } from '../../git/git-poller'
|
|
6
|
+
import { useAppStore } from '../../state/app-store'
|
|
7
|
+
import { GitPanel } from './git-panel'
|
|
8
|
+
|
|
9
|
+
interface GitPaneWidgetProps {
|
|
10
|
+
pollingEnabled: boolean
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const GitPaneWidget = memo(function GitPaneWidget({ pollingEnabled }: GitPaneWidgetProps) {
|
|
14
|
+
const gitPanel = useAppStore((s) => s.gitPanel)
|
|
15
|
+
const pathConfig = useAppStore((s) => s.gitPane.path)
|
|
16
|
+
const diffCountConfig = useAppStore((s) => s.gitPane.diffCount)
|
|
17
|
+
const currentSessionId = useAppStore((s) => s.currentSessionId)
|
|
18
|
+
const sessions = useAppStore((s) => s.sessions)
|
|
19
|
+
const currentSession = currentSessionId
|
|
20
|
+
? sessions.find((s) => s.id === currentSessionId)
|
|
21
|
+
: undefined
|
|
22
|
+
const projectPath = currentSession?.projectPath
|
|
23
|
+
|
|
24
|
+
useGitPanelPolling({ enabled: pollingEnabled, projectPath })
|
|
25
|
+
|
|
26
|
+
const lastGoodRef = useRef<GitPanelState | null>(null)
|
|
27
|
+
const prevProjectPathRef = useRef(projectPath)
|
|
28
|
+
if (prevProjectPathRef.current !== projectPath) {
|
|
29
|
+
prevProjectPathRef.current = projectPath
|
|
30
|
+
lastGoodRef.current = null
|
|
31
|
+
}
|
|
32
|
+
const isGood = gitPanel.error === null && gitPanel.branch !== null
|
|
33
|
+
if (isGood) {
|
|
34
|
+
lastGoodRef.current = gitPanel
|
|
35
|
+
}
|
|
36
|
+
const display = lastGoodRef.current ?? gitPanel
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<GitPanel
|
|
40
|
+
diffCountConfig={diffCountConfig}
|
|
41
|
+
gitPanel={display}
|
|
42
|
+
pathConfig={pathConfig}
|
|
43
|
+
projectPath={projectPath}
|
|
44
|
+
/>
|
|
45
|
+
)
|
|
46
|
+
})
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { memo, type ReactNode, useMemo } from 'react'
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
GitFileEntry,
|
|
5
|
+
GitFileSection,
|
|
6
|
+
GitPaneDiffCountConfig,
|
|
7
|
+
GitPanelState,
|
|
8
|
+
GitPanePathConfig,
|
|
9
|
+
} from '../../state/types'
|
|
4
10
|
|
|
5
11
|
import { theme } from '../theme'
|
|
6
12
|
|
|
@@ -8,6 +14,8 @@ interface GitPanelProps {
|
|
|
8
14
|
gitPanel: GitPanelState
|
|
9
15
|
projectPath: string | undefined
|
|
10
16
|
selectedFileKey?: string | null
|
|
17
|
+
pathConfig?: GitPanePathConfig
|
|
18
|
+
diffCountConfig?: GitPaneDiffCountConfig
|
|
11
19
|
}
|
|
12
20
|
|
|
13
21
|
export function fileKey(file: Pick<GitFileEntry, 'path' | 'section'>): string {
|
|
@@ -69,36 +77,69 @@ function stripTrailingSlash(prefix: string): string {
|
|
|
69
77
|
return prefix.endsWith('/') ? prefix.slice(0, -1) : prefix
|
|
70
78
|
}
|
|
71
79
|
|
|
72
|
-
function renderPath(file: GitFileEntry): ReactNode {
|
|
73
|
-
const
|
|
80
|
+
function renderPath(file: GitFileEntry, pathConfig: GitPanePathConfig): ReactNode {
|
|
81
|
+
const showDir = pathConfig.enabled
|
|
82
|
+
const transform = pathConfig.enabled ? pathConfig.pathFn : undefined
|
|
83
|
+
const displayPath = transform ? transform(file.path) : file.path
|
|
84
|
+
const { basename, prefix } = splitPath(displayPath)
|
|
74
85
|
const dir = stripTrailingSlash(prefix)
|
|
75
86
|
if (file.renamedFrom) {
|
|
76
|
-
const
|
|
87
|
+
const renamedDisplay = transform ? transform(file.renamedFrom) : file.renamedFrom
|
|
88
|
+
const renamed = splitPath(renamedDisplay)
|
|
77
89
|
const renamedDir = stripTrailingSlash(renamed.prefix)
|
|
78
90
|
return (
|
|
79
91
|
<text wrapMode="none">
|
|
80
92
|
<span fg={theme.text}>{renamed.basename}</span>
|
|
81
|
-
{renamedDir ? <span fg={theme.textMuted}> {renamedDir}</span> : null}
|
|
93
|
+
{showDir && renamedDir ? <span fg={theme.textMuted}> {renamedDir}</span> : null}
|
|
82
94
|
<span fg={theme.textMuted}> → </span>
|
|
83
95
|
<span fg={theme.text}>{basename}</span>
|
|
84
|
-
{dir ? <span fg={theme.textMuted}> {dir}</span> : null}
|
|
96
|
+
{showDir && dir ? <span fg={theme.textMuted}> {dir}</span> : null}
|
|
85
97
|
</text>
|
|
86
98
|
)
|
|
87
99
|
}
|
|
88
100
|
return (
|
|
89
101
|
<text wrapMode="none">
|
|
90
102
|
<span fg={theme.text}>{basename}</span>
|
|
91
|
-
{dir ? <span fg={theme.textMuted}> {dir}</span> : null}
|
|
103
|
+
{showDir && dir ? <span fg={theme.textMuted}> {dir}</span> : null}
|
|
92
104
|
</text>
|
|
93
105
|
)
|
|
94
106
|
}
|
|
95
107
|
|
|
108
|
+
function renderDiffCount(
|
|
109
|
+
file: GitFileEntry,
|
|
110
|
+
addedW: number,
|
|
111
|
+
removedW: number,
|
|
112
|
+
bg: string | undefined,
|
|
113
|
+
diffCountConfig: GitPaneDiffCountConfig,
|
|
114
|
+
hasNumstat: boolean
|
|
115
|
+
): ReactNode {
|
|
116
|
+
if (!diffCountConfig.enabled) return null
|
|
117
|
+
if (!hasNumstat) {
|
|
118
|
+
return (
|
|
119
|
+
<text fg={theme.textMuted} bg={bg} flexShrink={0}>
|
|
120
|
+
—
|
|
121
|
+
</text>
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
return (
|
|
125
|
+
<box flexDirection="row" flexShrink={0}>
|
|
126
|
+
<text fg={theme.success} bg={bg}>{`+${padRight(file.added, addedW)}`}</text>
|
|
127
|
+
<text fg={theme.dim} bg={bg}>
|
|
128
|
+
{' '}
|
|
129
|
+
</text>
|
|
130
|
+
<text fg={theme.danger} bg={bg}>{`−${padRight(file.removed, removedW)}`}</text>
|
|
131
|
+
</box>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
96
135
|
function renderFileRow(
|
|
97
136
|
file: GitFileEntry,
|
|
98
137
|
key: string,
|
|
99
138
|
addedW: number,
|
|
100
139
|
removedW: number,
|
|
101
|
-
isSelected: boolean
|
|
140
|
+
isSelected: boolean,
|
|
141
|
+
pathConfig: GitPanePathConfig,
|
|
142
|
+
diffCountConfig: GitPaneDiffCountConfig
|
|
102
143
|
): ReactNode {
|
|
103
144
|
const hasNumstat = file.added !== null || file.removed !== null
|
|
104
145
|
const bg = isSelected ? theme.panelHighlight : undefined
|
|
@@ -108,21 +149,9 @@ function renderFileRow(
|
|
|
108
149
|
<strong>{displayStatus(file)}</strong>
|
|
109
150
|
</text>
|
|
110
151
|
<box flexGrow={1} overflow="hidden">
|
|
111
|
-
{renderPath(file)}
|
|
152
|
+
{renderPath(file, pathConfig)}
|
|
112
153
|
</box>
|
|
113
|
-
{
|
|
114
|
-
<box flexDirection="row" flexShrink={0}>
|
|
115
|
-
<text fg={theme.success} bg={bg}>{`+${padRight(file.added, addedW)}`}</text>
|
|
116
|
-
<text fg={theme.dim} bg={bg}>
|
|
117
|
-
{' '}
|
|
118
|
-
</text>
|
|
119
|
-
<text fg={theme.danger} bg={bg}>{`−${padRight(file.removed, removedW)}`}</text>
|
|
120
|
-
</box>
|
|
121
|
-
) : (
|
|
122
|
-
<text fg={theme.textMuted} bg={bg} flexShrink={0}>
|
|
123
|
-
—
|
|
124
|
-
</text>
|
|
125
|
-
)}
|
|
154
|
+
{renderDiffCount(file, addedW, removedW, bg, diffCountConfig, hasNumstat)}
|
|
126
155
|
</box>
|
|
127
156
|
)
|
|
128
157
|
}
|
|
@@ -133,7 +162,9 @@ function renderSection(
|
|
|
133
162
|
files: GitFileEntry[],
|
|
134
163
|
addedW: number,
|
|
135
164
|
removedW: number,
|
|
136
|
-
selectedFileKey: string | null | undefined
|
|
165
|
+
selectedFileKey: string | null | undefined,
|
|
166
|
+
pathConfig: GitPanePathConfig,
|
|
167
|
+
diffCountConfig: GitPaneDiffCountConfig
|
|
137
168
|
): ReactNode {
|
|
138
169
|
if (files.length === 0) return null
|
|
139
170
|
return (
|
|
@@ -149,7 +180,9 @@ function renderSection(
|
|
|
149
180
|
`${section}-${i}`,
|
|
150
181
|
addedW,
|
|
151
182
|
removedW,
|
|
152
|
-
!!selectedFileKey && fileKey(file) === selectedFileKey
|
|
183
|
+
!!selectedFileKey && fileKey(file) === selectedFileKey,
|
|
184
|
+
pathConfig,
|
|
185
|
+
diffCountConfig
|
|
153
186
|
)
|
|
154
187
|
)}
|
|
155
188
|
</box>
|
|
@@ -190,8 +223,13 @@ function computeStatusPlaceholder(
|
|
|
190
223
|
return null
|
|
191
224
|
}
|
|
192
225
|
|
|
226
|
+
const DEFAULT_PATH_CONFIG: GitPanePathConfig = { enabled: true }
|
|
227
|
+
const DEFAULT_DIFF_COUNT_CONFIG: GitPaneDiffCountConfig = { enabled: true }
|
|
228
|
+
|
|
193
229
|
export const GitPanel = memo(function GitPanel({
|
|
230
|
+
diffCountConfig = DEFAULT_DIFF_COUNT_CONFIG,
|
|
194
231
|
gitPanel,
|
|
232
|
+
pathConfig = DEFAULT_PATH_CONFIG,
|
|
195
233
|
projectPath,
|
|
196
234
|
selectedFileKey,
|
|
197
235
|
}: GitPanelProps) {
|
|
@@ -220,7 +258,16 @@ export const GitPanel = memo(function GitPanel({
|
|
|
220
258
|
contentOptions={{ flexDirection: 'column', gap: 0 }}
|
|
221
259
|
>
|
|
222
260
|
{SECTION_ORDER.map((s) =>
|
|
223
|
-
renderSection(
|
|
261
|
+
renderSection(
|
|
262
|
+
s.key,
|
|
263
|
+
s.title,
|
|
264
|
+
groups[s.key],
|
|
265
|
+
addedW,
|
|
266
|
+
removedW,
|
|
267
|
+
selectedFileKey,
|
|
268
|
+
pathConfig,
|
|
269
|
+
diffCountConfig
|
|
270
|
+
)
|
|
224
271
|
)}
|
|
225
272
|
</scrollbox>
|
|
226
273
|
)}
|
|
@@ -1,60 +1,174 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { useTerminalDimensions } from '@opentui/react'
|
|
2
|
+
import { useLayoutEffect, useMemo, useRef } from 'react'
|
|
2
3
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { collectHelpEntries, type HelpEntry } from '../../input/keymap/help-entries'
|
|
5
|
+
import { dispatchGlobal } from '../../state/dispatch-ref'
|
|
6
|
+
import { useKeymap } from '../keymap-context'
|
|
5
7
|
import { theme } from '../theme'
|
|
6
8
|
import { uiTokens } from '../ui-tokens'
|
|
9
|
+
import { ModalFilterBar } from './modal-filter-bar'
|
|
7
10
|
import { ModalShell } from './modal-shell'
|
|
8
11
|
|
|
9
|
-
interface
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
interface HelpModalProps {
|
|
13
|
+
filter: string | null
|
|
14
|
+
selectedIndex: number
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
function matchesFilter(entry: HelpEntry, needle: string): boolean {
|
|
18
|
+
if (!needle) return true
|
|
19
|
+
const lower = needle.toLowerCase()
|
|
20
|
+
return (
|
|
21
|
+
(entry.description?.toLowerCase().includes(lower) ?? false) ||
|
|
22
|
+
entry.modeLabel.toLowerCase().includes(lower) ||
|
|
23
|
+
entry.keysDisplay.toLowerCase().includes(lower) ||
|
|
24
|
+
(entry.group?.toLowerCase().includes(lower) ?? false)
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Row =
|
|
29
|
+
| { kind: 'header'; label: string }
|
|
30
|
+
| { kind: 'entry'; entry: HelpEntry; entryIndex: number }
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
function buildRows(entries: HelpEntry[]): Row[] {
|
|
33
|
+
const rows: Row[] = []
|
|
34
|
+
let lastLabel: string | null = null
|
|
35
|
+
for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
|
|
36
|
+
const entry = entries[entryIndex]
|
|
37
|
+
if (!entry) continue
|
|
38
|
+
if (entry.modeLabel !== lastLabel) {
|
|
39
|
+
rows.push({ kind: 'header', label: entry.modeLabel })
|
|
40
|
+
lastLabel = entry.modeLabel
|
|
41
|
+
}
|
|
42
|
+
rows.push({ entry, entryIndex, kind: 'entry' })
|
|
43
|
+
}
|
|
44
|
+
return rows
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const KEYS_COLUMN_WIDTH = 24
|
|
48
|
+
const VIEWPORT_HEIGHT_RATIO = 0.6
|
|
49
|
+
const MODAL_CHROME_ROWS = 6
|
|
50
|
+
|
|
51
|
+
function clampSelection(index: number, count: number): number {
|
|
52
|
+
if (count === 0) return 0
|
|
53
|
+
return Math.max(0, Math.min(count - 1, index))
|
|
54
|
+
}
|
|
22
55
|
|
|
23
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Pick a window start that (a) keeps the selected row on screen and
|
|
58
|
+
* (b) only scrolls when the selection leaves the margin — avoids jumpy
|
|
59
|
+
* recentering on every keypress.
|
|
60
|
+
*/
|
|
61
|
+
function computeWindowStart(
|
|
62
|
+
prevStart: number,
|
|
63
|
+
selectedRowIndex: number,
|
|
64
|
+
total: number,
|
|
65
|
+
windowSize: number
|
|
66
|
+
): number {
|
|
67
|
+
if (total <= windowSize) return 0
|
|
68
|
+
const margin = 1
|
|
69
|
+
const maxStart = total - windowSize
|
|
70
|
+
let start = Math.max(0, Math.min(maxStart, prevStart))
|
|
71
|
+
const topThreshold = start + margin
|
|
72
|
+
const bottomThreshold = start + windowSize - 1 - margin
|
|
73
|
+
if (selectedRowIndex < topThreshold) {
|
|
74
|
+
start = Math.max(0, selectedRowIndex - margin)
|
|
75
|
+
} else if (selectedRowIndex > bottomThreshold) {
|
|
76
|
+
start = Math.min(maxStart, selectedRowIndex - windowSize + 1 + margin)
|
|
77
|
+
}
|
|
78
|
+
return start
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function HelpModal({ filter, selectedIndex }: HelpModalProps) {
|
|
24
82
|
const config = useKeymap()
|
|
25
|
-
const
|
|
83
|
+
const dimensions = useTerminalDimensions()
|
|
84
|
+
const allEntries = useMemo(() => collectHelpEntries(config), [config])
|
|
85
|
+
const filtered = useMemo(
|
|
86
|
+
() => allEntries.filter((e) => matchesFilter(e, filter ?? '')),
|
|
87
|
+
[allEntries, filter]
|
|
88
|
+
)
|
|
89
|
+
const rows = useMemo(() => buildRows(filtered), [filtered])
|
|
90
|
+
|
|
91
|
+
useLayoutEffect(() => {
|
|
92
|
+
dispatchGlobal({ count: filtered.length, type: 'set-help-entry-count' })
|
|
93
|
+
}, [filtered.length])
|
|
94
|
+
|
|
95
|
+
const effectiveIndex = clampSelection(selectedIndex, filtered.length)
|
|
96
|
+
const selectedRowIndex = Math.max(
|
|
97
|
+
0,
|
|
98
|
+
rows.findIndex((r) => r.kind === 'entry' && r.entryIndex === effectiveIndex)
|
|
99
|
+
)
|
|
100
|
+
const maxHeight = Math.max(6, Math.floor(dimensions.height * VIEWPORT_HEIGHT_RATIO))
|
|
101
|
+
const listHeight = Math.max(1, maxHeight - MODAL_CHROME_ROWS)
|
|
102
|
+
|
|
103
|
+
const prevStartRef = useRef(0)
|
|
104
|
+
const prevFilterRef = useRef<string | null>(filter)
|
|
105
|
+
// Reset scroll when filter changes; selection goes back to index 0.
|
|
106
|
+
if (prevFilterRef.current !== filter) {
|
|
107
|
+
prevFilterRef.current = filter
|
|
108
|
+
prevStartRef.current = 0
|
|
109
|
+
}
|
|
110
|
+
const start = computeWindowStart(prevStartRef.current, selectedRowIndex, rows.length, listHeight)
|
|
111
|
+
prevStartRef.current = start
|
|
112
|
+
const visible = rows.slice(start, start + listHeight)
|
|
26
113
|
|
|
27
114
|
return (
|
|
28
|
-
<ModalShell
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
115
|
+
<ModalShell
|
|
116
|
+
title="Keybindings"
|
|
117
|
+
keybindsModeId="modal.help"
|
|
118
|
+
width={uiTokens.modalWidth.lg}
|
|
119
|
+
footer={
|
|
120
|
+
<box flexDirection="column" gap={0}>
|
|
121
|
+
<text fg={theme.dim}>
|
|
122
|
+
{filtered.length === 0
|
|
123
|
+
? ''
|
|
124
|
+
: ` ${effectiveIndex + 1} / ${filtered.length}${filter ? '' : ' — type / to filter'}`}
|
|
125
|
+
</text>
|
|
126
|
+
<ModalFilterBar filter={filter} />
|
|
127
|
+
</box>
|
|
128
|
+
}
|
|
129
|
+
>
|
|
130
|
+
{filtered.length === 0 ? (
|
|
131
|
+
<text fg={theme.textMuted}>
|
|
132
|
+
{filter ? 'No matching bindings.' : 'No bindings registered.'}
|
|
133
|
+
</text>
|
|
134
|
+
) : (
|
|
135
|
+
<box height={listHeight} flexDirection="column" overflow="hidden">
|
|
136
|
+
{visible.map((row, i) => {
|
|
137
|
+
const rowIndex = start + i
|
|
138
|
+
if (row.kind === 'header') {
|
|
139
|
+
return (
|
|
140
|
+
<box key={`h-${rowIndex}`} paddingLeft={1} paddingTop={i === 0 ? 0 : 1}>
|
|
141
|
+
<text fg={theme.warning} wrapMode="none">
|
|
142
|
+
<strong>{row.label}</strong>
|
|
143
|
+
</text>
|
|
144
|
+
</box>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
const active = row.entryIndex === effectiveIndex
|
|
148
|
+
const bg = active ? theme.panelHighlight : undefined
|
|
149
|
+
return (
|
|
40
150
|
<box
|
|
41
|
-
key={
|
|
42
|
-
flexDirection="
|
|
151
|
+
key={`e-${rowIndex}`}
|
|
152
|
+
flexDirection="row"
|
|
153
|
+
paddingLeft={2}
|
|
154
|
+
paddingRight={1}
|
|
155
|
+
backgroundColor={bg}
|
|
43
156
|
>
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
157
|
+
<box width={KEYS_COLUMN_WIDTH} flexShrink={0}>
|
|
158
|
+
<text fg={active ? theme.accent : theme.accentAlt} bg={bg} wrapMode="none">
|
|
159
|
+
{row.entry.keysDisplay}
|
|
160
|
+
</text>
|
|
161
|
+
</box>
|
|
162
|
+
<box flexGrow={1} overflow="hidden">
|
|
163
|
+
<text fg={active ? theme.text : theme.textMuted} bg={bg} wrapMode="none">
|
|
164
|
+
{row.entry.description ?? ''}
|
|
165
|
+
</text>
|
|
166
|
+
</box>
|
|
53
167
|
</box>
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
168
|
+
)
|
|
169
|
+
})}
|
|
170
|
+
</box>
|
|
171
|
+
)}
|
|
58
172
|
</ModalShell>
|
|
59
173
|
)
|
|
60
174
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { describeBindings } from '../../input/keymap/describe-bindings'
|
|
4
|
+
import { useKeymap } from '../keymap-context'
|
|
5
|
+
import { theme } from '../theme'
|
|
6
|
+
import { Surface } from './surface'
|
|
7
|
+
|
|
8
|
+
const KEYS_COLUMN_WIDTH = 12
|
|
9
|
+
|
|
10
|
+
interface ModalKeybindsOverlayProps {
|
|
11
|
+
modeId: ModeId
|
|
12
|
+
limit?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ModalKeybindsOverlay({ limit, modeId }: ModalKeybindsOverlayProps) {
|
|
16
|
+
const config = useKeymap()
|
|
17
|
+
const bindings = describeBindings(config, modeId, {
|
|
18
|
+
dedupeByDescription: true,
|
|
19
|
+
withDescriptionOnly: true,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
if (bindings.length === 0) return null
|
|
23
|
+
const entries = typeof limit === 'number' ? bindings.slice(0, limit) : bindings
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<box position="absolute" bottom={3} right={5}>
|
|
27
|
+
<Surface tone="elevated" paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
|
28
|
+
{entries.map((binding) => (
|
|
29
|
+
<box key={`${binding.keys}-${binding.description}`} flexDirection="row">
|
|
30
|
+
<box width={KEYS_COLUMN_WIDTH}>
|
|
31
|
+
<text fg={theme.accentAlt}>{binding.keysDisplay}</text>
|
|
32
|
+
</box>
|
|
33
|
+
<text fg={theme.textMuted}>{binding.description ?? ''}</text>
|
|
34
|
+
</box>
|
|
35
|
+
))}
|
|
36
|
+
</Surface>
|
|
37
|
+
</box>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -1,18 +1,29 @@
|
|
|
1
|
+
import type { ModeId } from '@brimveyn/aimux-config'
|
|
1
2
|
import type { ReactNode } from 'react'
|
|
2
3
|
|
|
3
4
|
import { theme } from '../theme'
|
|
5
|
+
import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
|
|
4
6
|
import { Surface } from './surface'
|
|
5
7
|
|
|
6
8
|
interface ModalShellProps {
|
|
7
9
|
children: ReactNode
|
|
8
10
|
footer?: ReactNode
|
|
9
11
|
listGap?: number
|
|
10
|
-
|
|
12
|
+
subtitle?: string
|
|
13
|
+
keybindsModeId?: ModeId
|
|
11
14
|
title: string
|
|
12
15
|
width: number | `${number}%`
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
export function ModalShell({
|
|
18
|
+
export function ModalShell({
|
|
19
|
+
children,
|
|
20
|
+
footer,
|
|
21
|
+
keybindsModeId,
|
|
22
|
+
listGap = 1,
|
|
23
|
+
subtitle,
|
|
24
|
+
title,
|
|
25
|
+
width,
|
|
26
|
+
}: ModalShellProps) {
|
|
16
27
|
return (
|
|
17
28
|
<box
|
|
18
29
|
position="absolute"
|
|
@@ -36,12 +47,13 @@ export function ModalShell({ children, footer, help, listGap = 1, title, width }
|
|
|
36
47
|
<box width="100%" flexDirection="column" gap={listGap}>
|
|
37
48
|
<box flexDirection="column">
|
|
38
49
|
<text fg={theme.accentAlt}>{title}</text>
|
|
39
|
-
{
|
|
50
|
+
{subtitle ? <text fg={theme.textMuted}>{subtitle}</text> : null}
|
|
40
51
|
</box>
|
|
41
52
|
{children}
|
|
42
53
|
{footer ? <box>{footer}</box> : null}
|
|
43
54
|
</box>
|
|
44
55
|
</Surface>
|
|
56
|
+
{keybindsModeId ? <ModalKeybindsOverlay modeId={keybindsModeId} /> : null}
|
|
45
57
|
</box>
|
|
46
58
|
)
|
|
47
59
|
}
|