loadouts 0.2.5 → 0.3.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.
Files changed (180) hide show
  1. package/bundled/embedded/opencode/loadouts-runtime.tsx +14461 -0
  2. package/bundled/embedded/pi/loadouts-runtime.ts +14638 -0
  3. package/bundled/extensions/loadouts-runtime/index.ts +14638 -0
  4. package/bundled/extensions/loadouts-runtime/package.json +6 -0
  5. package/bundled/loadouts/opencode-runtime.yaml +7 -0
  6. package/bundled/loadouts/pi-runtime.yaml +5 -0
  7. package/bundled/opencode/commands/loadouts.md +3 -0
  8. package/bundled/opencode/plugins/loadouts-runtime-tui.tsx +14461 -0
  9. package/bundled/opencode/plugins/loadouts-runtime.ts +533 -0
  10. package/bundled/opencode/tui.jsonc +5 -0
  11. package/dist/builtins/agent-transforms.d.ts.map +1 -1
  12. package/dist/builtins/agent-transforms.js +1 -0
  13. package/dist/builtins/agent-transforms.js.map +1 -1
  14. package/dist/builtins/index.d.ts.map +1 -1
  15. package/dist/builtins/index.js +6 -0
  16. package/dist/builtins/index.js.map +1 -1
  17. package/dist/builtins/kinds/extension-dir.d.ts +3 -0
  18. package/dist/builtins/kinds/extension-dir.d.ts.map +1 -0
  19. package/dist/builtins/kinds/extension-dir.js +8 -0
  20. package/dist/builtins/kinds/extension-dir.js.map +1 -0
  21. package/dist/builtins/kinds/opencode-command.d.ts +3 -0
  22. package/dist/builtins/kinds/opencode-command.d.ts.map +1 -0
  23. package/dist/builtins/kinds/opencode-command.js +8 -0
  24. package/dist/builtins/kinds/opencode-command.js.map +1 -0
  25. package/dist/builtins/kinds/opencode-plugin.js +1 -1
  26. package/dist/builtins/kinds/opencode-plugin.js.map +1 -1
  27. package/dist/builtins/kinds/opencode-tui-config.d.ts +3 -0
  28. package/dist/builtins/kinds/opencode-tui-config.d.ts.map +1 -0
  29. package/dist/builtins/kinds/opencode-tui-config.js +8 -0
  30. package/dist/builtins/kinds/opencode-tui-config.js.map +1 -0
  31. package/dist/builtins/tools/opencode.d.ts.map +1 -1
  32. package/dist/builtins/tools/opencode.js +9 -0
  33. package/dist/builtins/tools/opencode.js.map +1 -1
  34. package/dist/builtins/tools/pi.d.ts.map +1 -1
  35. package/dist/builtins/tools/pi.js +2 -1
  36. package/dist/builtins/tools/pi.js.map +1 -1
  37. package/dist/cli/commands/activate.d.ts.map +1 -1
  38. package/dist/cli/commands/activate.js +7 -0
  39. package/dist/cli/commands/activate.js.map +1 -1
  40. package/dist/cli/commands/diff.d.ts +10 -0
  41. package/dist/cli/commands/diff.d.ts.map +1 -1
  42. package/dist/cli/commands/diff.js +71 -30
  43. package/dist/cli/commands/diff.js.map +1 -1
  44. package/dist/cli/commands/info.d.ts +10 -0
  45. package/dist/cli/commands/info.d.ts.map +1 -1
  46. package/dist/cli/commands/info.js +45 -1
  47. package/dist/cli/commands/info.js.map +1 -1
  48. package/dist/cli/commands/list.d.ts +18 -0
  49. package/dist/cli/commands/list.d.ts.map +1 -1
  50. package/dist/cli/commands/list.js +27 -0
  51. package/dist/cli/commands/list.js.map +1 -1
  52. package/dist/cli/commands/status.d.ts +10 -0
  53. package/dist/cli/commands/status.d.ts.map +1 -1
  54. package/dist/cli/commands/status.js +100 -2
  55. package/dist/cli/commands/status.js.map +1 -1
  56. package/dist/cli/commands/sync.d.ts.map +1 -1
  57. package/dist/cli/commands/sync.js +6 -0
  58. package/dist/cli/commands/sync.js.map +1 -1
  59. package/dist/cli/commands/tui.d.ts +3 -0
  60. package/dist/cli/commands/tui.d.ts.map +1 -0
  61. package/dist/cli/commands/tui.js +20 -0
  62. package/dist/cli/commands/tui.js.map +1 -0
  63. package/dist/cli/index.d.ts.map +1 -1
  64. package/dist/cli/index.js +2 -0
  65. package/dist/cli/index.js.map +1 -1
  66. package/dist/core/render.d.ts.map +1 -1
  67. package/dist/core/render.js +48 -7
  68. package/dist/core/render.js.map +1 -1
  69. package/dist/core/resolve.d.ts +1 -1
  70. package/dist/core/resolve.d.ts.map +1 -1
  71. package/dist/core/resolve.js +2 -2
  72. package/dist/core/resolve.js.map +1 -1
  73. package/dist/core/types.d.ts.map +1 -1
  74. package/dist/integrations/opencode-runtime/bridge.d.ts +17 -0
  75. package/dist/integrations/opencode-runtime/bridge.d.ts.map +1 -0
  76. package/dist/integrations/opencode-runtime/bridge.js +60 -0
  77. package/dist/integrations/opencode-runtime/bridge.js.map +1 -0
  78. package/dist/integrations/opencode-runtime/command.d.ts +4 -0
  79. package/dist/integrations/opencode-runtime/command.d.ts.map +1 -0
  80. package/dist/integrations/opencode-runtime/command.js +200 -0
  81. package/dist/integrations/opencode-runtime/command.js.map +1 -0
  82. package/dist/integrations/opencode-runtime/index.d.ts +6 -0
  83. package/dist/integrations/opencode-runtime/index.d.ts.map +1 -0
  84. package/dist/integrations/opencode-runtime/index.js +5 -0
  85. package/dist/integrations/opencode-runtime/index.js.map +1 -0
  86. package/dist/integrations/opencode-runtime/plugin.d.ts +59 -0
  87. package/dist/integrations/opencode-runtime/plugin.d.ts.map +1 -0
  88. package/dist/integrations/opencode-runtime/plugin.js +238 -0
  89. package/dist/integrations/opencode-runtime/plugin.js.map +1 -0
  90. package/dist/integrations/opencode-runtime/state.d.ts +8 -0
  91. package/dist/integrations/opencode-runtime/state.d.ts.map +1 -0
  92. package/dist/integrations/opencode-runtime/state.js +27 -0
  93. package/dist/integrations/opencode-runtime/state.js.map +1 -0
  94. package/dist/integrations/opencode-runtime/types.d.ts +38 -0
  95. package/dist/integrations/opencode-runtime/types.d.ts.map +1 -0
  96. package/dist/integrations/opencode-runtime/types.js +2 -0
  97. package/dist/integrations/opencode-runtime/types.js.map +1 -0
  98. package/dist/lib/gitignore.d.ts.map +1 -1
  99. package/dist/lib/gitignore.js +2 -0
  100. package/dist/lib/gitignore.js.map +1 -1
  101. package/dist/tui/core/actions.d.ts +9 -0
  102. package/dist/tui/core/actions.d.ts.map +1 -0
  103. package/dist/tui/core/actions.js +70 -0
  104. package/dist/tui/core/actions.js.map +1 -0
  105. package/dist/tui/core/data.d.ts +41 -0
  106. package/dist/tui/core/data.d.ts.map +1 -0
  107. package/dist/tui/core/data.js +150 -0
  108. package/dist/tui/core/data.js.map +1 -0
  109. package/dist/tui/core/host.d.ts +21 -0
  110. package/dist/tui/core/host.d.ts.map +1 -0
  111. package/dist/tui/core/host.js +2 -0
  112. package/dist/tui/core/host.js.map +1 -0
  113. package/dist/tui/core/intent.d.ts +80 -0
  114. package/dist/tui/core/intent.d.ts.map +1 -0
  115. package/dist/tui/core/intent.js +2 -0
  116. package/dist/tui/core/intent.js.map +1 -0
  117. package/dist/tui/core/keymap.d.ts +15 -0
  118. package/dist/tui/core/keymap.d.ts.map +1 -0
  119. package/dist/tui/core/keymap.js +36 -0
  120. package/dist/tui/core/keymap.js.map +1 -0
  121. package/dist/tui/core/model.d.ts +53 -0
  122. package/dist/tui/core/model.d.ts.map +1 -0
  123. package/dist/tui/core/model.js +33 -0
  124. package/dist/tui/core/model.js.map +1 -0
  125. package/dist/tui/core/theme.d.ts +15 -0
  126. package/dist/tui/core/theme.d.ts.map +1 -0
  127. package/dist/tui/core/theme.js +11 -0
  128. package/dist/tui/core/theme.js.map +1 -0
  129. package/dist/tui/core/update.d.ts +8 -0
  130. package/dist/tui/core/update.d.ts.map +1 -0
  131. package/dist/tui/core/update.js +192 -0
  132. package/dist/tui/core/update.js.map +1 -0
  133. package/dist/tui/core/view.d.ts +4 -0
  134. package/dist/tui/core/view.d.ts.map +1 -0
  135. package/dist/tui/core/view.js +150 -0
  136. package/dist/tui/core/view.js.map +1 -0
  137. package/dist/tui/core/viewspec.d.ts +44 -0
  138. package/dist/tui/core/viewspec.d.ts.map +1 -0
  139. package/dist/tui/core/viewspec.js +2 -0
  140. package/dist/tui/core/viewspec.js.map +1 -0
  141. package/dist/tui/hosts/_probe/embed-entry.d.ts +5 -0
  142. package/dist/tui/hosts/_probe/embed-entry.d.ts.map +1 -0
  143. package/dist/tui/hosts/_probe/embed-entry.js +8 -0
  144. package/dist/tui/hosts/_probe/embed-entry.js.map +1 -0
  145. package/dist/tui/hosts/pi/embed-entry.d.ts +3 -0
  146. package/dist/tui/hosts/pi/embed-entry.d.ts.map +1 -0
  147. package/dist/tui/hosts/pi/embed-entry.js +219 -0
  148. package/dist/tui/hosts/pi/embed-entry.js.map +1 -0
  149. package/dist/tui/hosts/pi/inject.d.ts +32 -0
  150. package/dist/tui/hosts/pi/inject.d.ts.map +1 -0
  151. package/dist/tui/hosts/pi/inject.js +53 -0
  152. package/dist/tui/hosts/pi/inject.js.map +1 -0
  153. package/dist/tui/hosts/standalone.d.ts +2 -0
  154. package/dist/tui/hosts/standalone.d.ts.map +1 -0
  155. package/dist/tui/hosts/standalone.js +145 -0
  156. package/dist/tui/hosts/standalone.js.map +1 -0
  157. package/dist/tui/skins/pi-tui/input.d.ts +5 -0
  158. package/dist/tui/skins/pi-tui/input.d.ts.map +1 -0
  159. package/dist/tui/skins/pi-tui/input.js +38 -0
  160. package/dist/tui/skins/pi-tui/input.js.map +1 -0
  161. package/dist/tui/skins/pi-tui/pi-theme-adapter.d.ts +4 -0
  162. package/dist/tui/skins/pi-tui/pi-theme-adapter.d.ts.map +1 -0
  163. package/dist/tui/skins/pi-tui/pi-theme-adapter.js +27 -0
  164. package/dist/tui/skins/pi-tui/pi-theme-adapter.js.map +1 -0
  165. package/dist/tui/skins/pi-tui/render.d.ts +35 -0
  166. package/dist/tui/skins/pi-tui/render.d.ts.map +1 -0
  167. package/dist/tui/skins/pi-tui/render.js +154 -0
  168. package/dist/tui/skins/pi-tui/render.js.map +1 -0
  169. package/dist/tui/skins/pi-tui/theme.d.ts +10 -0
  170. package/dist/tui/skins/pi-tui/theme.d.ts.map +1 -0
  171. package/dist/tui/skins/pi-tui/theme.js +58 -0
  172. package/dist/tui/skins/pi-tui/theme.js.map +1 -0
  173. package/docs/authoring.md +53 -1
  174. package/docs/commands.md +4 -0
  175. package/docs/compatibility.md +7 -7
  176. package/docs/concepts.md +4 -2
  177. package/docs/runtime.md +36 -1
  178. package/docs/troubleshooting.md +18 -1
  179. package/docs/tui-architecture.md +501 -0
  180. package/package.json +11 -2
@@ -0,0 +1,501 @@
1
+ # Loadouts TUI Architecture
2
+
3
+ Status: **Design / RFC** (no implementation yet)
4
+ Author: design pass, June 2026
5
+
6
+ A plan for turning the loadouts TUI from a passive popup into a beautiful, ergonomic,
7
+ standalone dashboard — in the spirit of `lazygit`, `mason.nvim`, `lazy.nvim`, and a
8
+ Call-of-Duty "create-a-class" menu — that can also run **embedded at runtime inside
9
+ OpenCode and Pi**, while preserving the `/loadouts <cmd>` entry points.
10
+
11
+ ---
12
+
13
+ ## 1. Goals & non-goals
14
+
15
+ ### Goals
16
+
17
+ - A genuinely interactive dashboard: browse loadouts, **activate/deactivate from a list**,
18
+ inspect their artifacts ("slots"), preview diffs, fix drift — all keyboard-driven.
19
+ - **One stack, multiple hosts.** Maximize shared code across:
20
+ - a standalone `loadouts tui` binary (any terminal),
21
+ - embedded in **Pi** at runtime (most important use case),
22
+ - embedded in **OpenCode** at runtime,
23
+ - launched from Cursor / Claude Code terminals (standalone, not embedded).
24
+ - Keep the existing `/loadouts <cmd>` fast paths; add `/loadouts` (no args) → open dashboard.
25
+ - Immediate toggle + undo (lazy/mason feel), with an optional diff preview before applying.
26
+
27
+ ### Non-goals (for v1)
28
+
29
+ - Replacing the CLI. The TUI is additive; `loadouts <cmd>` stays the source of truth.
30
+ - A GUI/webview for Cursor. Cursor is Electron/VS Code and has no terminal-TUI surface;
31
+ it gets the standalone binary in its integrated terminal.
32
+ - Native embedding inside Claude Code (no TUI plugin API); it gets the standalone binary.
33
+
34
+ ---
35
+
36
+ ## 2. The hard constraint: three incompatible renderers
37
+
38
+ Each host that can *embed* UI uses a different, mutually incompatible rendering model:
39
+
40
+ | Host | Renderer | Component model |
41
+ | ----------- | -------------------------------- | ------------------------------------------------- |
42
+ | **Pi** | `@earendil-works/pi-tui` | Imperative classes: `render(width): string[]`, `handleInput(data)` |
43
+ | **OpenCode**| `@opentui/solid` | JSX + Solid reactivity: `<box>`, `<text>` |
44
+ | Standalone | (free choice) | — |
45
+
46
+ There is **no shared JSX runtime** between Pi's `pi-tui` and OpenCode's `@opentui/solid`.
47
+ A single component tree cannot natively render in both. (An earlier assumption that Pi is
48
+ Ink-based was incorrect; the conclusion is the same — its renderer is incompatible with
49
+ `@opentui/solid`.)
50
+
51
+ ### Decision: `pi-tui` is the primary substrate; OpenCode gets a thin secondary skin
52
+
53
+ `@earendil-works/pi-tui` is a **standalone, npm-publishable** terminal UI library
54
+ (`new TUI(new ProcessTerminal())`), with built-in `SelectList`, `SettingsList`, `Box`,
55
+ `Text`, `Markdown`, overlays, theming, and key handling. Therefore:
56
+
57
+ - **Standalone `loadouts tui`** → built on `pi-tui`.
58
+ - **Pi embedded** → same `pi-tui` components mounted via `ctx.ui.custom(...)`.
59
+ - → Standalone and Pi share the rendering skin **1:1** (covers the priority use case).
60
+ - **OpenCode embedded** → a separate, thin `@opentui/solid` skin.
61
+
62
+ Both skins are **dumb projections** of one shared, headless view-model (below), so the
63
+ OpenCode skin stays small and no business logic is duplicated.
64
+
65
+ > If a single renderer across all hosts ever becomes a hard requirement, the fallback is to
66
+ > launch the standalone `pi-tui` binary from OpenCode instead of embedding — but native
67
+ > embedding via the thin `@opentui/solid` skin is preferred for UX.
68
+
69
+ ---
70
+
71
+ ## 3. Layered architecture
72
+
73
+ ```
74
+ ┌──────────────────────────────────────────────────────────────────────┐
75
+ │ CORE (src/tui/core/) — framework-agnostic, zero UI deps │
76
+ │ • data: read loadout/active/drift state (via `loadouts --json` │
77
+ │ or direct calls into src/core) │
78
+ │ • actions: activate / deactivate / sync / clear / diff │
79
+ │ (wrap render-engine.applyTargetSet, planRender, doctor) │
80
+ │ • reducer: Model + Intent -> Model (Elm-style state machine) │
81
+ │ • view: Model -> ViewSpec (declarative render spec) │
82
+ │ Pure & snapshot-testable. No terminal, no host APIs. │
83
+ └───────────────┬───────────────────────────────────────────┬──────────┘
84
+ │ ViewSpec (data) Intent (data) │
85
+ ┌───────▼─────────┐ ┌────────▼─────────┐
86
+ │ SKIN: pi-tui │ │ SKIN: opentui │
87
+ │ ViewSpec→pi-tui │ │ ViewSpec→<box> │
88
+ │ keys→Intent │ │ keymap→Intent │
89
+ └───┬─────────┬───┘ └────────┬─────────┘
90
+ │ │ │
91
+ ┌────────▼──┐ ┌───▼──────────┐ ┌────────▼─────────┐
92
+ │ HOST: │ │ HOST: Pi │ │ HOST: OpenCode │
93
+ │ standalone│ │ extension │ │ tui plugin + │
94
+ │ ProcessTUI│ │ (command + │ │ server plugin │
95
+ │ + fs state│ │ inject + ui) │ │ (route + inject) │
96
+ └───────────┘ └──────────────┘ └──────────────────┘
97
+ ```
98
+
99
+ Three layers:
100
+
101
+ 1. **Core** — all logic and state. Shared everywhere. (TEA: `Model`, `Intent`, `update`, `view`.)
102
+ 2. **Skin** — translate `ViewSpec` → native widgets, and native key input → `Intent`. Two skins.
103
+ 3. **Host adapter** — mount the skin, register `/loadouts`, perform runtime injection. Three adapters.
104
+
105
+ ---
106
+
107
+ ## 4. Proposed module layout
108
+
109
+ ```
110
+ src/tui/
111
+ core/
112
+ model.ts # Model (state) + initial state
113
+ intent.ts # Intent union (all user actions, host-agnostic)
114
+ update.ts # (Model, Intent) => Model [pure]
115
+ view.ts # Model => ViewSpec [pure]
116
+ viewspec.ts # ViewSpec types (rows, panes, footer, overlay, style tokens)
117
+ data.ts # DashboardData provider (reads loadout/active/drift state)
118
+ actions.ts # async ops: activate/deactivate/sync/clear/diff (effects)
119
+ theme.ts # ThemeTokens (abstract palette) + mapping helpers
120
+ keymap.ts # abstract key->Intent table (single source for help overlay)
121
+ skins/
122
+ pi-tui/
123
+ render.ts # ViewSpec => pi-tui Container/SelectList/Box/Text
124
+ input.ts # pi-tui key data => Intent
125
+ theme.ts # ThemeTokens => pi-tui theme.fg/bg callbacks
126
+ opentui/
127
+ Dashboard.tsx # ViewSpec => <box>/<text> (Solid)
128
+ input.ts # opentui keymap layer => Intent
129
+ theme.ts # ThemeTokens => api.theme.current
130
+ hosts/
131
+ standalone.ts # `loadouts tui` entry: ProcessTerminal + pi-tui skin
132
+ pi/
133
+ extension.ts # default ExtensionAPI factory: command + inject + mount
134
+ inject.ts # before_agent_start system-prompt block
135
+ opencode/
136
+ route.ts # api.route.register("loadouts") mounting opentui skin
137
+ (reuses src/integrations/opencode-runtime/* for command + injection)
138
+ ```
139
+
140
+ The existing `src/integrations/opencode-runtime/` (bridge, command parser, state,
141
+ system-block injection) is reused as-is by the OpenCode host; the new TUI route is layered
142
+ on top. A parallel `src/integrations/pi-runtime/` (or the `hosts/pi/` module above) provides
143
+ the Pi equivalent.
144
+
145
+ ---
146
+
147
+ ## 5. The view-model (headless core)
148
+
149
+ The core is an Elm-style state machine. The renderers never see loadouts domain types — only
150
+ `ViewSpec` and `Intent`.
151
+
152
+ ### 5.1 Model (state)
153
+
154
+ ```ts
155
+ type Section = "active" | "available" | "issues";
156
+ type ActivationMode = "filesystem" | "runtime"; // fs = persistent; runtime = this session
157
+
158
+ interface LoadoutRow {
159
+ name: string;
160
+ scope: "global" | "project";
161
+ description?: string;
162
+ status: "active" | "available" | "drift" | "broken";
163
+ activation: ActivationMode | null; // how it is currently active, if at all
164
+ counts: { rules: number; skills: number; instructions: number; extensions: number };
165
+ tools: string[]; // tools this loadout targets
166
+ issue?: string; // e.g. "missing include: foo"
167
+ }
168
+
169
+ interface Model {
170
+ rows: LoadoutRow[];
171
+ sections: Record<Section, string[]>; // ordered row names per section
172
+ cursor: { section: Section; index: number };
173
+ filter: { text: string; scope?: "global" | "project"; tool?: string };
174
+ mode: ActivationMode; // default target for toggles (host-defaulted)
175
+ staged?: Set<string>; // optional plan-mode (defer apply)
176
+ undo?: UndoSnapshot; // last applied set, for `u`
177
+ detail?: DetailModel; // expanded artifact view of cursor row
178
+ overlay?: "help" | "diff" | "confirm" | null;
179
+ busy?: { label: string } | null; // async op in flight
180
+ lastResult?: { text: string; variant: "info" | "success" | "error" };
181
+ }
182
+ ```
183
+
184
+ ### 5.2 Intent (every user action, host-agnostic)
185
+
186
+ ```ts
187
+ type Intent =
188
+ | { t: "move"; delta: number }
189
+ | { t: "section"; dir: 1 | -1 }
190
+ | { t: "toggle" } // space: activate/deactivate cursor row
191
+ | { t: "activate" } | { t: "deactivate" }
192
+ | { t: "sync" } | { t: "clear" }
193
+ | { t: "diffPreview" }
194
+ | { t: "edit" } // open YAML in $EDITOR (host-dependent)
195
+ | { t: "enter" } // drill into detail / confirm
196
+ | { t: "filterInput"; text: string }
197
+ | { t: "cycleScope" } | { t: "cycleTool" } | { t: "cycleMode" }
198
+ | { t: "undo" } | { t: "refresh" }
199
+ | { t: "openHelp" } | { t: "closeOverlay" }
200
+ | { t: "quit" };
201
+ ```
202
+
203
+ ### 5.3 ViewSpec (declarative render output — the contract with skins)
204
+
205
+ ```ts
206
+ type StyleToken =
207
+ | "text" | "muted" | "dim" | "accent"
208
+ | "success" | "error" | "warning" | "border";
209
+
210
+ interface SpecSpan { text: string; style?: StyleToken }
211
+ interface SpecRow {
212
+ id: string;
213
+ glyph: { char: string; style: StyleToken }; // ✓ + ~ - ! ? — (visual-language.md)
214
+ spans: SpecSpan[]; // pre-formatted, renderer pads/truncates
215
+ selected: boolean;
216
+ }
217
+ interface SpecPane { title: string; rows: SpecRow[] } // list panes (sections)
218
+ interface SpecDetail { title: string; blocks: SpecSpan[][] } // detail pane lines
219
+ interface SpecFooter {
220
+ equipped: SpecSpan[]; // the COD "EQUIPPED:" status bar
221
+ keys: { key: string; label: string }[]; // hint bar (from keymap.ts)
222
+ }
223
+ interface ViewSpec {
224
+ panes: SpecPane[];
225
+ detail?: SpecDetail;
226
+ footer: SpecFooter;
227
+ overlay?: { kind: "help" | "diff" | "confirm"; title: string; lines: SpecSpan[][] };
228
+ }
229
+ ```
230
+
231
+ A skin's entire job: render `ViewSpec` (mapping `StyleToken` → host colors, padding/truncating
232
+ spans to width) and map native key events → `Intent`. Both skins are interpreters of this spec,
233
+ so they stay thin and visually consistent. `ViewSpec` is snapshot-testable with zero terminal.
234
+
235
+ ### 5.4 Effects / actions
236
+
237
+ `update` stays pure; async work returns an effect descriptor the host runs, then feeds the
238
+ result back as an `Intent`-like message:
239
+
240
+ ```ts
241
+ type Effect =
242
+ | { t: "apply"; targetSet: string[]; mode: ActivationMode } // activate/deactivate/sync
243
+ | { t: "clear"; mode: ActivationMode }
244
+ | { t: "plan"; targetSet: string[] } // diff preview
245
+ | { t: "reload" } // re-read data
246
+ | { t: "editFile"; path: string };
247
+ ```
248
+
249
+ `actions.ts` implements effects:
250
+ - **filesystem mode** → call `render-engine.applyTargetSet` / `planRender` / `removeManaged`
251
+ (or shell out to `loadouts <cmd> --json`).
252
+ - **runtime mode** → call the host's injection adapter (Pi `before_agent_start`,
253
+ OpenCode `experimental.chat.system.transform`) via the existing runtime bridge.
254
+
255
+ ---
256
+
257
+ ## 6. Host adapters
258
+
259
+ Each host implements a small `HostContext` and mounts a skin.
260
+
261
+ ```ts
262
+ interface HostContext {
263
+ theme(): ThemeTokens; // host palette mapped into abstract tokens
264
+ requestRender(): void; // ask host to re-render the mounted skin
265
+ exit(): void; // close dashboard (route/overlay/process)
266
+ openEditor(path: string): Promise<void>;
267
+ data: DashboardData; // read state (loadouts, active set, drift)
268
+ runtime?: RuntimeInjector; // present only where runtime activation exists
269
+ }
270
+
271
+ interface RuntimeInjector { // session-scoped activation
272
+ activate(names: string[], scope: "local" | "global"): Promise<void>;
273
+ deactivate(): Promise<void>;
274
+ status(): RuntimeStatus;
275
+ }
276
+ ```
277
+
278
+ ### 6.1 Pi host (`@earendil-works/pi-coding-agent` extension)
279
+
280
+ Pi's extension API is a first-class fit — arguably the best host of the three:
281
+
282
+ | Need | Pi API |
283
+ | ---------------------------- | ---------------------------------------------------------------- |
284
+ | `/loadouts` command | `pi.registerCommand("loadouts", { handler })` |
285
+ | `/loadouts` (no args) → open | handler calls `ctx.ui.custom(mountDashboard, { overlay: true })` |
286
+ | Keyboard dashboard | `ctx.ui.custom(component)` with `render/handleInput/invalidate` |
287
+ | Runtime injection | `pi.on("before_agent_start", … => ({ systemPrompt }))` |
288
+ | Resource discovery (skills) | `pi.on("resources_discover", … => ({ skillPaths, promptPaths, themePaths }))` |
289
+ | EQUIPPED status bar | `ctx.ui.setStatus("loadouts", …)` / `ctx.ui.setWidget(...)` |
290
+ | Hotkey to open | `pi.registerShortcut("ctrl+l", …)` |
291
+ | Toasts | `ctx.ui.notify(message, variant)` |
292
+ | Hot reload | extensions in `~/.pi/agent/extensions/` reload via `/reload` |
293
+
294
+ Distribution: ship a bundled loadout `pi-runtime` whose `include` renders a Pi extension to
295
+ `{base}/extensions/loadouts-runtime.ts` (Pi `extension` kind already exists in
296
+ `src/builtins/tools/pi.ts:20`). The extension bundles the `core` + `pi-tui` skin.
297
+
298
+ Notable upside: `resources_discover` lets Pi natively pick up loadout-contributed
299
+ skills/prompts/themes at runtime — a cleaner injection path than OpenCode's system-block.
300
+
301
+ ### 6.2 OpenCode host (existing plugin, upgraded)
302
+
303
+ Reuse `src/integrations/opencode-runtime/` (command parsing, session state, system-block
304
+ injection). Add a TUI route:
305
+
306
+ | Need | OpenCode API |
307
+ | --------------------- | -------------------------------------------------------- |
308
+ | `/loadouts` command | already registered (`plugin.ts:271`) |
309
+ | `/loadouts` → open | TUI plugin registers `api.route.register("loadouts")` and a keymap layer; server plugin signals "open" via the existing cache-file event channel |
310
+ | Dashboard | `@opentui/solid` skin in `api.route` (full screen) |
311
+ | Runtime injection | `experimental.chat.system.transform` (`plugin.ts:318`) |
312
+ | EQUIPPED bar | `api.slots.register` (e.g. `session_prompt_right`) |
313
+ | Theme | `api.theme.current` |
314
+
315
+ The current passive popup (`bundled/opencode/plugins/loadouts-runtime-tui.tsx`) is replaced by
316
+ a real route; the toast/event channel remains for non-interactive `/loadouts <cmd>` feedback.
317
+
318
+ ### 6.3 Standalone host (`loadouts tui`)
319
+
320
+ - New CLI command (`src/cli/commands/tui.ts`) wired into `src/cli/index.ts`; optionally make
321
+ bare `loadouts` default to it.
322
+ - Mounts the `pi-tui` skin against `new TUI(new ProcessTerminal())`.
323
+ - Only **filesystem** activation (no session to inject into) — `mode` is fixed to `filesystem`,
324
+ `runtime` toggle hidden.
325
+ - This binary is also the integration for Cursor / Claude Code (run it in their terminal).
326
+
327
+ ---
328
+
329
+ ## 7. The `--json` CLI contract (prerequisite)
330
+
331
+ The TUI must consume structured data, not scrape formatted tables. Add a stable
332
+ `--json` output mode to the read/▶︎mutate commands the dashboard needs. Proposed shapes:
333
+
334
+ ```jsonc
335
+ // loadouts list --json
336
+ { "loadouts": [ { "name": "backend-py", "scope": "project", "description": "...",
337
+ "status": "active", "tools": ["claude-code","cursor","opencode"],
338
+ "counts": { "rules": 3, "skills": 0, "instructions": 1, "extensions": 0 } } ] }
339
+
340
+ // loadouts status --json (active set + drift)
341
+ { "active": ["backend-py","review-mode"], "drift": [ { "name": "data-eng",
342
+ "kind": "rule", "path": ".cursor/rules/x.mdc", "reason": "modified" } ] }
343
+
344
+ // loadouts info <name> --json (artifact-level "slots")
345
+ { "name": "backend-py", "items": [ { "kind": "rule", "relativePath": "py-style.md",
346
+ "tools": ["claude-code","cursor"] } ] }
347
+
348
+ // loadouts diff <names...> --json (planRender output)
349
+ { "changes": [ { "tool": "cursor", "path": ".cursor/rules/x.mdc", "op": "overwrite" } ] }
350
+ ```
351
+
352
+ Mutations (`activate`, `deactivate`, `sync`, `clear`) should accept `--json` and emit a
353
+ result envelope `{ ok, applied, removed, shadowed, error? }`. Standalone uses these (or calls
354
+ `src/core` directly); embedded hosts may prefer direct `src/core` calls to avoid spawning.
355
+
356
+ ---
357
+
358
+ ## 8. `/loadouts` entry-point integration
359
+
360
+ Symmetric model across surfaces:
361
+
362
+ | Input | Behavior |
363
+ | ----------------------------- | ----------------------------------------------------- |
364
+ | `/loadouts` (no args) | **Open the interactive dashboard** (route/overlay) |
365
+ | `/loadouts activate x [-l/-g]`| Fast non-interactive op (current behavior, unchanged) |
366
+ | `/loadouts status \| list \| info \| ...` | Fast path; result via toast/notify |
367
+ | `loadouts` (terminal) | Open standalone dashboard (or keep help; configurable)|
368
+ | `loadouts activate x` | CLI op (unchanged) |
369
+
370
+ The slash parser (`src/integrations/opencode-runtime/command.ts`) already maps aliases and
371
+ scope flags; extend it so an empty action opens the dashboard instead of printing help. Pi's
372
+ `registerCommand("loadouts")` handler mirrors the same parse → dispatch.
373
+
374
+ ---
375
+
376
+ ## 9. UX specification
377
+
378
+ ### 9.1 Layout (master-detail, mason-style)
379
+
380
+ ```
381
+ ┌─ LOADOUTS ────────────────────────────────────┬─ backend-py ───────────────────┐
382
+ │ ▸ Active (2) │ scope: project · active · synced│
383
+ │ ✓ backend-py project 3 rules [fs] │ "Python backend conventions" │
384
+ │ ✓ review-mode global 1 skill [session] │ │
385
+ │ ▸ Available (8) │ Artifacts (slots) │
386
+ │ - frontend-react project │ rule py-style → cc cu │
387
+ │ ~ data-eng project DRIFT │ skill pytest-run → cc op │
388
+ │ - security-audit global │ instr onboarding → all │
389
+ │ ▸ Issues (1) │ │
390
+ │ ! broken-loadout missing include │ Renders to │
391
+ │ │ claude-code cursor opencode │
392
+ │ │ ✓ + ✓ │
393
+ └────────────────────────────────────────────────┴────────────────────────────────┘
394
+ EQUIPPED: backend-py + review-mode → cc, cursor, opencode · in sync
395
+ [space] toggle [a]ctivate [d]eactivate [s]ync [D]iff [e]dit [/]find [?]help
396
+ ```
397
+
398
+ - Sections **Active / Available / Issues**; glyphs reuse `docs/visual-language.md`
399
+ (`✓ + ~ - ! ? —`).
400
+ - `[fs]` vs `[session]` badge makes filesystem vs runtime activation legible.
401
+ - Detail pane shows a loadout's artifacts as **"slots"** (COD create-a-class), each with its
402
+ target tools, plus a per-tool render-status row.
403
+ - The **EQUIPPED** bar is the COD flourish: current set, render targets, sync state. Injected
404
+ counts can reuse the logic in `runtimeActivationToast` (`opencode-runtime/plugin.ts:154`).
405
+
406
+ ### 9.2 Keymap (single source → drives the `?` overlay)
407
+
408
+ | Key | Intent |
409
+ | -------------- | --------------------------------------- |
410
+ | `j/k` `↑/↓` | move |
411
+ | `tab`/`S-tab` | cycle section |
412
+ | `space` | toggle activate/deactivate (immediate) |
413
+ | `a` / `d` | explicit activate / deactivate |
414
+ | `s` / `x` | sync / clear all |
415
+ | `enter` | drill into artifacts / confirm |
416
+ | `D` | diff preview overlay (`planRender`) |
417
+ | `e` | edit YAML in `$EDITOR` |
418
+ | `m` | cycle activation mode (fs ↔ session) |
419
+ | `g` / `t` | cycle scope / tool filter |
420
+ | `/` | fuzzy filter |
421
+ | `u` / `r` | undo / refresh |
422
+ | `?` / `q` | help / quit |
423
+
424
+ **Apply model:** immediate toggle + `u` undo (chosen). `staged`/plan-mode is reserved in the
425
+ `Model` for a later opt-in, and `D` gives a pre-apply diff for the cautious path.
426
+
427
+ ---
428
+
429
+ ## 10. Runtime vs filesystem activation in the UI
430
+
431
+ The dashboard surfaces both existing activation models (see `src/core/runtime.ts`):
432
+
433
+ - **Filesystem** (`render-engine.applyTargetSet`): persistent, writes files, tracked in
434
+ `.state.json`. Available in every host (and the only mode standalone offers).
435
+ - **Runtime** (session-injected): ephemeral, this-session-only.
436
+ - OpenCode: `experimental.chat.system.transform` (built).
437
+ - Pi: `before_agent_start` `{ systemPrompt }` + `resources_discover` (to build).
438
+
439
+ `mode` defaults per host (embedded → `runtime`, standalone → `filesystem`), is shown in the
440
+ EQUIPPED bar, and is toggled with `m`. Rows display which mode they're active under.
441
+
442
+ ---
443
+
444
+ ## 11. Packaging
445
+
446
+ Bundled loadouts (in `bundled/loadouts/`) distribute the host integrations, matching the
447
+ existing `opencode-runtime.yaml` pattern:
448
+
449
+ - `opencode-runtime.yaml` — existing; add the route-based TUI plugin alongside the server plugin.
450
+ - `pi-runtime.yaml` (new) — `include:` a Pi extension rendered to
451
+ `~/.pi/agent/extensions/loadouts-runtime.ts` (the `extension` kind in `pi.ts`).
452
+
453
+ The shared `core` + skins are compiled/bundled into each host's artifact (Pi loads `.ts`
454
+ extensions via jiti; OpenCode loads the `.tsx` plugin). The standalone skin ships in the main
455
+ `loadouts` binary.
456
+
457
+ ---
458
+
459
+ ## 12. Testing strategy
460
+
461
+ - **Core (pure):** unit-test `update` (Intent → Model) and snapshot-test `view`
462
+ (Model → ViewSpec). No terminal required. This is where most behavior is verified.
463
+ - **Skins:** `pi-tui` ships a `VirtualTerminal` (`@xterm/headless`) for headless rendering
464
+ assertions; OpenCode skin tested via tmux capture per `opencode-tui-plugins` skill.
465
+ - **Hosts:** integration-test command parsing + injection (existing
466
+ `opencode-runtime/*.test.ts` pattern); add a Pi equivalent.
467
+ - **E2E:** reuse the `runtime-injection-e2e` approach for the runtime activation path.
468
+
469
+ ---
470
+
471
+ ## 13. Phased plan
472
+
473
+ 1. **Core + `--json`.** Build `src/tui/core/` (Model/Intent/update/view/ViewSpec/data/actions)
474
+ and add `--json` to `list/status/info/diff` + mutation envelopes. Fully unit-tested,
475
+ no UI. *Largest, highest-leverage step.*
476
+ 2. **Standalone + `pi-tui` skin.** Ship `loadouts tui` on `pi-tui`. Filesystem mode only.
477
+ This proves the whole stack end-to-end in any terminal.
478
+ 3. **Pi host.** `pi-runtime` extension: `/loadouts`, `ctx.ui.custom` dashboard (reusing the
479
+ skin from step 2), `before_agent_start` injection, `resources_discover`, EQUIPPED status.
480
+ *The priority use case.*
481
+ 4. **OpenCode host.** `@opentui/solid` skin + `api.route.register`; reuse existing
482
+ command/injection plumbing; replace the passive popup with the route.
483
+ 5. **Polish.** Fuzzy search, multi-select bulk ops, drift one-key fix, diff overlay,
484
+ empty/first-run CTA, themes, `?` help generation.
485
+
486
+ ---
487
+
488
+ ## 14. Open questions / risks
489
+
490
+ - **Bundle size / load time** of embedding `core` + `pi-tui` inside a Pi extension and
491
+ `core` + `@opentui/solid` inside an OpenCode plugin — measure; consider prebuilding.
492
+ - **Direct-core vs shell-out** for embedded hosts: calling `src/core` directly avoids spawn
493
+ cost but couples the plugin to internal APIs; `--json` shell-out is cleaner but slower.
494
+ Recommendation: direct-core for embedded, `--json` for standalone/tests.
495
+ - **Pi runtime injection fidelity:** confirm `before_agent_start` + `resources_discover` fully
496
+ cover instructions/rules/skills the way `runtime.ts` expects for `pi` (`native-runtime`).
497
+ - **Doc drift:** `src/builtins/tools/pi.ts` omits a native `rule` target, but some top-level
498
+ docs still show Pi rule paths — reconcile so the UI's tool/render columns are accurate.
499
+ - **Theme parity:** define `ThemeTokens` so both skins look consistent; respect `NO_COLOR`
500
+ standalone.
501
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loadouts",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "Composable configuration bundles for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "bundled"
13
13
  ],
14
14
  "scripts": {
15
- "build": "tsc -p tsconfig.build.json && chmod +x dist/index.js",
15
+ "build": "tsc -p tsconfig.build.json && node scripts/build-embedded.mjs && chmod +x dist/index.js",
16
+ "build:embedded": "node scripts/build-embedded.mjs",
16
17
  "dev": "tsc --watch",
17
18
  "test": "vitest run",
18
19
  "test:watch": "vitest",
@@ -36,12 +37,20 @@
36
37
  "url": "https://github.com/evatths/loadouts.git"
37
38
  },
38
39
  "devDependencies": {
40
+ "@earendil-works/pi-coding-agent": "^0.79.7",
41
+ "@opencode-ai/plugin": "^1.17.8",
42
+ "@opentui/core": "^0.4.1",
43
+ "@opentui/keymap": "^0.4.1",
44
+ "@opentui/solid": "^0.4.1",
39
45
  "@types/node": "^20.12.0",
40
46
  "@types/update-notifier": "^6.0.8",
47
+ "esbuild": "^0.23.1",
48
+ "solid-js": "^1.9.12",
41
49
  "typescript": "^5.4.0",
42
50
  "vitest": "^1.5.0"
43
51
  },
44
52
  "dependencies": {
53
+ "@earendil-works/pi-tui": "^0.79.7",
45
54
  "chalk": "^5.3.0",
46
55
  "commander": "^12.0.0",
47
56
  "update-notifier": "^7.3.1",