@brimveyn/aimux 1.3.0 → 1.4.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-runtime-events.ts +13 -1
- package/src/app-runtime/pty-write.ts +7 -4
- package/src/app-runtime/side-effects.ts +72 -4
- package/src/app-runtime/snippet-actions.ts +3 -2
- package/src/app-runtime/use-backend-runtime.ts +7 -2
- package/src/app-runtime/use-renderer-bindings.ts +2 -2
- package/src/app-runtime/use-terminal-resize.ts +15 -6
- package/src/app.tsx +113 -37
- package/src/config.ts +32 -1
- package/src/daemon/daemon.ts +19 -2
- package/src/daemon/session-manager.ts +20 -5
- package/src/daemon/session-registry.ts +18 -11
- package/src/index.tsx +8 -1
- package/src/input/keymap/describe-bindings.ts +68 -0
- package/src/input/keymap/key-format.ts +67 -0
- package/src/input/modes/bridge.ts +1 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +3 -0
- package/src/ipc/manager-protocol.ts +51 -2
- package/src/ipc/protocol.ts +43 -2
- package/src/pty/pty-manager.ts +29 -3
- package/src/session-backend/local-session-backend.ts +39 -5
- package/src/session-backend/remote-session-backend.ts +18 -5
- package/src/session-backend/types.ts +5 -2
- package/src/state/dispatch-ref.ts +11 -0
- package/src/state/reducers/modal-state.ts +18 -1
- package/src/state/reducers/session-state.ts +28 -0
- package/src/state/reducers/tab-state.ts +20 -2
- package/src/state/reducers/ui-state.ts +8 -0
- package/src/state/session-catalog.ts +22 -1
- package/src/state/session-persistence.ts +9 -2
- package/src/state/store.ts +8 -1
- package/src/state/types.ts +37 -0
- package/src/state/validation.ts +10 -0
- package/src/state/workspace-save.ts +2 -0
- package/src/terminal-manager/manager-client.ts +29 -5
- package/src/terminal-manager/terminal-manager.ts +19 -3
- package/src/ui/components/create-session-modal.tsx +3 -5
- package/src/ui/components/git-commit-modal.tsx +3 -5
- package/src/ui/components/help-modal.tsx +49 -68
- package/src/ui/components/list-item.tsx +24 -5
- package/src/ui/components/new-tab-modal.tsx +8 -9
- package/src/ui/components/pending-chord-overlay.tsx +28 -0
- package/src/ui/components/session-bar.tsx +208 -0
- package/src/ui/components/session-name-modal.tsx +3 -5
- package/src/ui/components/session-picker-modal.tsx +3 -1
- package/src/ui/components/snippet-editor-modal.tsx +3 -1
- package/src/ui/components/snippet-picker-modal.tsx +3 -1
- package/src/ui/components/status-bar.tsx +6 -2
- package/src/ui/components/tab-item.tsx +3 -15
- package/src/ui/components/theme-picker-modal.tsx +3 -6
- package/src/ui/components/update-available-modal.tsx +42 -0
- package/src/ui/hooks/use-busy-spinner.ts +18 -0
- package/src/ui/keymap-context.ts +39 -0
- package/src/ui/root.tsx +16 -0
- package/src/ui/session-ordering.ts +34 -0
- package/src/ui/status-bar-model.ts +67 -39
- package/src/update/version-check.ts +67 -0
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { EventEmitter } from 'node:events'
|
|
2
2
|
import { connect, Socket } from 'node:net'
|
|
3
3
|
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
ScrollIntent,
|
|
6
|
+
TerminalModeState,
|
|
7
|
+
TerminalSnapshot,
|
|
8
|
+
WorkspaceSnapshotV1,
|
|
9
|
+
} from '../state/types'
|
|
5
10
|
|
|
6
11
|
import { getTerminalManagerSocketPath } from '../daemon/runtime-paths'
|
|
7
12
|
import { logDebug } from '../debug/input-log'
|
|
@@ -298,18 +303,29 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
|
298
303
|
})
|
|
299
304
|
}
|
|
300
305
|
|
|
301
|
-
resize(
|
|
306
|
+
resize(
|
|
307
|
+
sessionId: string,
|
|
308
|
+
cols: number,
|
|
309
|
+
rows: number,
|
|
310
|
+
intents?: Record<string, ScrollIntent>
|
|
311
|
+
): Promise<void> {
|
|
302
312
|
return this.sendExpectOk({
|
|
303
313
|
id: crypto.randomUUID(),
|
|
304
|
-
payload: { cols, rows, sessionId },
|
|
314
|
+
payload: { cols, intents, rows, sessionId },
|
|
305
315
|
type: 'resizeClient',
|
|
306
316
|
})
|
|
307
317
|
}
|
|
308
318
|
|
|
309
|
-
resizeTab(
|
|
319
|
+
resizeTab(
|
|
320
|
+
sessionId: string,
|
|
321
|
+
tabId: string,
|
|
322
|
+
cols: number,
|
|
323
|
+
rows: number,
|
|
324
|
+
intent?: ScrollIntent
|
|
325
|
+
): Promise<void> {
|
|
310
326
|
return this.sendExpectOk({
|
|
311
327
|
id: crypto.randomUUID(),
|
|
312
|
-
payload: { cols, rows, sessionId, tabId },
|
|
328
|
+
payload: { cols, intent, rows, sessionId, tabId },
|
|
313
329
|
type: 'resizeTab',
|
|
314
330
|
})
|
|
315
331
|
}
|
|
@@ -330,6 +346,14 @@ export class TerminalManagerClient extends EventEmitter<ManagerClientEvents> {
|
|
|
330
346
|
})
|
|
331
347
|
}
|
|
332
348
|
|
|
349
|
+
reapplyScrollIntent(sessionId: string, tabId: string, intent: ScrollIntent): Promise<void> {
|
|
350
|
+
return this.sendExpectOk({
|
|
351
|
+
id: crypto.randomUUID(),
|
|
352
|
+
payload: { intent, sessionId, tabId },
|
|
353
|
+
type: 'reapplyScrollIntent',
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
333
357
|
setActiveTab(sessionId: string, tabId: string | null): Promise<void> {
|
|
334
358
|
return this.sendExpectOk({
|
|
335
359
|
id: crypto.randomUUID(),
|
|
@@ -166,22 +166,29 @@ export async function runTerminalManager(): Promise<void> {
|
|
|
166
166
|
)
|
|
167
167
|
sendOk(socket, message.id)
|
|
168
168
|
break
|
|
169
|
-
case 'resizeClient':
|
|
169
|
+
case 'resizeClient': {
|
|
170
170
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
171
|
+
const intentsRecord = message.payload.intents
|
|
172
|
+
const intentsMap = intentsRecord
|
|
173
|
+
? new Map(Object.entries(intentsRecord))
|
|
174
|
+
: undefined
|
|
171
175
|
sessionManager.resize(
|
|
172
176
|
message.payload.sessionId,
|
|
173
177
|
message.payload.cols,
|
|
174
|
-
message.payload.rows
|
|
178
|
+
message.payload.rows,
|
|
179
|
+
intentsMap
|
|
175
180
|
)
|
|
176
181
|
sendOk(socket, message.id)
|
|
177
182
|
break
|
|
183
|
+
}
|
|
178
184
|
case 'resizeTab':
|
|
179
185
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
180
186
|
sessionManager.resizeTab(
|
|
181
187
|
message.payload.sessionId,
|
|
182
188
|
message.payload.tabId,
|
|
183
189
|
message.payload.cols,
|
|
184
|
-
message.payload.rows
|
|
190
|
+
message.payload.rows,
|
|
191
|
+
message.payload.intent
|
|
185
192
|
)
|
|
186
193
|
sendOk(socket, message.id)
|
|
187
194
|
break
|
|
@@ -199,6 +206,15 @@ export async function runTerminalManager(): Promise<void> {
|
|
|
199
206
|
sessionManager.scrollToBottom(message.payload.sessionId, message.payload.tabId)
|
|
200
207
|
sendOk(socket, message.id)
|
|
201
208
|
break
|
|
209
|
+
case 'reapplyScrollIntent':
|
|
210
|
+
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
211
|
+
sessionManager.reapplyScrollIntent(
|
|
212
|
+
message.payload.sessionId,
|
|
213
|
+
message.payload.tabId,
|
|
214
|
+
message.payload.intent
|
|
215
|
+
)
|
|
216
|
+
sendOk(socket, message.id)
|
|
217
|
+
break
|
|
202
218
|
case 'setActiveTab':
|
|
203
219
|
requireNegotiatedVersion(socket, negotiatedVersions)
|
|
204
220
|
sessionManager.setActiveTab(message.payload.sessionId, message.payload.tabId)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DirectoryResult } from '../../state/types'
|
|
2
2
|
|
|
3
|
+
import { useModalHelp } from '../keymap-context'
|
|
3
4
|
import { abbreviatePath } from '../path-format'
|
|
4
5
|
import { theme } from '../theme'
|
|
5
6
|
import { uiTokens } from '../ui-tokens'
|
|
@@ -50,13 +51,10 @@ export function CreateSessionModal({
|
|
|
50
51
|
}: CreateSessionModalProps) {
|
|
51
52
|
const dirActive = activeField === 'directory'
|
|
52
53
|
const nameActive = activeField === 'name'
|
|
54
|
+
const help = useModalHelp('modal.create-session')
|
|
53
55
|
|
|
54
56
|
return (
|
|
55
|
-
<ModalShell
|
|
56
|
-
title="Create session"
|
|
57
|
-
help="Tab switch field. Ctrl+n/p nav. Esc cancel."
|
|
58
|
-
width={uiTokens.modalWidth.xl}
|
|
59
|
-
>
|
|
57
|
+
<ModalShell title="Create session" help={help} width={uiTokens.modalWidth.xl}>
|
|
60
58
|
<box flexDirection="column">
|
|
61
59
|
<text fg={dirActive ? theme.text : theme.textMuted}>Search projects</text>
|
|
62
60
|
<InputField
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useModalHelp } from '../keymap-context'
|
|
1
2
|
import { theme } from '../theme'
|
|
2
3
|
import { uiTokens } from '../ui-tokens'
|
|
3
4
|
import { InputField } from './input-field'
|
|
@@ -13,13 +14,10 @@ interface GitCommitModalProps {
|
|
|
13
14
|
export function GitCommitModal({ activeField, body, cursorPos, title }: GitCommitModalProps) {
|
|
14
15
|
const titleActive = activeField === 'title'
|
|
15
16
|
const bodyActive = activeField === 'body'
|
|
17
|
+
const help = useModalHelp('modal.git-commit')
|
|
16
18
|
|
|
17
19
|
return (
|
|
18
|
-
<ModalShell
|
|
19
|
-
title="Commit"
|
|
20
|
-
help="Tab switch · ←→ move cursor · Enter newline (body) · Ctrl+Enter commit · Esc cancel"
|
|
21
|
-
width={uiTokens.modalWidth.xl}
|
|
22
|
-
>
|
|
20
|
+
<ModalShell title="Commit" help={help} width={uiTokens.modalWidth.xl}>
|
|
23
21
|
<box flexDirection="column">
|
|
24
22
|
<text fg={titleActive ? theme.text : theme.textMuted}>Title</text>
|
|
25
23
|
<InputField
|
|
@@ -1,79 +1,60 @@
|
|
|
1
|
+
import type { ModeId } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import { describeBindings, groupDescribedBindings } from '../../input/keymap/describe-bindings'
|
|
4
|
+
import { useKeymap, useModalHelp } from '../keymap-context'
|
|
1
5
|
import { theme } from '../theme'
|
|
2
6
|
import { uiTokens } from '../ui-tokens'
|
|
3
7
|
import { ModalShell } from './modal-shell'
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
9
|
+
interface ModeSection {
|
|
10
|
+
modeId: ModeId
|
|
11
|
+
title: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SECTIONS: ModeSection[] = [
|
|
15
|
+
{ modeId: 'navigation', title: 'Navigation' },
|
|
16
|
+
{ modeId: 'terminal-input', title: 'Terminal input' },
|
|
17
|
+
{ modeId: 'layout', title: 'Layout' },
|
|
18
|
+
{ modeId: 'git-mode', title: 'Git mode' },
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
const KEYS_COLUMN_WIDTH = 22
|
|
60
22
|
|
|
61
23
|
export function HelpModal() {
|
|
24
|
+
const config = useKeymap()
|
|
25
|
+
const help = useModalHelp('modal.help', 1)
|
|
26
|
+
|
|
62
27
|
return (
|
|
63
|
-
<ModalShell title="Keybindings" help=
|
|
64
|
-
{SECTIONS.map((section) =>
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
28
|
+
<ModalShell title="Keybindings" help={help} width={uiTokens.modalWidth.lg}>
|
|
29
|
+
{SECTIONS.map((section) => {
|
|
30
|
+
const bindings = describeBindings(config, section.modeId, {
|
|
31
|
+
dedupeByDescription: true,
|
|
32
|
+
withDescriptionOnly: true,
|
|
33
|
+
})
|
|
34
|
+
if (bindings.length === 0) return null
|
|
35
|
+
const groups = groupDescribedBindings(bindings)
|
|
36
|
+
return (
|
|
37
|
+
<box key={section.modeId} flexDirection="column">
|
|
38
|
+
<text fg={theme.text}>{section.title}</text>
|
|
39
|
+
{groups.map((group, groupIdx) => (
|
|
40
|
+
<box
|
|
41
|
+
key={`${section.modeId}-${group.group ?? 'root'}-${groupIdx}`}
|
|
42
|
+
flexDirection="column"
|
|
43
|
+
>
|
|
44
|
+
{group.group ? <text fg={theme.textMuted}> {group.group}</text> : null}
|
|
45
|
+
{group.bindings.map((binding) => (
|
|
46
|
+
<box key={`${binding.keys}-${binding.description}`} flexDirection="row">
|
|
47
|
+
<box width={KEYS_COLUMN_WIDTH}>
|
|
48
|
+
<text fg={theme.accentAlt}> {binding.keysDisplay}</text>
|
|
49
|
+
</box>
|
|
50
|
+
<text fg={theme.textMuted}>{binding.description ?? ''}</text>
|
|
51
|
+
</box>
|
|
52
|
+
))}
|
|
71
53
|
</box>
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
))}
|
|
54
|
+
))}
|
|
55
|
+
</box>
|
|
56
|
+
)
|
|
57
|
+
})}
|
|
77
58
|
</ModalShell>
|
|
78
59
|
)
|
|
79
60
|
}
|
|
@@ -3,24 +3,43 @@ import type { ReactNode } from 'react'
|
|
|
3
3
|
import { theme } from '../theme'
|
|
4
4
|
import { Surface } from './surface'
|
|
5
5
|
|
|
6
|
+
export type ListItemDirection = 'row' | 'column'
|
|
7
|
+
|
|
6
8
|
interface ListItemProps {
|
|
7
9
|
active: boolean
|
|
10
|
+
direction?: ListItemDirection
|
|
8
11
|
leading?: ReactNode
|
|
9
12
|
subtitle?: ReactNode
|
|
10
13
|
title: ReactNode
|
|
11
14
|
trailing?: ReactNode
|
|
12
15
|
}
|
|
13
16
|
|
|
14
|
-
export function ListItem({
|
|
17
|
+
export function ListItem({
|
|
18
|
+
active,
|
|
19
|
+
direction = 'column',
|
|
20
|
+
leading,
|
|
21
|
+
subtitle,
|
|
22
|
+
title,
|
|
23
|
+
trailing,
|
|
24
|
+
}: ListItemProps) {
|
|
25
|
+
const isRow = direction === 'row'
|
|
15
26
|
return (
|
|
16
|
-
<Surface
|
|
27
|
+
<Surface
|
|
28
|
+
tone={active ? 'selected' : 'elevated'}
|
|
29
|
+
paddingLeft={isRow ? 2 : 1}
|
|
30
|
+
paddingRight={isRow ? 2 : 1}
|
|
31
|
+
>
|
|
17
32
|
<box flexDirection="column">
|
|
18
33
|
<box flexDirection="row">
|
|
19
|
-
|
|
20
|
-
|
|
34
|
+
{isRow ? null : (
|
|
35
|
+
<>
|
|
36
|
+
<text fg={active ? theme.accent : theme.dim}>{active ? '›' : '·'}</text>
|
|
37
|
+
<text> </text>
|
|
38
|
+
</>
|
|
39
|
+
)}
|
|
21
40
|
{leading}
|
|
22
41
|
{leading ? <text> </text> : null}
|
|
23
|
-
<box flexGrow={1}>{title}</box>
|
|
42
|
+
<box flexGrow={isRow ? 0 : 1}>{title}</box>
|
|
24
43
|
{trailing ? <box>{trailing}</box> : null}
|
|
25
44
|
</box>
|
|
26
45
|
{subtitle ? <box paddingLeft={2}>{subtitle}</box> : null}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getAllAssistantOptions } from '../../pty/command-registry'
|
|
2
|
+
import { useModalHelp } from '../keymap-context'
|
|
2
3
|
import { theme } from '../theme'
|
|
3
4
|
import { uiTokens } from '../ui-tokens'
|
|
4
5
|
import { InputField } from './input-field'
|
|
@@ -14,17 +15,15 @@ interface NewTabModalProps {
|
|
|
14
15
|
export function NewTabModal({ customCommands, editBuffer, selectedIndex }: NewTabModalProps) {
|
|
15
16
|
const options = getAllAssistantOptions(customCommands)
|
|
16
17
|
const selectedOption = options[selectedIndex]
|
|
18
|
+
const editingHelp = useModalHelp('modal.new-tab.command-edit')
|
|
19
|
+
const browsingHelp = useModalHelp('modal.new-tab')
|
|
20
|
+
const help =
|
|
21
|
+
editBuffer !== null
|
|
22
|
+
? `Editing command for ${selectedOption?.label}. ${editingHelp}`
|
|
23
|
+
: browsingHelp
|
|
17
24
|
|
|
18
25
|
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
|
-
>
|
|
26
|
+
<ModalShell title="New assistant tab" help={help} width={uiTokens.modalWidth.md}>
|
|
28
27
|
{editBuffer !== null ? (
|
|
29
28
|
<InputField active value={editBuffer} />
|
|
30
29
|
) : (
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { parseKeyNotation } from '../../input/keymap/key-chord'
|
|
2
|
+
import { formatChord } from '../../input/keymap/key-format'
|
|
3
|
+
import { useAppStore } from '../../state/app-store'
|
|
4
|
+
import { useKeymap } from '../keymap-context'
|
|
5
|
+
import { theme } from '../theme'
|
|
6
|
+
import { Surface } from './surface'
|
|
7
|
+
|
|
8
|
+
export function PendingChordOverlay() {
|
|
9
|
+
const pendingChords = useAppStore((s) => s.pendingChords)
|
|
10
|
+
const config = useKeymap()
|
|
11
|
+
|
|
12
|
+
if (!pendingChords || pendingChords.length === 0) return null
|
|
13
|
+
|
|
14
|
+
const leaderChord = parseKeyNotation(config.leader)[0]
|
|
15
|
+
const display = pendingChords.map((c) => formatChord(c, leaderChord)).join(' ')
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<box position="absolute" bottom={2} right={1}>
|
|
19
|
+
<Surface tone="elevated" paddingLeft={1} paddingRight={1}>
|
|
20
|
+
<box flexDirection="row">
|
|
21
|
+
<text fg={theme.textMuted}>pending: </text>
|
|
22
|
+
<text fg={theme.accent}>{display}</text>
|
|
23
|
+
<text fg={theme.textMuted}> …</text>
|
|
24
|
+
</box>
|
|
25
|
+
</Surface>
|
|
26
|
+
</box>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import type { BoxRenderable, MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import { useMemo, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import type { SessionRecord } from '../../state/types'
|
|
6
|
+
|
|
7
|
+
import { useAppStore } from '../../state/app-store'
|
|
8
|
+
import { dispatchGlobal, runSideEffectGlobal } from '../../state/dispatch-ref'
|
|
9
|
+
import { useBusySpinner } from '../hooks/use-busy-spinner'
|
|
10
|
+
import { moveIdToIdPosition, orderSessionsForDisplay } from '../session-ordering'
|
|
11
|
+
import { theme } from '../theme'
|
|
12
|
+
|
|
13
|
+
export function SessionBar() {
|
|
14
|
+
const sessions = useAppStore((s) => s.sessions)
|
|
15
|
+
const currentId = useAppStore((s) => s.currentSessionId)
|
|
16
|
+
const bar = useAppStore((s) => s.sessionBar)
|
|
17
|
+
const busyMap = useAppStore((s) => s.sessionsBusy)
|
|
18
|
+
|
|
19
|
+
const [draggingId, setDraggingId] = useState<string | null>(null)
|
|
20
|
+
const [dragOrder, setDragOrder] = useState<string[] | null>(null)
|
|
21
|
+
// Hysteresis: the id of the chip we most recently swapped with. While the
|
|
22
|
+
// cursor remains over that chip we refuse to swap back (prevents oscillation
|
|
23
|
+
// when a long chip's new bounds still cover the cursor after a swap).
|
|
24
|
+
const lastSwapWithRef = useRef<string | null>(null)
|
|
25
|
+
// Live bounds of each chip after render, keyed by session id.
|
|
26
|
+
const chipRefs = useRef(new Map<string, BoxRenderable>())
|
|
27
|
+
|
|
28
|
+
const ordered = useMemo(() => orderSessionsForDisplay(sessions), [sessions])
|
|
29
|
+
if (!bar.visible || ordered.length === 0) return null
|
|
30
|
+
|
|
31
|
+
const visibleSessions =
|
|
32
|
+
dragOrder !== null
|
|
33
|
+
? dragOrder
|
|
34
|
+
.map((id) => ordered.find((s) => s.id === id))
|
|
35
|
+
.filter((s): s is SessionRecord => !!s)
|
|
36
|
+
: ordered
|
|
37
|
+
|
|
38
|
+
const baselineOrder = ordered.map((s) => s.id)
|
|
39
|
+
|
|
40
|
+
function setChipRef(id: string, ref: BoxRenderable | null): void {
|
|
41
|
+
if (ref) chipRefs.current.set(id, ref)
|
|
42
|
+
else chipRefs.current.delete(id)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findChipAtX(x: number): string | null {
|
|
46
|
+
for (const [id, ref] of chipRefs.current) {
|
|
47
|
+
if (x >= ref.x && x < ref.x + ref.width) return id
|
|
48
|
+
}
|
|
49
|
+
return null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const handleMouseDown = (id: string) => {
|
|
53
|
+
setDraggingId(id)
|
|
54
|
+
setDragOrder(baselineOrder)
|
|
55
|
+
lastSwapWithRef.current = null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const handleMouseDrag = (event: OtuiMouseEvent) => {
|
|
59
|
+
if (!draggingId) return
|
|
60
|
+
const hit = findChipAtX(event.x)
|
|
61
|
+
if (hit === null) {
|
|
62
|
+
// Cursor left the bar entirely — allow the next hit to re-trigger a swap.
|
|
63
|
+
lastSwapWithRef.current = null
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
if (hit === draggingId) {
|
|
67
|
+
// Over the dragged chip itself — reset hysteresis so re-entering a
|
|
68
|
+
// neighbour can swap again.
|
|
69
|
+
lastSwapWithRef.current = null
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
if (hit === lastSwapWithRef.current) return
|
|
73
|
+
setDragOrder((prev) => (prev ? moveIdToIdPosition(prev, draggingId, hit) : prev))
|
|
74
|
+
lastSwapWithRef.current = hit
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const commitDrop = () => {
|
|
78
|
+
const source = draggingId
|
|
79
|
+
const finalOrder = dragOrder
|
|
80
|
+
setDraggingId(null)
|
|
81
|
+
setDragOrder(null)
|
|
82
|
+
lastSwapWithRef.current = null
|
|
83
|
+
|
|
84
|
+
if (!source || !finalOrder) return
|
|
85
|
+
|
|
86
|
+
const changed = !arraysEqual(finalOrder, baselineOrder)
|
|
87
|
+
if (changed) {
|
|
88
|
+
dispatchGlobal({ orderedIds: finalOrder, type: 'reorder-sessions' })
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Drag did not change anything → treat as click, switch to that session.
|
|
93
|
+
const idx = baselineOrder.indexOf(source)
|
|
94
|
+
if (idx >= 0) {
|
|
95
|
+
runSideEffectGlobal({ index: idx + 1, type: 'switch-session-by-index' })
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const cancelDrag = () => {
|
|
100
|
+
setDraggingId(null)
|
|
101
|
+
setDragOrder(null)
|
|
102
|
+
lastSwapWithRef.current = null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<box
|
|
107
|
+
width="100%"
|
|
108
|
+
flexDirection="row"
|
|
109
|
+
paddingLeft={1}
|
|
110
|
+
paddingRight={1}
|
|
111
|
+
backgroundColor={theme.panelMuted}
|
|
112
|
+
>
|
|
113
|
+
{visibleSessions.map((session) => {
|
|
114
|
+
const displayIndex = baselineOrder.indexOf(session.id) + 1
|
|
115
|
+
return (
|
|
116
|
+
<SessionChip
|
|
117
|
+
key={session.id}
|
|
118
|
+
session={session}
|
|
119
|
+
index={displayIndex}
|
|
120
|
+
active={session.id === currentId}
|
|
121
|
+
busy={busyMap[session.id] ?? false}
|
|
122
|
+
dragging={draggingId === session.id}
|
|
123
|
+
onRef={(r) => setChipRef(session.id, r)}
|
|
124
|
+
onMouseDown={() => handleMouseDown(session.id)}
|
|
125
|
+
onMouseDrag={handleMouseDrag}
|
|
126
|
+
onMouseUp={commitDrop}
|
|
127
|
+
onMouseDragEnd={cancelDrag}
|
|
128
|
+
/>
|
|
129
|
+
)
|
|
130
|
+
})}
|
|
131
|
+
</box>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function arraysEqual(a: string[], b: string[]): boolean {
|
|
136
|
+
if (a.length !== b.length) return false
|
|
137
|
+
for (let i = 0; i < a.length; i++) {
|
|
138
|
+
if (a[i] !== b[i]) return false
|
|
139
|
+
}
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface SessionChipProps {
|
|
144
|
+
session: SessionRecord
|
|
145
|
+
index: number
|
|
146
|
+
active: boolean
|
|
147
|
+
busy: boolean
|
|
148
|
+
dragging: boolean
|
|
149
|
+
onRef: (ref: BoxRenderable | null) => void
|
|
150
|
+
onMouseDown: (event: OtuiMouseEvent) => void
|
|
151
|
+
onMouseDrag: (event: OtuiMouseEvent) => void
|
|
152
|
+
onMouseUp: (event: OtuiMouseEvent) => void
|
|
153
|
+
onMouseDragEnd: (event: OtuiMouseEvent) => void
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function SessionChip({
|
|
157
|
+
active,
|
|
158
|
+
busy,
|
|
159
|
+
dragging,
|
|
160
|
+
index,
|
|
161
|
+
onMouseDown,
|
|
162
|
+
onMouseDrag,
|
|
163
|
+
onMouseDragEnd,
|
|
164
|
+
onMouseUp,
|
|
165
|
+
onRef,
|
|
166
|
+
session,
|
|
167
|
+
}: SessionChipProps) {
|
|
168
|
+
const showSpinner = busy && !active
|
|
169
|
+
const spinner = useBusySpinner(showSpinner)
|
|
170
|
+
const indicator = showSpinner ? spinner : '●'
|
|
171
|
+
const indicatorColor = active || showSpinner ? theme.accent : theme.success
|
|
172
|
+
const labelColor = active ? theme.text : theme.textMuted
|
|
173
|
+
const bgColor = dragging || active ? theme.panelHighlight : undefined
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<box
|
|
177
|
+
ref={onRef}
|
|
178
|
+
flexDirection="row"
|
|
179
|
+
paddingLeft={1}
|
|
180
|
+
paddingRight={1}
|
|
181
|
+
backgroundColor={bgColor}
|
|
182
|
+
onMouseDown={(e) => {
|
|
183
|
+
e.preventDefault()
|
|
184
|
+
onMouseDown(e)
|
|
185
|
+
}}
|
|
186
|
+
onMouseDrag={(e) => {
|
|
187
|
+
onMouseDrag(e)
|
|
188
|
+
}}
|
|
189
|
+
onMouseUp={(e) => {
|
|
190
|
+
e.preventDefault()
|
|
191
|
+
onMouseUp(e)
|
|
192
|
+
}}
|
|
193
|
+
onMouseDragEnd={(e) => {
|
|
194
|
+
onMouseDragEnd(e)
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
<text fg={indicatorColor} selectable={false}>
|
|
198
|
+
{indicator}{' '}
|
|
199
|
+
</text>
|
|
200
|
+
<text fg={labelColor} selectable={false}>
|
|
201
|
+
[{index}] {session.name}
|
|
202
|
+
</text>
|
|
203
|
+
<text fg={theme.dim} selectable={false}>
|
|
204
|
+
{' '}
|
|
205
|
+
</text>
|
|
206
|
+
</box>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -1,14 +1,12 @@
|
|
|
1
|
+
import { useModalHelp } from '../keymap-context'
|
|
1
2
|
import { uiTokens } from '../ui-tokens'
|
|
2
3
|
import { InputField } from './input-field'
|
|
3
4
|
import { ModalShell } from './modal-shell'
|
|
4
5
|
|
|
5
6
|
export function SessionNameModal({ title, value }: { title: string; value: string }) {
|
|
7
|
+
const help = useModalHelp('modal.session-name')
|
|
6
8
|
return (
|
|
7
|
-
<ModalShell
|
|
8
|
-
title={title}
|
|
9
|
-
help="Type a session name. Enter confirm, Esc cancel."
|
|
10
|
-
width={uiTokens.modalWidth.md}
|
|
11
|
-
>
|
|
9
|
+
<ModalShell title={title} help={help} width={uiTokens.modalWidth.md}>
|
|
12
10
|
<InputField active value={value} />
|
|
13
11
|
</ModalShell>
|
|
14
12
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SessionRecord } from '../../state/types'
|
|
2
2
|
|
|
3
3
|
import { filterSessions } from '../../state/selectors'
|
|
4
|
+
import { useModalHelp } from '../keymap-context'
|
|
4
5
|
import { abbreviatePath } from '../path-format'
|
|
5
6
|
import { theme } from '../theme'
|
|
6
7
|
import { uiTokens } from '../ui-tokens'
|
|
@@ -47,11 +48,12 @@ export function SessionPickerModal({
|
|
|
47
48
|
const hasFilter = !!filter
|
|
48
49
|
const showFilteredEmptyState = filtered.length === 0 && sessions.length > 0
|
|
49
50
|
const showInitialEmptyState = filtered.length === 0 && sessions.length === 0
|
|
51
|
+
const help = useModalHelp('modal.session-picker')
|
|
50
52
|
|
|
51
53
|
return (
|
|
52
54
|
<ModalShell
|
|
53
55
|
title="Sessions"
|
|
54
|
-
help=
|
|
56
|
+
help={help}
|
|
55
57
|
width={uiTokens.modalWidth.lg}
|
|
56
58
|
footer={<ModalFilterBar filter={filter} />}
|
|
57
59
|
>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useModalHelp } from '../keymap-context'
|
|
1
2
|
import { theme } from '../theme'
|
|
2
3
|
import { uiTokens } from '../ui-tokens'
|
|
3
4
|
import { InputField } from './input-field'
|
|
@@ -18,11 +19,12 @@ export function SnippetEditorModal({
|
|
|
18
19
|
}: SnippetEditorModalProps) {
|
|
19
20
|
const nameActive = activeField === 'name'
|
|
20
21
|
const contentActive = activeField === 'content'
|
|
22
|
+
const help = useModalHelp('modal.snippet-editor')
|
|
21
23
|
|
|
22
24
|
return (
|
|
23
25
|
<ModalShell
|
|
24
26
|
title={isEditing ? 'Edit snippet' : 'Create snippet'}
|
|
25
|
-
help=
|
|
27
|
+
help={help}
|
|
26
28
|
width={uiTokens.modalWidth.xl}
|
|
27
29
|
>
|
|
28
30
|
<box flexDirection="column">
|