@brimveyn/aimux 1.3.0 → 1.3.1

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.
Files changed (49) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/backend-runtime-events.ts +7 -1
  3. package/src/app-runtime/pty-write.ts +7 -4
  4. package/src/app-runtime/side-effects.ts +54 -4
  5. package/src/app-runtime/snippet-actions.ts +3 -2
  6. package/src/app-runtime/use-backend-runtime.ts +7 -2
  7. package/src/app-runtime/use-renderer-bindings.ts +2 -2
  8. package/src/app-runtime/use-terminal-resize.ts +15 -6
  9. package/src/app.tsx +89 -30
  10. package/src/config.ts +11 -0
  11. package/src/daemon/daemon.ts +19 -2
  12. package/src/daemon/session-manager.ts +20 -5
  13. package/src/daemon/session-registry.ts +18 -11
  14. package/src/index.tsx +8 -1
  15. package/src/input/keymap/describe-bindings.ts +68 -0
  16. package/src/input/keymap/key-format.ts +67 -0
  17. package/src/input/modes/bridge.ts +1 -0
  18. package/src/input/modes/transitions.ts +2 -0
  19. package/src/input/modes/types.ts +2 -0
  20. package/src/ipc/manager-protocol.ts +51 -2
  21. package/src/ipc/protocol.ts +43 -2
  22. package/src/pty/pty-manager.ts +29 -3
  23. package/src/session-backend/local-session-backend.ts +12 -5
  24. package/src/session-backend/remote-session-backend.ts +18 -5
  25. package/src/session-backend/types.ts +4 -2
  26. package/src/state/reducers/modal-state.ts +18 -1
  27. package/src/state/reducers/tab-state.ts +20 -2
  28. package/src/state/session-persistence.ts +9 -2
  29. package/src/state/types.ts +23 -0
  30. package/src/state/validation.ts +8 -0
  31. package/src/terminal-manager/manager-client.ts +29 -5
  32. package/src/terminal-manager/terminal-manager.ts +19 -3
  33. package/src/ui/components/create-session-modal.tsx +3 -5
  34. package/src/ui/components/git-commit-modal.tsx +3 -5
  35. package/src/ui/components/help-modal.tsx +49 -68
  36. package/src/ui/components/list-item.tsx +24 -5
  37. package/src/ui/components/new-tab-modal.tsx +8 -9
  38. package/src/ui/components/pending-chord-overlay.tsx +28 -0
  39. package/src/ui/components/session-name-modal.tsx +3 -5
  40. package/src/ui/components/session-picker-modal.tsx +3 -1
  41. package/src/ui/components/snippet-editor-modal.tsx +3 -1
  42. package/src/ui/components/snippet-picker-modal.tsx +3 -1
  43. package/src/ui/components/status-bar.tsx +6 -2
  44. package/src/ui/components/theme-picker-modal.tsx +3 -6
  45. package/src/ui/components/update-available-modal.tsx +42 -0
  46. package/src/ui/keymap-context.ts +39 -0
  47. package/src/ui/root.tsx +12 -0
  48. package/src/ui/status-bar-model.ts +67 -39
  49. package/src/update/version-check.ts +67 -0
@@ -1,6 +1,8 @@
1
1
  import type { AppState } from '../../state/types'
2
2
 
3
+ import { version as APP_VERSION } from '../../../package.json'
3
4
  import { useAppStore } from '../../state/app-store'
5
+ import { useKeymap } from '../keymap-context'
4
6
  import { getStatusBarModel } from '../status-bar-model'
5
7
  import { theme } from '../theme'
6
8
 
@@ -43,7 +45,8 @@ function getModeLabel(focusMode: AppState['focusMode']): string {
43
45
  export function StatusBar() {
44
46
  const state = useAppStore((s) => s)
45
47
  const activeTab = state.tabs.find((tab) => tab.id === state.activeTabId)
46
- const model = getStatusBarModel(state, activeTab)
48
+ const config = useKeymap()
49
+ const model = getStatusBarModel(state, activeTab, config)
47
50
 
48
51
  return (
49
52
  <box
@@ -60,8 +63,9 @@ export function StatusBar() {
60
63
  <text> </text>
61
64
  <text fg={theme.text}>{model.left}</text>
62
65
  </box>
63
- <box width="100%">
66
+ <box width="100%" flexDirection="row" justifyContent="space-between">
64
67
  <text fg={theme.textMuted}>{model.right}</text>
68
+ <text fg={theme.dim}>v{APP_VERSION}</text>
65
69
  </box>
66
70
  </box>
67
71
  )
@@ -1,3 +1,4 @@
1
+ import { useModalHelp } from '../keymap-context'
1
2
  import { theme } from '../theme'
2
3
  import { THEME_IDS, type ThemeId, THEMES } from '../themes'
3
4
  import { uiTokens } from '../ui-tokens'
@@ -10,13 +11,9 @@ interface ThemePickerModalProps {
10
11
  }
11
12
 
12
13
  export function ThemePickerModal({ currentThemeId, selectedIndex }: ThemePickerModalProps) {
14
+ const help = useModalHelp('modal.theme-picker')
13
15
  return (
14
- <ModalShell
15
- title="Select theme"
16
- help="j/k move, Enter confirm, Esc cancel."
17
- width={uiTokens.modalWidth.md}
18
- listGap={0}
19
- >
16
+ <ModalShell title="Select theme" help={help} width={uiTokens.modalWidth.md} listGap={0}>
20
17
  {THEME_IDS.map((id, index) => {
21
18
  const entry = THEMES[id]
22
19
  const active = index === selectedIndex
@@ -0,0 +1,42 @@
1
+ import { useModalHelp } from '../keymap-context'
2
+ import { theme } from '../theme'
3
+ import { uiTokens } from '../ui-tokens'
4
+ import { ListItem } from './list-item'
5
+ import { ModalShell } from './modal-shell'
6
+
7
+ interface UpdateAvailableModalProps {
8
+ currentVersion: string
9
+ latestVersion: string
10
+ selectedIndex: number
11
+ }
12
+
13
+ const OPTIONS: { label: string }[] = [
14
+ { label: 'Yes, update now' },
15
+ { label: 'No, skip this version' },
16
+ ]
17
+
18
+ export function UpdateAvailableModal({
19
+ currentVersion,
20
+ latestVersion,
21
+ selectedIndex,
22
+ }: UpdateAvailableModalProps) {
23
+ const modalHelp = useModalHelp('modal.update-available')
24
+ const help = `${currentVersion} → ${latestVersion} ${modalHelp}`
25
+ return (
26
+ <ModalShell title="Update available" help={help} width={uiTokens.modalWidth.md} listGap={1}>
27
+ <box flexDirection="row" gap={1} marginTop={1}>
28
+ {OPTIONS.map((option, index) => {
29
+ const active = index === selectedIndex
30
+ return (
31
+ <ListItem
32
+ key={option.label}
33
+ active={active}
34
+ direction="row"
35
+ title={<text fg={active ? theme.text : theme.textMuted}>{option.label}</text>}
36
+ />
37
+ )
38
+ })}
39
+ </box>
40
+ </ModalShell>
41
+ )
42
+ }
@@ -0,0 +1,39 @@
1
+ import {
2
+ getDefaultKeymapConfig,
3
+ type ModeId,
4
+ type ResolvedKeymapConfig,
5
+ } from '@brimveyn/aimux-config'
6
+ import { createContext, useContext, useMemo } from 'react'
7
+
8
+ import { describeBindings } from '../input/keymap/describe-bindings'
9
+
10
+ const FALLBACK_CONFIG = getDefaultKeymapConfig()
11
+
12
+ export const KeymapContext = createContext<ResolvedKeymapConfig>(FALLBACK_CONFIG)
13
+
14
+ export function useKeymap(): ResolvedKeymapConfig {
15
+ return useContext(KeymapContext)
16
+ }
17
+
18
+ const HELP_JOINER = ' · '
19
+ const MAX_HELP_ENTRIES = 5
20
+
21
+ /** Build a single-line help string for a mode (used in modal headers and status bar). */
22
+ export function buildHintText(
23
+ config: ResolvedKeymapConfig,
24
+ modeId: ModeId,
25
+ limit: number = MAX_HELP_ENTRIES
26
+ ): string {
27
+ const bindings = describeBindings(config, modeId, {
28
+ dedupeByDescription: true,
29
+ withDescriptionOnly: true,
30
+ })
31
+ if (bindings.length === 0) return ''
32
+ const trimmed = bindings.slice(0, limit)
33
+ return trimmed.map((b) => `${b.keysDisplay} ${b.description ?? ''}`.trim()).join(HELP_JOINER)
34
+ }
35
+
36
+ export function useModalHelp(modeId: ModeId, limit?: number): string {
37
+ const config = useKeymap()
38
+ return useMemo(() => buildHintText(config, modeId, limit), [config, modeId, limit])
39
+ }
package/src/ui/root.tsx CHANGED
@@ -11,6 +11,7 @@ import { GitCommitModal } from './components/git-commit-modal'
11
11
  import { GitView } from './components/git-view'
12
12
  import { HelpModal } from './components/help-modal'
13
13
  import { NewTabModal } from './components/new-tab-modal'
14
+ import { PendingChordOverlay } from './components/pending-chord-overlay'
14
15
  import { SessionNameModal } from './components/session-name-modal'
15
16
  import { SessionPickerModal } from './components/session-picker-modal'
16
17
  import { Sidebar } from './components/sidebar'
@@ -20,6 +21,7 @@ import { SplitLayout } from './components/split-layout'
20
21
  import { StatusBar } from './components/status-bar'
21
22
  import { TerminalPane } from './components/terminal-pane'
22
23
  import { ThemePickerModal } from './components/theme-picker-modal'
24
+ import { UpdateAvailableModal } from './components/update-available-modal'
23
25
  import { theme } from './theme'
24
26
 
25
27
  function getCreateSessionFields(modal: ModalState) {
@@ -132,6 +134,14 @@ function renderModal(
132
134
  return (
133
135
  <ThemePickerModal selectedIndex={modal.selectedIndex} currentThemeId={options.themeId} />
134
136
  )
137
+ case 'update-available':
138
+ return (
139
+ <UpdateAvailableModal
140
+ selectedIndex={modal.selectedIndex}
141
+ currentVersion={modal.currentVersion}
142
+ latestVersion={modal.latestVersion}
143
+ />
144
+ )
135
145
  case 'help':
136
146
  return <HelpModal />
137
147
  case 'git-commit': {
@@ -216,6 +226,7 @@ export function RootView({
216
226
  <box flexDirection="column" width="100%" height="100%" backgroundColor={theme.background}>
217
227
  <GitView />
218
228
  <StatusBar />
229
+ <PendingChordOverlay />
219
230
  {renderModal(modal, {
220
231
  createSessionFields,
221
232
  currentSessionId,
@@ -280,6 +291,7 @@ export function RootView({
280
291
  )}
281
292
  </box>
282
293
  <StatusBar />
294
+ <PendingChordOverlay />
283
295
  {renderModal(modal, {
284
296
  createSessionFields,
285
297
  currentSessionId,
@@ -1,5 +1,8 @@
1
+ import type { ModeId, ResolvedKeymapConfig } from '@brimveyn/aimux-config'
2
+
1
3
  import type { AppState, TabSession } from '../state/types'
2
4
 
5
+ import { buildHintText } from './keymap-context'
3
6
  import { abbreviatePath } from './path-format'
4
7
 
5
8
  export interface StatusBarModel {
@@ -8,6 +11,7 @@ export interface StatusBarModel {
8
11
  }
9
12
 
10
13
  const MAX_TAB_LABEL_LENGTH = 24
14
+ const HINT_LIMIT = 6
11
15
 
12
16
  function truncateLabel(label: string): string {
13
17
  if (label.length <= MAX_TAB_LABEL_LENGTH) {
@@ -25,39 +29,15 @@ function getActiveTabLabel(tab?: TabSession): string {
25
29
  return `${truncateLabel(tab.title)} (${tab.status})`
26
30
  }
27
31
 
28
- function getNavigationHint(activeTab?: TabSession): string {
29
- if (!activeTab) {
30
- return 'Ctrl+g sessions Ctrl+n new Ctrl+b toggle Ctrl+h/l resize ? help'
31
- }
32
-
33
- if (activeTab.status === 'disconnected') {
34
- return 'Ctrl+r restart restored tab Ctrl+w close i focus'
35
- }
36
-
37
- return 'Ctrl+g sessions j/k move Shift+J/K reorder Ctrl+r restart Ctrl+w close i focus ? help'
38
- }
39
-
40
- function getInputHint(activeTab?: TabSession): string {
41
- if (!activeTab) {
42
- return 'Ctrl+n new no active tab to focus'
43
- }
44
-
45
- if (activeTab.status === 'disconnected') {
46
- return 'Ctrl+z unfocus Ctrl+w layout Ctrl+r restart'
47
- }
48
-
49
- return 'Ctrl+z unfocus Ctrl+w layout typing goes to active tab'
32
+ function hintForMode(config: ResolvedKeymapConfig, modeId: ModeId): string {
33
+ return buildHintText(config, modeId, HINT_LIMIT)
50
34
  }
51
35
 
52
- function getGitHint(modalType: AppState['modal']['type']): string {
53
- if (modalType === 'git-commit') {
54
- return 'Tab switch field Enter newline Ctrl+Enter commit Esc cancel'
55
- }
56
-
57
- return 'j/k file Ctrl+d/u page a stage d unstage/delete c commit p push Esc exit'
58
- }
59
-
60
- export function getStatusBarModel(state: AppState, activeTab?: TabSession): StatusBarModel {
36
+ export function getStatusBarModel(
37
+ state: AppState,
38
+ activeTab: TabSession | undefined,
39
+ config: ResolvedKeymapConfig
40
+ ): StatusBarModel {
61
41
  const currentSession = state.currentSessionId
62
42
  ? state.sessions.find((session) => session.id === state.currentSessionId)
63
43
  : undefined
@@ -73,33 +53,81 @@ export function getStatusBarModel(state: AppState, activeTab?: TabSession): Stat
73
53
  case 'terminal-input':
74
54
  return {
75
55
  left: `${getActiveTabLabel(activeTab)} ${sessionIcon} ${sessionLabel}`,
76
- right: getInputHint(activeTab),
56
+ right: hintForMode(config, 'terminal-input'),
77
57
  }
78
- case 'modal':
58
+ case 'modal': {
59
+ const modalMode = deriveModalModeId(state.modal.type)
79
60
  return {
80
61
  left: `${sessionIcon} ${sessionLabel}`,
81
- right: 'j/k move Enter confirm n/r/d actions Esc cancel',
62
+ right: modalMode ? hintForMode(config, modalMode) : '',
82
63
  }
64
+ }
83
65
  case 'layout':
84
66
  return {
85
67
  left: `${getActiveTabLabel(activeTab)} ${sessionIcon} ${sessionLabel}`,
86
- right: 'h/j/k/l focus |/- split H/L resize q close Esc cancel',
68
+ right: hintForMode(config, 'layout'),
87
69
  }
88
70
  case 'git':
89
71
  return {
90
72
  left: `${sessionIcon} ${sessionLabel}`,
91
- right: getGitHint(state.modal.type),
73
+ right: hintForMode(config, 'git-mode'),
92
74
  }
93
- case 'command-edit':
75
+ case 'command-edit': {
76
+ const commandEditMode = deriveCommandEditModeId(state.modal.type)
94
77
  return {
95
78
  left: `${sessionIcon} ${sessionLabel}`,
96
- right: state.modal.type === 'git-commit' ? getGitHint('git-commit') : 'Esc cancel',
79
+ right: commandEditMode ? hintForMode(config, commandEditMode) : '',
97
80
  }
81
+ }
98
82
  case 'navigation':
99
83
  default:
100
84
  return {
101
85
  left: `${sessionIcon} ${sessionLabel} ${getActiveTabLabel(activeTab)}`,
102
- right: getNavigationHint(activeTab),
86
+ right: hintForMode(config, 'navigation'),
103
87
  }
104
88
  }
105
89
  }
90
+
91
+ function deriveModalModeId(modalType: AppState['modal']['type']): ModeId | null {
92
+ switch (modalType) {
93
+ case 'help':
94
+ return 'modal.help'
95
+ case 'new-tab':
96
+ return 'modal.new-tab'
97
+ case 'session-picker':
98
+ return 'modal.session-picker'
99
+ case 'snippet-picker':
100
+ return 'modal.snippet-picker'
101
+ case 'split-picker':
102
+ return 'modal.split-picker'
103
+ case 'theme-picker':
104
+ return 'modal.theme-picker'
105
+ case 'update-available':
106
+ return 'modal.update-available'
107
+ default:
108
+ return null
109
+ }
110
+ }
111
+
112
+ function deriveCommandEditModeId(modalType: AppState['modal']['type']): ModeId | null {
113
+ switch (modalType) {
114
+ case 'create-session':
115
+ return 'modal.create-session'
116
+ case 'git-commit':
117
+ return 'modal.git-commit'
118
+ case 'new-tab':
119
+ return 'modal.new-tab.command-edit'
120
+ case 'rename-tab':
121
+ return 'modal.rename-tab'
122
+ case 'session-name':
123
+ return 'modal.session-name'
124
+ case 'session-picker':
125
+ return 'modal.session-picker.filtering'
126
+ case 'snippet-editor':
127
+ return 'modal.snippet-editor'
128
+ case 'snippet-picker':
129
+ return 'modal.snippet-picker.filtering'
130
+ default:
131
+ return null
132
+ }
133
+ }
@@ -0,0 +1,67 @@
1
+ import { logDebug } from '../debug/input-log'
2
+
3
+ const NPM_REGISTRY_BASE = 'https://registry.npmjs.org'
4
+
5
+ export async function getCurrentPackageVersion(): Promise<string> {
6
+ const { version } = await import('../../package.json')
7
+ return version
8
+ }
9
+
10
+ export async function fetchLatestNpmVersion(packageName: string): Promise<string | null> {
11
+ const debugOverride = process.env.AIMUX_DEBUG_UPDATE_LATEST
12
+ if (debugOverride) {
13
+ return debugOverride
14
+ }
15
+
16
+ try {
17
+ const url = `${NPM_REGISTRY_BASE}/${encodeURIComponent(packageName).replace('%40', '@')}/latest`
18
+ const controller = new AbortController()
19
+ const timer = setTimeout(() => controller.abort(), 5_000)
20
+ const res = await fetch(url, { signal: controller.signal })
21
+ clearTimeout(timer)
22
+ if (!res.ok) {
23
+ logDebug('update.fetchLatest.nonOk', { packageName, status: res.status })
24
+ return null
25
+ }
26
+ const data = (await res.json()) as { version?: unknown }
27
+ if (typeof data.version !== 'string' || data.version.length === 0) {
28
+ return null
29
+ }
30
+ return data.version
31
+ } catch (error) {
32
+ logDebug('update.fetchLatest.error', {
33
+ error: error instanceof Error ? error.message : String(error),
34
+ packageName,
35
+ })
36
+ return null
37
+ }
38
+ }
39
+
40
+ function parseSemver(version: string): number[] | null {
41
+ const trimmed = version.trim().replace(/^v/, '')
42
+ const core = trimmed.split(/[-+]/)[0] ?? ''
43
+ const parts = core.split('.')
44
+ if (parts.length === 0) return null
45
+ const numeric: number[] = []
46
+ for (const part of parts) {
47
+ const n = Number.parseInt(part, 10)
48
+ if (!Number.isFinite(n) || n < 0) return null
49
+ numeric.push(n)
50
+ }
51
+ return numeric
52
+ }
53
+
54
+ export function isNewerVersion(latest: string, current: string): boolean {
55
+ const a = parseSemver(latest)
56
+ const b = parseSemver(current)
57
+ if (!a || !b) return false
58
+
59
+ const len = Math.max(a.length, b.length)
60
+ for (let i = 0; i < len; i++) {
61
+ const av = a[i] ?? 0
62
+ const bv = b[i] ?? 0
63
+ if (av > bv) return true
64
+ if (av < bv) return false
65
+ }
66
+ return false
67
+ }