@brimveyn/aimux-config 0.10.9 → 0.11.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/src/index.ts CHANGED
@@ -5,15 +5,19 @@ export * as actions from './actions'
5
5
 
6
6
  // TUI theme system (1:1 port of opencode TUI).
7
7
  export {
8
+ BUILTIN_THEME_IDS,
8
9
  type ClaudeThemeFile,
10
+ clearRuntimeThemes,
11
+ getTuiTheme,
9
12
  isKnownThemeId,
10
13
  migrateThemeId,
14
+ registerTuiTheme,
11
15
  resolveClaudeTheme,
12
16
  type ResolvedTuiTheme,
13
17
  resolveTuiTheme,
14
18
  type RGBA,
15
- THEME_IDS,
16
19
  type ThemeId,
20
+ themeIds,
17
21
  type ThemeMode,
18
22
  TUI_COLOR_TOKENS,
19
23
  TUI_THEMES,
@@ -28,6 +32,13 @@ export { GroupBuilder, KeymapBuilder, ModeBindingBuilder } from './keymap-builde
28
32
  export { getDefaultKeymapConfig } from './defaults'
29
33
  export { resolveConfig } from './resolver'
30
34
  export { isAutoCommitEnabled, setAutoCommitEnabled } from './auto-commit-runtime'
35
+ export {
36
+ clearPluginActions,
37
+ hasPluginAction,
38
+ pluginAction,
39
+ pluginActionNames,
40
+ registerPluginAction,
41
+ } from './plugin-actions-runtime'
31
42
  export { getMultiRepoConfig, setMultiRepoConfig } from './multi-repo-runtime'
32
43
  export { getStatusBarSeparator, setStatusBarSeparator } from './status-bar-runtime'
33
44
  export {
@@ -38,6 +49,13 @@ export {
38
49
  } from './external-editor-runtime'
39
50
  export { DEFAULT_MULTI_REPO_CONFIG } from './defaults'
40
51
 
52
+ // The settings screen's row and section shapes; a plugin registers one.
53
+ export type * from './settings-types'
54
+
55
+ // The application state, mode and layout shapes. Defined in this package —
56
+ // `src/` re-exports them rather than declaring its own copies.
57
+ export type * from './app-types'
58
+
41
59
  // User-facing config types (keymap/backends/projects/etc.).
42
60
  export type {
43
61
  Action,
@@ -47,45 +65,38 @@ export type {
47
65
  AIUsageTool,
48
66
  AIUsageToolConfig,
49
67
  AppAction,
50
- AppState,
51
68
  AutoCommitConfig,
52
69
  AutoRenameConfig,
53
70
  BackendConfig,
54
71
  BindingDef,
55
- DiscoveredRepo,
56
72
  ExternalEditorConfig,
57
- FocusMode,
58
- GitFileListMode,
59
73
  GitPaneConfig,
60
- GitPaneDiffCountConfig,
61
74
  GitPaneEmbeddedConfig,
62
75
  GitPanePaneConfig,
63
- GitPanePathConfig,
64
- GitPaneState,
65
76
  GroupBuilderApi,
66
77
  HooksConfig,
67
78
  KeyInput,
68
79
  KeymapBuilderApi,
69
80
  KeyResult,
81
+ LayoutLeaf,
82
+ LayoutLeafKind,
70
83
  LayoutNode,
71
- ModalState,
84
+ LayoutSplit,
72
85
  ModeBindingBuilderApi,
73
86
  ModeContext,
74
87
  ModeId,
75
88
  ModeKeymapDef,
76
89
  MultiRepoConfig,
77
- MultiRepoState,
78
- ProjectRecord,
90
+ PluginConfigDecl,
91
+ PluginConfigEntry,
79
92
  ResolvedConfig,
80
93
  ResolvedKeymapConfig,
81
94
  SidebarConfig,
82
95
  SideEffect,
83
96
  SnippetDef,
84
- SnippetRecord,
85
97
  SnippetShellVar,
86
98
  SnippetVar,
87
99
  SplitDirection,
88
100
  StatusBarConfig,
89
101
  StatusBarSeparator,
90
- TabSession,
91
102
  } from './types'
@@ -10,6 +10,8 @@ import type {
10
10
  ResolvedKeymapConfig,
11
11
  } from './types'
12
12
 
13
+ import { pluginAction } from './plugin-actions-runtime'
14
+
13
15
  // ---------------------------------------------------------------------------
14
16
  // GroupBuilder — sugar for <leader>-prefixed sub-trees
15
17
  // ---------------------------------------------------------------------------
@@ -101,6 +103,10 @@ export class KeymapBuilder implements KeymapBuilderApi {
101
103
  return this
102
104
  }
103
105
 
106
+ plugin(name: string): Action {
107
+ return pluginAction(name)
108
+ }
109
+
104
110
  mode(
105
111
  id: ModeId | readonly ModeId[],
106
112
  configure: (m: ModeBindingBuilderApi) => ModeBindingBuilderApi
@@ -0,0 +1,50 @@
1
+ import type { ActionFn, KeyResult, ModeContext } from './types'
2
+
3
+ /**
4
+ * Named actions a plugin contributes, so a user's keymap can bind one by name
5
+ * before the plugin that answers it has loaded — or at all.
6
+ *
7
+ * A keymap is resolved at startup from `aimux.config.ts`, and plugins load
8
+ * after that. Binding a function directly would mean the config file could
9
+ * only reference plugins it imported, which is exactly the coupling a plugin
10
+ * system is supposed to remove. Binding a *name* defers the lookup to the
11
+ * moment the key is pressed.
12
+ *
13
+ * Lives in this package, alongside the other runtime singletons
14
+ * (`auto-commit-runtime`, `multi-repo-runtime`), because that is where the
15
+ * action factories a config file calls already are.
16
+ */
17
+
18
+ /** Qualified `<pluginId>.<verb>` — the id a keymap writes. */
19
+ const handlers = new Map<string, ActionFn>()
20
+
21
+ export function registerPluginAction(name: string, handler: ActionFn): () => void {
22
+ handlers.set(name, handler)
23
+ return () => {
24
+ if (handlers.get(name) === handler) handlers.delete(name)
25
+ }
26
+ }
27
+
28
+ /** Test seam. Never called by the app. */
29
+ export function clearPluginActions(): void {
30
+ handlers.clear()
31
+ }
32
+
33
+ export function pluginActionNames(): string[] {
34
+ return [...handlers.keys()]
35
+ }
36
+
37
+ export function hasPluginAction(name: string): boolean {
38
+ return handlers.has(name)
39
+ }
40
+
41
+ /**
42
+ * The `Action` a keymap binds. Resolved on every keypress rather than at
43
+ * registration: a plugin that is not loaded yet, is disabled, or failed simply
44
+ * yields `null`, which is the same "this key does nothing here" a mode with no
45
+ * binding produces. Anything louder would turn one broken plugin into a broken
46
+ * keyboard.
47
+ */
48
+ export function pluginAction(name: string): ActionFn {
49
+ return (ctx: ModeContext): KeyResult | null => handlers.get(name)?.(ctx) ?? null
50
+ }
package/src/resolver.ts CHANGED
@@ -5,6 +5,8 @@ import type {
5
5
  ModeId,
6
6
  ModeKeymapDef,
7
7
  MultiRepoConfig,
8
+ PluginConfigDecl,
9
+ PluginConfigEntry,
8
10
  ResolvedConfig,
9
11
  ResolvedKeymapConfig,
10
12
  } from './types'
@@ -60,6 +62,7 @@ export function resolveConfig(userConfig: AimuxUserConfig): ResolvedConfig {
60
62
  integrations: resolveIntegrations(userConfig.integrations),
61
63
  keymaps,
62
64
  multiRepo,
65
+ plugins: resolvePlugins(userConfig.plugins),
63
66
  // ponytail: `sessionBar` is the pre-rename key. Unknown keys parse
64
67
  // silently, so without the fallback the setting just vanishes.
65
68
  projectBar: resolveProjectBar(userConfig.projectBar ?? userConfig.sessionBar),
@@ -71,6 +74,25 @@ export function resolveConfig(userConfig: AimuxUserConfig): ResolvedConfig {
71
74
  }
72
75
  }
73
76
 
77
+ /**
78
+ * Normalises the shorthand. A declaration with neither `path` nor `id` names
79
+ * nothing loadable, so it is dropped here rather than becoming a mystery
80
+ * "plugin not found" at load time.
81
+ */
82
+ function resolvePlugins(declarations: PluginConfigDecl[] | undefined): PluginConfigEntry[] {
83
+ if (!declarations) return []
84
+ const entries: PluginConfigEntry[] = []
85
+ for (const declaration of declarations) {
86
+ if (typeof declaration === 'string') {
87
+ if (declaration.trim() !== '') entries.push({ path: declaration })
88
+ continue
89
+ }
90
+ if (declaration.path === undefined && declaration.id === undefined) continue
91
+ entries.push(declaration)
92
+ }
93
+ return entries
94
+ }
95
+
74
96
  function resolveIntegrations(
75
97
  userConfig: AimuxUserConfig['integrations']
76
98
  ): ResolvedConfig['integrations'] {
@@ -0,0 +1,160 @@
1
+ // -----------------------------------------------------------------------------
2
+ // The settings screen's row and section shapes.
3
+ //
4
+ // In this package rather than in `src/` because a plugin registers a settings
5
+ // section, and a plugin has to be able to type one without depending on the
6
+ // aimux binary. `src/settings/types.ts` re-exports from here.
7
+ // -----------------------------------------------------------------------------
8
+
9
+ import type { AppState, ProjectRecord } from './app-types'
10
+ import type { AimuxUserConfig } from './types'
11
+
12
+ export type SettingValue = boolean | number | string
13
+
14
+ /**
15
+ * The values the settings screen owns, keyed by row id. Sparse on purpose: a
16
+ * missing key means "never touched", which is not the same as `false`.
17
+ */
18
+ export type StoredSettings = Record<string, SettingValue>
19
+
20
+ export interface SettingCtx {
21
+ state: AppState
22
+ values: StoredSettings
23
+ }
24
+
25
+ export interface SettingOption {
26
+ value: SettingValue
27
+ label: string
28
+ }
29
+
30
+ /** How a row draws itself, and what activating it does. */
31
+ type SettingKind =
32
+ | { kind: 'toggle' }
33
+ | { kind: 'select'; options: readonly SettingOption[] }
34
+ | { kind: 'number'; min: number; max: number; step: number }
35
+ /** Activating it opens a one-field modal. Empty means "unset", not "empty string". */
36
+ | { kind: 'text'; placeholder?: string }
37
+
38
+ interface SettingRowBase {
39
+ id: string
40
+ label: string
41
+ description?: string
42
+ }
43
+
44
+ /**
45
+ * A row with no other home for its value: it lives in the `settings` block of
46
+ * `aimux.json`, and `readRow`/`writeRow` reach it by id. Nothing here repeats
47
+ * the id, so a row can't read one key and write another.
48
+ */
49
+ interface StoredRow {
50
+ storage: 'settings'
51
+ /** Used when neither the config file nor the settings screen has a value. */
52
+ fallback: SettingValue
53
+ /**
54
+ * The value the user's `aimux.config.ts` declares for this row, or undefined
55
+ * when it doesn't declare it. Declared means it wins at every startup — a UI
56
+ * edit then lasts until the next launch, and the row says so.
57
+ */
58
+ fromConfig?: (config: AimuxUserConfig) => SettingValue | undefined
59
+ /**
60
+ * Hands a new value to whatever owns it in the running app. Not every live row
61
+ * needs one — a value whose only reader subscribes to this store is live on its
62
+ * own, which is why "needs a restart" is declared rather than inferred.
63
+ */
64
+ apply?: (value: SettingValue) => void
65
+ /** The running app won't see the new value; the row says so. */
66
+ restart?: true
67
+ }
68
+
69
+ /**
70
+ * A view over a value `AppState` already owns (git pane preferences, bar
71
+ * visibility, …). It delegates to that value's existing action or side effect
72
+ * rather than keeping a second copy.
73
+ */
74
+ interface DerivedRow {
75
+ storage: 'app'
76
+ read: (ctx: SettingCtx) => SettingValue
77
+ /** Same context `read` gets, so a write that has to merge into a record can. */
78
+ write: (value: SettingValue, ctx: SettingCtx) => void
79
+ }
80
+
81
+ /**
82
+ * A row over one key of a plugin's configuration.
83
+ *
84
+ * Not a `settings` row: that block of `aimux.json` is never read by plugin
85
+ * discovery, so a value written there would silently reach no plugin. Not an
86
+ * `app` row either — its value has neither a home in `AppState` nor a place in
87
+ * the settings screen's hydration, and the two marks a plugin row most needs
88
+ * (`~` the registry has an override, `*` `aimux.config.ts` declares it and
89
+ * keeps winning) come from the plugin's own layers rather than from this
90
+ * screen's bookkeeping.
91
+ *
92
+ * The three functions are closures, like `DerivedRow`'s: the settings store
93
+ * must not learn how to reach a plugin registry.
94
+ */
95
+ interface PluginConfigRow {
96
+ storage: 'plugin'
97
+ /** For the detail view, and for a test to name the row it means. */
98
+ pluginId: string
99
+ field: string
100
+ /** Never rendered and edited from empty. */
101
+ secret?: boolean
102
+ /** `aimux.config.ts` declares this key and keeps winning. Drives `*`. */
103
+ fromConfigFile: boolean
104
+ /** A layer above the manifest default set it. Drives `~`. */
105
+ isSet: boolean
106
+ read: () => SettingValue
107
+ write: (value: SettingValue) => void
108
+ /** Drop the override and fall back through the layers underneath. */
109
+ reset: () => void
110
+ }
111
+
112
+ /** A row whose value lives in the `settings` block of `aimux.json`. */
113
+ export type StoredSettingRow = SettingRowBase & SettingKind & StoredRow
114
+
115
+ /** A row over one key of a plugin's config. Narrowed by `storage === 'plugin'`. */
116
+ export type PluginSettingRow = SettingRowBase & SettingKind & PluginConfigRow
117
+
118
+ export type SettingRow =
119
+ | StoredSettingRow
120
+ | PluginSettingRow
121
+ | (SettingRowBase & SettingKind & DerivedRow)
122
+ | (SettingRowBase & { kind: 'info'; value: (ctx: SettingCtx) => string })
123
+ /**
124
+ * Not a setting: a button. For the things a row cannot hold — a multi-line
125
+ * script, a list — where the honest move is to hand over to whatever can.
126
+ */
127
+ | (SettingRowBase & { kind: 'action'; value: (ctx: SettingCtx) => string; run: () => void })
128
+
129
+ export interface SettingSection {
130
+ id: string
131
+ /**
132
+ * One cell, text presentation, present in the base fonts — the same rule the
133
+ * stats screen's section glyphs follow, so the eye finds a section by shape
134
+ * before it reads the label.
135
+ *
136
+ * Required, and on the section rather than in a lookup keyed by id: a map
137
+ * would need a fallback, and a fallback turns a renamed or added section into
138
+ * a silent placeholder instead of a compile error.
139
+ */
140
+ glyph: string
141
+ label: string
142
+ /** Shown once under the section's title, for a caveat that covers every row. */
143
+ description?: string
144
+ /**
145
+ * A function when the rows depend on the projects — one row per project, say.
146
+ * The projects and nothing else: a builder handed the whole state would be free
147
+ * to depend on anything in it, and every caller would have to have all of it.
148
+ *
149
+ * Building a row may cost something (Setup reads a script off disk), so anything
150
+ * that only needs to know *how many* rows there are asks `rowCount` instead.
151
+ */
152
+ rows: SettingRow[] | ((projects: readonly ProjectRecord[]) => SettingRow[])
153
+ /**
154
+ * How many rows there will be, without building them. Required alongside a
155
+ * dynamic `rows`, and kept honest by `settings-schema.test.ts`: the reducer
156
+ * clamps the cursor with this, and a count that disagrees with the list is a
157
+ * cursor that stops one row short of the end, or one past it.
158
+ */
159
+ rowCount?: (projects: readonly ProjectRecord[]) => number
160
+ }
package/src/tui/index.ts CHANGED
@@ -1,4 +1,14 @@
1
- export { isKnownThemeId, migrateThemeId, THEME_IDS, type ThemeId, TUI_THEMES } from './registry'
1
+ export {
2
+ BUILTIN_THEME_IDS,
3
+ clearRuntimeThemes,
4
+ getTuiTheme,
5
+ isKnownThemeId,
6
+ migrateThemeId,
7
+ registerTuiTheme,
8
+ type ThemeId,
9
+ themeIds,
10
+ TUI_THEMES,
11
+ } from './registry'
2
12
  export { type ClaudeThemeFile, resolveClaudeTheme, resolveTuiTheme } from './resolve'
3
13
  export { type TuiShikiOptions, tuiThemeToShiki } from './shiki'
4
14
  export { TUI_COLOR_TOKENS, type TuiColorToken } from './tokens'
@@ -76,12 +76,55 @@ export const TUI_THEMES: Record<string, TuiThemeJson> = {
76
76
  'zenburn': zenburn as TuiThemeJson,
77
77
  }
78
78
 
79
- export type ThemeId = keyof typeof TUI_THEMES
79
+ /**
80
+ * A shipped theme id. Widened with `(string & {})` because a theme can also be
81
+ * registered at runtime — from `<profile>/themes/` or by a plugin — and those
82
+ * ids are not knowable here. The literal half survives, so autocomplete still
83
+ * lists the shipped ones.
84
+ */
85
+ export type ThemeId = keyof typeof TUI_THEMES | (string & {})
80
86
 
81
- export const THEME_IDS: ThemeId[] = Object.keys(TUI_THEMES).sort() as ThemeId[]
87
+ /** Ids aimux ships, sorted. Runtime-registered ones are not in here. */
88
+ export const BUILTIN_THEME_IDS: string[] = Object.keys(TUI_THEMES).sort()
89
+
90
+ const runtimeThemes = new Map<string, TuiThemeJson>()
91
+
92
+ /**
93
+ * Registers a theme loaded at runtime — a JSON file in `<profile>/themes/`, or
94
+ * one a plugin ships. Returns the disposer; unregistering a theme that is
95
+ * currently applied leaves the applied colours alone, since they were already
96
+ * resolved into the theme store.
97
+ *
98
+ * A runtime id may not shadow a shipped one: silently replacing `dracula`
99
+ * would make "which dracula?" depend on load order.
100
+ */
101
+ export function registerTuiTheme(id: string, theme: TuiThemeJson): () => void {
102
+ if (id in TUI_THEMES) {
103
+ throw new Error(`theme "${id}" is shipped with aimux and cannot be replaced`)
104
+ }
105
+ runtimeThemes.set(id, theme)
106
+ return () => {
107
+ runtimeThemes.delete(id)
108
+ }
109
+ }
110
+
111
+ /** Test seam. Never called by the app. */
112
+ export function clearRuntimeThemes(): void {
113
+ runtimeThemes.clear()
114
+ }
115
+
116
+ /** Every id the picker offers: shipped plus runtime-registered, sorted. */
117
+ export function themeIds(): string[] {
118
+ if (runtimeThemes.size === 0) return BUILTIN_THEME_IDS
119
+ return [...BUILTIN_THEME_IDS, ...runtimeThemes.keys()].sort()
120
+ }
121
+
122
+ export function getTuiTheme(id: string): TuiThemeJson | undefined {
123
+ return TUI_THEMES[id] ?? runtimeThemes.get(id)
124
+ }
82
125
 
83
126
  export function isKnownThemeId(id: string): id is ThemeId {
84
- return id in TUI_THEMES
127
+ return id in TUI_THEMES || runtimeThemes.has(id)
85
128
  }
86
129
 
87
130
  const LEGACY_ID_MAP: Record<string, ThemeId> = {