@brimveyn/aimux 1.1.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/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +79 -0
- package/src/app-runtime/backend-attach-runtime.ts +135 -0
- package/src/app-runtime/backend-runtime-events.ts +78 -0
- package/src/app-runtime/click-selection-resolver.ts +130 -0
- package/src/app-runtime/pty-write.ts +32 -0
- package/src/app-runtime/render-invalidation.ts +20 -0
- package/src/app-runtime/selection-clipboard.ts +69 -0
- package/src/app-runtime/selection-scroll.ts +143 -0
- package/src/app-runtime/session-actions.ts +170 -0
- package/src/app-runtime/side-effects.ts +434 -0
- package/src/app-runtime/snippet-actions.ts +90 -0
- package/src/app-runtime/split-drag-controller.ts +15 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
- package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
- package/src/app-runtime/use-backend-runtime.ts +99 -0
- package/src/app-runtime/use-directory-search.ts +52 -0
- package/src/app-runtime/use-mouse-handlers.ts +183 -0
- package/src/app-runtime/use-renderer-bindings.ts +163 -0
- package/src/app-runtime/use-terminal-resize.ts +141 -0
- package/src/app-runtime/use-workspace-autosave.ts +39 -0
- package/src/app.tsx +247 -0
- package/src/config/loader.ts +36 -0
- package/src/config.ts +146 -0
- package/src/daemon/daemon.ts +236 -0
- package/src/daemon/runtime-paths.ts +69 -0
- package/src/daemon/session-manager.ts +109 -0
- package/src/daemon/session-registry.ts +207 -0
- package/src/debug/input-log.ts +29 -0
- package/src/doctor.ts +106 -0
- package/src/git/git-poller.ts +49 -0
- package/src/git/git-status.ts +183 -0
- package/src/index.tsx +56 -0
- package/src/input/keymap/build-handlers.ts +34 -0
- package/src/input/keymap/key-chord.ts +201 -0
- package/src/input/keymap/keymap-mode-handler.ts +88 -0
- package/src/input/keymap/sequence-resolver.ts +117 -0
- package/src/input/keymap/trie.ts +103 -0
- package/src/input/modes/bridge.ts +58 -0
- package/src/input/modes/handlers/index.ts +18 -0
- package/src/input/modes/handlers/shared.ts +70 -0
- package/src/input/modes/registry.ts +34 -0
- package/src/input/modes/transitions.ts +37 -0
- package/src/input/modes/types.ts +63 -0
- package/src/input/mouse-forwarding.ts +98 -0
- package/src/input/multi-click-detector.ts +55 -0
- package/src/input/paste.ts +12 -0
- package/src/input/raw-input-handler.ts +191 -0
- package/src/input/terminal-text-extraction.ts +78 -0
- package/src/ipc/protocol.ts +341 -0
- package/src/platform/clipboard.ts +13 -0
- package/src/platform/daemon-control.ts +62 -0
- package/src/platform/id.ts +7 -0
- package/src/platform/project-search.ts +75 -0
- package/src/pty/command-registry.ts +69 -0
- package/src/pty/pty-manager.ts +268 -0
- package/src/pty/terminal-snapshot.ts +210 -0
- package/src/restart-daemon.ts +28 -0
- package/src/session-backend/bootstrap.ts +107 -0
- package/src/session-backend/local-session-backend.ts +164 -0
- package/src/session-backend/remote-session-backend.ts +373 -0
- package/src/session-backend/types.ts +47 -0
- package/src/state/app-store.ts +19 -0
- package/src/state/layout-resize.ts +56 -0
- package/src/state/layout-tree.ts +323 -0
- package/src/state/reducers/git-panel-state.ts +102 -0
- package/src/state/reducers/modal-state.ts +358 -0
- package/src/state/reducers/session-state.ts +86 -0
- package/src/state/reducers/tab-state.ts +531 -0
- package/src/state/reducers/ui-state.ts +29 -0
- package/src/state/selectors.ts +30 -0
- package/src/state/session-catalog.ts +96 -0
- package/src/state/session-persistence.ts +210 -0
- package/src/state/snippet-catalog.ts +90 -0
- package/src/state/store.ts +93 -0
- package/src/state/terminal-modes.ts +11 -0
- package/src/state/types.ts +367 -0
- package/src/state/validation.ts +154 -0
- package/src/state/workspace-save.ts +33 -0
- package/src/ui/components/create-session-modal.tsx +100 -0
- package/src/ui/components/git-panel.tsx +200 -0
- package/src/ui/components/help-modal.tsx +79 -0
- package/src/ui/components/input-field.tsx +18 -0
- package/src/ui/components/list-item.tsx +30 -0
- package/src/ui/components/modal-filter-bar.tsx +18 -0
- package/src/ui/components/modal-shell.tsx +47 -0
- package/src/ui/components/new-tab-modal.tsx +52 -0
- package/src/ui/components/pending-chord-indicator.tsx +41 -0
- package/src/ui/components/session-name-modal.tsx +15 -0
- package/src/ui/components/session-picker-modal.tsx +93 -0
- package/src/ui/components/sidebar-group-metadata.ts +44 -0
- package/src/ui/components/sidebar-scroll.ts +33 -0
- package/src/ui/components/sidebar.tsx +225 -0
- package/src/ui/components/snippet-editor-modal.tsx +39 -0
- package/src/ui/components/snippet-picker-modal.tsx +56 -0
- package/src/ui/components/split-layout.tsx +201 -0
- package/src/ui/components/status-bar.tsx +60 -0
- package/src/ui/components/surface.tsx +63 -0
- package/src/ui/components/tab-item.tsx +118 -0
- package/src/ui/components/terminal-pane.tsx +215 -0
- package/src/ui/components/theme-picker-modal.tsx +35 -0
- package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
- package/src/ui/components/use-sidebar-branch.ts +36 -0
- package/src/ui/directory-search.ts +1 -0
- package/src/ui/git-branch.ts +11 -0
- package/src/ui/path-format.ts +6 -0
- package/src/ui/root.tsx +261 -0
- package/src/ui/status-bar-model.ts +87 -0
- package/src/ui/theme.ts +9 -0
- package/src/ui/themes.ts +255 -0
- package/src/ui/ui-tokens.ts +11 -0
- package/src/update.ts +62 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { memo, type ReactNode, useMemo } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { GitFileEntry, GitFileSection, GitPanelState } from '../../state/types'
|
|
4
|
+
|
|
5
|
+
import { theme } from '../theme'
|
|
6
|
+
|
|
7
|
+
interface GitPanelProps {
|
|
8
|
+
gitPanel: GitPanelState
|
|
9
|
+
projectPath: string | undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SECTION_ORDER: { key: GitFileSection; title: string }[] = [
|
|
13
|
+
{ key: 'staged', title: 'Staged Changes' },
|
|
14
|
+
{ key: 'unstaged', title: 'Changes' },
|
|
15
|
+
{ key: 'untracked', title: 'Untracked' },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const STATUS_COLORS: Record<GitFileEntry['status'], string> = {
|
|
19
|
+
'?': theme.textMuted,
|
|
20
|
+
'A': theme.success,
|
|
21
|
+
'C': theme.accent,
|
|
22
|
+
'D': theme.danger,
|
|
23
|
+
'M': theme.warning,
|
|
24
|
+
'R': theme.accent,
|
|
25
|
+
'U': theme.danger,
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function groupBySection(files: GitFileEntry[]): Record<GitFileSection, GitFileEntry[]> {
|
|
29
|
+
const groups: Record<GitFileSection, GitFileEntry[]> = {
|
|
30
|
+
staged: [],
|
|
31
|
+
unstaged: [],
|
|
32
|
+
untracked: [],
|
|
33
|
+
}
|
|
34
|
+
for (const file of files) groups[file.section].push(file)
|
|
35
|
+
return groups
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function maxDigitWidth(files: GitFileEntry[]): { added: number; removed: number } {
|
|
39
|
+
let addedMax = 1
|
|
40
|
+
let removedMax = 1
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
if (f.added !== null) addedMax = Math.max(addedMax, String(f.added).length)
|
|
43
|
+
if (f.removed !== null) removedMax = Math.max(removedMax, String(f.removed).length)
|
|
44
|
+
}
|
|
45
|
+
return { added: addedMax, removed: removedMax }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function padRight(value: number | null, width: number): string {
|
|
49
|
+
return String(value ?? 0).padStart(width, ' ')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function splitPath(path: string): { prefix: string; basename: string } {
|
|
53
|
+
const slash = path.lastIndexOf('/')
|
|
54
|
+
if (slash < 0) return { basename: path, prefix: '' }
|
|
55
|
+
return { basename: path.slice(slash + 1), prefix: path.slice(0, slash + 1) }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stripTrailingSlash(prefix: string): string {
|
|
59
|
+
return prefix.endsWith('/') ? prefix.slice(0, -1) : prefix
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function renderPath(file: GitFileEntry): ReactNode {
|
|
63
|
+
const { basename, prefix } = splitPath(file.path)
|
|
64
|
+
const dir = stripTrailingSlash(prefix)
|
|
65
|
+
if (file.renamedFrom) {
|
|
66
|
+
const renamed = splitPath(file.renamedFrom)
|
|
67
|
+
const renamedDir = stripTrailingSlash(renamed.prefix)
|
|
68
|
+
return (
|
|
69
|
+
<text wrapMode="none">
|
|
70
|
+
<span fg={theme.text}>{renamed.basename}</span>
|
|
71
|
+
{renamedDir ? <span fg={theme.textMuted}> {renamedDir}</span> : null}
|
|
72
|
+
<span fg={theme.textMuted}> → </span>
|
|
73
|
+
<span fg={theme.text}>{basename}</span>
|
|
74
|
+
{dir ? <span fg={theme.textMuted}> {dir}</span> : null}
|
|
75
|
+
</text>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
return (
|
|
79
|
+
<text wrapMode="none">
|
|
80
|
+
<span fg={theme.text}>{basename}</span>
|
|
81
|
+
{dir ? <span fg={theme.textMuted}> {dir}</span> : null}
|
|
82
|
+
</text>
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function renderFileRow(
|
|
87
|
+
file: GitFileEntry,
|
|
88
|
+
key: string,
|
|
89
|
+
addedW: number,
|
|
90
|
+
removedW: number
|
|
91
|
+
): ReactNode {
|
|
92
|
+
const hasNumstat = file.added !== null || file.removed !== null
|
|
93
|
+
return (
|
|
94
|
+
<box key={key} flexDirection="row" gap={1} paddingLeft={1}>
|
|
95
|
+
<text fg={STATUS_COLORS[file.status]}>
|
|
96
|
+
<strong>{file.status}</strong>
|
|
97
|
+
</text>
|
|
98
|
+
<box flexGrow={1} overflow="hidden">
|
|
99
|
+
{renderPath(file)}
|
|
100
|
+
</box>
|
|
101
|
+
{hasNumstat ? (
|
|
102
|
+
<box flexDirection="row" flexShrink={0}>
|
|
103
|
+
<text fg={theme.success}>{`+${padRight(file.added, addedW)}`}</text>
|
|
104
|
+
<text fg={theme.dim}> </text>
|
|
105
|
+
<text fg={theme.danger}>{`−${padRight(file.removed, removedW)}`}</text>
|
|
106
|
+
</box>
|
|
107
|
+
) : (
|
|
108
|
+
<text fg={theme.textMuted} flexShrink={0}>
|
|
109
|
+
—
|
|
110
|
+
</text>
|
|
111
|
+
)}
|
|
112
|
+
</box>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function renderSection(
|
|
117
|
+
section: GitFileSection,
|
|
118
|
+
title: string,
|
|
119
|
+
files: GitFileEntry[],
|
|
120
|
+
addedW: number,
|
|
121
|
+
removedW: number
|
|
122
|
+
): ReactNode {
|
|
123
|
+
if (files.length === 0) return null
|
|
124
|
+
return (
|
|
125
|
+
<box key={section} flexDirection="column">
|
|
126
|
+
<text fg={theme.accentAlt}>
|
|
127
|
+
<strong>
|
|
128
|
+
{title} ({files.length})
|
|
129
|
+
</strong>
|
|
130
|
+
</text>
|
|
131
|
+
{files.map((file, i) => renderFileRow(file, `${section}-${i}`, addedW, removedW))}
|
|
132
|
+
</box>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
interface StatusPlaceholder {
|
|
137
|
+
label: string
|
|
138
|
+
labelColor: string
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function renderStatus(gitPanel: GitPanelState, hasProjectPath: boolean): ReactNode | null {
|
|
142
|
+
const placeholder = computeStatusPlaceholder(gitPanel, hasProjectPath)
|
|
143
|
+
if (!placeholder) return null
|
|
144
|
+
return (
|
|
145
|
+
<box flexGrow={1} flexDirection="column" alignItems="center" paddingTop={1}>
|
|
146
|
+
<text fg={placeholder.labelColor}>{placeholder.label}</text>
|
|
147
|
+
</box>
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function computeStatusPlaceholder(
|
|
152
|
+
gitPanel: GitPanelState,
|
|
153
|
+
hasProjectPath: boolean
|
|
154
|
+
): StatusPlaceholder | null {
|
|
155
|
+
if (!hasProjectPath) {
|
|
156
|
+
return { label: 'No active session', labelColor: theme.textMuted }
|
|
157
|
+
}
|
|
158
|
+
if (gitPanel.error === 'not-a-repo') {
|
|
159
|
+
return { label: 'Not a git repository', labelColor: theme.textMuted }
|
|
160
|
+
}
|
|
161
|
+
if (gitPanel.error === 'unknown') {
|
|
162
|
+
return { label: 'Git error', labelColor: theme.danger }
|
|
163
|
+
}
|
|
164
|
+
if (gitPanel.files.length === 0) {
|
|
165
|
+
return { label: 'Working tree clean', labelColor: theme.textMuted }
|
|
166
|
+
}
|
|
167
|
+
return null
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const GitPanel = memo(function GitPanel({ gitPanel, projectPath }: GitPanelProps) {
|
|
171
|
+
const groups = useMemo(() => groupBySection(gitPanel.files), [gitPanel.files])
|
|
172
|
+
const { added: addedW, removed: removedW } = useMemo(
|
|
173
|
+
() => maxDigitWidth(gitPanel.files),
|
|
174
|
+
[gitPanel.files]
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
const statusNode = renderStatus(gitPanel, !!projectPath)
|
|
178
|
+
|
|
179
|
+
const hasRemoteTracking = gitPanel.ahead > 0 || gitPanel.behind > 0
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
<box flexDirection="column" flexGrow={1} gap={0}>
|
|
183
|
+
{hasRemoteTracking ? (
|
|
184
|
+
<text fg={theme.textMuted}>
|
|
185
|
+
↑{gitPanel.ahead} ↓{gitPanel.behind}
|
|
186
|
+
</text>
|
|
187
|
+
) : null}
|
|
188
|
+
{statusNode ?? (
|
|
189
|
+
<scrollbox
|
|
190
|
+
flexGrow={1}
|
|
191
|
+
scrollY
|
|
192
|
+
viewportCulling
|
|
193
|
+
contentOptions={{ flexDirection: 'column', gap: 0 }}
|
|
194
|
+
>
|
|
195
|
+
{SECTION_ORDER.map((s) => renderSection(s.key, s.title, groups[s.key], addedW, removedW))}
|
|
196
|
+
</scrollbox>
|
|
197
|
+
)}
|
|
198
|
+
</box>
|
|
199
|
+
)
|
|
200
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { theme } from '../theme'
|
|
2
|
+
import { uiTokens } from '../ui-tokens'
|
|
3
|
+
import { ModalShell } from './modal-shell'
|
|
4
|
+
|
|
5
|
+
const SECTIONS = [
|
|
6
|
+
{
|
|
7
|
+
bindings: [
|
|
8
|
+
['j / k', 'Move between tabs'],
|
|
9
|
+
['Shift+J / K', 'Reorder tabs'],
|
|
10
|
+
['i', 'Focus terminal input'],
|
|
11
|
+
['r', 'Rename tab'],
|
|
12
|
+
['?', 'Show this help'],
|
|
13
|
+
],
|
|
14
|
+
title: 'Navigation',
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
bindings: [
|
|
18
|
+
['Ctrl+n', 'New tab'],
|
|
19
|
+
['Ctrl+w', 'Close tab'],
|
|
20
|
+
['Ctrl+r', 'Restart tab'],
|
|
21
|
+
['Ctrl+g', 'Session picker'],
|
|
22
|
+
['Ctrl+s', 'Snippet picker'],
|
|
23
|
+
['Ctrl+t', 'Theme picker'],
|
|
24
|
+
],
|
|
25
|
+
title: 'Tabs & Sessions',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
bindings: [
|
|
29
|
+
['Ctrl+b', 'Toggle sidebar'],
|
|
30
|
+
['Ctrl+h / l', 'Resize sidebar'],
|
|
31
|
+
['Shift+G', 'Toggle git panel'],
|
|
32
|
+
['Ctrl+j / k', 'Resize git panel'],
|
|
33
|
+
],
|
|
34
|
+
title: 'Sidebar',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
bindings: [
|
|
38
|
+
['Ctrl+z', 'Back to navigation'],
|
|
39
|
+
['Ctrl+w', 'Enter layout mode'],
|
|
40
|
+
['Ctrl+b', 'Toggle sidebar'],
|
|
41
|
+
],
|
|
42
|
+
title: 'Terminal Input',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
bindings: [
|
|
46
|
+
['|', 'Split vertical'],
|
|
47
|
+
['-', 'Split horizontal'],
|
|
48
|
+
['h / j / k / l', 'Focus pane'],
|
|
49
|
+
['Shift+H/J/K/L', 'Resize pane'],
|
|
50
|
+
['q', 'Close pane'],
|
|
51
|
+
['Esc', 'Cancel'],
|
|
52
|
+
],
|
|
53
|
+
title: 'Layout Mode (Ctrl+w)',
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
bindings: [['Ctrl+c', 'Quit']],
|
|
57
|
+
title: 'General',
|
|
58
|
+
},
|
|
59
|
+
] as const
|
|
60
|
+
|
|
61
|
+
export function HelpModal() {
|
|
62
|
+
return (
|
|
63
|
+
<ModalShell title="Keybindings" help="Press Esc to close." width={uiTokens.modalWidth.lg}>
|
|
64
|
+
{SECTIONS.map((section) => (
|
|
65
|
+
<box key={section.title} flexDirection="column">
|
|
66
|
+
<text fg={theme.text}>{section.title}</text>
|
|
67
|
+
{section.bindings.map(([key, desc]) => (
|
|
68
|
+
<box key={key} flexDirection="row">
|
|
69
|
+
<box width={18}>
|
|
70
|
+
<text fg={theme.accentAlt}> {key}</text>
|
|
71
|
+
</box>
|
|
72
|
+
<text fg={theme.textMuted}>{desc}</text>
|
|
73
|
+
</box>
|
|
74
|
+
))}
|
|
75
|
+
</box>
|
|
76
|
+
))}
|
|
77
|
+
</ModalShell>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { theme } from '../theme'
|
|
2
|
+
import { Surface } from './surface'
|
|
3
|
+
|
|
4
|
+
interface InputFieldProps {
|
|
5
|
+
active: boolean
|
|
6
|
+
value: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function InputField({ active, value }: InputFieldProps) {
|
|
10
|
+
return (
|
|
11
|
+
<Surface tone={active ? 'inputActive' : 'input'} padding={1}>
|
|
12
|
+
<text fg={active ? theme.text : theme.textMuted}>
|
|
13
|
+
{value}
|
|
14
|
+
{active ? '_' : ''}
|
|
15
|
+
</text>
|
|
16
|
+
</Surface>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { theme } from '../theme'
|
|
4
|
+
import { Surface } from './surface'
|
|
5
|
+
|
|
6
|
+
interface ListItemProps {
|
|
7
|
+
active: boolean
|
|
8
|
+
leading?: ReactNode
|
|
9
|
+
subtitle?: ReactNode
|
|
10
|
+
title: ReactNode
|
|
11
|
+
trailing?: ReactNode
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function ListItem({ active, leading, subtitle, title, trailing }: ListItemProps) {
|
|
15
|
+
return (
|
|
16
|
+
<Surface tone={active ? 'selected' : 'elevated'} paddingLeft={1} paddingRight={1}>
|
|
17
|
+
<box flexDirection="column">
|
|
18
|
+
<box flexDirection="row">
|
|
19
|
+
<text fg={active ? theme.accent : theme.dim}>{active ? '›' : '·'}</text>
|
|
20
|
+
<text> </text>
|
|
21
|
+
{leading}
|
|
22
|
+
{leading ? <text> </text> : null}
|
|
23
|
+
<box flexGrow={1}>{title}</box>
|
|
24
|
+
{trailing ? <box>{trailing}</box> : null}
|
|
25
|
+
</box>
|
|
26
|
+
{subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
|
|
27
|
+
</box>
|
|
28
|
+
</Surface>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { theme } from '../theme'
|
|
2
|
+
import { Surface } from './surface'
|
|
3
|
+
|
|
4
|
+
interface ModalFilterBarProps {
|
|
5
|
+
filter: string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function ModalFilterBar({ filter }: ModalFilterBarProps) {
|
|
9
|
+
if (filter === null) {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<Surface tone="input" paddingLeft={1} paddingRight={1} paddingTop={1} paddingBottom={1}>
|
|
15
|
+
<text fg={theme.text}>/{filter}_</text>
|
|
16
|
+
</Surface>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
import { theme } from '../theme'
|
|
4
|
+
import { Surface } from './surface'
|
|
5
|
+
|
|
6
|
+
interface ModalShellProps {
|
|
7
|
+
children: ReactNode
|
|
8
|
+
footer?: ReactNode
|
|
9
|
+
listGap?: number
|
|
10
|
+
help?: string
|
|
11
|
+
title: string
|
|
12
|
+
width: number | `${number}%`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ModalShell({ children, footer, help, listGap = 1, title, width }: ModalShellProps) {
|
|
16
|
+
return (
|
|
17
|
+
<box
|
|
18
|
+
position="absolute"
|
|
19
|
+
top={0}
|
|
20
|
+
left={0}
|
|
21
|
+
width="100%"
|
|
22
|
+
height="100%"
|
|
23
|
+
justifyContent="center"
|
|
24
|
+
alignItems="center"
|
|
25
|
+
>
|
|
26
|
+
<box
|
|
27
|
+
position="absolute"
|
|
28
|
+
top={0}
|
|
29
|
+
left={0}
|
|
30
|
+
width="100%"
|
|
31
|
+
height="100%"
|
|
32
|
+
backgroundColor={theme.overlay}
|
|
33
|
+
opacity={0.7}
|
|
34
|
+
/>
|
|
35
|
+
<Surface tone="elevated" padding={1} gap={1} width={width}>
|
|
36
|
+
<box width="100%" flexDirection="column" gap={listGap}>
|
|
37
|
+
<box flexDirection="column">
|
|
38
|
+
<text fg={theme.accentAlt}>{title}</text>
|
|
39
|
+
{help ? <text fg={theme.textMuted}>{help}</text> : null}
|
|
40
|
+
</box>
|
|
41
|
+
{children}
|
|
42
|
+
{footer ? <box>{footer}</box> : null}
|
|
43
|
+
</box>
|
|
44
|
+
</Surface>
|
|
45
|
+
</box>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getAllAssistantOptions } from '../../pty/command-registry'
|
|
2
|
+
import { theme } from '../theme'
|
|
3
|
+
import { uiTokens } from '../ui-tokens'
|
|
4
|
+
import { InputField } from './input-field'
|
|
5
|
+
import { ListItem } from './list-item'
|
|
6
|
+
import { ModalShell } from './modal-shell'
|
|
7
|
+
|
|
8
|
+
interface NewTabModalProps {
|
|
9
|
+
selectedIndex: number
|
|
10
|
+
customCommands: Record<string, string>
|
|
11
|
+
editBuffer: string | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function NewTabModal({ customCommands, editBuffer, selectedIndex }: NewTabModalProps) {
|
|
15
|
+
const options = getAllAssistantOptions(customCommands)
|
|
16
|
+
const selectedOption = options[selectedIndex]
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<ModalShell
|
|
20
|
+
title="New assistant tab"
|
|
21
|
+
help={
|
|
22
|
+
editBuffer !== null
|
|
23
|
+
? `Editing command for ${selectedOption?.label}. Enter to confirm, Esc to cancel.`
|
|
24
|
+
: 'Use j/k or arrows, Enter to confirm, e to edit command, Esc to cancel.'
|
|
25
|
+
}
|
|
26
|
+
width={uiTokens.modalWidth.md}
|
|
27
|
+
>
|
|
28
|
+
{editBuffer !== null ? (
|
|
29
|
+
<InputField active value={editBuffer} />
|
|
30
|
+
) : (
|
|
31
|
+
options.map((option, index) => {
|
|
32
|
+
const active = index === selectedIndex
|
|
33
|
+
const customCmd = customCommands[option.id]
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<ListItem
|
|
37
|
+
key={option.id}
|
|
38
|
+
active={active}
|
|
39
|
+
title={<text fg={active ? theme.text : theme.textMuted}>{option.label}</text>}
|
|
40
|
+
subtitle={
|
|
41
|
+
<box flexDirection="column">
|
|
42
|
+
<text fg={theme.textMuted}>{option.description}</text>
|
|
43
|
+
{customCmd ? <text fg={theme.accent}>{customCmd}</text> : null}
|
|
44
|
+
</box>
|
|
45
|
+
}
|
|
46
|
+
/>
|
|
47
|
+
)
|
|
48
|
+
})
|
|
49
|
+
)}
|
|
50
|
+
</ModalShell>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useAppStore } from '../../state/app-store'
|
|
2
|
+
import { theme } from '../theme'
|
|
3
|
+
|
|
4
|
+
function formatChord(chord: string): string {
|
|
5
|
+
if (chord === 'space') return '␣'
|
|
6
|
+
if (chord === 'return') return '⏎'
|
|
7
|
+
if (chord === 'escape') return 'Esc'
|
|
8
|
+
if (chord === 'tab') return 'Tab'
|
|
9
|
+
if (chord === 'backspace') return '⌫'
|
|
10
|
+
if (chord === 'up') return '↑'
|
|
11
|
+
if (chord === 'down') return '↓'
|
|
12
|
+
if (chord === 'left') return '←'
|
|
13
|
+
if (chord === 'right') return '→'
|
|
14
|
+
return chord
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatSequence(chords: string[]): string {
|
|
18
|
+
return chords.map(formatChord).join(' ')
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function PendingChordIndicator() {
|
|
22
|
+
const pendingChords = useAppStore((s) => s.pendingChords)
|
|
23
|
+
|
|
24
|
+
if (!pendingChords || pendingChords.length === 0) return null
|
|
25
|
+
|
|
26
|
+
const label = formatSequence(pendingChords)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<box
|
|
30
|
+
position="absolute"
|
|
31
|
+
bottom={2}
|
|
32
|
+
right={2}
|
|
33
|
+
paddingLeft={1}
|
|
34
|
+
paddingRight={1}
|
|
35
|
+
backgroundColor={theme.accent}
|
|
36
|
+
borderColor={theme.borderActive}
|
|
37
|
+
>
|
|
38
|
+
<text fg={theme.background}>{label}…</text>
|
|
39
|
+
</box>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { uiTokens } from '../ui-tokens'
|
|
2
|
+
import { InputField } from './input-field'
|
|
3
|
+
import { ModalShell } from './modal-shell'
|
|
4
|
+
|
|
5
|
+
export function SessionNameModal({ title, value }: { title: string; value: string }) {
|
|
6
|
+
return (
|
|
7
|
+
<ModalShell
|
|
8
|
+
title={title}
|
|
9
|
+
help="Type a session name. Enter confirm, Esc cancel."
|
|
10
|
+
width={uiTokens.modalWidth.md}
|
|
11
|
+
>
|
|
12
|
+
<InputField active value={value} />
|
|
13
|
+
</ModalShell>
|
|
14
|
+
)
|
|
15
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { SessionRecord } from '../../state/types'
|
|
2
|
+
|
|
3
|
+
import { filterSessions } from '../../state/selectors'
|
|
4
|
+
import { abbreviatePath } from '../path-format'
|
|
5
|
+
import { theme } from '../theme'
|
|
6
|
+
import { uiTokens } from '../ui-tokens'
|
|
7
|
+
import { ListItem } from './list-item'
|
|
8
|
+
import { ModalFilterBar } from './modal-filter-bar'
|
|
9
|
+
import { ModalShell } from './modal-shell'
|
|
10
|
+
|
|
11
|
+
interface SessionPickerModalProps {
|
|
12
|
+
sessions: SessionRecord[]
|
|
13
|
+
selectedIndex: number
|
|
14
|
+
currentSessionId: string | null
|
|
15
|
+
currentTabCount: number
|
|
16
|
+
filter: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function formatSessionLine(
|
|
20
|
+
session: SessionRecord,
|
|
21
|
+
currentSessionId: string | null,
|
|
22
|
+
currentTabCount: number
|
|
23
|
+
): string {
|
|
24
|
+
const tabCount =
|
|
25
|
+
session.id === currentSessionId
|
|
26
|
+
? currentTabCount
|
|
27
|
+
: (session.workspaceSnapshot?.tabs.length ?? 0)
|
|
28
|
+
return `${session.name} (${tabCount} tab${tabCount === 1 ? '' : 's'})`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getEmptyStateMessage(hasFilter: boolean): string {
|
|
32
|
+
if (hasFilter) {
|
|
33
|
+
return 'No matching sessions.'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return 'No sessions yet. Press Enter or n to create your first session.'
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function SessionPickerModal({
|
|
40
|
+
currentSessionId,
|
|
41
|
+
currentTabCount,
|
|
42
|
+
filter,
|
|
43
|
+
selectedIndex,
|
|
44
|
+
sessions,
|
|
45
|
+
}: SessionPickerModalProps) {
|
|
46
|
+
const filtered = filterSessions(sessions, filter)
|
|
47
|
+
const hasFilter = !!filter
|
|
48
|
+
const showFilteredEmptyState = filtered.length === 0 && sessions.length > 0
|
|
49
|
+
const showInitialEmptyState = filtered.length === 0 && sessions.length === 0
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<ModalShell
|
|
53
|
+
title="Sessions"
|
|
54
|
+
help="j/k move, Enter resume, n new, r rename, d delete, / filter, Esc cancel."
|
|
55
|
+
width={uiTokens.modalWidth.lg}
|
|
56
|
+
footer={<ModalFilterBar filter={filter} />}
|
|
57
|
+
>
|
|
58
|
+
{showFilteredEmptyState ? (
|
|
59
|
+
<text fg={theme.textMuted}>{getEmptyStateMessage(hasFilter)}</text>
|
|
60
|
+
) : null}
|
|
61
|
+
{showInitialEmptyState ? (
|
|
62
|
+
<text fg={theme.textMuted}>{getEmptyStateMessage(false)}</text>
|
|
63
|
+
) : null}
|
|
64
|
+
{filtered.map((session, index) => {
|
|
65
|
+
const active = index === selectedIndex
|
|
66
|
+
return (
|
|
67
|
+
<ListItem
|
|
68
|
+
key={session.id}
|
|
69
|
+
active={active}
|
|
70
|
+
title={
|
|
71
|
+
<text fg={active ? theme.text : theme.textMuted}>
|
|
72
|
+
{formatSessionLine(session, currentSessionId, currentTabCount)}
|
|
73
|
+
</text>
|
|
74
|
+
}
|
|
75
|
+
subtitle={
|
|
76
|
+
session.projectPath ? (
|
|
77
|
+
<text fg={theme.textMuted}>{abbreviatePath(session.projectPath)}</text>
|
|
78
|
+
) : undefined
|
|
79
|
+
}
|
|
80
|
+
/>
|
|
81
|
+
)
|
|
82
|
+
})}
|
|
83
|
+
<ListItem
|
|
84
|
+
active={selectedIndex === filtered.length}
|
|
85
|
+
title={
|
|
86
|
+
<text fg={selectedIndex === filtered.length ? theme.text : theme.textMuted}>
|
|
87
|
+
Create new session
|
|
88
|
+
</text>
|
|
89
|
+
}
|
|
90
|
+
/>
|
|
91
|
+
</ModalShell>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { TabSession } from '../../state/types'
|
|
2
|
+
|
|
3
|
+
import { allLeafIds, type LayoutNode } from '../../state/layout-tree'
|
|
4
|
+
|
|
5
|
+
export interface TabGroupInfo {
|
|
6
|
+
inLayout: boolean
|
|
7
|
+
groupStart: number
|
|
8
|
+
groupEnd: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function buildTabGroupInfo(
|
|
12
|
+
layoutTrees: Record<string, LayoutNode>,
|
|
13
|
+
tabs: TabSession[]
|
|
14
|
+
): Map<string, TabGroupInfo> {
|
|
15
|
+
const tabGroupInfo = new Map<string, TabGroupInfo>()
|
|
16
|
+
|
|
17
|
+
for (const tree of Object.values(layoutTrees)) {
|
|
18
|
+
const ids = allLeafIds(tree)
|
|
19
|
+
if (ids.length <= 1) {
|
|
20
|
+
continue
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const idSet = new Set(ids)
|
|
24
|
+
let groupStart = -1
|
|
25
|
+
let groupEnd = -1
|
|
26
|
+
|
|
27
|
+
for (const [index, tab] of tabs.entries()) {
|
|
28
|
+
if (!idSet.has(tab.id)) {
|
|
29
|
+
continue
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (groupStart === -1) {
|
|
33
|
+
groupStart = index
|
|
34
|
+
}
|
|
35
|
+
groupEnd = index
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const id of ids) {
|
|
39
|
+
tabGroupInfo.set(id, { groupEnd, groupStart, inLayout: true })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return tabGroupInfo
|
|
44
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type SidebarScrollTarget = 'none' | 'active-item' | 'top' | 'bottom'
|
|
2
|
+
|
|
3
|
+
interface SidebarScrollTargetOptions {
|
|
4
|
+
previousActiveIndex: number
|
|
5
|
+
nextActiveIndex: number
|
|
6
|
+
tabCount: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getSidebarScrollTarget({
|
|
10
|
+
nextActiveIndex,
|
|
11
|
+
previousActiveIndex,
|
|
12
|
+
tabCount,
|
|
13
|
+
}: SidebarScrollTargetOptions): SidebarScrollTarget {
|
|
14
|
+
if (tabCount === 0 || nextActiveIndex < 0) {
|
|
15
|
+
return 'none'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (previousActiveIndex < 0 || previousActiveIndex === nextActiveIndex) {
|
|
19
|
+
return previousActiveIndex < 0 ? 'active-item' : 'none'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const lastIndex = tabCount - 1
|
|
23
|
+
|
|
24
|
+
if (previousActiveIndex === lastIndex && nextActiveIndex === 0) {
|
|
25
|
+
return 'top'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (previousActiveIndex === 0 && nextActiveIndex === lastIndex) {
|
|
29
|
+
return 'bottom'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return 'active-item'
|
|
33
|
+
}
|