@brimveyn/aimux 1.6.1 → 1.7.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 +2 -2
- package/src/app-runtime/backend-attach-runtime.ts +6 -0
- package/src/app-runtime/backend-runtime-events.ts +21 -21
- package/src/app-runtime/side-effects.ts +43 -12
- package/src/app-runtime/use-backend-runtime.ts +2 -20
- package/src/app-runtime/use-directory-search.ts +6 -1
- package/src/app.tsx +6 -1
- package/src/config.ts +28 -1
- package/src/daemon/daemon.ts +197 -15
- package/src/daemon/session-manager.ts +9 -0
- package/src/daemon/session-registry.ts +5 -5
- package/src/git/diff-hash.ts +10 -0
- package/src/index.tsx +10 -2
- package/src/input/keymap/help-entries.ts +5 -5
- package/src/input/modes/bridge.ts +5 -8
- package/src/input/modes/transitions.ts +15 -23
- package/src/input/modes/types.ts +3 -5
- package/src/ipc/protocol.ts +49 -5
- package/src/platform/project-search.ts +45 -12
- package/src/pty/assistant-status-detection-loop.ts +192 -0
- package/src/pty/assistant-status-detector.ts +226 -0
- package/src/pty/pty-manager.ts +3 -37
- package/src/session-backend/bootstrap.ts +4 -1
- package/src/session-backend/local-session-backend.ts +43 -56
- package/src/session-backend/remote-session-backend.ts +15 -0
- package/src/session-backend/types.ts +10 -1
- package/src/state/git-tree.ts +42 -16
- package/src/state/reducers/diff-cache.ts +64 -0
- package/src/state/reducers/git-mode-state.ts +91 -33
- package/src/state/reducers/git-panel-state.ts +33 -2
- package/src/state/reducers/modal-state.ts +91 -134
- package/src/state/reducers/session-state.ts +13 -6
- package/src/state/reducers/tab-state.ts +0 -10
- package/src/state/selectors.ts +12 -0
- package/src/state/session-persistence.ts +20 -15
- package/src/state/store.ts +13 -3
- package/src/state/types.ts +87 -14
- package/src/ui/breaking-update-screen.tsx +31 -0
- package/src/ui/components/bare-input.tsx +44 -0
- package/src/ui/components/create-session-modal.tsx +16 -8
- package/src/ui/components/diff-renderer/fold-strip.tsx +5 -13
- package/src/ui/components/diff-renderer/pierre-diff.tsx +9 -31
- package/src/ui/components/diff-renderer/prepare-diff.ts +66 -0
- package/src/ui/components/diff-renderer/split-view.tsx +35 -16
- package/src/ui/components/diff-renderer/stacked-view.tsx +30 -12
- package/src/ui/components/diff-renderer/use-diff-prefetch.ts +191 -0
- package/src/ui/components/diff-renderer/use-diff-preparation.ts +106 -0
- package/src/ui/components/git-pane-widget.tsx +2 -0
- package/src/ui/components/git-panel.tsx +27 -10
- package/src/ui/components/git-view.tsx +23 -9
- package/src/ui/components/help-modal.tsx +45 -160
- package/src/ui/components/input-field.tsx +6 -2
- package/src/ui/components/list-item.tsx +36 -28
- package/src/ui/components/modal-shell.tsx +51 -13
- package/src/ui/components/new-tab-modal.tsx +78 -45
- package/src/ui/components/picker.tsx +179 -0
- package/src/ui/components/session-bar.tsx +47 -21
- package/src/ui/components/session-picker-modal.tsx +58 -56
- package/src/ui/components/sidebar.tsx +49 -22
- package/src/ui/components/snippet-picker-modal.tsx +43 -34
- package/src/ui/components/status-bar.tsx +3 -2
- package/src/ui/components/surface.tsx +11 -8
- package/src/ui/components/tab-item.tsx +38 -22
- package/src/ui/components/terminal-pane.tsx +8 -8
- package/src/ui/components/theme-picker-modal.tsx +51 -91
- package/src/ui/root.tsx +14 -23
- package/src/ui/status-bar-model.ts +5 -5
- package/src/ui/theme-store.ts +26 -2
- package/src/ui/theme.ts +9 -1
- package/src/ui/components/modal-filter-bar.tsx +0 -19
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
|
|
14
14
|
import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
15
15
|
import { buildGitTreeRows, type GitTreeFileRow, type GitTreeFolderRow } from '../../state/git-tree'
|
|
16
|
-
import { getCurrentTheme, useTheme } from '../theme'
|
|
16
|
+
import { getCurrentTheme, getTransparent, useTheme, useTransparent } from '../theme'
|
|
17
17
|
|
|
18
18
|
interface GitPanelProps {
|
|
19
19
|
collapsedFolders?: Record<string, true>
|
|
@@ -24,6 +24,7 @@ interface GitPanelProps {
|
|
|
24
24
|
pathConfig?: GitPanePathConfig
|
|
25
25
|
diffCountConfig?: GitPaneDiffCountConfig
|
|
26
26
|
headOffset?: number
|
|
27
|
+
compact?: boolean
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function sectionTitle(section: GitFileSection, headOffset: number): string {
|
|
@@ -158,7 +159,10 @@ function renderDiffCount(
|
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode {
|
|
161
|
-
const bg =
|
|
162
|
+
const bg =
|
|
163
|
+
isSelected && !getTransparent()
|
|
164
|
+
? getCurrentTheme().colors['list.activeSelectionBackground']
|
|
165
|
+
: undefined
|
|
162
166
|
const onSelect = (): void => {
|
|
163
167
|
dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
|
|
164
168
|
}
|
|
@@ -172,7 +176,10 @@ function renderFolderRow(row: GitTreeFolderRow, isSelected: boolean): ReactNode
|
|
|
172
176
|
<text fg={getCurrentTheme().colors['descriptionForeground']} bg={bg}>
|
|
173
177
|
{row.isCollapsed ? '▸' : '▾'}
|
|
174
178
|
</text>
|
|
175
|
-
<text fg={getCurrentTheme().colors['
|
|
179
|
+
<text fg={getCurrentTheme().colors['textLink.foreground']} bg={bg}>
|
|
180
|
+
{row.isCollapsed ? '\uf07b' : '\uf07c'}
|
|
181
|
+
</text>
|
|
182
|
+
<text fg={getCurrentTheme().colors['textLink.foreground']} bg={bg} wrapMode="none">
|
|
176
183
|
{row.name}
|
|
177
184
|
</text>
|
|
178
185
|
</box>
|
|
@@ -192,7 +199,10 @@ function renderFileRow(
|
|
|
192
199
|
): ReactNode {
|
|
193
200
|
const file = row.file
|
|
194
201
|
const hasNumstat = file.added !== null || file.removed !== null
|
|
195
|
-
const bg =
|
|
202
|
+
const bg =
|
|
203
|
+
isSelected && !getTransparent()
|
|
204
|
+
? getCurrentTheme().colors['list.activeSelectionBackground']
|
|
205
|
+
: undefined
|
|
196
206
|
const onSelect = (): void => {
|
|
197
207
|
dispatchGlobal({ key: row.key, type: 'git-mode-select-entry-by-key' })
|
|
198
208
|
}
|
|
@@ -222,7 +232,8 @@ function renderTreeSection(
|
|
|
222
232
|
selectedEntryKey: string | null | undefined,
|
|
223
233
|
showListModeToggle: boolean,
|
|
224
234
|
pathConfig: GitPanePathConfig,
|
|
225
|
-
diffCountConfig: GitPaneDiffCountConfig
|
|
235
|
+
diffCountConfig: GitPaneDiffCountConfig,
|
|
236
|
+
marginTop: number
|
|
226
237
|
): ReactNode {
|
|
227
238
|
if (files.length === 0) return null
|
|
228
239
|
const nextFileListMode = fileListMode === 'tree' ? 'flat' : 'tree'
|
|
@@ -231,7 +242,7 @@ function renderTreeSection(
|
|
|
231
242
|
runSideEffectGlobal({ mode: nextFileListMode, type: 'persist-git-file-list-mode' })
|
|
232
243
|
}
|
|
233
244
|
return (
|
|
234
|
-
<box key={section} flexDirection="column">
|
|
245
|
+
<box key={section} flexDirection="column" marginTop={marginTop}>
|
|
235
246
|
<box flexDirection="row" justifyContent="space-between">
|
|
236
247
|
<text fg={getCurrentTheme().colors['terminal.ansiMagenta']}>
|
|
237
248
|
<strong>
|
|
@@ -327,6 +338,7 @@ const DEFAULT_DIFF_COUNT_CONFIG: GitPaneDiffCountConfig = { enabled: true }
|
|
|
327
338
|
|
|
328
339
|
export const GitPanel = memo(function GitPanel({
|
|
329
340
|
collapsedFolders = {},
|
|
341
|
+
compact = false,
|
|
330
342
|
diffCountConfig = DEFAULT_DIFF_COUNT_CONFIG,
|
|
331
343
|
fileListMode = 'tree',
|
|
332
344
|
gitPanel,
|
|
@@ -336,11 +348,12 @@ export const GitPanel = memo(function GitPanel({
|
|
|
336
348
|
selectedEntryKey,
|
|
337
349
|
}: GitPanelProps) {
|
|
338
350
|
const theme = useTheme()
|
|
351
|
+
useTransparent()
|
|
339
352
|
const sectionOrder = headOffset > 0 ? HISTORICAL_SECTION_ORDER : BASE_SECTION_ORDER
|
|
340
353
|
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
341
354
|
const tree = useMemo(
|
|
342
|
-
() => buildGitTreeRows(gitPanel.files, collapsedFolders, fileListMode),
|
|
343
|
-
[collapsedFolders, fileListMode, gitPanel.files]
|
|
355
|
+
() => buildGitTreeRows(gitPanel.files, collapsedFolders, fileListMode, compact),
|
|
356
|
+
[collapsedFolders, compact, fileListMode, gitPanel.files]
|
|
344
357
|
)
|
|
345
358
|
const { added: addedW, removed: removedW } = useMemo(
|
|
346
359
|
() => maxDigitWidth(gitPanel.files),
|
|
@@ -382,8 +395,11 @@ export const GitPanel = memo(function GitPanel({
|
|
|
382
395
|
viewportCulling
|
|
383
396
|
contentOptions={{ flexDirection: 'column', gap: 0 }}
|
|
384
397
|
>
|
|
385
|
-
{sectionOrder.map((key) => {
|
|
398
|
+
{sectionOrder.map((key, idx) => {
|
|
386
399
|
const section = tree.sections.find((entry) => entry.section === key)
|
|
400
|
+
const priorHasFiles = sectionOrder
|
|
401
|
+
.slice(0, idx)
|
|
402
|
+
.some((k) => (tree.sections.find((e) => e.section === k)?.files.length ?? 0) > 0)
|
|
387
403
|
return renderTreeSection(
|
|
388
404
|
key,
|
|
389
405
|
sectionTitle(key, headOffset),
|
|
@@ -395,7 +411,8 @@ export const GitPanel = memo(function GitPanel({
|
|
|
395
411
|
selectedEntryKey,
|
|
396
412
|
key === toggleSection,
|
|
397
413
|
pathConfig,
|
|
398
|
-
diffCountConfig
|
|
414
|
+
diffCountConfig,
|
|
415
|
+
priorHasFiles ? 1 : 0
|
|
399
416
|
)
|
|
400
417
|
})}
|
|
401
418
|
</scrollbox>
|
|
@@ -4,14 +4,16 @@ import { memo, useEffect, useRef } from 'react'
|
|
|
4
4
|
import type { DiffData, GitDiffView } from '../../state/types'
|
|
5
5
|
import type { ThemeId } from '../themes'
|
|
6
6
|
|
|
7
|
+
import { diffHash } from '../../git/diff-hash'
|
|
7
8
|
import { fetchDiff } from '../../git/git-diff'
|
|
8
9
|
import { useGitPanelPolling } from '../../git/git-poller'
|
|
9
10
|
import { useAppStore } from '../../state/app-store'
|
|
10
11
|
import { dispatchGlobal } from '../../state/dispatch-ref'
|
|
11
12
|
import { getSelectedGitFile, gitFileKey } from '../../state/git-tree'
|
|
12
13
|
import { setGitDiffScroller } from '../git-view-controls'
|
|
13
|
-
import { useTheme } from '../theme'
|
|
14
|
+
import { useBg, useTheme } from '../theme'
|
|
14
15
|
import { PierreDiff, type PierreDiffHandle } from './diff-renderer'
|
|
16
|
+
import { useDiffPrefetch } from './diff-renderer/use-diff-prefetch'
|
|
15
17
|
import { GitPanel } from './git-panel'
|
|
16
18
|
|
|
17
19
|
interface DiffStageProps {
|
|
@@ -104,6 +106,7 @@ interface GitViewProps {
|
|
|
104
106
|
|
|
105
107
|
export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
106
108
|
const theme = useTheme()
|
|
109
|
+
const sidebarBg = useBg('sideBar.background')
|
|
107
110
|
const dimensions = useTerminalDimensions()
|
|
108
111
|
const gitPane = useAppStore((s) => s.gitPane)
|
|
109
112
|
const gitPanel = useAppStore((s) => s.gitPanel)
|
|
@@ -124,6 +127,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
124
127
|
|
|
125
128
|
const selectedFile = getSelectedGitFile(gitPanel.files, {
|
|
126
129
|
collapsedFolders: gitMode.collapsedFolders,
|
|
130
|
+
compact: gitPane.treeCompaction,
|
|
127
131
|
fileListMode: gitPane.fileListMode,
|
|
128
132
|
selectedEntryKey: gitMode.selectedEntryKey,
|
|
129
133
|
})
|
|
@@ -147,6 +151,13 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
147
151
|
return () => setGitDiffScroller(null)
|
|
148
152
|
}, [])
|
|
149
153
|
|
|
154
|
+
useDiffPrefetch(gitMode.selectedEntryKey, gitPane.prefetchRadius, {
|
|
155
|
+
enabled: focusMode === 'git',
|
|
156
|
+
headOffset: gitMode.headOffset,
|
|
157
|
+
projectPath,
|
|
158
|
+
themeId,
|
|
159
|
+
})
|
|
160
|
+
|
|
150
161
|
useEffect(() => {
|
|
151
162
|
if (focusMode !== 'git') return
|
|
152
163
|
if (!selectedFile || !projectPath) return
|
|
@@ -154,7 +165,14 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
154
165
|
if (diff || loading) return
|
|
155
166
|
dispatchGlobal({ key: selectedDiffKey, loading: true, type: 'git-mode-set-loading' })
|
|
156
167
|
void fetchDiff(projectPath, selectedFile, gitMode.headOffset)
|
|
157
|
-
.then((d) =>
|
|
168
|
+
.then((d) =>
|
|
169
|
+
dispatchGlobal({
|
|
170
|
+
diff: d,
|
|
171
|
+
hash: diffHash(d.rawDiff),
|
|
172
|
+
key: selectedDiffKey,
|
|
173
|
+
type: 'git-mode-set-diff',
|
|
174
|
+
})
|
|
175
|
+
)
|
|
158
176
|
.catch(() =>
|
|
159
177
|
dispatchGlobal({ key: selectedDiffKey, loading: false, type: 'git-mode-set-loading' })
|
|
160
178
|
)
|
|
@@ -193,7 +211,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
193
211
|
<box
|
|
194
212
|
width={fileBarWidth}
|
|
195
213
|
flexDirection="column"
|
|
196
|
-
backgroundColor={
|
|
214
|
+
backgroundColor={sidebarBg}
|
|
197
215
|
padding={0}
|
|
198
216
|
gap={0}
|
|
199
217
|
>
|
|
@@ -219,6 +237,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
219
237
|
</text>
|
|
220
238
|
<GitPanel
|
|
221
239
|
collapsedFolders={gitMode.collapsedFolders}
|
|
240
|
+
compact={gitPane.treeCompaction}
|
|
222
241
|
fileListMode={gitPane.fileListMode}
|
|
223
242
|
gitPanel={gitPanel}
|
|
224
243
|
headOffset={gitMode.headOffset}
|
|
@@ -236,12 +255,7 @@ export const GitView = memo(function GitView({ themeId }: GitViewProps) {
|
|
|
236
255
|
/>
|
|
237
256
|
</box>
|
|
238
257
|
{footerNode ? (
|
|
239
|
-
<box
|
|
240
|
-
paddingLeft={1}
|
|
241
|
-
paddingRight={1}
|
|
242
|
-
backgroundColor={theme.colors['sideBar.background']}
|
|
243
|
-
flexDirection="column"
|
|
244
|
-
>
|
|
258
|
+
<box paddingLeft={1} paddingRight={1} backgroundColor={sidebarBg} flexDirection="column">
|
|
245
259
|
{footerNode}
|
|
246
260
|
</box>
|
|
247
261
|
) : null}
|
|
@@ -1,207 +1,92 @@
|
|
|
1
1
|
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { useLayoutEffect, useMemo, useRef } from 'react'
|
|
3
|
+
import { useLayoutEffect, useMemo } from 'react'
|
|
5
4
|
|
|
6
|
-
import {
|
|
7
|
-
collectHelpEntries,
|
|
8
|
-
HELP_MODE_LABELS,
|
|
9
|
-
type HelpEntry,
|
|
10
|
-
} from '../../input/keymap/help-entries'
|
|
5
|
+
import { collectHelpEntries, HELP_MODE_LABELS } from '../../input/keymap/help-entries'
|
|
11
6
|
import { dispatchGlobal } from '../../state/dispatch-ref'
|
|
12
7
|
import { useKeymap } from '../keymap-context'
|
|
13
8
|
import { useTheme } from '../theme'
|
|
14
9
|
import { uiTokens } from '../ui-tokens'
|
|
15
|
-
import {
|
|
16
|
-
import { ModalShell } from './modal-shell'
|
|
10
|
+
import { Picker, type PickerItem } from './picker'
|
|
17
11
|
|
|
18
12
|
interface HelpModalProps {
|
|
19
13
|
filter: string | null
|
|
20
14
|
selectedIndex: number
|
|
21
15
|
scope: ModeId | null
|
|
16
|
+
cursorPos?: number
|
|
22
17
|
}
|
|
23
18
|
|
|
24
|
-
function matchesFilter(
|
|
19
|
+
function matchesFilter(needle: string, ...fields: (string | undefined)[]): boolean {
|
|
25
20
|
if (!needle) return true
|
|
26
21
|
const lower = needle.toLowerCase()
|
|
27
|
-
return (
|
|
28
|
-
(entry.description?.toLowerCase().includes(lower) ?? false) ||
|
|
29
|
-
entry.modeLabel.toLowerCase().includes(lower) ||
|
|
30
|
-
entry.keysDisplay.toLowerCase().includes(lower) ||
|
|
31
|
-
(entry.group?.toLowerCase().includes(lower) ?? false)
|
|
32
|
-
)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
type Row =
|
|
36
|
-
| { kind: 'header'; label: string }
|
|
37
|
-
| { kind: 'entry'; entry: HelpEntry; entryIndex: number }
|
|
38
|
-
|
|
39
|
-
function buildRows(entries: HelpEntry[]): Row[] {
|
|
40
|
-
const rows: Row[] = []
|
|
41
|
-
let lastLabel: string | null = null
|
|
42
|
-
for (let entryIndex = 0; entryIndex < entries.length; entryIndex++) {
|
|
43
|
-
const entry = entries[entryIndex]
|
|
44
|
-
if (!entry) continue
|
|
45
|
-
if (entry.modeLabel !== lastLabel) {
|
|
46
|
-
rows.push({ kind: 'header', label: entry.modeLabel })
|
|
47
|
-
lastLabel = entry.modeLabel
|
|
48
|
-
}
|
|
49
|
-
rows.push({ entry, entryIndex, kind: 'entry' })
|
|
50
|
-
}
|
|
51
|
-
return rows
|
|
22
|
+
return fields.some((f) => f?.toLowerCase().includes(lower))
|
|
52
23
|
}
|
|
53
24
|
|
|
54
|
-
|
|
55
|
-
const VIEWPORT_HEIGHT_RATIO = 0.6
|
|
56
|
-
const MODAL_CHROME_ROWS = 6
|
|
57
|
-
|
|
58
|
-
function clampSelection(index: number, count: number): number {
|
|
59
|
-
if (count === 0) return 0
|
|
60
|
-
return Math.max(0, Math.min(count - 1, index))
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Pick a window start that (a) keeps the selected row on screen and
|
|
65
|
-
* (b) only scrolls when the selection leaves the margin — avoids jumpy
|
|
66
|
-
* recentering on every keypress.
|
|
67
|
-
*/
|
|
68
|
-
function computeWindowStart(
|
|
69
|
-
prevStart: number,
|
|
70
|
-
selectedRowIndex: number,
|
|
71
|
-
total: number,
|
|
72
|
-
windowSize: number
|
|
73
|
-
): number {
|
|
74
|
-
if (total <= windowSize) return 0
|
|
75
|
-
const margin = 1
|
|
76
|
-
const maxStart = total - windowSize
|
|
77
|
-
let start = Math.max(0, Math.min(maxStart, prevStart))
|
|
78
|
-
const topThreshold = start + margin
|
|
79
|
-
const bottomThreshold = start + windowSize - 1 - margin
|
|
80
|
-
if (selectedRowIndex < topThreshold) {
|
|
81
|
-
start = Math.max(0, selectedRowIndex - margin)
|
|
82
|
-
} else if (selectedRowIndex > bottomThreshold) {
|
|
83
|
-
start = Math.min(maxStart, selectedRowIndex - windowSize + 1 + margin)
|
|
84
|
-
}
|
|
85
|
-
return start
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function HelpModal({ filter, scope, selectedIndex }: HelpModalProps) {
|
|
25
|
+
export function HelpModal({ cursorPos, filter, scope, selectedIndex }: HelpModalProps) {
|
|
89
26
|
const theme = useTheme()
|
|
90
27
|
const config = useKeymap()
|
|
91
|
-
const dimensions = useTerminalDimensions()
|
|
92
28
|
const allEntries = useMemo(() => collectHelpEntries(config), [config])
|
|
93
29
|
const scoped = useMemo(
|
|
94
30
|
() => (scope ? allEntries.filter((e) => e.mode === scope) : allEntries),
|
|
95
31
|
[allEntries, scope]
|
|
96
32
|
)
|
|
97
33
|
const filtered = useMemo(
|
|
98
|
-
() =>
|
|
34
|
+
() =>
|
|
35
|
+
scoped.filter((e) =>
|
|
36
|
+
matchesFilter(filter ?? '', e.description, e.modeLabel, e.keysDisplay, e.group)
|
|
37
|
+
),
|
|
99
38
|
[scoped, filter]
|
|
100
39
|
)
|
|
40
|
+
|
|
101
41
|
const title = useMemo(() => {
|
|
102
42
|
if (!scope) return 'Keybindings'
|
|
103
43
|
const label = HELP_MODE_LABELS.find((m) => m.modeId === scope)?.label ?? scope
|
|
104
44
|
return `${label} — keybindings`
|
|
105
45
|
}, [scope])
|
|
106
|
-
const rows = useMemo(() => buildRows(filtered), [filtered])
|
|
107
46
|
|
|
108
47
|
useLayoutEffect(() => {
|
|
109
48
|
dispatchGlobal({ count: filtered.length, type: 'set-help-entry-count' })
|
|
110
49
|
}, [filtered.length])
|
|
111
50
|
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
51
|
+
const items: PickerItem[] = filtered.map((entry, index) => {
|
|
52
|
+
const active = index === selectedIndex
|
|
53
|
+
return {
|
|
54
|
+
group: entry.modeLabel,
|
|
55
|
+
key: `${entry.mode}::${entry.keysDisplay}::${entry.description ?? ''}::${index}`,
|
|
56
|
+
title: (
|
|
57
|
+
<text
|
|
58
|
+
fg={active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']}
|
|
59
|
+
wrapMode="none"
|
|
60
|
+
>
|
|
61
|
+
{entry.description ?? ''}
|
|
62
|
+
</text>
|
|
63
|
+
),
|
|
64
|
+
trailing: (
|
|
65
|
+
<text
|
|
66
|
+
fg={active ? theme.colors['textLink.foreground'] : theme.colors['terminal.ansiMagenta']}
|
|
67
|
+
wrapMode="none"
|
|
68
|
+
>
|
|
69
|
+
{entry.keysDisplay}
|
|
70
|
+
</text>
|
|
71
|
+
),
|
|
72
|
+
}
|
|
73
|
+
})
|
|
130
74
|
|
|
131
75
|
return (
|
|
132
|
-
<
|
|
76
|
+
<Picker
|
|
133
77
|
title={title}
|
|
134
|
-
keybindsModeId="modal.help"
|
|
78
|
+
keybindsModeId="modal.help.filtering"
|
|
135
79
|
width={uiTokens.modalWidth.lg}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
: ` ${effectiveIndex + 1} / ${filtered.length}${filter ? '' : ' — type / to filter'}`}
|
|
142
|
-
</text>
|
|
143
|
-
<ModalFilterBar filter={filter} />
|
|
144
|
-
</box>
|
|
145
|
-
}
|
|
146
|
-
>
|
|
147
|
-
{filtered.length === 0 ? (
|
|
80
|
+
filter={filter}
|
|
81
|
+
cursorPos={cursorPos}
|
|
82
|
+
items={items}
|
|
83
|
+
selectedIndex={selectedIndex}
|
|
84
|
+
emptyState={
|
|
148
85
|
<text fg={theme.colors['descriptionForeground']}>
|
|
149
86
|
{filter ? 'No matching bindings.' : 'No bindings registered.'}
|
|
150
87
|
</text>
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const rowIndex = start + i
|
|
155
|
-
if (row.kind === 'header') {
|
|
156
|
-
return (
|
|
157
|
-
<box key={`h-${rowIndex}`} paddingLeft={1} paddingTop={i === 0 ? 0 : 1}>
|
|
158
|
-
<text fg={theme.colors['editorWarning.foreground']} wrapMode="none">
|
|
159
|
-
<strong>{row.label}</strong>
|
|
160
|
-
</text>
|
|
161
|
-
</box>
|
|
162
|
-
)
|
|
163
|
-
}
|
|
164
|
-
const active = row.entryIndex === effectiveIndex
|
|
165
|
-
const bg = active ? theme.colors['list.activeSelectionBackground'] : undefined
|
|
166
|
-
return (
|
|
167
|
-
<box
|
|
168
|
-
key={`e-${rowIndex}`}
|
|
169
|
-
flexDirection="row"
|
|
170
|
-
paddingLeft={2}
|
|
171
|
-
paddingRight={1}
|
|
172
|
-
backgroundColor={bg}
|
|
173
|
-
>
|
|
174
|
-
<box width={KEYS_COLUMN_WIDTH} flexShrink={0}>
|
|
175
|
-
<text
|
|
176
|
-
fg={
|
|
177
|
-
active
|
|
178
|
-
? theme.colors['textLink.foreground']
|
|
179
|
-
: theme.colors['terminal.ansiMagenta']
|
|
180
|
-
}
|
|
181
|
-
bg={bg}
|
|
182
|
-
wrapMode="none"
|
|
183
|
-
>
|
|
184
|
-
{row.entry.keysDisplay}
|
|
185
|
-
</text>
|
|
186
|
-
</box>
|
|
187
|
-
<box flexGrow={1} overflow="hidden">
|
|
188
|
-
<text
|
|
189
|
-
fg={
|
|
190
|
-
active
|
|
191
|
-
? theme.colors['editor.foreground']
|
|
192
|
-
: theme.colors['descriptionForeground']
|
|
193
|
-
}
|
|
194
|
-
bg={bg}
|
|
195
|
-
wrapMode="none"
|
|
196
|
-
>
|
|
197
|
-
{row.entry.description ?? ''}
|
|
198
|
-
</text>
|
|
199
|
-
</box>
|
|
200
|
-
</box>
|
|
201
|
-
)
|
|
202
|
-
})}
|
|
203
|
-
</box>
|
|
204
|
-
)}
|
|
205
|
-
</ModalShell>
|
|
88
|
+
}
|
|
89
|
+
onHover={(index) => dispatchGlobal({ index, type: 'set-modal-selection-index' })}
|
|
90
|
+
/>
|
|
206
91
|
)
|
|
207
92
|
}
|
|
@@ -5,15 +5,19 @@ interface InputFieldProps {
|
|
|
5
5
|
active: boolean
|
|
6
6
|
value: string
|
|
7
7
|
cursorPos?: number
|
|
8
|
+
placeholder?: string
|
|
8
9
|
}
|
|
9
10
|
|
|
10
|
-
export function InputField({ active, cursorPos, value }: InputFieldProps) {
|
|
11
|
+
export function InputField({ active, cursorPos, placeholder, value }: InputFieldProps) {
|
|
11
12
|
const theme = useTheme()
|
|
12
13
|
const fg = active ? theme.colors['editor.foreground'] : theme.colors['descriptionForeground']
|
|
13
14
|
if (!active) {
|
|
15
|
+
const showPlaceholder = !value && !!placeholder
|
|
14
16
|
return (
|
|
15
17
|
<Surface tone="input" padding={1}>
|
|
16
|
-
<text fg={fg}>
|
|
18
|
+
<text fg={showPlaceholder ? theme.colors['editorLineNumber.foreground'] : fg}>
|
|
19
|
+
{showPlaceholder ? placeholder : value}
|
|
20
|
+
</text>
|
|
17
21
|
</Surface>
|
|
18
22
|
)
|
|
19
23
|
}
|
|
@@ -8,16 +8,22 @@ export type ListItemDirection = 'row' | 'column'
|
|
|
8
8
|
interface ListItemProps {
|
|
9
9
|
active: boolean
|
|
10
10
|
direction?: ListItemDirection
|
|
11
|
+
id?: string
|
|
11
12
|
leading?: ReactNode
|
|
12
13
|
subtitle?: ReactNode
|
|
13
14
|
title: ReactNode
|
|
14
15
|
trailing?: ReactNode
|
|
16
|
+
onHover?: () => void
|
|
17
|
+
onClick?: () => void
|
|
15
18
|
}
|
|
16
19
|
|
|
17
20
|
export function ListItem({
|
|
18
21
|
active,
|
|
19
22
|
direction = 'column',
|
|
23
|
+
id,
|
|
20
24
|
leading,
|
|
25
|
+
onClick,
|
|
26
|
+
onHover,
|
|
21
27
|
subtitle,
|
|
22
28
|
title,
|
|
23
29
|
trailing,
|
|
@@ -25,34 +31,36 @@ export function ListItem({
|
|
|
25
31
|
const theme = useTheme()
|
|
26
32
|
const isRow = direction === 'row'
|
|
27
33
|
return (
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
<box flexDirection="
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
34
|
+
<box id={id} onMouseOver={onHover} onMouseDown={onClick}>
|
|
35
|
+
<Surface
|
|
36
|
+
tone={active ? 'selected' : 'elevated'}
|
|
37
|
+
paddingLeft={isRow ? 2 : 1}
|
|
38
|
+
paddingRight={isRow ? 2 : 1}
|
|
39
|
+
>
|
|
40
|
+
<box flexDirection="column">
|
|
41
|
+
<box flexDirection="row">
|
|
42
|
+
{isRow ? null : (
|
|
43
|
+
<>
|
|
44
|
+
<text
|
|
45
|
+
fg={
|
|
46
|
+
active
|
|
47
|
+
? theme.colors['textLink.foreground']
|
|
48
|
+
: theme.colors['editor.lineHighlightBackground']
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
{active ? '›' : '·'}
|
|
52
|
+
</text>
|
|
53
|
+
<text> </text>
|
|
54
|
+
</>
|
|
55
|
+
)}
|
|
56
|
+
{leading}
|
|
57
|
+
{leading ? <text> </text> : null}
|
|
58
|
+
<box flexGrow={isRow ? 0 : 1}>{title}</box>
|
|
59
|
+
{trailing ? <box>{trailing}</box> : null}
|
|
60
|
+
</box>
|
|
61
|
+
{subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
|
|
53
62
|
</box>
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
</Surface>
|
|
63
|
+
</Surface>
|
|
64
|
+
</box>
|
|
57
65
|
)
|
|
58
66
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
|
-
import type { ReactNode } from 'react'
|
|
3
2
|
|
|
4
|
-
import {
|
|
3
|
+
import { type BoxRenderable, type OptimizedBuffer, RGBA } from '@opentui/core'
|
|
4
|
+
import { type ReactNode } from 'react'
|
|
5
|
+
|
|
6
|
+
import { useTheme, useTransparent } from '../theme'
|
|
5
7
|
import { ModalKeybindsOverlay } from './modal-keybinds-overlay'
|
|
6
|
-
import { Surface } from './surface'
|
|
7
8
|
|
|
8
9
|
interface ModalShellProps {
|
|
9
10
|
children: ReactNode
|
|
@@ -15,6 +16,43 @@ interface ModalShellProps {
|
|
|
15
16
|
width: number | `${number}%`
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
const TRANSPARENT_RGBA = RGBA.fromValues(0, 0, 0, 0)
|
|
20
|
+
|
|
21
|
+
// opentui's BoxRenderable only paints a fill when `backgroundColor.a > 0`, so
|
|
22
|
+
// a fully transparent modal bg leaks the chrome characters underneath. The
|
|
23
|
+
// `renderAfter` hook runs after the box paints itself but before its children
|
|
24
|
+
// render, so we manually overwrite every interior cell with a blank space.
|
|
25
|
+
//
|
|
26
|
+
// Two gotchas we hit by reading the zig source (packages/core/src/zig/buffer.zig):
|
|
27
|
+
// 1. `setCellWithAlphaBlending` early-returns via `isFullyTransparent` when
|
|
28
|
+
// both fg and bg alpha are 0 — nothing gets written.
|
|
29
|
+
// 2. Its `blendCells` deliberately preserves the destination char when the
|
|
30
|
+
// overlay char is `DEFAULT_SPACE_CHAR` (codepoint 32) so that drawing a
|
|
31
|
+
// space on top of existing text does not erase it.
|
|
32
|
+
//
|
|
33
|
+
// `setCell` (→ `bufferSetCell` → zig `buffer.set`) bypasses both: it writes
|
|
34
|
+
// the cell unconditionally, no alpha check, no blending, no char preservation.
|
|
35
|
+
// That overwrites each chrome char with a space while keeping the cell bg
|
|
36
|
+
// transparent, so the terminal emulator's own (blurred) window bg still shows
|
|
37
|
+
// through. Children (modal content) then render on top as normal.
|
|
38
|
+
function fillModalInteriorWithSpaces(this: BoxRenderable, buffer: OptimizedBuffer): void {
|
|
39
|
+
const x0 = this.screenX
|
|
40
|
+
const y0 = this.screenY
|
|
41
|
+
const w = this.width
|
|
42
|
+
const h = this.height
|
|
43
|
+
// Inset by 1 on each side so we don't erase the border that renderSelf just
|
|
44
|
+
// drew. The modal always has a single-cell border.
|
|
45
|
+
const startX = x0 + 1
|
|
46
|
+
const startY = y0 + 1
|
|
47
|
+
const endX = x0 + w - 1
|
|
48
|
+
const endY = y0 + h - 1
|
|
49
|
+
for (let y = startY; y < endY; y++) {
|
|
50
|
+
for (let x = startX; x < endX; x++) {
|
|
51
|
+
buffer.setCell(x, y, ' ', TRANSPARENT_RGBA, TRANSPARENT_RGBA)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
18
56
|
export function ModalShell({
|
|
19
57
|
children,
|
|
20
58
|
footer,
|
|
@@ -25,6 +63,8 @@ export function ModalShell({
|
|
|
25
63
|
width,
|
|
26
64
|
}: ModalShellProps) {
|
|
27
65
|
const theme = useTheme()
|
|
66
|
+
const transparent = useTransparent()
|
|
67
|
+
const bg = transparent ? 'transparent' : theme.colors['sideBar.background']
|
|
28
68
|
return (
|
|
29
69
|
<box
|
|
30
70
|
position="absolute"
|
|
@@ -36,15 +76,13 @@ export function ModalShell({
|
|
|
36
76
|
alignItems="center"
|
|
37
77
|
>
|
|
38
78
|
<box
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
/>
|
|
47
|
-
<Surface tone="elevated" padding={1} gap={1} width={width}>
|
|
79
|
+
border
|
|
80
|
+
borderColor={theme.colors['focusBorder']}
|
|
81
|
+
backgroundColor={bg}
|
|
82
|
+
padding={1}
|
|
83
|
+
width={width}
|
|
84
|
+
renderAfter={transparent ? fillModalInteriorWithSpaces : undefined}
|
|
85
|
+
>
|
|
48
86
|
<box width="100%" flexDirection="column" gap={listGap}>
|
|
49
87
|
<box flexDirection="column">
|
|
50
88
|
<text fg={theme.colors['terminal.ansiMagenta']}>{title}</text>
|
|
@@ -53,7 +91,7 @@ export function ModalShell({
|
|
|
53
91
|
{children}
|
|
54
92
|
{footer ? <box>{footer}</box> : null}
|
|
55
93
|
</box>
|
|
56
|
-
</
|
|
94
|
+
</box>
|
|
57
95
|
{keybindsModeId ? <ModalKeybindsOverlay modeId={keybindsModeId} /> : null}
|
|
58
96
|
</box>
|
|
59
97
|
)
|